feat: Traditional Chinese version (#1163)

* First commit

* Update mkdocs.yml

* Translate all the docs to traditional Chinese

* Translate the code files.

* Translate the docker file

* Fix mkdocs.yml

* Translate all the figures from SC to TC

* 二叉搜尋樹 -> 二元搜尋樹

* Update terminology.

* Update terminology

* 构造函数/构造方法 -> 建構子
异或 -> 互斥或

* 擴充套件 -> 擴展

* constant - 常量 - 常數

* 類	-> 類別

* AVL -> AVL 樹

* 數組 -> 陣列

* 係統 -> 系統
斐波那契數列 -> 費波那契數列
運算元量 -> 運算量
引數 -> 參數

* 聯絡 -> 關聯

* 麵試 -> 面試

* 面向物件 -> 物件導向
歸併排序 -> 合併排序
范式 -> 範式

* Fix 算法 -> 演算法

* 錶示 -> 表示
反碼 -> 一補數
補碼 -> 二補數
列列尾部 -> 佇列尾部
區域性性 -> 區域性
一摞 -> 一疊

* Synchronize with main branch

* 賬號 -> 帳號
推匯 -> 推導

* Sync with main branch

* First commit

* Update mkdocs.yml

* Translate all the docs to traditional Chinese

* Translate the code files.

* Translate the docker file

* Fix mkdocs.yml

* Translate all the figures from SC to TC

* 二叉搜尋樹 -> 二元搜尋樹

* Update terminology

* 构造函数/构造方法 -> 建構子
异或 -> 互斥或

* 擴充套件 -> 擴展

* constant - 常量 - 常數

* 類	-> 類別

* AVL -> AVL 樹

* 數組 -> 陣列

* 係統 -> 系統
斐波那契數列 -> 費波那契數列
運算元量 -> 運算量
引數 -> 參數

* 聯絡 -> 關聯

* 麵試 -> 面試

* 面向物件 -> 物件導向
歸併排序 -> 合併排序
范式 -> 範式

* Fix 算法 -> 演算法

* 錶示 -> 表示
反碼 -> 一補數
補碼 -> 二補數
列列尾部 -> 佇列尾部
區域性性 -> 區域性
一摞 -> 一疊

* Synchronize with main branch

* 賬號 -> 帳號
推匯 -> 推導

* Sync with main branch

* Update terminology.md

* 操作数量(num. of operations)-> 操作數量

* 字首和->前綴和

* Update figures

* 歸 -> 迴
記憶體洩漏 -> 記憶體流失

* Fix the bug of the file filter

* 支援 -> 支持
Add zh-Hant/README.md

* Add the zh-Hant chapter covers.
Bug fixes.

* 外掛 -> 擴充功能

* Add the landing page for zh-Hant version

* Unify the font of the chapter covers for the zh, en, and zh-Hant version

* Move zh-Hant/ to zh-hant/

* Translate terminology.md to traditional Chinese
This commit is contained in:
Yudong Jin
2024-04-06 02:30:11 +08:00
committed by GitHub
parent 33d7f8a2e5
commit 5f7385c8a3
1875 changed files with 102923 additions and 18 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

@@ -0,0 +1,489 @@
# 回溯演算法
<u>回溯演算法(backtracking algorithm</u>是一種透過窮舉來解決問題的方法,它的核心思想是從一個初始狀態出發,暴力搜尋所有可能的解決方案,當遇到正確的解則將其記錄,直到找到解或者嘗試了所有可能的選擇都無法找到解為止。
回溯演算法通常採用“深度優先搜尋”來走訪解空間。在“二元樹”章節中,我們提到前序、中序和後序走訪都屬於深度優先搜尋。接下來,我們利用前序走訪構造一個回溯問題,逐步瞭解回溯演算法的工作原理。
!!! question "例題一"
給定一棵二元樹,搜尋並記錄所有值為 $7$ 的節點,請返回節點串列。
對於此題,我們前序走訪這棵樹,並判斷當前節點的值是否為 $7$ ,若是,則將該節點的值加入結果串列 `res` 之中。相關過程實現如下圖和以下程式碼所示:
```src
[file]{preorder_traversal_i_compact}-[class]{}-[func]{pre_order}
```
![在前序走訪中搜索節點](backtracking_algorithm.assets/preorder_find_nodes.png)
## 嘗試與回退
**之所以稱之為回溯演算法,是因為該演算法在搜尋解空間時會採用“嘗試”與“回退”的策略**。當演算法在搜尋過程中遇到某個狀態無法繼續前進或無法得到滿足條件的解時,它會撤銷上一步的選擇,退回到之前的狀態,並嘗試其他可能的選擇。
對於例題一,訪問每個節點都代表一次“嘗試”,而越過葉節點或返回父節點的 `return` 則表示“回退”。
值得說明的是,**回退並不僅僅包括函式返回**。為解釋這一點,我們對例題一稍作拓展。
!!! question "例題二"
在二元樹中搜索所有值為 $7$ 的節點,**請返回根節點到這些節點的路徑**。
在例題一程式碼的基礎上,我們需要藉助一個串列 `path` 記錄訪問過的節點路徑。當訪問到值為 $7$ 的節點時,則複製 `path` 並新增進結果串列 `res` 。走訪完成後,`res` 中儲存的就是所有的解。程式碼如下所示:
```src
[file]{preorder_traversal_ii_compact}-[class]{}-[func]{pre_order}
```
在每次“嘗試”中,我們透過將當前節點新增進 `path` 來記錄路徑;而在“回退”前,我們需要將該節點從 `path` 中彈出,**以恢復本次嘗試之前的狀態**。
觀察下圖所示的過程,**我們可以將嘗試和回退理解為“前進”與“撤銷”**,兩個操作互為逆向。
=== "<1>"
![嘗試與回退](backtracking_algorithm.assets/preorder_find_paths_step1.png)
=== "<2>"
![preorder_find_paths_step2](backtracking_algorithm.assets/preorder_find_paths_step2.png)
=== "<3>"
![preorder_find_paths_step3](backtracking_algorithm.assets/preorder_find_paths_step3.png)
=== "<4>"
![preorder_find_paths_step4](backtracking_algorithm.assets/preorder_find_paths_step4.png)
=== "<5>"
![preorder_find_paths_step5](backtracking_algorithm.assets/preorder_find_paths_step5.png)
=== "<6>"
![preorder_find_paths_step6](backtracking_algorithm.assets/preorder_find_paths_step6.png)
=== "<7>"
![preorder_find_paths_step7](backtracking_algorithm.assets/preorder_find_paths_step7.png)
=== "<8>"
![preorder_find_paths_step8](backtracking_algorithm.assets/preorder_find_paths_step8.png)
=== "<9>"
![preorder_find_paths_step9](backtracking_algorithm.assets/preorder_find_paths_step9.png)
=== "<10>"
![preorder_find_paths_step10](backtracking_algorithm.assets/preorder_find_paths_step10.png)
=== "<11>"
![preorder_find_paths_step11](backtracking_algorithm.assets/preorder_find_paths_step11.png)
## 剪枝
複雜的回溯問題通常包含一個或多個約束條件,**約束條件通常可用於“剪枝”**。
!!! question "例題三"
在二元樹中搜索所有值為 $7$ 的節點,請返回根節點到這些節點的路徑,**並要求路徑中不包含值為 $3$ 的節點**。
為了滿足以上約束條件,**我們需要新增剪枝操作**:在搜尋過程中,若遇到值為 $3$ 的節點,則提前返回,不再繼續搜尋。程式碼如下所示:
```src
[file]{preorder_traversal_iii_compact}-[class]{}-[func]{pre_order}
```
“剪枝”是一個非常形象的名詞。如下圖所示,在搜尋過程中,**我們“剪掉”了不滿足約束條件的搜尋分支**,避免許多無意義的嘗試,從而提高了搜尋效率。
![根據約束條件剪枝](backtracking_algorithm.assets/preorder_find_constrained_paths.png)
## 框架程式碼
接下來,我們嘗試將回溯的“嘗試、回退、剪枝”的主體框架提煉出來,提升程式碼的通用性。
在以下框架程式碼中,`state` 表示問題的當前狀態,`choices` 表示當前狀態下可以做出的選擇:
=== "Python"
```python title=""
def backtrack(state: State, choices: list[choice], res: list[state]):
"""回溯演算法框架"""
# 判斷是否為解
if is_solution(state):
# 記錄解
record_solution(state, res)
# 不再繼續搜尋
return
# 走訪所有選擇
for choice in choices:
# 剪枝:判斷選擇是否合法
if is_valid(state, choice):
# 嘗試:做出選擇,更新狀態
make_choice(state, choice)
backtrack(state, choices, res)
# 回退:撤銷選擇,恢復到之前的狀態
undo_choice(state, choice)
```
=== "C++"
```cpp title=""
/* 回溯演算法框架 */
void backtrack(State *state, vector<Choice *> &choices, vector<State *> &res) {
// 判斷是否為解
if (isSolution(state)) {
// 記錄解
recordSolution(state, res);
// 不再繼續搜尋
return;
}
// 走訪所有選擇
for (Choice choice : choices) {
// 剪枝:判斷選擇是否合法
if (isValid(state, choice)) {
// 嘗試:做出選擇,更新狀態
makeChoice(state, choice);
backtrack(state, choices, res);
// 回退:撤銷選擇,恢復到之前的狀態
undoChoice(state, choice);
}
}
}
```
=== "Java"
```java title=""
/* 回溯演算法框架 */
void backtrack(State state, List<Choice> choices, List<State> res) {
// 判斷是否為解
if (isSolution(state)) {
// 記錄解
recordSolution(state, res);
// 不再繼續搜尋
return;
}
// 走訪所有選擇
for (Choice choice : choices) {
// 剪枝:判斷選擇是否合法
if (isValid(state, choice)) {
// 嘗試:做出選擇,更新狀態
makeChoice(state, choice);
backtrack(state, choices, res);
// 回退:撤銷選擇,恢復到之前的狀態
undoChoice(state, choice);
}
}
}
```
=== "C#"
```csharp title=""
/* 回溯演算法框架 */
void Backtrack(State state, List<Choice> choices, List<State> res) {
// 判斷是否為解
if (IsSolution(state)) {
// 記錄解
RecordSolution(state, res);
// 不再繼續搜尋
return;
}
// 走訪所有選擇
foreach (Choice choice in choices) {
// 剪枝:判斷選擇是否合法
if (IsValid(state, choice)) {
// 嘗試:做出選擇,更新狀態
MakeChoice(state, choice);
Backtrack(state, choices, res);
// 回退:撤銷選擇,恢復到之前的狀態
UndoChoice(state, choice);
}
}
}
```
=== "Go"
```go title=""
/* 回溯演算法框架 */
func backtrack(state *State, choices []Choice, res *[]State) {
// 判斷是否為解
if isSolution(state) {
// 記錄解
recordSolution(state, res)
// 不再繼續搜尋
return
}
// 走訪所有選擇
for _, choice := range choices {
// 剪枝:判斷選擇是否合法
if isValid(state, choice) {
// 嘗試:做出選擇,更新狀態
makeChoice(state, choice)
backtrack(state, choices, res)
// 回退:撤銷選擇,恢復到之前的狀態
undoChoice(state, choice)
}
}
}
```
=== "Swift"
```swift title=""
/* 回溯演算法框架 */
func backtrack(state: inout State, choices: [Choice], res: inout [State]) {
// 判斷是否為解
if isSolution(state: state) {
// 記錄解
recordSolution(state: state, res: &res)
// 不再繼續搜尋
return
}
// 走訪所有選擇
for choice in choices {
// 剪枝:判斷選擇是否合法
if isValid(state: state, choice: choice) {
// 嘗試:做出選擇,更新狀態
makeChoice(state: &state, choice: choice)
backtrack(state: &state, choices: choices, res: &res)
// 回退:撤銷選擇,恢復到之前的狀態
undoChoice(state: &state, choice: choice)
}
}
}
```
=== "JS"
```javascript title=""
/* 回溯演算法框架 */
function backtrack(state, choices, res) {
// 判斷是否為解
if (isSolution(state)) {
// 記錄解
recordSolution(state, res);
// 不再繼續搜尋
return;
}
// 走訪所有選擇
for (let choice of choices) {
// 剪枝:判斷選擇是否合法
if (isValid(state, choice)) {
// 嘗試:做出選擇,更新狀態
makeChoice(state, choice);
backtrack(state, choices, res);
// 回退:撤銷選擇,恢復到之前的狀態
undoChoice(state, choice);
}
}
}
```
=== "TS"
```typescript title=""
/* 回溯演算法框架 */
function backtrack(state: State, choices: Choice[], res: State[]): void {
// 判斷是否為解
if (isSolution(state)) {
// 記錄解
recordSolution(state, res);
// 不再繼續搜尋
return;
}
// 走訪所有選擇
for (let choice of choices) {
// 剪枝:判斷選擇是否合法
if (isValid(state, choice)) {
// 嘗試:做出選擇,更新狀態
makeChoice(state, choice);
backtrack(state, choices, res);
// 回退:撤銷選擇,恢復到之前的狀態
undoChoice(state, choice);
}
}
}
```
=== "Dart"
```dart title=""
/* 回溯演算法框架 */
void backtrack(State state, List<Choice>, List<State> res) {
// 判斷是否為解
if (isSolution(state)) {
// 記錄解
recordSolution(state, res);
// 不再繼續搜尋
return;
}
// 走訪所有選擇
for (Choice choice in choices) {
// 剪枝:判斷選擇是否合法
if (isValid(state, choice)) {
// 嘗試:做出選擇,更新狀態
makeChoice(state, choice);
backtrack(state, choices, res);
// 回退:撤銷選擇,恢復到之前的狀態
undoChoice(state, choice);
}
}
}
```
=== "Rust"
```rust title=""
/* 回溯演算法框架 */
fn backtrack(state: &mut State, choices: &Vec<Choice>, res: &mut Vec<State>) {
// 判斷是否為解
if is_solution(state) {
// 記錄解
record_solution(state, res);
// 不再繼續搜尋
return;
}
// 走訪所有選擇
for choice in choices {
// 剪枝:判斷選擇是否合法
if is_valid(state, choice) {
// 嘗試:做出選擇,更新狀態
make_choice(state, choice);
backtrack(state, choices, res);
// 回退:撤銷選擇,恢復到之前的狀態
undo_choice(state, choice);
}
}
}
```
=== "C"
```c title=""
/* 回溯演算法框架 */
void backtrack(State *state, Choice *choices, int numChoices, State *res, int numRes) {
// 判斷是否為解
if (isSolution(state)) {
// 記錄解
recordSolution(state, res, numRes);
// 不再繼續搜尋
return;
}
// 走訪所有選擇
for (int i = 0; i < numChoices; i++) {
// 剪枝:判斷選擇是否合法
if (isValid(state, &choices[i])) {
// 嘗試:做出選擇,更新狀態
makeChoice(state, &choices[i]);
backtrack(state, choices, numChoices, res, numRes);
// 回退:撤銷選擇,恢復到之前的狀態
undoChoice(state, &choices[i]);
}
}
}
```
=== "Kotlin"
```kotlin title=""
/* 回溯演算法框架 */
fun backtrack(state: State?, choices: List<Choice?>, res: List<State?>?) {
// 判斷是否為解
if (isSolution(state)) {
// 記錄解
recordSolution(state, res)
// 不再繼續搜尋
return
}
// 走訪所有選擇
for (choice in choices) {
// 剪枝:判斷選擇是否合法
if (isValid(state, choice)) {
// 嘗試:做出選擇,更新狀態
makeChoice(state, choice)
backtrack(state, choices, res)
// 回退:撤銷選擇,恢復到之前的狀態
undoChoice(state, choice)
}
}
}
```
=== "Ruby"
```ruby title=""
```
=== "Zig"
```zig title=""
```
接下來,我們基於框架程式碼來解決例題三。狀態 `state` 為節點走訪路徑,選擇 `choices` 為當前節點的左子節點和右子節點,結果 `res` 是路徑串列:
```src
[file]{preorder_traversal_iii_template}-[class]{}-[func]{backtrack}
```
根據題意,我們在找到值為 $7$ 的節點後應該繼續搜尋,**因此需要將記錄解之後的 `return` 語句刪除**。下圖對比了保留或刪除 `return` 語句的搜尋過程。
![保留與刪除 return 的搜尋過程對比](backtracking_algorithm.assets/backtrack_remove_return_or_not.png)
相比基於前序走訪的程式碼實現,基於回溯演算法框架的程式碼實現雖然顯得囉唆,但通用性更好。實際上,**許多回溯問題可以在該框架下解決**。我們只需根據具體問題來定義 `state` 和 `choices` ,並實現框架中的各個方法即可。
## 常用術語
為了更清晰地分析演算法問題,我們總結一下回溯演算法中常用術語的含義,並對照例題三給出對應示例,如下表所示。
<p align="center"> 表 <id> &nbsp; 常見的回溯演算法術語 </p>
| 名詞 | 定義 | 例題三 |
| ---------------------- | -------------------------------------------------------------------------- | -------------------------------------------------------------------- |
| 解(solution) | 解是滿足問題特定條件的答案,可能有一個或多個 | 根節點到節點 $7$ 的滿足約束條件的所有路徑 |
| 約束條件(constraint) | 約束條件是問題中限制解的可行性的條件,通常用於剪枝 | 路徑中不包含節點 $3$ |
| 狀態(state) | 狀態表示問題在某一時刻的情況,包括已經做出的選擇 | 當前已訪問的節點路徑,即 `path` 節點串列 |
| 嘗試(attempt) | 嘗試是根據可用選擇來探索解空間的過程,包括做出選擇,更新狀態,檢查是否為解 | 遞迴訪問左(右)子節點,將節點新增進 `path` ,判斷節點的值是否為 $7$ |
| 回退(backtracking) | 回退指遇到不滿足約束條件的狀態時,撤銷前面做出的選擇,回到上一個狀態 | 當越過葉節點、結束節點訪問、遇到值為 $3$ 的節點時終止搜尋,函式返回 |
| 剪枝(pruning) | 剪枝是根據問題特性和約束條件避免無意義的搜尋路徑的方法,可提高搜尋效率 | 當遇到值為 $3$ 的節點時,則不再繼續搜尋 |
!!! tip
問題、解、狀態等概念是通用的,在分治、回溯、動態規劃、貪婪等演算法中都有涉及。
## 優點與侷限性
回溯演算法本質上是一種深度優先搜尋演算法,它嘗試所有可能的解決方案直到找到滿足條件的解。這種方法的優點在於能夠找到所有可能的解決方案,而且在合理的剪枝操作下,具有很高的效率。
然而,在處理大規模或者複雜問題時,**回溯演算法的執行效率可能難以接受**。
- **時間**:回溯演算法通常需要走訪狀態空間的所有可能,時間複雜度可以達到指數階或階乘階。
- **空間**:在遞迴呼叫中需要儲存當前的狀態(例如路徑、用於剪枝的輔助變數等),當深度很大時,空間需求可能會變得很大。
即便如此,**回溯演算法仍然是某些搜尋問題和約束滿足問題的最佳解決方案**。對於這些問題,由於無法預測哪些選擇可生成有效的解,因此我們必須對所有可能的選擇進行走訪。在這種情況下,**關鍵是如何最佳化效率**,常見的效率最佳化方法有兩種。
- **剪枝**:避免搜尋那些肯定不會產生解的路徑,從而節省時間和空間。
- **啟發式搜尋**:在搜尋過程中引入一些策略或者估計值,從而優先搜尋最有可能產生有效解的路徑。
## 回溯典型例題
回溯演算法可用於解決許多搜尋問題、約束滿足問題和組合最佳化問題。
**搜尋問題**:這類問題的目標是找到滿足特定條件的解決方案。
- 全排列問題:給定一個集合,求出其所有可能的排列組合。
- 子集和問題:給定一個集合和一個目標和,找到集合中所有和為目標和的子集。
- 河內塔問題:給定三根柱子和一系列大小不同的圓盤,要求將所有圓盤從一根柱子移動到另一根柱子,每次只能移動一個圓盤,且不能將大圓盤放在小圓盤上。
**約束滿足問題**:這類問題的目標是找到滿足所有約束條件的解。
- $n$ 皇后:在 $n \times n$ 的棋盤上放置 $n$ 個皇后,使得它們互不攻擊。
- 數獨:在 $9 \times 9$ 的網格中填入數字 $1$ ~ $9$ ,使得每行、每列和每個 $3 \times 3$ 子網格中的數字不重複。
- 圖著色問題:給定一個無向圖,用最少的顏色給圖的每個頂點著色,使得相鄰頂點顏色不同。
**組合最佳化問題**:這類問題的目標是在一個組合空間中找到滿足某些條件的最優解。
- 0-1 背包問題:給定一組物品和一個背包,每個物品有一定的價值和重量,要求在背包容量限制內,選擇物品使得總價值最大。
- 旅行商問題:在一個圖中,從一個點出發,訪問所有其他點恰好一次後返回起點,求最短路徑。
- 最大團問題:給定一個無向圖,找到最大的完全子圖,即子圖中的任意兩個頂點之間都有邊相連。
請注意,對於許多組合最佳化問題,回溯不是最優解決方案。
- 0-1 背包問題通常使用動態規劃解決,以達到更高的時間效率。
- 旅行商是一個著名的 NP-Hard 問題,常用解法有遺傳演算法和蟻群演算法等。
- 最大團問題是圖論中的一個經典問題,可用貪婪演算法等啟發式演算法來解決。
@@ -0,0 +1,9 @@
# 回溯
![回溯](../assets/covers/chapter_backtracking.jpg)
!!! abstract
我們如同迷宮中的探索者,在前進的道路上可能會遇到困難。
回溯的力量讓我們能夠重新開始,不斷嘗試,最終找到通往光明的出口。
Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

@@ -0,0 +1,49 @@
# n 皇后問題
!!! question
根據國際象棋的規則,皇后可以攻擊與同處一行、一列或一條斜線上的棋子。給定 $n$ 個皇后和一個 $n \times n$ 大小的棋盤,尋找使得所有皇后之間無法相互攻擊的擺放方案。
如下圖所示,當 $n = 4$ 時,共可以找到兩個解。從回溯演算法的角度看,$n \times n$ 大小的棋盤共有 $n^2$ 個格子,給出了所有的選擇 `choices` 。在逐個放置皇后的過程中,棋盤狀態在不斷地變化,每個時刻的棋盤就是狀態 `state`
![4 皇后問題的解](n_queens_problem.assets/solution_4_queens.png)
下圖展示了本題的三個約束條件:**多個皇后不能在同一行、同一列、同一條對角線上**。值得注意的是,對角線分為主對角線 `\` 和次對角線 `/` 兩種。
![n 皇后問題的約束條件](n_queens_problem.assets/n_queens_constraints.png)
### 逐行放置策略
皇后的數量和棋盤的行數都為 $n$ ,因此我們容易得到一個推論:**棋盤每行都允許且只允許放置一個皇后**。
也就是說,我們可以採取逐行放置策略:從第一行開始,在每行放置一個皇后,直至最後一行結束。
下圖所示為 $4$ 皇后問題的逐行放置過程。受畫幅限制,下圖僅展開了第一行的其中一個搜尋分支,並且將不滿足列約束和對角線約束的方案都進行了剪枝。
![逐行放置策略](n_queens_problem.assets/n_queens_placing.png)
從本質上看,**逐行放置策略起到了剪枝的作用**,它避免了同一行出現多個皇后的所有搜尋分支。
### 列與對角線剪枝
為了滿足列約束,我們可以利用一個長度為 $n$ 的布林型陣列 `cols` 記錄每一列是否有皇后。在每次決定放置前,我們透過 `cols` 將已有皇后的列進行剪枝,並在回溯中動態更新 `cols` 的狀態。
那麼,如何處理對角線約束呢?設棋盤中某個格子的行列索引為 $(row, col)$ ,選定矩陣中的某條主對角線,我們發現該對角線上所有格子的行索引減列索引都相等,**即對角線上所有格子的 $row - col$ 為恆定值**。
也就是說,如果兩個格子滿足 $row_1 - col_1 = row_2 - col_2$ ,則它們一定處在同一條主對角線上。利用該規律,我們可以藉助下圖所示的陣列 `diags1` 記錄每條主對角線上是否有皇后。
同理,**次對角線上的所有格子的 $row + col$ 是恆定值**。我們同樣也可以藉助陣列 `diags2` 來處理次對角線約束。
![處理列約束和對角線約束](n_queens_problem.assets/n_queens_cols_diagonals.png)
### 程式碼實現
請注意,$n$ 維方陣中 $row - col$ 的範圍是 $[-n + 1, n - 1]$ $row + col$ 的範圍是 $[0, 2n - 2]$ ,所以主對角線和次對角線的數量都為 $2n - 1$ ,即陣列 `diags1``diags2` 的長度都為 $2n - 1$ 。
```src
[file]{n_queens}-[class]{}-[func]{n_queens}
```
逐行放置 $n$ 次,考慮列約束,則從第一行到最後一行分別有 $n$、$n-1$、$\dots$、$2$、$1$ 個選擇,使用 $O(n!)$ 時間。當記錄解時,需要複製矩陣 `state` 並新增進 `res` ,複製操作使用 $O(n^2)$ 時間。因此,**總體時間複雜度為 $O(n! \cdot n^2)$** 。實際上,根據對角線約束的剪枝也能夠大幅縮小搜尋空間,因而搜尋效率往往優於以上時間複雜度。
陣列 `state` 使用 $O(n^2)$ 空間,陣列 `cols``diags1``diags2` 皆使用 $O(n)$ 空間。最大遞迴深度為 $n$ ,使用 $O(n)$ 堆疊幀空間。因此,**空間複雜度為 $O(n^2)$** 。
Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

@@ -0,0 +1,95 @@
# 全排列問題
全排列問題是回溯演算法的一個典型應用。它的定義是在給定一個集合(如一個陣列或字串)的情況下,找出其中元素的所有可能的排列。
下表列舉了幾個示例資料,包括輸入陣列和對應的所有排列。
<p align="center"> 表 <id> &nbsp; 全排列示例 </p>
| 輸入陣列 | 所有排列 |
| :---------- | :----------------------------------------------------------------- |
| $[1]$ | $[1]$ |
| $[1, 2]$ | $[1, 2], [2, 1]$ |
| $[1, 2, 3]$ | $[1, 2, 3], [1, 3, 2], [2, 1, 3], [2, 3, 1], [3, 1, 2], [3, 2, 1]$ |
## 無相等元素的情況
!!! question
輸入一個整數陣列,其中不包含重複元素,返回所有可能的排列。
從回溯演算法的角度看,**我們可以把生成排列的過程想象成一系列選擇的結果**。假設輸入陣列為 $[1, 2, 3]$ ,如果我們先選擇 $1$ ,再選擇 $3$ ,最後選擇 $2$ ,則獲得排列 $[1, 3, 2]$ 。回退表示撤銷一個選擇,之後繼續嘗試其他選擇。
從回溯程式碼的角度看,候選集合 `choices` 是輸入陣列中的所有元素,狀態 `state` 是直至目前已被選擇的元素。請注意,每個元素只允許被選擇一次,**因此 `state` 中的所有元素都應該是唯一的**。
如下圖所示,我們可以將搜尋過程展開成一棵遞迴樹,樹中的每個節點代表當前狀態 `state` 。從根節點開始,經過三輪選擇後到達葉節點,每個葉節點都對應一個排列。
![全排列的遞迴樹](permutations_problem.assets/permutations_i.png)
### 重複選擇剪枝
為了實現每個元素只被選擇一次,我們考慮引入一個布林型陣列 `selected` ,其中 `selected[i]` 表示 `choices[i]` 是否已被選擇,並基於它實現以下剪枝操作。
- 在做出選擇 `choice[i]` 後,我們就將 `selected[i]` 賦值為 $\text{True}$ ,代表它已被選擇。
- 走訪選擇串列 `choices` 時,跳過所有已被選擇的節點,即剪枝。
如下圖所示,假設我們第一輪選擇 1 ,第二輪選擇 3 ,第三輪選擇 2 ,則需要在第二輪剪掉元素 1 的分支,在第三輪剪掉元素 1 和元素 3 的分支。
![全排列剪枝示例](permutations_problem.assets/permutations_i_pruning.png)
觀察上圖發現,該剪枝操作將搜尋空間大小從 $O(n^n)$ 減小至 $O(n!)$ 。
### 程式碼實現
想清楚以上資訊之後,我們就可以在框架程式碼中做“完形填空”了。為了縮短整體程式碼,我們不單獨實現框架程式碼中的各個函式,而是將它們展開在 `backtrack()` 函式中:
```src
[file]{permutations_i}-[class]{}-[func]{permutations_i}
```
## 考慮相等元素的情況
!!! question
輸入一個整數陣列,**陣列中可能包含重複元素**,返回所有不重複的排列。
假設輸入陣列為 $[1, 1, 2]$ 。為了方便區分兩個重複元素 $1$ ,我們將第二個 $1$ 記為 $\hat{1}$ 。
如下圖所示,上述方法生成的排列有一半是重複的。
![重複排列](permutations_problem.assets/permutations_ii.png)
那麼如何去除重複的排列呢?最直接地,考慮藉助一個雜湊表,直接對排列結果進行去重。然而這樣做不夠優雅,**因為生成重複排列的搜尋分支沒有必要,應當提前識別並剪枝**,這樣可以進一步提升演算法效率。
### 相等元素剪枝
觀察下圖,在第一輪中,選擇 $1$ 或選擇 $\hat{1}$ 是等價的,在這兩個選擇之下生成的所有排列都是重複的。因此應該把 $\hat{1}$ 剪枝。
同理,在第一輪選擇 $2$ 之後,第二輪選擇中的 $1$ 和 $\hat{1}$ 也會產生重複分支,因此也應將第二輪的 $\hat{1}$ 剪枝。
從本質上看,**我們的目標是在某一輪選擇中,保證多個相等的元素僅被選擇一次**。
![重複排列剪枝](permutations_problem.assets/permutations_ii_pruning.png)
### 程式碼實現
在上一題的程式碼的基礎上,我們考慮在每一輪選擇中開啟一個雜湊表 `duplicated` ,用於記錄該輪中已經嘗試過的元素,並將重複元素剪枝:
```src
[file]{permutations_ii}-[class]{}-[func]{permutations_ii}
```
假設元素兩兩之間互不相同,則 $n$ 個元素共有 $n!$ 種排列(階乘);在記錄結果時,需要複製長度為 $n$ 的串列,使用 $O(n)$ 時間。**因此時間複雜度為 $O(n!n)$** 。
最大遞迴深度為 $n$ ,使用 $O(n)$ 堆疊幀空間。`selected` 使用 $O(n)$ 空間。同一時刻最多共有 $n$ 個 `duplicated` ,使用 $O(n^2)$ 空間。**因此空間複雜度為 $O(n^2)$** 。
### 兩種剪枝對比
請注意,雖然 `selected``duplicated` 都用於剪枝,但兩者的目標不同。
- **重複選擇剪枝**:整個搜尋過程中只有一個 `selected` 。它記錄的是當前狀態中包含哪些元素,其作用是避免某個元素在 `state` 中重複出現。
- **相等元素剪枝**:每輪選擇(每個呼叫的 `backtrack` 函式)都包含一個 `duplicated` 。它記錄的是在本輪走訪(`for` 迴圈)中哪些元素已被選擇過,其作用是保證相等元素只被選擇一次。
下圖展示了兩個剪枝條件的生效範圍。注意,樹中的每個節點代表一個選擇,從根節點到葉節點的路徑上的各個節點構成一個排列。
![兩種剪枝條件的作用範圍](permutations_problem.assets/permutations_ii_pruning_summary.png)
Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

@@ -0,0 +1,95 @@
# 子集和問題
## 無重複元素的情況
!!! question
給定一個正整數陣列 `nums` 和一個目標正整數 `target` ,請找出所有可能的組合,使得組合中的元素和等於 `target` 。給定陣列無重複元素,每個元素可以被選取多次。請以串列形式返回這些組合,串列中不應包含重複組合。
例如,輸入集合 $\{3, 4, 5\}$ 和目標整數 $9$ ,解為 $\{3, 3, 3\}, \{4, 5\}$ 。需要注意以下兩點。
- 輸入集合中的元素可以被無限次重複選取。
- 子集不區分元素順序,比如 $\{4, 5\}$ 和 $\{5, 4\}$ 是同一個子集。
### 參考全排列解法
類似於全排列問題,我們可以把子集的生成過程想象成一系列選擇的結果,並在選擇過程中實時更新“元素和”,當元素和等於 `target` 時,就將子集記錄至結果串列。
而與全排列問題不同的是,**本題集合中的元素可以被無限次選取**,因此無須藉助 `selected` 布林串列來記錄元素是否已被選擇。我們可以對全排列程式碼進行小幅修改,初步得到解題程式碼:
```src
[file]{subset_sum_i_naive}-[class]{}-[func]{subset_sum_i_naive}
```
向以上程式碼輸入陣列 $[3, 4, 5]$ 和目標元素 $9$ ,輸出結果為 $[3, 3, 3], [4, 5], [5, 4]$ 。**雖然成功找出了所有和為 $9$ 的子集,但其中存在重複的子集 $[4, 5]$ 和 $[5, 4]$** 。
這是因為搜尋過程是區分選擇順序的,然而子集不區分選擇順序。如下圖所示,先選 $4$ 後選 $5$ 與先選 $5$ 後選 $4$ 是不同的分支,但對應同一個子集。
![子集搜尋與越界剪枝](subset_sum_problem.assets/subset_sum_i_naive.png)
為了去除重複子集,**一種直接的思路是對結果串列進行去重**。但這個方法效率很低,有兩方面原因。
- 當陣列元素較多,尤其是當 `target` 較大時,搜尋過程會產生大量的重複子集。
- 比較子集(陣列)的異同非常耗時,需要先排序陣列,再比較陣列中每個元素的異同。
### 重複子集剪枝
**我們考慮在搜尋過程中透過剪枝進行去重**。觀察下圖,重複子集是在以不同順序選擇陣列元素時產生的,例如以下情況。
1. 當第一輪和第二輪分別選擇 $3$ 和 $4$ 時,會生成包含這兩個元素的所有子集,記為 $[3, 4, \dots]$ 。
2. 之後,當第一輪選擇 $4$ 時,**則第二輪應該跳過 $3$** ,因為該選擇產生的子集 $[4, 3, \dots]$ 和第 `1.` 步中生成的子集完全重複。
在搜尋過程中,每一層的選擇都是從左到右被逐個嘗試的,因此越靠右的分支被剪掉的越多。
1. 前兩輪選擇 $3$ 和 $5$ ,生成子集 $[3, 5, \dots]$ 。
2. 前兩輪選擇 $4$ 和 $5$ ,生成子集 $[4, 5, \dots]$ 。
3. 若第一輪選擇 $5$ ,**則第二輪應該跳過 $3$ 和 $4$** ,因為子集 $[5, 3, \dots]$ 和 $[5, 4, \dots]$ 與第 `1.` 步和第 `2.` 步中描述的子集完全重複。
![不同選擇順序導致的重複子集](subset_sum_problem.assets/subset_sum_i_pruning.png)
總結來看,給定輸入陣列 $[x_1, x_2, \dots, x_n]$ ,設搜尋過程中的選擇序列為 $[x_{i_1}, x_{i_2}, \dots, x_{i_m}]$ ,則該選擇序列需要滿足 $i_1 \leq i_2 \leq \dots \leq i_m$ ,**不滿足該條件的選擇序列都會造成重複,應當剪枝**。
### 程式碼實現
為實現該剪枝,我們初始化變數 `start` ,用於指示走訪起始點。**當做出選擇 $x_{i}$ 後,設定下一輪從索引 $i$ 開始走訪**。這樣做就可以讓選擇序列滿足 $i_1 \leq i_2 \leq \dots \leq i_m$ ,從而保證子集唯一。
除此之外,我們還對程式碼進行了以下兩項最佳化。
- 在開啟搜尋前,先將陣列 `nums` 排序。在走訪所有選擇時,**當子集和超過 `target` 時直接結束迴圈**,因為後邊的元素更大,其子集和一定超過 `target`
- 省去元素和變數 `total` **透過在 `target` 上執行減法來統計元素和**,當 `target` 等於 $0$ 時記錄解。
```src
[file]{subset_sum_i}-[class]{}-[func]{subset_sum_i}
```
下圖所示為將陣列 $[3, 4, 5]$ 和目標元素 $9$ 輸入以上程式碼後的整體回溯過程。
![子集和 I 回溯過程](subset_sum_problem.assets/subset_sum_i.png)
## 考慮重複元素的情況
!!! question
給定一個正整數陣列 `nums` 和一個目標正整數 `target` ,請找出所有可能的組合,使得組合中的元素和等於 `target` 。**給定陣列可能包含重複元素,每個元素只可被選擇一次**。請以串列形式返回這些組合,串列中不應包含重複組合。
相比於上題,**本題的輸入陣列可能包含重複元素**,這引入了新的問題。例如,給定陣列 $[4, \hat{4}, 5]$ 和目標元素 $9$ ,則現有程式碼的輸出結果為 $[4, 5], [\hat{4}, 5]$ ,出現了重複子集。
**造成這種重複的原因是相等元素在某輪中被多次選擇**。在下圖中,第一輪共有三個選擇,其中兩個都為 $4$ ,會產生兩個重複的搜尋分支,從而輸出重複子集;同理,第二輪的兩個 $4$ 也會產生重複子集。
![相等元素導致的重複子集](subset_sum_problem.assets/subset_sum_ii_repeat.png)
### 相等元素剪枝
為解決此問題,**我們需要限制相等元素在每一輪中只能被選擇一次**。實現方式比較巧妙:由於陣列是已排序的,因此相等元素都是相鄰的。這意味著在某輪選擇中,若當前元素與其左邊元素相等,則說明它已經被選擇過,因此直接跳過當前元素。
與此同時,**本題規定每個陣列元素只能被選擇一次**。幸運的是,我們也可以利用變數 `start` 來滿足該約束:當做出選擇 $x_{i}$ 後,設定下一輪從索引 $i + 1$ 開始向後走訪。這樣既能去除重複子集,也能避免重複選擇元素。
### 程式碼實現
```src
[file]{subset_sum_ii}-[class]{}-[func]{subset_sum_ii}
```
下圖展示了陣列 $[4, 4, 5]$ 和目標元素 $9$ 的回溯過程,共包含四種剪枝操作。請你將圖示與程式碼註釋相結合,理解整個搜尋過程,以及每種剪枝操作是如何工作的。
![子集和 II 回溯過程](subset_sum_problem.assets/subset_sum_ii.png)
@@ -0,0 +1,23 @@
# 小結
### 重點回顧
- 回溯演算法本質是窮舉法,透過對解空間進行深度優先走訪來尋找符合條件的解。在搜尋過程中,遇到滿足條件的解則記錄,直至找到所有解或走訪完成後結束。
- 回溯演算法的搜尋過程包括嘗試與回退兩個部分。它透過深度優先搜尋來嘗試各種選擇,當遇到不滿足約束條件的情況時,則撤銷上一步的選擇,退回到之前的狀態,並繼續嘗試其他選擇。嘗試與回退是兩個方向相反的操作。
- 回溯問題通常包含多個約束條件,它們可用於實現剪枝操作。剪枝可以提前結束不必要的搜尋分支,大幅提升搜尋效率。
- 回溯演算法主要可用於解決搜尋問題和約束滿足問題。組合最佳化問題雖然可以用回溯演算法解決,但往往存在效率更高或效果更好的解法。
- 全排列問題旨在搜尋給定集合元素的所有可能的排列。我們藉助一個陣列來記錄每個元素是否被選擇,剪掉重複選擇同一元素的搜尋分支,確保每個元素只被選擇一次。
- 在全排列問題中,如果集合中存在重複元素,則最終結果會出現重複排列。我們需要約束相等元素在每輪中只能被選擇一次,這通常藉助一個雜湊表來實現。
- 子集和問題的目標是在給定集合中找到和為目標值的所有子集。集合不區分元素順序,而搜尋過程會輸出所有順序的結果,產生重複子集。我們在回溯前將資料進行排序,並設定一個變數來指示每一輪的走訪起始點,從而將生成重複子集的搜尋分支進行剪枝。
- 對於子集和問題,陣列中的相等元素會產生重複集合。我們利用陣列已排序的前置條件,透過判斷相鄰元素是否相等實現剪枝,從而確保相等元素在每輪中只能被選中一次。
- $n$ 皇后問題旨在尋找將 $n$ 個皇后放置到 $n \times n$ 尺寸棋盤上的方案,要求所有皇后兩兩之間無法攻擊對方。該問題的約束條件有行約束、列約束、主對角線和次對角線約束。為滿足行約束,我們採用按行放置的策略,保證每一行放置一個皇后。
- 列約束和對角線約束的處理方式類似。對於列約束,我們利用一個陣列來記錄每一列是否有皇后,從而指示選中的格子是否合法。對於對角線約束,我們藉助兩個陣列來分別記錄該主、次對角線上是否存在皇后;難點在於找處在到同一主(副)對角線上格子滿足的行列索引規律。
### Q & A
**Q**:怎麼理解回溯和遞迴的關係?
總的來看,回溯是一種“演算法策略”,而遞迴更像是一個“工具”。
- 回溯演算法通常基於遞迴實現。然而,回溯是遞迴的應用場景之一,是遞迴在搜尋問題中的應用。
- 遞迴的結構體現了“子問題分解”的解題範式,常用於解決分治、回溯、動態規劃(記憶化遞迴)等問題。