回溯(Backtracking)
範圍 — 帶「復原」動作的系統性搜尋:choose/explore/un-choose 骨架、
start_idx的控制、跳過重複、剪枝,以及每個必會題型剛好一份的標準模板 — 大量的實作解答與 hard 級的帶狀態模板放在兩個衛星檔裡。 另見:backtrack_examples.md — 這些模板對應的 LC 實作解;backtrack_advanced.md — 用 Trie 剪枝的格子搜尋、運算式建構、刪除次數預算、記憶化切分;dfs.md — 沒有復原步驟的走訪;recursion.md — 遞迴的機制;tree_backtrack.md — 根到葉的路徑問題;dp.md — 什麼時候把搜尋記憶化比硬搜划算。
LeetCode 題目清單
總覽
回溯就是在決策樹上做暴力搜尋:每一步做一個選擇、往下遞迴,然後把選擇復原(「回溯」)再試下一個。 要產生所有子集/排列/組合,或是在限制條件下找出任一組可行解(N 皇后、數獨、單字搜尋), 第一個該想到的就是它。
關鍵性質
- 核心想法:
選擇 → 探索(遞迴)→ 取消選擇(復原) - 時間複雜度:指數級 —
O(b^d),b= 分支數,d= 決策樹深度 - 空間複雜度:
O(d)的遞迴深度(不算輸出的清單) - 什麼時候用:題目問所有/每一種/有幾種配置,或是要在限制下擺放/填滿/切分
- 最佳化路線:回溯(暴力)→ 加上剪枝 → 常常再進一步 → DP(把重疊子問題記憶化)
- 演算法:DFS + 遞迴
- 常用資料結構:
dict(去重用的 counter)、set(visited/限制條件)、array/list(路徑)
每個回溯都要追蹤的 3 件事
| 元素 | 意義 |
|---|---|
| 路徑(Route) | 到目前為止做過的選擇(也就是當前路徑) |
| 選擇清單(Choice list) | 此刻還能做的選擇 |
| 結束條件 | 決策樹的葉節點 — 記下路徑然後返回 |

