回溯(Backtracking)

動態規劃與遞迴優先度 5/5 — 必備 — 幾乎每一輪面試都會出現必備 更新於 Nov 12, 2026
章節優先度優先度 5/5 — 必備 — 幾乎每一輪面試都會出現必備優先度 4/5 — 高價值 — 這裡有缺口就會掉關高價值優先度 3/5 — 值得會 — 多半是必備模式的變形值得會優先度 2/5 — 冷門 — 讀過一次即可,除非目標公司已知會問冷門只標在真正需要的章節上 —— 沒標的是背景/參考資料。

範圍 — 帶「復原」動作的系統性搜尋: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 是把每條合法路徑複製進結果的成本。 剪枝砍掉分支、也砍掉常數項,但不會改變最壞情況的量級。

參考資料

辨識

輸入規模是第一個線索:回溯是指數時間,所以限制條件都很小 (限制尺規見 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
# 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
// 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 216 k = 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
# 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
// 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 大的候選處結束迴圈:

text
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
# 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
// 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
# 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(不含輸出)。

試跑

text
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
# 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
// 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
# 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
// 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 縮小,答案只出現在葉節點:

text
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
# 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
# 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
// 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
# 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
// 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
# 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
// 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 段。

試跑

text
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 2 i vs i + 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
# 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
# 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
// 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
# 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 而直接返回的呼叫不會顯示。

text
 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

從中讀出三件事:

  1. (0,0) 從第 1 步到第 14 步都是 -1 —— 跨越它的兩條分支(往右在 2–7,往下在 8–13)。標記屬於這個 frame,所以它的四個呼叫共用同一個標記;正是這點讓一條路徑不會 踩回自己已經佔用的格子。
  2. (0,1) 在第 7 步、也就是 (0,0) 嘗試下一個方向之前就被還原 —— 所以第 10 步 先往下走的那條路徑能走到 (0,1),再收集一次它的 2。兄弟分支是不同的路徑,一條 用過的格子,另一條必須還能使用。這就是要還原的全部理由。
  3. 還原的順序與標記的順序相反(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
# 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
// 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
# 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
// 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
# 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
// 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
# 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
// 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
# 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
// 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() 走一遍,再加上它走一遍:

text
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
# 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 — 同一套搜尋的記憶化版本