回溯 — 實戰題解

動態規劃與遞迴優先度 3/5 — 值得會 — 多半是必備模式的變形值得會 更新於 Nov 12, 2026

範圍 — 不是模板錨定題的那些回溯題(LC 17、784、996、93、140、980、22),每一題都是一張 problem card,歸檔在它所歸約到的 backtrack.md 模板底下;錨定題本身在那邊自己的 card 上解,這份不教任何自己的模板。 另見:backtrack.md — 這裡每張 card 歸約到的模板,以及挑模板用的決策表;backtrack_advanced.md — Hard 等級的模板(LC 212、282、301);tree_backtrack.md — 樹上 root→leaf 的路徑題;dfs.md — 不需要撤銷的走訪。

LeetCode 題目清單

總覽

這是 backtrack.md 的題目集:模板在那邊,題目在這邊。每張 card 寫出它歸約到哪個模板、以及它多加的那一步。各節依核心表的模板順序排列;題目都已在自己 card 上解完的模板,這裡就沒有那一節。

如果某張 card 看起來陌生,回到核心表它依靠的那一段:

如果這個看起來陌生 回去看
為什麼要 path.pop(),什麼時候不需要 Template 14
遞迴呼叫傳 i 還是 i + 1 Template 2
跳過重複的值 跳過重複
剪枝時用 break 還是 continue 排序、去重、剪枝 — 何時
從題目敘述挑出正確的形狀 決策表

索引

LC 題目 歸約到 難度
17 Letter Combinations of a Phone Number Template 1 選擇 → 探索 → 撤銷 Medium
784 Letter Case Permutation Template 3 子集,選/不選的寫法 Medium
996 Number of Squareful Arrays Template 5 排列 + LC 47 的跳過規則 Hard
93 Restore IP Addresses Template 8 選在哪裡切 Medium
140 Word Break II Template 8 選在哪裡切,加記憶化 Hard
980 Unique Paths III Template 9 標記、遞迴、還原 Hard
22 Generate Parentheses Template 12 遞迴前先檢查 Medium

在別處有完整解

這些題目只在擁有它們的那張 card 上解一次。列在這裡,是讓搜尋這個題號的人有地方可落腳。

LC 題目 所在
216 Combination Sum III backtrack.md § Template 1(錨定題)
39 / 40 Combination Sum / II backtrack.md § Template 2 與 § Template 7(錨定題)
78 Subsets backtrack.md § Template 3(錨定題)
90 Subsets II backtrack.md § Template 4(錨定題)
46 / 47 Permutations / II backtrack.md § Template 5(錨定題,LC 47 在它底下)
77 Combinations backtrack.md § Template 6(錨定題)
79 Word Search backtrack.md § Template 9(錨定題)
526 Beautiful Arrangement backtrack.md § Template 12(錨定題)
1219 Path with Maximum Gold dfs.md § Template 9(錨定題);標記存續時間的逐步追蹤在 backtrack.md § Template 9 底下
139 Word Break dp.md § Template 1b(錨定題):只問切法存不存在,所以是 DP,不是回溯
207 Course Schedule topology_sorting_examples.md:visiting 集合是在偵測環,不是在搜尋答案

選擇 → 探索 → 撤銷 範例

Letter Combinations of a Phone Number — LC 17

關鍵 — 深度就是 digits 的索引,而每一層有它自己的選項清單(digits[i] 對應的字母)。 兩層之間不會從同一個池子取,所以不需要 start_idx,也不需要 used[]:每個數字都選了一個字母, 路徑就完成了。

歸約到:backtrack.md § Template 1 — 選項清單改由目前這一層決定,而不是 start..n。

python
# python
# LC 17 - Letter Combinations of a Phone Number
# IDEA: one depth per digit; the choice list at depth i is the letters of digits[i]
# time = O(4^n * n), space = O(n)   n = len(digits); up to 4 letters per digit, O(n) to join each
def letterCombinations(digits):
    if not digits:
        return []                        # no digits: no combinations, not [""]
    letters = {"2": "abc", "3": "def", "4": "ghi", "5": "jkl",
               "6": "mno", "7": "pqrs", "8": "tuv", "9": "wxyz"}
    res = []
    path = []

    def backtrack(i):
        if i == len(digits):             # one letter chosen for every digit
            res.append("".join(path))
            return
        for ch in letters[digits[i]]:
            path.append(ch)
            backtrack(i + 1)
            path.pop()                   # undo the LAST letter

    backtrack(0)
    return res