上面那三列直接對應到 Template 1 —
path是路徑,for迴圈是選擇清單,最上面那個if是結束條件。
各題型的時間複雜度
| 題型 | 典型時間 | 空間(不含輸出) | 範例 |
|---|---|---|---|
| 子集 | O(2^n · n) | O(n) | LC 78, 90 |
| 排列 | O(n! · n) | O(n) | LC 46, 47 |
| 組合 | O(C(n,k) · k) | O(k) | LC 77 |
| 組合總和 | 指數級 | O(target / min) | LC 39, 40 |
| 切分 | O(2^n · n) | O(n) | LC 131 |
| N 皇后 | O(n!) | O(n) | LC 51 |
後面那個
· n/· k是把每條合法路徑複製進結果的成本。 剪枝砍掉分支、也砍掉常數項,但不會改變最壞情況的量級。
參考資料
- labuladong — Backtracking framework
- labuladong — Two views of backtracking
- LeetCode — A general approach to backtracking (Java)
辨識
輸入規模是第一個線索:回溯是指數時間,所以限制條件都很小 (限制尺規見 complexity_cheatsheet.md)。
| 題目敘述裡的訊號 | 限制條件線索 | 該拿 | 不是這個的情況 |
|---|---|---|---|
| 「回傳 k 個數的所有組合」、「找出所有合法的選法」 | n ≤ 20 | Template 1 選擇 → 探索 → 取消選擇 | 只問個數或是非 → dp.md |
| 「同一個數字可以被選無限次」vs「每個數字只能用一次」 | target ≤ 500 | Template 2 i vs i + 1 |
順序有差([1, 3] ≠ [3, 1])→ Template 5 |
| 「回傳所有可能的子集」、「冪集合」 | n ≤ 10 | Template 3 每個節點都記錄 | 輸入有重複 → Template 4 |
| 「可能包含重複」、「不能有重複的子集/組合」 | n ≤ 10 | Template 4 排序 + 同層跳過 | |
| 「所有可能的排列」、「每一種順序」 | n ≤ 8 | Template 5 used[],每層從 0 開始 |
答案是集合而不是順序 → Template 3 |
| 「從 1…n 選 k 個數的所有組合」 | C(n, k) 小 | Template 6 長度關卡 | |
| 「總和等於 target 的所有不重複組合」 | n ≤ 100,target ≤ 30 | Template 7 排序 + break + 跳過 |
只問方法數 → knapsack.md |
| 「把 s 切分成每段都是回文/合法 IP 段/單字」 | n ≤ 16 | Template 8 選擇切在哪 | 只問能不能、或最少切幾刀 → dp.md |
| 「相鄰格子依序組成」、「同一格不能用超過一次」 | m, n ≤ 6 | Template 9 標記、遞迴、還原 | 同一個盤面查很多字 → backtrack_advanced.md Trie |
| 「擺 n 個互不攻擊的皇后」 | n ≤ 9 | Template 10 每層一列 + 3 個集合 | |
| 「填滿格子,使每列、每行、每宮⋯」 | 固定 9×9 | Template 11 每層一格 | |
每個選擇都帶一條限制(「perm[i] 能被 i 整除」) |
n ≤ 15 | Template 12 遞迴前就檢查 | 同一個狀態會重複出現 → 位元遮罩 DP,dp_bitmask.md |
| 「分成總和相等的 k 個子集」、「拼成正方形」 | n ≤ 16 | Template 13 k 個桶,由大到小 | |
| 在樹或格子上同時往下帶著路徑和累計和 | Template 14 只復原可變的那部分 |
題型分類
四種形狀幾乎涵蓋所有回溯題。下面這張分類表決定你該拿哪份模板; 程式碼在 Templates & Algorithms, 完整解答在 backtrack_examples.md。
| # | 形狀 | 要 start_idx 嗎? |
標準模板 | LC |
|---|---|---|---|---|
| 1 | 子集(Subsets) | ✅ i + 1 |
Template 3 / Template 4 | 78, 90 |
| 2 | 排列(Permutations) | ❌ used[] |
Template 5 | 46, 47 |
| 3 | 組合(Combinations) | ✅ i + 1 |
Template 6 | 77, 216 |
| 4 | 組合總和 | ✅ i(可重複用)或 i + 1 |
Template 7 | 39, 40 |
| 5 | 切分 | ✅ 在子字串/桶上用 i + 1 |
Template 8 / Template 13 | 131, 698, 473 |
| 6 | 格子/單字搜尋 | ❌ 標記格子再還原 | Template 9 | 79, 980, 1219 |
| 7 | 限制條件滿足 | ❌ 每層處理一列/一格 | Template 10 / Template 11 | 51, 37 |
| 8 | 括號/字串建構 | ❌ 用計數器當限制 | backtrack_examples.md | 20, 22, 93 |
各型態筆記
每種形狀的隨手筆記 — 迴圈主體的口訣,以及會踩到的坑。
-
型態 1) :
Subsets(子集)- 題目 : LC 78, 90, 17
- 代碼隨想錄(leetcode-master)— 0078.子集
- 呼叫輔助函式 +
start_idx+ for 迴圈 +pop() - 樹上的每個節點都記錄;
start_idx把已經用過的數字排除在外 - 不需要
contains檢查:下一層的start_idx傳i + 1,每個元素就最多用一次(傳i會重複使用)。排列用visited[]記錄用過的索引,不是用值去contains,所以[1,1]兩個 1 都能用;LC 47 的重複值再另外做同層跳過
-
Subsets II- LC 90
start_idx+ 回溯 + 同層去重- 去重:排序後跳過
i > start and a[i] == a[i-1],或改成對計數器迴圈
-
型態 2) :
Permutations(排列)- 題目 : LC 46, 47
- 呼叫輔助函式 +
used[]+pop() - 每層都從 0 開始迴圈;
used[]把已經在路徑上的數字排除在外 - 不需要用 start_idx
-
型態 3) :
Combinations(組合)- LC 77
- 呼叫輔助函式 +
start_idx+ for 迴圈 +len(path) == k就停 +pop()
-
型態 4) :
Others -
括號(Parentheses)
- LC 20, LC 22
模板與演算法
模板對照表
| 模板 | 形狀 | 迴圈/下一個索引 | 復原 | LC |
|---|---|---|---|---|
| 1 | 選擇 → 探索 → 取消選擇 | for i in start..n |
path.pop() |
216 |
| 2 | start_idx 控制 |
i(可重用)vs i + 1(只用一次) |
path.pop() |
39 vs 40 |
| 3 | 子集 | 每個節點都記錄,用 i + 1 |
path.pop() |
78 |
| 4 | 子集 II | i > start && a[i] == a[i-1] 就跳過 |
path.pop() |
90 |
| 5 | 排列 | 掃全部,跳過 used[i] |
pop() + used[i] = False |
46, 47 |
| 6 | 組合 | len(path) == k 就停 |
pop() |
77 |
| 7 | 組合總和 | i 可重用/i + 1 只用一次,加 break 剪枝 |
pop() |
39, 40 |
| 8 | 回文切分 | for end in start..n-1,過回文這關 |
pop() |
131 |
| 9 | 格子/單字搜尋 | 每格往四個方向走 | 還原該格 | 79 |
| 10 | N 皇后 | 每層放一列,三個限制集合 | 三個集合都要移除 | 51 |
| 11 | 數獨 | 每層填一個空格,列/行/宮格集合 | 重設格子與集合 | 37 |
| 12 | 在選擇時剪枝 | 遞迴前先檢查,排序過的候選用 break |
— | 526 |
| 13 | k 桶切分 | 對每個桶試一次,先由大到小排序 | buckets[i] -= v |
698, 473 |
| 14 | 可變 vs 不可變狀態 | — | 只有可變狀態要復原 | 113, 1740 |
Template 1: choose → explore → un-choose — LC 216 優先度 5/5 — 必備 — 幾乎每一輪面試都會出現
辨識 — 「回傳所有組合/每一種合法的選法⋯」、「找出任一個滿足⋯的安排」。輸入很小: 子集題 n ≤ 10–20(2^n),排列題 n ≤ 8–10(n!)。這種上限就是題目在告訴你:指數搜尋是預期中的解法。
關鍵 — path 永遠恰好存著決策樹上從根走到目前節點所做的選擇:每一次 append
都在遞迴呼叫返回後配一次 pop,所以迴圈換到下一個選擇時,路徑又回到父節點的那條。
每個回溯都要追蹤的三件事直接對應到程式碼:path 是路徑,for 迴圈是選擇清單,
最上面那個 if 是結束條件。
# python
# LC 216 - Combination Sum III
# IDEA: choose a digit, explore with the digits after it, un-choose; record at the leaf
# time = O(C(9,k) * k), space = O(k) at most C(9,k) leaves, O(k) to copy each
def combinationSum3(k, n):
res = []
path = []
def backtrack(start, remain):
if len(path) == k: # end condition: a leaf of the decision tree
if remain == 0:
res.append(path[:]) # copy: path keeps changing after this line
return
for digit in range(start, 10): # choice list: the digits not tried yet, in order
if digit > remain: # digits ascend, so every later one is too big
break
path.append(digit) # 1) choose
backtrack(digit + 1, remain - digit) # 2) explore (digit + 1 -> each used once)
path.pop() # 3) un-choose
backtrack(1, n)
return res
// java
// LC 216 - Combination Sum III
// IDEA: choose a digit, explore with the digits after it, un-choose; record at the leaf
// time = O(C(9,k) * k), space = O(k)
public List<List<Integer>> combinationSum3(int k, int n) {
List<List<Integer>> res = new ArrayList<>();
backtrack(1, n, k, new ArrayList<>(), res);
return res;
}
private void backtrack(int start, int remain, int k, List<Integer> path, List<List<Integer>> res) {
if (path.size() == k) { // end condition
if (remain == 0) {
res.add(new ArrayList<>(path)); // copy, not the live reference
}
return;
}
for (int digit = start; digit <= 9; digit++) {
if (digit > remain) { // ascending digits: the rest are too big
break;
}
path.add(digit); // 1) choose
backtrack(digit + 1, remain - digit, k, path, res); // 2) explore
path.remove(path.size() - 1); // 3) un-choose
}
}
複雜度
- 時間 O(C(9,k) · k):
for digit迴圈配上digit + 1,1…9 的每個 k 元子集最多走到一次, 而每個葉節點的path[:]花 O(k)。一般而言回溯是 O(b^d · w):分支數b來自for迴圈, 深度d來自結束條件,w是在葉節點做的工作。 - 空間 O(k):遞迴最多
k層,path最多存k個數字(輸出的 list 不算)。
陷阱
res.append(path)沒有複製:每一筆都是同一個 list,最後一次pop()之後它就空了, 所以 LC 216k = 3, n = 7回傳[[]]。- 忘了
pop():下一條分支接著上一條分支的路徑繼續長,路徑只會變長,再也不會在對的總和上 剛好長度k——k = 3, n = 9回傳[]。 break之所以合法,只因為選擇是排序過的(range遞增)。在沒排序的候選上,它會略過 後面合法的選擇;改用continue,或先排序。
練習 — LC 216 ⭐ · LC 77 (沒有總和,只看長度 k) · LC 17 (選擇清單是該數字對應的字母) · LC 22 (選擇清單是「左括號或右括號」,由計數把關)
Template 2: start_idx — i vs i + 1 — LC 39 優先度 5/5 — 必備 — 幾乎每一輪面試都會出現
辨識 — 「組合」、「子集」、「每個數字只能用一次」或「同一個數字可以被選無限次」,
而且 [2, 3] 跟 [3, 2] 算同一個答案。
關鍵 — 迴圈從 start_idx 開始,所以一條路徑上的選擇永遠依索引順序排列:每個集合只會以
一種順序被建出來一次。下一層傳什麼起點決定能不能重用 —— i 讓當前元素還能再選(無限),
i + 1 跳過它(只用一次)。
| 傳什麼 | 意義 | 類比 | 例子 |
|---|---|---|---|
i |
同一個元素可以再用一次 | 完全背包(數量無限) | LC 39 (Combination Sum);LC 518(Coin Change II)是它的 DP 對應題,只計數不列舉 |
i + 1 |
每個元素最多用一次 | 0/1 背包、子集 | LC 40 (Combination Sum II), LC 78/90 (Subsets), LC 131, LC 494 |
# python
# LC 39 - Combination Sum
# IDEA: pass i (not i + 1) so a candidate can be picked again; start_idx keeps each set in one order
# time = O(n^(T/M + 1)), space = O(T/M) T = target, M = smallest candidate
def combinationSum(candidates, target):
candidates.sort() # sorted, so the first too-big candidate ends the loop
res = []
path = []
def backtrack(start, remain):
if remain == 0:
res.append(path[:])
return
for i in range(start, len(candidates)):
if candidates[i] > remain: # every candidate after i is bigger still
break
path.append(candidates[i])
backtrack(i, remain - candidates[i]) # i: the same candidate stays available
path.pop()
backtrack(0, target)
return res
// java
// LC 39 - Combination Sum
// IDEA: pass i (not i + 1) so a candidate can be picked again; start_idx keeps each set in one order
// time = O(n^(T/M + 1)), space = O(T/M)
public List<List<Integer>> combinationSum(int[] candidates, int target) {
Arrays.sort(candidates);
List<List<Integer>> res = new ArrayList<>();
backtrack(candidates, 0, target, new ArrayList<>(), res);
return res;
}
private void backtrack(int[] candidates, int start, int remain, List<Integer> path, List<List<Integer>> res) {
if (remain == 0) {
res.add(new ArrayList<>(path));
return;
}
for (int i = start; i < candidates.length; i++) {
if (candidates[i] > remain) { // sorted: the rest are bigger
break;
}
path.add(candidates[i]);
backtrack(candidates, i, remain - candidates[i], path, res); // i: reuse allowed
path.remove(path.size() - 1);
}
}
複雜度
- 時間 O(n^(T/M + 1)):樹的每一層最多有
n個選擇(for i迴圈),而因為傳回去的是i, 深度要等remain用完才結束 —— 最多選T/M次最小的候選。 - 空間 O(T/M):遞迴深度與
path長度都以T/M為上限。
試跑 — candidates = [2, 3, 6, 7]、target = 7;每個節點是 backtrack(start, remain),
break 會在第一個比 remain 大的候選處結束迴圈:
backtrack(0, 7)
├─ 2 → backtrack(0, 5)
│ ├─ 2 → backtrack(0, 3)
│ │ ├─ 2 → backtrack(0, 1) 2 > 1: break
│ │ ├─ 3 → backtrack(1, 0) ✅ [2, 2, 3]
│ │ └─ 6 > 3: break
│ ├─ 3 → backtrack(1, 2) 3 > 2: break
│ └─ 6 > 5: break
├─ 3 → backtrack(1, 4)
│ ├─ 3 → backtrack(1, 1) 3 > 1: break
│ └─ 6 > 4: break
├─ 6 → backtrack(2, 1) 6 > 1: break
└─ 7 → backtrack(3, 0) ✅ [7]
start 只會往前走(0 → 1 → 2 → 3),所以不會再回頭用更前面的候選,也不會有組合出現兩次。
把 i 改傳 i + 1,這就變成 LC 40。
陷阱
- 在 LC 39 傳
i + 1:[2, 3, 6, 7], 7會漏掉[2, 2, 3],因為 2 不能再重複。 - 傳
0而不是start:[2, 3]和[3, 2]都會被建出來,LC 39[2, 3, 6, 7], 7回傳[[2, 2, 3], [2, 3, 2], [3, 2, 2], [7]]。 - 在 LC 78 傳
i:同一個元素被無限次加進去,遞迴永遠不會結束。
不適用時 — 選擇的順序有差([1, 3] 和 [3, 1] 是不同答案):那是排列,追蹤的是
used[] 而不是 start_idx(Template 5)。LC 377(Combination Sum IV)
也一樣:順序有差,所以每一層都從 0 開始迴圈;而且它只要計數,是 DP 而不是回溯。
練習 — LC 39 ⭐ · LC 40 (i + 1 加上跳過重複) · LC 216 (固定長度 k) · LC 518 (只要個數:背包 DP)
Template 3: Subsets — LC 78 優先度 5/5 — 必備 — 幾乎每一輪面試都會出現
辨識 — 「回傳所有可能的子集(冪集合)」、「解集合不能包含重複的子集」;元素互不相同。 n ≤ 10 → 2^n · n 大約 10⁴。
關鍵 — 決策樹上的每個節點都是一個子集,不只葉節點:在每次呼叫進入時就記錄 path
(前序)。i + 1 讓每個元素最多用一次,索引順序讓每個子集剛好出現一次。
# python
# LC 78 - Subsets
# IDEA: record the path at EVERY node (pre-order); pass i + 1 so each element is used once
# time = O(2^n * n), space = O(n) 2^n subsets, O(n) to copy each
def subsets(nums):
res = []
path = []
def backtrack(start):
res.append(path[:]) # every node is an answer, the empty set included
for i in range(start, len(nums)):
path.append(nums[i])
backtrack(i + 1)
path.pop()
backtrack(0)
return res
// java
// LC 78 - Subsets
// IDEA: record the path at EVERY node (pre-order); pass i + 1 so each element is used once
// time = O(2^n * n), space = O(n)
public List<List<Integer>> subsets(int[] nums) {
List<List<Integer>> res = new ArrayList<>();
backtrack(nums, 0, new ArrayList<>(), res);
return res;
}
private void backtrack(int[] nums, int start, List<Integer> path, List<List<Integer>> res) {
res.add(new ArrayList<>(path)); // every node is an answer
for (int i = start; i < nums.length; i++) {
path.add(nums[i]);
backtrack(nums, i + 1, path, res); // i + 1: each element used once
path.remove(path.size() - 1);
}
}
第二種解法,留下來是因為它畫的是另一棵樹:每個元素做一次二元決定 —— 放或不放,答案只在葉節點。 它用同樣的代價產生同樣的 2^n 個子集,而 LC 784 和每一題「拿/不拿」的 DP 都是從這個形狀出發。
# python
# LC 78 - Subsets (include / exclude)
# IDEA: at depth i decide nums[i]: take it, recurse, undo, then recurse without it; record at the leaf
# time = O(2^n * n), space = O(n)
def subsets(nums):
res = []
path = []
def backtrack(i):
if i == len(nums): # every element decided: one subset
res.append(path[:])
return
path.append(nums[i]) # include nums[i]
backtrack(i + 1)
path.pop()
backtrack(i + 1) # exclude nums[i]
backtrack(0)
return res
複雜度
- 時間 O(2^n · n):樹上每個子集對應一個節點,共 2^n 個,每個節點的
path[:]最多複製n個元素。 - 空間 O(n):遞迴深度與
path都最多n(不含輸出)。
試跑
nums = [1, 2, 3] record on entry, then try i = start .. n-1
start=0 [] -> [1] -> start=1 [1] -> [1,2] -> start=2 [1,2] -> [1,2,3]
-> [1,3]
-> [2] -> start=2 [2] -> [2,3]
-> [3]
res = [], [1], [1,2], [1,2,3], [1,3], [2], [2,3], [3]
陷阱
- 為了「避免重複」加上
res.contains(path)/path not in res檢查:start_idx已經保證每個子集 只出現一次,而這個掃描讓每次記錄都變成 O(2^n · n)。 - 加上
path.contains(nums[i])檢查:那是排列的寫法;i + 1已經讓每個元素最多用一次。 - 只在葉節點記錄(
if start == len(nums))只會留下以最後一個元素結尾的子集:[1, 2, 3]回傳[[1, 2, 3], [1, 3], [2, 3], [3]]。
不適用時 — 輸入有重複([1, 2, 2]):排序後跳過同層的重複,
Template 4。
練習 — LC 78 ⭐ · LC 90 (有重複) · LC 1863 (所有子集 XOR 的總和) · LC 2597 (選中的元素之間有限制)
Template 4: Subsets II (skip same-level duplicates) — LC 90 優先度 5/5 — 必備 — 幾乎每一輪面試都會出現
辨識 — 跟 Template 3 一樣的敘述,但「nums 可能包含重複」而且「不能有重複的子集」。n ≤ 10。
關鍵 — 排序後,相等的值會擠在一起。兩個兄弟節點選了同一個值,會長出一模一樣的子樹,
所以每一層只拿一串相同值裡的第一個(i > start and nums[i] == nums[i-1]);更深的一層
仍然可以拿下一個複本。
# python
# LC 90 - Subsets II
# IDEA: sort, then skip a value equal to the previous one at the SAME level (i > start)
# time = O(2^n * n), space = O(n) the O(n log n) sort is dominated
def subsetsWithDup(nums):
nums.sort() # duplicates become adjacent
res = []
path = []
def backtrack(start):
res.append(path[:])
for i in range(start, len(nums)):
if i > start and nums[i] == nums[i - 1]: # same level, same value: same subtree
continue
path.append(nums[i])
backtrack(i + 1)
path.pop()
backtrack(0)
return res
// java
// LC 90 - Subsets II
// IDEA: sort, then skip a value equal to the previous one at the SAME level (i > start)
// time = O(2^n * n), space = O(n)
public List<List<Integer>> subsetsWithDup(int[] nums) {
Arrays.sort(nums);
List<List<Integer>> res = new ArrayList<>();
backtrack(nums, 0, new ArrayList<>(), res);
return res;
}
private void backtrack(int[] nums, int start, List<Integer> path, List<List<Integer>> res) {
res.add(new ArrayList<>(path));
for (int i = start; i < nums.length; i++) {
if (i > start && nums[i] == nums[i - 1]) { // same level, same value: skip
continue;
}
path.add(nums[i]);
backtrack(nums, i + 1, path, res);
path.remove(path.size() - 1);
}
}
複雜度
- 時間 O(2^n · n):最壞情況(所有值都不同)樹就是 Template 3 那棵;跳過只會刪掉節點。
sort是 O(n log n),被吃掉了。 - 空間 O(n):遞迴深度與
path。
陷阱
- 用
i > 0而不是i > start:在path = [1, 2]、start = 2 時,第二個2被跳過,所以[1, 2, 2]的結果裡沒有[1, 2, 2]。 - 沒有
sort():[2, 1, 2]的兩個 2 分開了,[2, 1]和[1, 2]都會出現。
不適用時 — 答案是有重複元素的排列(LC 47):跳過要用 used[] 的寫法 !used[i-1],見下方。
練習 — LC 90 ⭐ · LC 40 (同樣的跳過,加上 target) · LC 47 (排列版本的跳過) · LC 491 (輸入沒排序:改用每層一個 set)
跳過重複 — 同一層才跳的規則 優先度 5/5 — 必備 — 幾乎每一輪面試都會出現
我們不是每個重複值都跳 — 只跳出現在同一層遞迴的重複值。兩個兄弟選了相等的值,會長出
一模一樣的子樹;父節點和子節點選了相等的值,建出的是 [2, 2],那是另一個答案。同一條規則的三種寫法:
| 寫法 | 什麼時候跳 | 要排序嗎? | 用在 |
|---|---|---|---|
| 以索引為準 | i > start and a[i] == a[i-1] |
要 | LC 40, LC 90 |
| 以 visited 為準 | i > 0 and a[i] == a[i-1] and not used[i-1] |
要 | LC 47, LC 996 |
| 以計數器為準 | 對不同的值迴圈,count[x] > 0 時拿一個 |
不用 | LC 47 |
在以 visited 為準的寫法裡,not used[i-1] 代表前一個複本在這一層試過、而且已經復原了,
所以 a[i] 會重複它的子樹;used[i-1] 代表它在目前路徑上、高一層,a[i] 是它合法的下一個複本。
Template 5: Permutations — LC 46 優先度 5/5 — 必備 — 幾乎每一輪面試都會出現
辨識 — 「回傳所有可能的排列」、「每一種安排/順序」、「可以任意順序回傳」;
[1, 2] 和 [2, 1] 是不同答案。n ≤ 6–8 → n! · n 沒問題。
關鍵 — 順序有差,所以每一層都可以選任何還不在路徑上的元素:迴圈從 0 開始,
防止元素出現兩次的是 used[i](不是 start_idx)。used 在選擇時設定、取消選擇時清掉,
所以它永遠恰好描述 path。
# python
# LC 46 - Permutations
# IDEA: BACKTRACK with a `used` array: loop from 0 every level, skip what is already on the path
# time = O(n! * n), space = O(n) n! permutations, O(n) to copy each
def permute(nums):
res = []
path = []
used = [False] * len(nums)
def backtrack():
if len(path) == len(nums):
res.append(path[:]) # copy the path
return
for i in range(len(nums)):
if used[i]:
continue
used[i] = True # choose
path.append(nums[i])
backtrack() # explore
path.pop() # un-choose
used[i] = False
backtrack()
return res
// java
// LC 46 - Permutations
// IDEA: BACKTRACK with a `used` array: loop from 0 every level, skip what is already on the path
// time = O(n! * n), space = O(n)
public List<List<Integer>> permute(int[] nums) {
List<List<Integer>> res = new ArrayList<>();
backtrack(nums, new boolean[nums.length], new ArrayList<>(), res);
return res;
}
private void backtrack(int[] nums, boolean[] used, List<Integer> path, List<List<Integer>> res) {
if (path.size() == nums.length) {
res.add(new ArrayList<>(path));
return;
}
for (int i = 0; i < nums.length; i++) { // no start index: order matters
if (used[i]) {
continue;
}
used[i] = true; // choose
path.add(nums[i]);
backtrack(nums, used, path, res); // explore
path.remove(path.size() - 1); // un-choose
used[i] = false;
}
}
複雜度
- 時間 O(n! · n):樹的第
d層試n - d個沒用過的元素,所以有 n! 個葉節點,每個複製花 O(n)。for i迴圈在每個節點都掃過全部n個索引。 - 空間 O(n):
used、path與遞迴深度。
試跑 — nums = [1, 2, 3];每一層都掃過全部的 nums、跳過已用的,所以分支數從 3 → 2 → 1
縮小,答案只出現在葉節點:
dfs([]) used={}
├─ 1 → dfs([1]) used={1}
│ ├─ 2 → dfs([1,2]) → 3 → [1,2,3] ✅
│ └─ 3 → dfs([1,3]) → 2 → [1,3,2] ✅
├─ 2 → dfs([2]) used={2}
│ ├─ 1 → [2,1] → 3 → [2,1,3] ✅
│ └─ 3 → [2,3] → 1 → [2,3,1] ✅
└─ 3 → dfs([3]) used={3}
├─ 1 → [3,1] → 2 → [3,1,2] ✅
└─ 2 → [3,2] → 1 → [3,2,1] ✅
陷阱
- 用
path.contains(nums[i])取代used[i]:每個選擇多一次 O(n) 檢查,而且值一重複就錯 ——[1, 1, 2]永遠放不下第二個1,回傳[]。 - 記錄前先做
res.contains(path)檢查:每個葉節點一次 O(n! · n) 的掃描,而且元素互不相同時根本不需要。 - 清掉
used[i]卻沒 pop(或反過來):used不再描述path,後面的排列會變太長或少一個元素。
不適用時 — 答案是集合而不是順序([1, 2] = [2, 1]):傳 start_idx,
Template 3 / Template 6。
練習 — LC 46 ⭐ · LC 47 (有重複:見下方) · LC 784 (每個字母兩種選擇) · LC 526 (在選擇時剪枝:Template 12) · LC 996 (相鄰兩數的和必須是平方數)
有重複元素的排列 — LC 47
關鍵 — 對
Counter裡不同的值迴圈,而不是對索引迴圈,所以兩個相等的值永遠不會 是兄弟,同一個排列也不會被建兩次。
歸約到:Template 5 —— 用 count[value] 取代
used[i]:選的時候減一,取消選擇時加回去。排序 + used[] 的另一種寫法,就是
跳過重複裡以 visited 為準的那一列。
# python
# LC 47 - Permutations II
# IDEA: BACKTRACK over a Counter of distinct values (decrement on choose, increment on un-choose)
# time = O(n! * n), space = O(n) duplicates only prune, they do not change the bound
from collections import Counter
def permuteUnique(nums):
res = []
path = []
count = Counter(nums)
def backtrack():
if len(path) == len(nums):
res.append(path[:])
return
for value in count: # each DISTINCT value once per level
if count[value] == 0:
continue
path.append(value)
count[value] -= 1
backtrack()
path.pop() # undo both: the path and the count
count[value] += 1
backtrack()
return res
Template 6: Combinations — LC 77
辨識 — 「回傳從 1…n 中選 k 個數的所有組合」。長度固定;順序不重要。C(n, k) 小,例如 n ≤ 20。
關鍵 — 就是 Template 1 加上結束條件 len(path) == k:start_idx 讓每個組合都維持遞增順序,
所以每個組合剛好被建一次,而且一湊滿 k 個數遞迴就停。
# python
# LC 77 - Combinations
# IDEA: start_idx + a size gate, and never start a branch that cannot still reach k numbers
# time = O(C(n,k) * k), space = O(k) one copy per combination
def combine(n, k):
res = []
path = []
def backtrack(start):
if len(path) == k:
res.append(path[:]) # path[:] and list(path) are both a copy
return
need = k - len(path)
for i in range(start, n - need + 2): # leave room for the need - 1 numbers after i
path.append(i)
backtrack(i + 1)
path.pop()
backtrack(1)
return res
// java
// LC 77 - Combinations
// IDEA: start_idx + a size gate, and never start a branch that cannot still reach k numbers
// time = O(C(n,k) * k), space = O(k)
public List<List<Integer>> combine(int n, int k) {
List<List<Integer>> res = new ArrayList<>();
backtrack(1, n, k, new ArrayList<>(), res);
return res;
}
private void backtrack(int start, int n, int k, List<Integer> path, List<List<Integer>> res) {
if (path.size() == k) {
res.add(new ArrayList<>(path));
return;
}
int need = k - path.size();
for (int i = start; i <= n - need + 1; i++) { // leave room for the need - 1 numbers after i
path.add(i);
backtrack(i + 1, n, k, path, res);
path.remove(path.size() - 1);
}
}
複雜度
- 時間 O(C(n,k) · k):有 C(n,k) 個葉節點,每次複製 O(k)。
i有上限,所以走到的每個節點都在通往某個葉節點的路上,內部節點最多再多一個 k 倍。 - 空間 O(k):遞迴在深度
k就停。
陷阱
- 在從 1 開始編號的題目寫
range(start, n):n永遠選不到,所以n = 4, k = 2會漏掉[1, 4]、[2, 4]、[3, 4]。 - 拿掉
i的上限:迴圈會開出永遠湊不滿k個的分支。k = n時那就是每一個子集,為了一個答案呼叫 2ⁿ 次;有上限時只剩一條路徑。
練習 — LC 77 ⭐ · LC 216 (再加一個總和限制) · LC 17 (每個位置一份選擇清單) · LC 1286 (以同樣順序做成迭代器)
Template 7: Combination Sum — LC 39 / LC 40 優先度 4/5 — 高價值 — 這裡有缺口就會掉關
辨識 — 「找出 candidates 中所有總和等於 target 的不重複組合」。LC 39:「同一個數字可以被選無限次」。
LC 40:「每個數字只能用一次」,而且輸入可能有重複。
關鍵 — sort() 之後,兩種剪枝都是安全的:某個候選一超過就 break(後面的都更大),以及同一層
跳過跟左邊鄰居相等的值(它會重複鄰居的子樹)。LC 39 傳 i;LC 40 傳 i + 1 —— LC 39 的程式碼在
Template 2;下面這份是 LC 40。
# python
# LC 40 - Combination Sum II
# IDEA: sort; skip same-level duplicates; break once the sum overshoots; pass i + 1 (use once)
# time = O(2^n * n), space = O(n) each candidate used at most once
def combinationSum2(candidates, target):
candidates.sort()
res = []
path = []
def backtrack(start, remain):
if remain == 0:
res.append(path[:])
return
for i in range(start, len(candidates)):
if i > start and candidates[i] == candidates[i - 1]: # same level, same value
continue
if candidates[i] > remain: # sorted: everything after i overshoots too
break
path.append(candidates[i])
backtrack(i + 1, remain - candidates[i]) # i + 1: each candidate once
path.pop()
backtrack(0, target)
return res
// java
// LC 40 - Combination Sum II
// IDEA: sort; skip same-level duplicates; break once the sum overshoots; pass i + 1 (use once)
// time = O(2^n * n), space = O(n)
public List<List<Integer>> combinationSum2(int[] candidates, int target) {
Arrays.sort(candidates);
List<List<Integer>> res = new ArrayList<>();
backtrack(candidates, 0, target, new ArrayList<>(), res);
return res;
}
private void backtrack(int[] candidates, int start, int remain, List<Integer> path, List<List<Integer>> res) {
if (remain == 0) {
res.add(new ArrayList<>(path));
return;
}
for (int i = start; i < candidates.length; i++) {
if (i > start && candidates[i] == candidates[i - 1]) { // skip duplicates
continue;
}
if (candidates[i] > remain) { // sorted: stop
break;
}
path.add(candidates[i]);
backtrack(candidates, i + 1, remain - candidates[i], path, res);
path.remove(path.size() - 1);
}
}
複雜度
- LC 40 是 時間 O(2^n · n):用
i + 1時每個候選只有選或不選,所以最多 2^n 條路徑,每條複製 O(n)。 LC 39 能重複用,所以改成 O(n^(T/M + 1))(Template 2)。 - 空間 O(n):遞迴深度與
path(LC 39:O(T/M))。
陷阱
- 沒有
sort()就break:[10, 1, 2, 7, 6, 1, 5], 8一碰到10就停,回傳[]。 - LC 40 沒有跳過重複:同一個輸入裡的兩個
1會讓[1, 2, 5]和[1, 7]各出現兩次。 - 用
i > 0而不是i > start來跳,會漏掉[1, 1, 6]。
練習 — LC 39 ⭐ · LC 40 ⭐ · LC 216 (固定長度 k,數字 1–9) · LC 377 (順序有差而且只問個數:DP)
Template 8: Palindrome Partitioning — LC 131 優先度 5/5 — 必備 — 幾乎每一輪面試都會出現
辨識 — 「把 s 切分成每一段都是回文/合法 IP 段/字典裡的單字;回傳所有可能的切法」。n ≤ 16。
關鍵 — 這一頁其他每份模板選的都是要拿哪個元素,切分題選的則是要切在哪裡。站在
start 上,選擇清單就是剩餘字串的每一個前綴 s[start..end];只有通過關卡的才拿,然後從
end + 1 往下遞迴。走到 start == n 代表每一段都過了關卡,path 就是一組完整解。
這個重新框定就是整題的全部。長度 n 的字串有 n - 1 個縫隙,一個切分其實就是挑一個要切開的
縫隙子集 —— 所以 LC 131 是 LC 78 Subsets 換了層皮,只是多了
is_palindrome 這個過濾器。
# python
# LC 131 - Palindrome Partitioning
# IDEA: choose WHERE TO CUT: try every prefix s[start..end] that is a palindrome, recurse from end + 1
# time = O(2^n * n), space = O(n) 2^(n-1) cut sets, O(n) per palindrome check + copy
def partition(s):
n = len(s)
res = []
path = []
def is_palindrome(lo, hi):
while lo < hi:
if s[lo] != s[hi]:
return False
lo += 1
hi -= 1
return True
def backtrack(start):
if start == n: # no string left: every piece passed the gate
res.append(path[:])
return
for end in range(start, n): # every prefix, including the whole rest
if is_palindrome(start, end):
path.append(s[start:end + 1])
backtrack(end + 1) # recurse on the REST, from end + 1
path.pop()
backtrack(0)
return res
// java
// LC 131 - Palindrome Partitioning
// IDEA: choose WHERE TO CUT: try every prefix s[start..end] that is a palindrome, recurse from end + 1
// time = O(2^n * n), space = O(n)
public List<List<String>> partition(String s) {
List<List<String>> res = new ArrayList<>();
backtrack(s, 0, new ArrayList<>(), res);
return res;
}
private void backtrack(String s, int start, List<String> path, List<List<String>> res) {
if (start == s.length()) {
res.add(new ArrayList<>(path));
return;
}
for (int end = start; end < s.length(); end++) {
if (isPalindrome(s, start, end)) {
path.add(s.substring(start, end + 1));
backtrack(s, end + 1, path, res); // the rest starts after this piece
path.remove(path.size() - 1);
}
}
}
private boolean isPalindrome(String s, int lo, int hi) {
while (lo < hi) {
if (s.charAt(lo) != s.charAt(hi)) {
return false;
}
lo++;
hi--;
}
return true;
}
複雜度
- 時間 O(2^n · n):
2^(n-1)種候選切分(n - 1個縫隙的子集),每一段的is_palindrome關卡與複製都花 O(n)。 - 空間 O(n):遞迴深度與
path最多n段。
試跑
s = "aab" gaps: a|a|b -> 2 gaps -> 2^2 = 4 candidate partitions
start=0 "a" ok ─┬─ start=1 "a" ok ── start=2 "b" ok ── start=3 ["a","a","b"]
└─ start=1 "ab" no (not a palindrome — branch dies here)
start=0 "aa" ok ─── start=2 "b" ok ─────────────────── start=3 ["aa","b"]
start=0 "aab" no (not a palindrome)
陷阱
- 要從
end + 1遞迴,不是從start + 1。 切掉一段 2 個字元的片段後,start + 1會把它的 第二個字元再吃一次:"aab"會回傳["aa", "a", "b"]。這就是切分題版本的 Template 2ivsi + 1問題。 - 在葉節點要複製路徑(
path[:]/new ArrayList<>(path)),否則每個答案都是同一個被清空的 list。 傳path + [piece]的切片寫法可以繞過這件事 —— 每次呼叫都是全新的 list,這也是它不需要pop()的原因;見 Template 14。 - 對
s[i:]遞迴的切片寫法讀起來乾淨,但每次呼叫都是 O(n) 的複製;改成帶著start走。
不適用時 — 題目只問切分存不存在(LC 139),或最少要切幾刀(LC 132):把每種切法都列舉
出來是浪費,拿掉路徑就變成對 start 的 DP。這就是 Key Properties 裡那個典型的
回溯 → DP 轉折。
練習 — LC 131 ⭐ · LC 93 (關卡 = 合法 IP 段,剛好 4 段) · LC 140 (關卡 = 字典單字,對 start 記憶化) · LC 842 (關卡取決於前兩段)
模式 — 一個關卡,三個旋鈕
形狀是固定的;轉動三個旋鈕就能變成其他每一道切分題:
| 旋鈕 | LC 131 | 換成⋯ |
|---|---|---|
關卡 is_valid(piece) |
piece == piece[::-1] |
0 <= int(piece) <= 255 且不能有前導零 → LC 93;piece in wordDict → LC 140 |
切分範圍 for end in … |
每一個前綴,start .. n-1 |
有長度上限 —— IP 的每段 <= 3,字典斷詞 <= maxWordLen |
| 結束條件 | start == n |
start == n 而且 len(path) == 4(LC 93 要求剛好四段) |
讓關卡變成 O(1) — 預先算好的迴文表 優先度 3/5 — 值得會 — 多半是必備模式的變形
關卡位在最內層迴圈,每次呼叫要花 O(n)。先用 O(n^2) 把
dp[i][j] = 「s[i..j] 是不是迴文」 算好,之後每次檢查就只要 O(1)。輸出量本身仍是
指數等級,所以複雜度等級不會改變 —— 但這是實實在在的常數項優化,而且講得出這一點
正是面試官在等的追問。
# python
# LC 131 - Palindrome Partitioning (precomputed palindrome table)
# IDEA : BACKTRACK + DP gate. dp[i][j] is built bottom-up so dp[i+1][j-1] is already known.
# time = O(2^n * n), space = O(n^2) the O(n^2) table replaces an O(n) check per cut
def partition(s):
n = len(s)
dp = [[False] * n for _ in range(n)]
for i in range(n - 1, -1, -1): # i descends so the inner span is ready
for j in range(i, n):
dp[i][j] = s[i] == s[j] and (j - i < 2 or dp[i + 1][j - 1])
res, path = [], []
def backtrack(start):
if start == n:
res.append(path[:]) # copy — `path` is one shared list here
return
for end in range(start, n):
if dp[start][end]: # O(1) gate
path.append(s[start:end + 1])
backtrack(end + 1) # `end + 1`, not `start + 1`
path.pop()
backtrack(0)
return res
類似題 — 切分題家族
| LC | 題目 | 跟 LC 131 差在哪 |
|---|---|---|
| 93 | Restore IP Addresses | 關卡 = 0..255 且無前導零;切分長度上限 3;結束條件還要求剛好 4 段(實作解) |
| 140 | Word Break II | 關卡 = piece in wordDict;要對 start 做記憶化,否則 "aaaa…aaab" 會爆掉(實作解) |
| 139 | Word Break | 同一套搜尋,但它只問能不能 —— 把路徑拿掉就塌縮成對 start 的 DP/BFS |
| 132 | Palindrome Partitioning II | 問的是最少切幾刀,不是所有切法 —— 什麼都不用列舉,直接對 start 做 DP |
| 842 | Split Array into Fibonacci Sequence | 關卡 = 「這一段等於前兩段相加」,所以遞迴要帶著前兩個值當狀態 |
| 306 | Additive Number | LC 842 的是非題雙胞胎 —— 第一次成功就回傳,不用收集 |
| 816 | Ambiguous Coordinates | 兩層切分:先切成 2 段,再在每一段裡面插小數點 |
| 291 | Word Pattern II | 切分 + 一個 char -> piece 的雙射,回溯時必須復原 |
| 698 | Partition to K Equal Sum Subsets | 切的是多重集合分成 k 桶,不是把序列切成連續片段 —— 是另一份模板(Template 13) |
Template 9: Grid / Word Search — LC 79 優先度 4/5 — 高價值 — 這裡有缺口就會掉關
辨識 — 「單字由依序相鄰格子裡的字母組成」、「同一格不能用超過一次」、「每格最多走一次的路徑」。 m, n ≤ 6 加上一個短單字:每個起點 4^L 沒問題。
關鍵 — 一個格子在目前路徑上時被標記,路徑一離開它就還原。在第一個遞迴呼叫之前標記一次、 在最後一個之後還原一次,所以底下每個呼叫都看得到標記,每條兄弟路徑都會發現這格又空出來了。
如果不能改動輸入,就另外開一個 visited[][] 矩陣,在下面程式寫入與還原 '#' 的地方改成設定/清除
visited[r][c]:同一個演算法,多 O(m · n) 空間。計數的變體(LC 980)在
backtrack_examples.md 有完整解;取最大值的變體(LC 1219)在下方逐步追蹤。
# python
# LC 79 - Word Search
# IDEA: DFS + backtracking: mark the cell, try 4 directions, restore the cell, then return
# time = O(m * n * 4^L), space = O(L) L = len(word); 4 directions, minus the one we came from
def exist(board, word):
rows, cols = len(board), len(board[0])
def dfs(r, c, idx):
if r < 0 or r >= rows or c < 0 or c >= cols:
return False
if board[r][c] != word[idx]: # also rejects "#", a cell on the current path
return False
if idx == len(word) - 1:
return True
saved = board[r][c]
board[r][c] = "#" # mark: on the current path
found = (dfs(r + 1, c, idx + 1) or
dfs(r - 1, c, idx + 1) or
dfs(r, c + 1, idx + 1) or
dfs(r, c - 1, idx + 1))
board[r][c] = saved # restore BEFORE returning
return found
for r in range(rows):
for c in range(cols):
if dfs(r, c, 0):
return True
return False
// java
// LC 79 - Word Search
// IDEA: DFS + backtracking: mark the cell, try 4 directions, restore the cell, then return
// time = O(m * n * 4^L), space = O(L)
public boolean exist(char[][] board, String word) {
for (int r = 0; r < board.length; r++) {
for (int c = 0; c < board[0].length; c++) {
if (dfs(board, word, r, c, 0)) {
return true;
}
}
}
return false;
}
private boolean dfs(char[][] board, String word, int r, int c, int idx) {
if (r < 0 || r >= board.length || c < 0 || c >= board[0].length) {
return false;
}
if (board[r][c] != word.charAt(idx)) {
return false;
}
if (idx == word.length() - 1) {
return true;
}
char saved = board[r][c];
board[r][c] = '#'; // mark
boolean found = dfs(board, word, r + 1, c, idx + 1)
|| dfs(board, word, r - 1, c, idx + 1)
|| dfs(board, word, r, c + 1, idx + 1)
|| dfs(board, word, r, c - 1, idx + 1);
board[r][c] = saved; // restore before returning
return found;
}
複雜度
- 時間 O(m · n · 4^L):雙層迴圈把每一格都當起點試一次,每個
dfs往 4 個方向分支(第一步之後是 3 個, 因為上一格已被標記),最多L層。 - 空間 O(L):遞迴深度最多是單字長度;盤面是就地標記的。
陷阱
- 標記之後直接
return dfs(...) or dfs(...):還原那一行永遠到不了,所以失敗的起點會把"#"留給之後每一個起點。[["B", "A", "A"]]、"AAB"會回傳False:從中間那個A出發的嘗試標記了右邊的A,而右邊那個起點正好需要它。 先存下found、還原,然後才回傳。 - 在四個呼叫之間還原(
mark; dfs(down); restore; dfs(up) …):之後的分支可以走回這格重複使用 ——"ABCB"回傳True。 - 在邊界檢查之後才檢查
idx == len(word):結尾落在盤面最後一格的單字會被拒絕,因為下一個呼叫在能回報成功之前就先出界了。
不適用時 — 同一個盤面要查很多單字(LC 212):每個單字跑一次 DFS 會重複走同樣的前綴;改走 Trie (backtrack_advanced.md)。而如果格子永遠不需要釋放(數島嶼、flood fill), 那就是普通的 DFS 加上永久的 visited 標記。
練習 — LC 79 ⭐ · LC 1219 (在所有路徑中取最大) · LC 980 (數走遍每一格的路徑) · LC 212 (很多單字:Trie 剪枝)
標記能維持多久 — 它底下的每一個呼叫都看得到,旁邊的都看不到 優先度 4/5 — 高價值 — 這裡有缺口就會掉關
以 LC 1219(Path with Maximum Gold)追蹤。執行 grid[y][x] = -1 之後,誰會看到這個 -1,又會看到什麼時候? 把這件事搞清楚,
mark-and-restore 才會正確;而答案要以*呼叫框(frame)*來想,不是以單一次呼叫來想:
規則 — 在 frame A 裡標記的格子,會對 A 發出的每一個遞迴呼叫都保持標記 —— 四個方向 全部,以及它們再往下發出的每一個呼叫 —— 直到最後一個呼叫返回後才還原。A 的子樹以外 永遠看不到這個標記:A 的呼叫者的其他分支看不到,下一個起點也看不到。
# python
# time = O(g * 3^g), space = O(g) g = gold cells (<= 25); after the first step a path has <= 3 unvisited directions
# LC 1219 - Path with Maximum Gold
# IDEA : DFS + backtrack from every gold cell; -1 marks "on the CURRENT path"
# https://github.com/yennanliu/CS_basics/blob/master/leetcode_python/Backtracking/path-with-maximum-gold.py
class Solution(object):
def getMaximumGold(self, grid):
self.max_gold = 0
for y in range(len(grid)):
for x in range(len(grid[0])):
if grid[y][x] > 0:
self.dfs_helper(grid, x, y, 0)
return self.max_gold
def dfs_helper(self, grid, x, y, cur_gold):
if x < 0 or x >= len(grid[0]) or y < 0 or y >= len(grid):
return
if grid[y][x] <= 0: # 0 = no gold, -1 = already on THIS path
return
cur_gold += grid[y][x] # int: this frame's own copy, never undone
self.max_gold = max(self.max_gold, cur_gold)
cache = grid[y][x]
grid[y][x] = -1 # ---- mark: from here ...
self.dfs_helper(grid, x + 1, y, cur_gold) # every one of these four calls,
self.dfs_helper(grid, x - 1, y, cur_gold) # and every call THEY make,
self.dfs_helper(grid, x, y + 1, cur_gold) # sees (y, x) as -1
self.dfs_helper(grid, x, y - 1, cur_gold)
grid[y][x] = cache # ---- ... to here: undo after the LAST branch returns
實際追蹤 — grid = [[1, 2], [3, 4]],從 (0, 0) 啟動一次,格子寫成 (row, col),
縮排 = 遞迴深度。這是上面程式碼在每次標記與還原後加上 print 的真實輸出;撞牆或碰到 -1
而直接返回的呼叫不會顯示。
1 enter (0,0) mark -> [-1, 2] [ 3, 4] cur=1
2 enter (0,1) mark -> [-1, -1] [ 3, 4] cur=3 (0,0) still -1
3 enter (1,1) mark -> [-1, -1] [ 3, -1] cur=7
4 enter (1,0) mark -> [-1, -1] [-1, -1] cur=10 whole path 1->2->4->3 marked
5 leave (1,0) undo -> [-1, -1] [ 3, -1]
6 leave (1,1) undo -> [-1, -1] [ 3, 4]
7 leave (0,1) undo -> [-1, 2] [ 3, 4] (0,1) free again, (0,0) is NOT
8 enter (1,0) mark -> [-1, 2] [-1, 4] cur=4 (0,0)'s next direction: down
9 enter (1,1) mark -> [-1, 2] [-1, -1] cur=8
10 enter (0,1) mark -> [-1, -1] [-1, -1] cur=10 (0,1) collected AGAIN, on a new path
11 leave (0,1) undo -> [-1, 2] [-1, -1]
12 leave (1,1) undo -> [-1, 2] [-1, 4]
13 leave (1,0) undo -> [-1, 2] [ 3, 4]
14 leave (0,0) undo -> [ 1, 2] [ 3, 4] grid exactly as it started
從中讀出三件事:
(0,0)從第 1 步到第 14 步都是-1—— 跨越它的兩條分支(往右在 2–7,往下在 8–13)。標記屬於這個 frame,所以它的四個呼叫共用同一個標記;正是這點讓一條路徑不會 踩回自己已經佔用的格子。(0,1)在第 7 步、也就是(0,0)嘗試下一個方向之前就被還原 —— 所以第 10 步 先往下走的那條路徑能走到(0,1),再收集一次它的2。兄弟分支是不同的路徑,一條 用過的格子,另一條必須還能使用。這就是要還原的全部理由。- 還原的順序與標記的順序相反(5、6、7 · 11、12、13 · 14)。每個 frame 只還原它自己 標記的那一格,所以呼叫堆疊一層層退回時,grid 也一格格復原。
| 正在執行的程式碼 | 看到 (0,0) 是 -1? |
原因 |
|---|---|---|
(0,0) 的四個呼叫,以及它們底下的每一個呼叫 |
✅ 是 | 它們在標記與還原之間執行 |
外層迴圈的下一個起點,例如 dfs(0, 1) |
❌ 否 | (0,0) 返回前已經還原自己 |
兄弟分支,例如第 8 步的 (1,0) 看向 (0,1) |
— | (0,1) 是空的:它自己的 frame 在第 7 步已還原 |
為什麼子呼叫看得到父呼叫的標記。 整個遞迴只有一個 grid list,每個 frame 拿到的都是
同一個物件的參考(reference)—— 所以任何一個 frame 的寫入,所有其他 frame 都看得到,直到
有人把它寫回去。cur_gold 是 int:+= 只是讓區域名稱指向新物件,每個 frame 各自持有
一份,沒有東西需要還原。這跟
Template 14 裡 LC 113 的
cur_sum 與 cache 是同一條規則。
把這段時間窗弄錯的兩種寫法 —— 都拿題目自己的範例跑過:
| 錯誤 | 結果 |
|---|---|
| 完全不還原 | 第一次啟動就把黃金清掉,之後每個起點都受影響 —— Example 1 回傳 23,不是 24 |
在四個呼叫之間還原(mark; dfs(right); undo; dfs(left) ...) |
第一條分支返回後,之後的分支可以走回這格,這格又能再走回去 —— Example 1 直接 RecursionError |
標記一次,在第一個遞迴呼叫之前;還原一次,在最後一個之後。任何在中間就返回的寫法 —— 例如上面的
return (dfs(...) or dfs(...))—— 一樣會跳過還原。這題完整的三種寫法比較 (在哪裡檢查、在哪裡累加)在 dfs.md Template 9。
Template 10: N-Queens — LC 51
辨識 — 「擺 n 個皇后/棋子,使任兩個都不互相攻擊」、「回傳所有不同的棋盤配置」;每列一個棋子是必然的。n ≤ 9。
關鍵 — 遞迴深度 = 列,所以兩個皇后永遠不會在同一列。三個集合存著已被佔用的行與兩條對角線
(一條對角線上 row - col 固定,另一條上 row + col 固定),所以「這格會被攻擊嗎?」就是三次 O(1) 查詢。
# python
# LC 51 - N-Queens
# IDEA: one row per depth; cols / diag1 (row - col) / diag2 (row + col) sets make the attack test O(1)
# time = O(n!), space = O(n^2) n choices, then at most n-1, ...; the board is n^2
def solveNQueens(n):
result = []
board = [["."] * n for _ in range(n)]
cols = set()
diag1 = set() # row - col: top-left to bottom-right
diag2 = set() # row + col: top-right to bottom-left
def backtrack(row):
if row == n:
result.append(["".join(r) for r in board])
return
for col in range(n):
if col in cols or (row - col) in diag1 or (row + col) in diag2:
continue
cols.add(col)
diag1.add(row - col)
diag2.add(row + col)
board[row][col] = "Q"
backtrack(row + 1)
board[row][col] = "." # undo all four pieces of state
cols.remove(col)
diag1.remove(row - col)
diag2.remove(row + col)
backtrack(0)
return result
// java
// LC 51 - N-Queens
// IDEA: one row per depth; boolean arrays for columns and both diagonals make the attack test O(1)
// time = O(n!), space = O(n^2)
public List<List<String>> solveNQueens(int n) {
List<List<String>> result = new ArrayList<>();
char[][] board = new char[n][n];
for (char[] row : board) {
Arrays.fill(row, '.');
}
// diag1 is indexed by row - col + n - 1 so it is never negative
place(0, n, board, new boolean[n], new boolean[2 * n - 1], new boolean[2 * n - 1], result);
return result;
}
private void place(int row, int n, char[][] board, boolean[] cols, boolean[] diag1,
boolean[] diag2, List<List<String>> result) {
if (row == n) {
List<String> rows = new ArrayList<>();
for (char[] r : board) {
rows.add(new String(r));
}
result.add(rows);
return;
}
for (int col = 0; col < n; col++) {
int d1 = row - col + n - 1;
int d2 = row + col;
if (cols[col] || diag1[d1] || diag2[d2]) {
continue;
}
cols[col] = diag1[d1] = diag2[d2] = true;
board[row][col] = 'Q';
place(row + 1, n, board, cols, diag1, diag2, result);
board[row][col] = '.';
cols[col] = diag1[d1] = diag2[d2] = false;
}
}
複雜度
- 時間 O(n!):第 0 列試
n行,第 1 列最多n - 1個空行,依此類推;三個集合的檢查是 O(1), 所以每個節點花 O(1),外加組出一個完成棋盤的 O(n²)。 - 空間 O(n²):棋盤。集合與遞迴是 O(n)。
陷阱
- 只檢查
cols:在同一條對角線上的兩個皇后會通過,n = 4回傳全部 24 種排列,而不是它的 2 個棋盤。 - 在 Java 拿
row - col當陣列索引:在主對角線下方它是負的;要位移n - 1。 - 還原了棋盤卻沒還原集合(或三個集合只還原了一個):後面的列會看到不存在的攻擊,
n = 4一個棋盤都回傳不出來。
練習 — LC 51 ⭐ · LC 52 (只計數:不用棋盤) · LC 1001 (同樣的對角線鍵,改成查詢)
Template 11: Sudoku Solver — LC 37
辨識 — 「填滿空格,使每一列、每一行與每個 3×3 宮格都剛好包含 1–9」、「輸入剛好只有一個解」。盤面固定 9×9。
關鍵 — 遞迴依固定順序走過空格,每一層一格。rows、cols 與 boxes 永遠恰好存著盤面上的數字,
所以每次檢查候選都是 O(1);第一次把所有空格都填滿時,盤面就是答案 —— 一路往上回傳 True。
# python
# LC 37 - Sudoku Solver
# IDEA: one empty cell per depth; row / col / box sets make each digit test O(1); stop at the first fill
# time = O(9^m), space = O(m) m = blank cells; the sets are a fixed 3 x 9
def solveSudoku(board):
rows = [set() for _ in range(9)]
cols = [set() for _ in range(9)]
boxes = [set() for _ in range(9)]
empty = []
for r in range(9):
for c in range(9):
if board[r][c] == ".":
empty.append((r, c))
else:
digit = board[r][c]
rows[r].add(digit)
cols[c].add(digit)
boxes[(r // 3) * 3 + c // 3].add(digit)
def backtrack(idx):
if idx == len(empty):
return True # every blank filled: done
r, c = empty[idx]
box = (r // 3) * 3 + c // 3
for digit in "123456789":
if digit in rows[r] or digit in cols[c] or digit in boxes[box]:
continue
board[r][c] = digit
rows[r].add(digit)
cols[c].add(digit)
boxes[box].add(digit)
if backtrack(idx + 1):
return True # keep the board as it is
board[r][c] = "."
rows[r].remove(digit)
cols[c].remove(digit)
boxes[box].remove(digit)
return False
backtrack(0)
// java
// LC 37 - Sudoku Solver
// IDEA: one empty cell per depth; boolean tables for row / col / box; stop at the first fill
// time = O(9^m), space = O(m)
public void solveSudoku(char[][] board) {
boolean[][] rows = new boolean[9][10];
boolean[][] cols = new boolean[9][10];
boolean[][] boxes = new boolean[9][10];
List<int[]> empty = new ArrayList<>();
for (int r = 0; r < 9; r++) {
for (int c = 0; c < 9; c++) {
if (board[r][c] == '.') {
empty.add(new int[]{r, c});
} else {
int d = board[r][c] - '0';
rows[r][d] = true;
cols[c][d] = true;
boxes[(r / 3) * 3 + c / 3][d] = true;
}
}
}
fill(0, board, empty, rows, cols, boxes);
}
private boolean fill(int idx, char[][] board, List<int[]> empty,
boolean[][] rows, boolean[][] cols, boolean[][] boxes) {
if (idx == empty.size()) {
return true;
}
int r = empty.get(idx)[0];
int c = empty.get(idx)[1];
int box = (r / 3) * 3 + c / 3;
for (int d = 1; d <= 9; d++) {
if (rows[r][d] || cols[c][d] || boxes[box][d]) {
continue;
}
board[r][c] = (char) ('0' + d);
rows[r][d] = cols[c][d] = boxes[box][d] = true;
if (fill(idx + 1, board, empty, rows, cols, boxes)) {
return true;
}
board[r][c] = '.';
rows[r][d] = cols[c][d] = boxes[box][d] = false;
}
return false;
}
複雜度
- 時間 O(9^m):
m個空格每格最多試 9 個數字;三張表讓每次檢查是 O(1)。實務上限制條件把它砍到每個盤面幾毫秒。 - 空間 O(m):
empty串列與遞迴深度;三張表固定 3 × 81。
陷阱
- 沒有把
True往上回傳:盤面解完了迴圈還繼續跑,還原那幾行把它清掉,函式結束時盤面又是原來的空格。 - 在 Python 用
/寫宮格索引(r // 3) * 3 + c // 3:得到浮點數索引,TypeError。
不適用時 — 單純回溯在盤面上太慢(更難的變體、或很多盤面):放下一個數字就把它傳播到同伴格, 某格候選歸零就立刻失敗 —— backtrack_advanced.md。
練習 — LC 37 ⭐ · LC 36 (只做合法性檢查) · LC 51 (同樣每條限制一個集合的想法)
Template 12: Pruning at the Choice — LC 526 優先度 4/5 — 高價值 — 這裡有缺口就會掉關
辨識 — 暴力搜尋樹是 n! 或 2^n,n ≤ 15–20,而且每個選擇都帶一條限制(「perm[i] 能被 i 整除或整除 i」、
「總和不能超過 target」),讓大多數分支在走到葉節點之前很久就被排除。
關鍵 — 在遞迴之前、在做選擇的當下就檢查限制,而不是等到葉節點。不合格的分支根本不會進去, 它底下的整棵子樹成本為零 —— 實際搜尋的只有「合法前綴」構成的那棵樹。
# python
# LC 526 - Beautiful Arrangement
# IDEA: fill position pos = 1..n; try only a value that passes the divisibility test AT THE CHOICE
# time = O(n!) worst case, far fewer after the prune, space = O(n)
def countArrangement(n):
used = [False] * (n + 1)
def backtrack(pos):
if pos > n:
return 1 # every position filled with a legal value
count = 0
for value in range(1, n + 1):
if used[value]:
continue
if value % pos != 0 and pos % value != 0: # prune here, not at the leaf
continue
used[value] = True
count += backtrack(pos + 1)
used[value] = False
return count
return backtrack(1)
// java
// LC 526 - Beautiful Arrangement
// IDEA: fill position pos = 1..n; try only a value that passes the divisibility test AT THE CHOICE
// time = O(n!) worst case, far fewer after the prune, space = O(n)
public int countArrangement(int n) {
return count(1, n, new boolean[n + 1]);
}
private int count(int pos, int n, boolean[] used) {
if (pos > n) {
return 1;
}
int total = 0;
for (int value = 1; value <= n; value++) {
if (used[value]) {
continue;
}
if (value % pos != 0 && pos % value != 0) { // prune at the choice
continue;
}
used[value] = true;
total += count(pos + 1, n, used);
used[value] = false;
}
return total;
}
複雜度
- 最壞 時間 O(n!) ——
n層裡每一層的for value迴圈 —— 但整除性的continue在大多數分支的根就把它砍掉:n = 15呼叫不到一百萬次,不剪枝則是 15! ≈ 1.3 × 10¹² 個葉節點。 - 空間 O(n):
used與遞迴深度。
陷阱
- 先組出完整排列再在葉節點檢查:答案正確,但
n = 15要走過全部 15! 個葉節點,永遠跑不完。 - 在只有
continue才正確的地方用break:這些值並不是依「是否通過檢查」排序的,一個值不通過不代表下一個也不通過。
不適用時 — 同一個狀態會經由不同路徑重複出現(這裡是:某個位置上已用過的值的集合):
對 used 的位元遮罩做記憶化,就變成 DP —— O(n · 2^n)(dp_bitmask.md)。
練習 — LC 526 ⭐ · LC 39 (排序過的候選:超過就 break) · LC 40 (再加上跳過重複) · LC 698 (三種剪枝一起上:Template 13)
剪枝的四種類型
| 類型 | 砍掉什麼 | 寫法 | LC |
|---|---|---|---|
| 限制條件 | 已經違反規則的選擇 | 遞迴前先檢查,continue |
51, 526 |
| 界限 | 已經不可能達到目標的分支 | 排序,總和一超過就 break |
39, 40, 216 |
| 對稱性 | 會重複兄弟子樹的分支 | 排序後 i > start and a[i] == a[i-1];跳過總和相同的桶 |
40, 90, 698 |
| 記憶化 | 經由另一條路徑又走到的狀態 | 對狀態快取,而不是對路徑 | 140, 698 |
剪枝能砍分支、降常數;它不會改變最壞情況的複雜度等級。
Template 13: k-Bucket Partitioning — LC 698 / LC 473
辨識 — 「把陣列分成 k 個非空子集,且每個子集總和相等」、「用上每一根火柴拼成正方形」。n ≤ 16,k ≤ n。
關鍵 — 對數字遞迴,每個數字試它放得進的每一個桶。接下來只跟桶的總和有關,所以兩個總和相同的桶 可以互換:試第一個,其餘跳過。由大到小排序,讓太大的數字在樹的頂端就失敗。
# python
# LC 698 - Partition to K Equal Sum Subsets
# IDEA: place each number (largest first) into a bucket; skip a bucket whose sum equals the previous one
# time = O(k^n) worst case, space = O(n) memoising on a used-mask instead gives O(n * 2^n)
def canPartitionKSubsets(nums, k):
total = sum(nums)
if total % k != 0:
return False
target = total // k
nums.sort(reverse=True) # large numbers fail early
if nums[0] > target:
return False
buckets = [0] * k
def backtrack(index):
if index == len(nums):
return True # every number placed, no bucket over target
for i in range(k):
if buckets[i] + nums[index] > target:
continue
if i > 0 and buckets[i] == buckets[i - 1]: # same sum: same subtree
continue
buckets[i] += nums[index]
if backtrack(index + 1):
return True # return up the stack at once
buckets[i] -= nums[index]
return False
return backtrack(0)
// java
// LC 698 - Partition to K Equal Sum Subsets
// IDEA: place each number (largest first) into a bucket; skip a bucket whose sum equals the previous one
// time = O(k^n) worst case, space = O(n)
public boolean canPartitionKSubsets(int[] nums, int k) {
int total = 0;
for (int x : nums) {
total += x;
}
if (total % k != 0) {
return false;
}
Arrays.sort(nums); // ascending: walk it from the end
int target = total / k;
if (nums[nums.length - 1] > target) {
return false;
}
return place(nums, nums.length - 1, new int[k], target);
}
private boolean place(int[] nums, int index, int[] buckets, int target) {
if (index < 0) {
return true;
}
for (int i = 0; i < buckets.length; i++) {
if (buckets[i] + nums[index] > target) {
continue;
}
if (i > 0 && buckets[i] == buckets[i - 1]) { // same sum: same subtree
continue;
}
buckets[i] += nums[index];
if (place(nums, index - 1, buckets, target)) {
return true; // decision problem: stop at the first success
}
buckets[i] -= nums[index];
}
return false;
}
複雜度
- 最壞 時間 O(k^n):
n個數字每個最多試k個桶。三種剪枝(total % k、由大到小、跳過相同總和的桶) 讓它實務上很快,但不改變複雜度等級。 - 空間 O(n):遞迴深度;
buckets是 O(k)。
陷阱
- 由小到大排序:小數字先把每個桶都填了一部分,之後才發現大數字放不下。在
[3, 3, 10, 2, 6, 5, 10, 6, 8, 3, 2, 1, 6, 10, 7, 2], k = 6(無解)上要大約 200 萬次呼叫,由大到小只要 163 次。 - 沒有跳過總和相同的桶:k 個空桶讓每種切法都有 k! 種換標籤的版本:同一個輸入要大約 50 萬次呼叫,而不是 163 次。
- 忽略
backtrack(index + 1)的結果:成功之後還原照跑、迴圈繼續,函式回傳False—— 連 LC 698 的 第一個範例[4, 3, 2, 3, 5, 2, 1], k = 4都是。
不適用時 — n ≤ 16 而單純搜尋超時:對已用數字的位元遮罩做記憶化(dp[mask] = 目前這個桶的填充量),
就是 O(n · 2^n) —— dp_bitmask.md。
練習 — LC 698 ⭐ · LC 473 (k 固定為 4) · LC 2305 (讓最大的桶最小) · LC 1723 (同一題,換成工作與工人)
變體 — LC 473 (Matchsticks to Square):就是同一份 k 桶模板,只是把
k寫死成 4,且target = 周長 // 4。同樣的三個剪枝照用:sum % 4 != 0或max(nums) > target就直接失敗、先由大到小排序,以及復原之後if buckets[i] == 0: break(再去試第二個空桶只是把同一種分法換個標籤)。
Template 14: When to Undo — Mutable vs Immutable State — LC 113 優先度 4/5 — 高價值 — 這裡有缺口就會掉關
辨識 — 任何沿著遞迴往下帶著兩種狀態的回溯:一個累計的數字(總和、長度、深度),和一個集合 (路徑、visited 集合、盤面)。問題在於哪一個需要復原那一行。
關鍵 — 只有以參考共用的狀態需要復原。list、set、盤面或 StringBuilder 是每個 frame 都寫進去的
同一個物件,所以子層的改動父層看得到,直到它被復原為止。int 或 str 是在本地重新綁定
(cur_sum += x 在這個 frame 產生一個新值),父層的那份從來沒變,所以沒有東西要復原。
| 資料型別 | 需要回溯? | 原因 |
|---|---|---|
基本型別(int、char、boolean 等) |
❌ 不用 | 傳值;每次遞迴呼叫都有自己的副本 |
可變物件(List、Set、Map、StringBuilder 等) |
✅ 要 | 傳參考;改動會影響到所有遞迴呼叫 |
不可變物件(String、Integer 等) |
❌ 不用 | 改動會產生新的實例 |
# python
# LC 113 - Path Sum II
# IDEA: carry cur_sum (int, never undone) and cache (list, popped after both children)
# time = O(n^2), space = O(n) O(n) root-to-leaf paths, O(n) to copy each
class Solution(object):
def pathSum(self, root, targetSum):
self.res = []
if not root:
return self.res
self.helper(root, targetSum, 0, [])
return self.res
def helper(self, root, targetSum, cur_sum, cache):
if not root:
return
cur_sum += root.val # int -> rebinds LOCAL name to a NEW int (immutable)
cache.append(root.val) # list -> mutates the ONE shared list
if not root.left and not root.right and cur_sum == targetSum:
self.res.append(cache[:]) # snapshot, else later pops corrupt it
self.helper(root.left, targetSum, cur_sum, cache)
self.helper(root.right, targetSum, cur_sum, cache)
cache.pop() # MUST backtrack the list ...
# ... and NO `cur_sum -= root.val`: the int never changed for the parent
// java
// LC 113 - Path Sum II
// IDEA: curSum is an int (a copy per frame, never undone); path is one shared List (undone)
// time = O(n^2), space = O(n)
public List<List<Integer>> pathSum(TreeNode root, int targetSum) {
List<List<Integer>> res = new ArrayList<>();
helper(root, targetSum, 0, new ArrayList<>(), res);
return res;
}
private void helper(TreeNode node, int targetSum, int curSum, List<Integer> path,
List<List<Integer>> res) {
if (node == null) {
return;
}
curSum += node.val; // int: this frame's own copy
path.add(node.val); // List: the one shared object
if (node.left == null && node.right == null && curSum == targetSum) {
res.add(new ArrayList<>(path));
}
helper(node.left, targetSum, curSum, path, res);
helper(node.right, targetSum, curSum, path, res);
path.remove(path.size() - 1); // undo the List, never the int
}
複雜度
- 時間 O(n²):每個節點走一次,最多 O(n) 個根到葉的符合路徑,每條複製最多
n個值(cache[:])。 - 空間 O(n):一個共用的
cache加上遞迴深度,兩者最多都是樹高。
試跑 — 父層在 cur_sum = 5、cache = [5],往下走進一個值為 3 的子節點:
| 往下走進子節點 | 回到父層 | |
|---|---|---|
cache(list) |
cache.append(3) → [5, 3](同一個物件) |
沒有 pop() 就會停在 [5, 3] → 父層被汙染 → 一定要回溯 |
cur_sum(int) |
cur_sum + 3 → 8(新的 int,是區域變數) |
子層 frame 被銷毀 → 父層的 cur_sum 還是 5 → 不需要回溯 |
拿 1 → (2, 3) 這棵樹,先不 cache.pop() 走一遍,再加上它走一遍:
without pop with pop
visit 1: cache = [1] visit 1: cache = [1]
visit 2: cache = [1,2] visit 2: cache = [1,2]
return return -> pop() => [1]
visit 3: cache = [1,2,3] wrong visit 3: cache = [1,3] correct
陷阱
- 沒有
cache.pop():左子節點的值漏進右子節點的路徑,就像上面那樣;LC 113 的第一個範例會把[5, 4, 11, 2]回報成[5, 4, 11, 7, 2]。 - 連
int也復原(呼叫之後寫cur_sum -= root.val):在 Python 無害,但代表心智模型錯了 —— 而如果它是 欄位(self.cur_sum),情況就反過來:欄位是共用的,的確需要復原。 res.append(cache)沒有複製:每個答案都是同一個 list,到最後是空的。- 同一條規則也決定 LC 79 的索引:
dfs(..., idx + 1)傳的是一個新的int,所以不用寫idx -= 1; LC 1740 的dist + 1同樣不需要復原。
不適用時 — 每次呼叫都傳一個新的集合(path + [node.val]):每條分支都有自己的副本,不需要 pop()
—— tree_backtrack.md 用的就是這種形式。代價是每次呼叫 O(n) 的複製,換掉的是「共用一個 list 加回溯」。
另見 python_trick.md §1-54。
練習 — LC 113 ⭐ · LC 112 (不用保留路徑:完全不需要復原) · LC 257 (字串路徑:不可變,不用復原) · LC 1219 (可變的格子標記:必須復原)
練習路徑
| 階段 | 題目(由易到難) | 新增了什麼 |
|---|---|---|
| 核心 ⭐ | LC 78 未評定 · LC 39 again · LC 40 未評定 · LC 46 未評定 · LC 17 again · LC 22 未評定 · LC 79 ok · LC 131 again · LC 51 again | 子集、可重用 vs 只用一次、跳過重複、排列、每個位置一份選擇清單、用計數當限制、格子標記再還原、切分、每層一列 |
| 練習 | LC 77 未評定 · LC 90 未評定 · LC 216 未評定 · LC 47 未評定 · LC 93 ok · LC 526 未評定 · LC 1219 ok · LC 113 ok | 長度關卡、子集與排列裡的重複、IP 段切分、在選擇時剪枝、在格子路徑中取最大、可變/不可變的復原 |
| 進階 | LC 698 未評定 · LC 473 未評定 · LC 37 未評定 · LC 212 again · LC 980 未評定 · LC 301 未評定 | k 個桶、第一次填滿就停的解題器、Trie 剪枝的格子搜尋、走遍每一格、刪除額度 |
總結與速查
決策表 — 該用哪種回溯形狀? 優先度 5/5 — 必備 — 幾乎每一輪面試都會出現
| 題型 | 要用 start_idx 嗎? |
範例題 |
|---|---|---|
| 子集 | ✅ 要 | Leetcode 78 |
| 組合 | ✅ 要 | Leetcode 77 |
| 組合總和 | ✅ 要 | Leetcode 39 |
| 排列 | ❌ 不用 | Leetcode 46 |
| N 皇后 | ❌ 不用 | Leetcode 51 |
| 切分 | ✅ 要 | Leetcode 131 |
排序、去重、剪枝 — 什麼時候做
| 問題 | 答案 |
|---|---|
| 要先排序輸入嗎? | 只要你必須跳過重複(LC 40, 47, 90, 996),或想在排序過的候選集上用 break 剪枝(LC 39, 216),就要 |
| 怎麼跳過重複? | 只跳同一層:i > start && a[i] == a[i-1](用索引,LC 40/90)或 i > 0 && a[i] == a[i-1] && !used[i-1](用 visited,LC 47/996);Counter 是第三種寫法 |
用 break 還是 continue? |
候選集已排序且一超出就代表後面整串都沒救時用 break(LC 39, 216);只有這一個候選不合法時用 continue(LC 698 桶滿了) |
| 剪枝要放哪? | 遞迴之前 — 在做選擇的當下檢查限制,而不是到葉節點才檢查(LC 526, 996) |
| 什麼時候該停止剪枝、改成記憶化? | 當重複出現的是同一個狀態(而不是同一條路徑)時 — 那就是 DP 的分界線 |
需要 start_idx 的題目
組合、子集,以及元素可重複使用的題型:順序不重要,而且前面的選擇不該再被走一次。
| 題目 | 是否用 start_idx |
為什麼? |
|---|---|---|
Subsets (Leetcode 78) |
✅ 要 | 避免產生重複的子集 |
Combination Sum (Leetcode 39) |
✅ 要 | 可以重複用,但要照順序 |
Combination Sum II (Leetcode 40) |
✅ 要 | 不能重複用,還要跳過重複值 |
Combinations (Leetcode 77) |
✅ 要 | 從 n 個裡照順序選 k 個 |
Palindrome Partitioning |
✅ 要 | 從 start 開始往後試子字串 |
不用 start_idx 的題目
排列型的題目:順序有意義,每一種排法都是不同的答案,而且前面的選擇還會被再次使用。
| 題目 | 是否用 start_idx |
為什麼不用? |
|---|---|---|
Permutations (Leetcode 46) |
❌ 不用 | 所有排法都合法 |
Permutations II (Leetcode 47) |
❌ 不用 | 只要聰明地跳過重複值就好 |
N-Queens |
❌ 不用 | 每層遞迴處理一列 |
Word Break II |
❌ 不用 | 選擇取決於子字串能不能配到字 |
終止條件的樣式
三種基底條件的形狀(片段 — 挑一個跟題目的「做完了」條件對得上的):
# python
if len(current) == target_length: # fixed-size result (permutations, combinations of size k)
result.append(current[:])
return
if sum(current) == target: # value-based result (subset sum, coin change)
result.append(current[:])
return
if index == len(input): # exhausted input (string partition, IP addresses)
if is_valid(current):
result.append(result_repr)
return
分支數與剪枝速查
| 題目 | 分支數 | 深度 | 剪枝 | 最壞情況 |
|---|---|---|---|---|
| 子集 | 2 | n | 無 | O(2^n) |
| 排列 | n, n-1, … | n | used 集合 | O(n!) |
| 組合 | n-k+1 | k | 起始索引 | O(C(n,k)) |
| N 皇后 | n | n | 3 個集合 | O(n!) → 實務上好很多 |
| 數獨 | 9 | 81 | 列/行/宮格 | O(9^81) → 實務上每盤約 O(1) |
最壞情況的量級在上面的 Time Complexity by Problem Type 表格裡;這張表講的是靠什麼把它剪掉。
相關主題
- backtrack_examples.md — 不是模板錨定題的那些題目的 problem card
- backtrack_advanced.md — 用 Trie 剪枝的格子搜尋(LC 212)、運算式建構(LC 282)、刪除次數預算(LC 301)、記憶化/通用切分
- tree_backtrack.md — 在明確的樹上做根到葉的路徑回溯
- dfs.md — 沒有復原步驟的 DFS 走訪
- dp.md / knapsack.md — 同一套搜尋的記憶化版本