回溯 — 實戰題解
範圍 — 不是模板錨定題的那些回溯題(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
# 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
// 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
# 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
# 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
# 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
# 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":
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
# 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
# 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
# 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
// 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:後者會允許")("。