java
// java
// LC 17 - Letter Combinations of a Phone Number
// IDEA: one depth per digit; the choice list at depth i is the letters of digits[i]
// time = O(4^n * n), space = O(n)
private static final String[] LETTERS = {
    "", "", "abc", "def", "ghi", "jkl", "mno", "pqrs", "tuv", "wxyz"
};

public List<String> letterCombinations(String digits) {
    List<String> res = new ArrayList<>();
    if (digits.isEmpty()) {
        return res;                                   // no digits: no combinations, not [""]
    }
    backtrack(digits, 0, new StringBuilder(), res);
    return res;
}

private void backtrack(String digits, int i, StringBuilder path, List<String> res) {
    if (i == digits.length()) {
        res.add(path.toString());
        return;
    }
    String choices = LETTERS[digits.charAt(i) - '0'];
    for (char ch : choices.toCharArray()) {
        path.append(ch);
        backtrack(digits, i + 1, path, res);
        path.deleteCharAt(path.length() - 1);         // undo the LAST char, not the first
    }
}

第二種解法,留下來是因為它是不同的演算法,而不只是換個寫法:一層一層建出答案,完全不遞迴。 它同時保留所有部分組合,所以要 O(4^n) 記憶體,而回溯只保留一條路徑。

python
# python
# IDEA: iterative product: extend every partial combination by each letter of the next digit
# time = O(4^n * n), space = O(4^n * n)   every partial combination is kept
def letterCombinations(digits):
    if not digits:
        return []
    letters = {"2": "abc", "3": "def", "4": "ghi", "5": "jkl",
               "6": "mno", "7": "pqrs", "8": "tuv", "9": "wxyz"}
    combos = [""]
    for digit in digits:
        extended = []
        for prefix in combos:
            for ch in letters[digit]:
                extended.append(prefix + ch)
        combos = extended
    return combos

陷阱

  • digits = "" 必須回傳 []。只靠 base case 會回傳 [""],也就是一個空組合。
  • 撤銷時移除的是最後一個字元。deleteCharAt(0) 在 "2" 會過,在 "23" 會錯。

子集範例

Letter Case Permutation — LC 784

關鍵 — 雖然名字叫排列,字元的順序其實是固定的:每個字母是二選一(小寫或大寫), 每個數字只有一種選擇。這就是子集那棵樹的選/不選寫法,每個位置一次二元決定,所以 L 個字母 有 2^L 個葉節點。

歸約到:backtrack.md § Template 3 — 選/不選的寫法,兩個分支換成「小寫/大寫」。

python
# python
# LC 784 - Letter Case Permutation
# IDEA: fixed order, one decision per index: a letter branches two ways, a digit one way
# time = O(2^L * n), space = O(n)   L = letters in s; O(n) to join each answer
def letterCasePermutation(s):
    res = []
    path = []

    def backtrack(i):
        if i == len(s):
            res.append("".join(path))
            return
        ch = s[i]
        if ch.isalpha():
            options = [ch.lower(), ch.upper()]
        else:
            options = [ch]               # a digit has no choice to make
        for option in options:
            path.append(option)
            backtrack(i + 1)
            path.pop()

    backtrack(0)
    return res

陷阱

  • 把它當成 Template 5(排列)去重排字元:"a1b2" 有 4 個答案,不是 4! × 4。
  • 在數字上也分支,會讓每個答案重複一倍。

排列範例

Number of Squareful Arrays — LC 996

關鍵 — 排列迴圈裡有兩道過濾,都在遞迴之前做:LC 47 的同層跳過規則(輸入排序後, 相等的左鄰居不在路徑上時就跳過 nums[i]),讓相等的值不會被數兩次;以及對 path[-1] 的 相鄰檢查,讓一對數不合格時那個分支當場結束。

歸約到:backtrack.md § Template 5 — 加上 LC 47 的跳過規則 與一道兩兩檢查。

python
# python
# LC 996 - Number of Squareful Arrays
# IDEA: permutations with duplicates (sort + same-level skip) + prune on the adjacent pair
# time = O(n!) worst case, far less after pruning, space = O(n)
import math

def numSquarefulPerms(nums):
    nums = sorted(nums)                  # equal values adjacent: the skip needs it
    n = len(nums)
    used = [False] * n
    path = []
    count = 0

    def is_square(x):
        root = math.isqrt(x)             # integer square root: no float rounding
        return root * root == x

    def backtrack():
        nonlocal count
        if len(path) == n:
            count += 1
            return
        for i in range(n):
            if used[i]:
                continue
            # same value as the left neighbour, which is not on the path: same level, skip
            if i > 0 and nums[i] == nums[i - 1] and not used[i - 1]:
                continue
            # the new neighbour pair must sum to a square
            if path and not is_square(path[-1] + nums[i]):
                continue
            used[i] = True
            path.append(nums[i])
            backtrack()
            path.pop()
            used[i] = False

    backtrack()
    return count

