回溯演算法原理#
回溯法(Backtracking)本質上是一種帶有**剪枝(Pruning)**功能的深度優先搜尋(DFS)。
核心概念#
回溯法的精髓:嘗試 ➡️ 檢查 ➡️ 遞迴 ➡️ 失敗則撤銷
當發現當前選擇無法得到有效解時,「回溯」到上一步嘗試其他選擇。
回溯程式碼模板#
flowchart TD
A[開始] --> B{滿足結束條件?}
B -->|是| C[記錄結果並返回]
B -->|否| D[遍歷所有選擇]
D --> E{選擇有效?}
E -->|否| F[跳過 - 剪枝]
E -->|是| G[做選擇<br/>path.append]
G --> H[遞迴<br/>backtrack]
H --> I[撤銷選擇<br/>path.pop]
I --> D
F --> D
style G fill:#e8f5e9
style H fill:#e3f2fd
style I fill:#ffebeeval result = mutableListOf<List<Int>>()
fun backtrack(path: MutableList<Int>, choices: List<Int>) {
// 終止條件
if (滿足結束條件) {
result.add(ArrayList(path)) // 複製一份路徑存起來(不能直接存 path 本身)
return
}
for (choice in choices) {
// 剪枝:跳過無效選擇
if (!isValid(choice)) continue
// 做選擇
path.add(choice)
// 遞迴
backtrack(path, updatedChoices)
// 撤銷選擇(回溯)
path.removeAt(path.size - 1)
}
}
result.add(ArrayList(path))一定要複製一份。若直接result.add(path),存進去的只是同一個MutableList的參照,後續的add/removeAt會把已記錄的結果一起改掉。
「撤銷選擇」是回溯法最關鍵的一步! 若忘記恢復狀態,會導致後續搜尋誤判,遺漏正確解答。
經典案例:括號生成 ↗#
給定 n,生成所有合法的 n 對括號組合。
解題思路#
- 總長度:2n 個字元
- 每個位置:選擇
(或) - 剪枝規則:
- 左括號沒用完,隨時可加
- 右括號數量不能超過已用的左括號數量
程式碼實作#
fun generateParenthesis(n: Int): List<String> {
val result = mutableListOf<String>()
fun backtrack(left: Int, right: Int, path: String) {
// 終止條件:左右括號都用完
if (left == n && right == n) {
result.add(path) // path 是 String(不可變),直接存即可,無須複製
return
}
// 加左括號(只要還有剩)
if (left < n) {
backtrack(left + 1, right, "$path(")
}
// 加右括號(已用左括號 > 已用右括號)
if (right < left) {
backtrack(left, right + 1, "$path)")
}
}
backtrack(0, 0, "")
return result
}複雜度分析#
- 時間複雜度:
O(4ⁿ / √n),與卡特蘭數相關 - 空間複雜度:
O(n),遞迴深度
回溯 vs 暴力搜尋#
| 特性 | 暴力搜尋 | 回溯法 |
|---|---|---|
| 搜尋策略 | 窮舉所有組合 | 剪枝排除無效分支 |
| 時間複雜度 | O(2²ⁿ) | O(4ⁿ / √n) |
| 實用性 | 僅適用小規模 | 可處理中等規模 |
回溯法的效率取決於剪枝的力度。剪枝越精準,搜尋空間越小,效率越高。
回溯適用場景#
| 問題類型 | 典型範例 |
|---|---|
| 排列問題 | 全排列、字串排列 |
| 組合問題 | 子集、組合總和 |
| 搜尋問題 | N 皇后、數獨、迷宮 |
| 路徑問題 | 所有可能的路徑 |