陷阱

  • 沒有先排序,跳過規則比較的是不相干的鄰居:[2, 2, 2] 必須得到 1。
  • int(x ** 0.5) 遇到很大的完全平方數可能會往下捨入;math.isqrt 不會。

回文切分範例

Restore IP Addresses — LC 93

關鍵 — 跟 LC 131 同一個「選在哪裡切」的迴圈,只是關卡不同:一段是 1–3 位數、不超過 255、 而且沒有前導零。有兩種情況會結束一個分支:四段剛好用完整個字串(記錄下來),或四段之後 還剩數字(丟掉)。讓它變快的剪枝:還要放 k 段時,剩下的數字必須介於 k 到 3k 個之間, 所以一個不可能完成的分支在遞迴之前就被剪掉。

歸約到:backtrack.md § Template 8 — 關卡是「合法的一段」,深度上限是 4。

python
# python
# LC 93 - Restore IP Addresses
# IDEA: cut s into 4 pieces; each piece is 1-3 digits, <= 255, no leading zero;
#       prune when the digits left cannot fill the pieces left (need k..3k digits for k pieces)
# time = O(3^4 * n), space = O(n)   at most 3 choices per piece, 4 pieces, O(n) to join
def restoreIpAddresses(s):
    res = []
    path = []

    def backtrack(start):
        if len(path) == 4:
            if start == len(s):          # every digit used
                res.append(".".join(path))
            return
        pieces_left = 4 - len(path)
        digits_left = len(s) - start
        if digits_left < pieces_left or digits_left > 3 * pieces_left:
            return                       # cannot finish: too few, or too many, digits left
        for end in range(start + 1, min(start + 3, len(s)) + 1):
            piece = s[start:end]
            if len(piece) > 1 and piece[0] == "0":
                break                    # "0x" is invalid, and so is every longer piece
            if int(piece) > 255:
                break                    # longer pieces are larger still
            path.append(piece)
            backtrack(end)
            path.pop()

    backtrack(0)
    return res

手動追蹤 — s = "0000":

text
path              start  pieces_left  digits_left  action
[]                0      4            4            try "0" (len 1); "00" breaks on the leading zero
["0"]             1      3            3            try "0"
["0","0"]         2      2            2            try "0"
["0","0","0"]     3      1            1            try "0"
["0","0","0","0"] 4      -            -            4 pieces and start == len(s) -> record "0.0.0.0"

陷阱

  • "0000" → ["0.0.0.0"]:單獨一個 0 是合法的一段;只有更長的段不能以 0 開頭。
  • 湊滿四段就記錄、卻沒檢查 start == len(s),會回傳漏掉數字的位址。
  • 只在一開始檢查 len(s) > 12,在根節點以下什麼都剪不掉:"1111111111"(10 位數)切出 ["1"] 後剩 9 個數字給 3 段,已經是死路。每次呼叫都檢查的 k..3k 界線會立刻把它丟掉。

Word Break II — LC 140

關鍵 — 在前綴是字典單字的地方切開,再解剩下的後綴。從 start 開始的後綴,不論前綴怎麼切, 拼出來的句子都一樣,所以用 start 當鍵把它們快取起來。沒有快取的話,"aaaa…aaab" 配 ["a", "aa", "aaa"] 會以指數次數重解同樣的後綴,最後還是什麼都找不到。

歸約到:backtrack.md § Template 8 — 關卡是 piece in words,遞迴回傳後綴的句子,好讓它能被記憶化。

python
# python
# LC 140 - Word Break II
# IDEA: cut at every dictionary prefix; memoise the sentences of each suffix by its start index
# time = O(n^3) for the n x n slices of up to n chars, plus building every cached sentence
#        (exponential in n in the worst case); space = O(n^2) chars in memo even for one answer
#        ("a" * n with ["a"]: memo[i] holds a sentence of length ~2(n - i)), plus the output
def wordBreak(s, wordDict):
    words = set(wordDict)                # O(1) membership
    memo = {}                            # start -> every sentence that spells s[start:]

    def sentences_from(start):
        if start == len(s):
            return [""]                  # one way to spell the empty suffix
        if start in memo:
            return memo[start]
        sentences = []
        for end in range(start + 1, len(s) + 1):
            word = s[start:end]
            if word not in words:
                continue
            for rest in sentences_from(end):
                if rest:
                    sentences.append(word + " " + rest)
                else:
                    sentences.append(word)
        memo[start] = sentences
        return sentences

    return sentences_from(0)

陷阱

  • 迴圈跑字典、測試接起來的前綴(而不是切 s),會在拒絕之前先建出比 s 還長的句子。
  • 如果只問切法存不存在,那是 LC 139:布林 DP, dp.md § Template 1b。

格子範例

Unique Paths III — LC 980

關鍵 — LC 79 的標記 → 遞迴 → 還原骨架,但每條路徑都要走到底,所以不能提早回傳:改成計數。 只有走過每一個可走格子之後抵達終點,那條路徑才算數,所以一個 remaining 計數器跟著遞迴往下傳。

歸約到:backtrack.md § Template 9 — 回傳計數,終點多一個「走遍每一格」的條件。

python
# python
# LC 980 - Unique Paths III
# IDEA: grid backtrack from the start; count arrivals at the end that used every walkable cell
# time = O(3^(m*n)), space = O(m*n)   after the first step at most 3 unvisited directions
def uniquePathsIII(grid):
    rows, cols = len(grid), len(grid[0])
    walkable = 0                         # start + end + empty cells
    start_r = start_c = 0
    for r in range(rows):
        for c in range(cols):
            if grid[r][c] != -1:
                walkable += 1
            if grid[r][c] == 1:
                start_r, start_c = r, c
    count = 0

    def dfs(r, c, remaining):            # remaining = walkable cells not yet on the path
        nonlocal count
        if grid[r][c] == 2:
            if remaining == 0:           # the end only counts once every cell is covered
                count += 1
            return
        saved = grid[r][c]
        grid[r][c] = -1                  # mark: an obstacle for the calls below
        for dr, dc in ((1, 0), (-1, 0), (0, 1), (0, -1)):
            nr, nc = r + dr, c + dc
            if 0 <= nr < rows and 0 <= nc < cols and grid[nr][nc] != -1:
                dfs(nr, nc, remaining - 1)
        grid[r][c] = saved               # restore after the last direction

    dfs(start_r, start_c, walkable - 1)  # the start cell is already on the path
    return count

陷阱

  • 每次抵達終點都算一次:[[1,0,0,0],[0,0,0,0],[0,0,2,-1]] 會得到遠多於 2 的數。
  • 像 LC 79 那樣第一次成功就回傳,計數會停在 1。

剪枝範例

Generate Parentheses — LC 22

關鍵 — 帶著兩個計數,在放括號之前檢查:已開的 ( 少於 n 時可以放 (,已放的 ) 少於 ( 時可以放 )。這樣建出來的每個前綴都是合法前綴,每個葉節點都是答案,沒有任何東西是 建出來只為了被丟掉。

歸約到:backtrack.md § Template 12 — 遞迴前的檢查就是括號是否平衡。

python
# python
# LC 22 - Generate Parentheses
# IDEA: count the brackets placed; '(' while open < n, ')' while close < open
# time = O(4^n / sqrt(n)), space = O(n)   the n-th Catalan number of answers, O(n) each
def generateParenthesis(n):
    res = []
    path = []

    def backtrack(open_count, close_count):
        if len(path) == 2 * n:
            res.append("".join(path))
            return
        if open_count < n:               # an opener is still available
            path.append("(")
            backtrack(open_count + 1, close_count)
            path.pop()
        if close_count < open_count:     # a closer has something to match
            path.append(")")
            backtrack(open_count, close_count + 1)
            path.pop()

    backtrack(0, 0)
    return res
java
// java
// LC 22 - Generate Parentheses
// IDEA: count the brackets placed; '(' while open < n, ')' while close < open
// time = O(4^n / sqrt(n)), space = O(n)
public List<String> generateParenthesis(int n) {
    List<String> res = new ArrayList<>();
    build(n, 0, 0, new StringBuilder(), res);
    return res;
}

private void build(int n, int open, int close, StringBuilder path, List<String> res) {
    if (path.length() == 2 * n) {
        res.add(path.toString());
        return;
    }
    if (open < n) {                                   // an opener is still available
        path.append('(');
        build(n, open + 1, close, path, res);
        path.deleteCharAt(path.length() - 1);
    }
    if (close < open) {                               // a closer has something to match
        path.append(')');
        build(n, open, close + 1, path, res);
        path.deleteCharAt(path.length() - 1);
    }
}

陷阱

  • 產生全部 2^(2n) 個字串再在葉節點逐一驗證,是最直覺的第一個想法,也是指數級的浪費:兩個計數讓驗證變得多餘。
  • 是 close < open,不是 close < n:後者會允許 ")("。