DFS(深度優先搜尋)
範圍 — DFS 的主文件:十個核心的深度優先模板 — 樹走訪、網格填色(flood fill)、路徑搜尋、回溯、樹結構修改、後序彙總、邊界消除、形狀簽名與帶權邊走訪 — 並附上用來挑選模板的辨識表。 另見 — 從本檔案拆出去的深入主題:dfs_advanced.md — 雙網格驗證、邊方向標記、跨連通分量配對計數、字典樹 + 萬用字元 DFS、以深度為索引的堆疊 DFS、距離桶配對葉節點、N 元樹與
parent[]的彙總;graph_advanced.md — 尤拉路徑(Hierholzer)與 Tarjan low-link;dfs_examples.md — 經典題目,依本檔的模板分組,附索引。 鄰近文件:bfs.md — 廣度優先的對應版本以及如何取捨;backtrack.md — 回程時會還原狀態的 DFS;graph.md — 圖的表示法;tree.md — 樹上的 DFS。
LeetCode 題目清單
總覽
深度優先搜尋(DFS) 是一種圖/樹的走訪演算法,沿著每個分支盡可能往深處走,走不下去才回溯。它用遞迴或堆疊來維護走訪路徑。
關鍵性質
- 時間複雜度:圖是 O(V + E),樹是 O(n)
- 空間複雜度:遞迴堆疊 O(h),其中 h = 高度
- 核心想法:先往深走,再往廣走
- 資料結構:堆疊(遞迴的隱含堆疊或顯式堆疊)
- 什麼時候用:路徑搜尋、環偵測、拓撲排序、樹走訪、回溯類題目
參考資料
辨識
下面每個模式都只出現一次,以卡片形式放在「模板與演算法」底下。先比對題目敘述裡的用語,再拿限制條件對照 限制條件尺規,動手寫程式前先讀最後一欄。
| 題目敘述裡的訊號 | 限制提示 | 該用 | 不適用時 |
|---|---|---|---|
| 「中序/前序/後序」、「走訪所有節點」、「序列化」、「是不是鏡像」、「兩棵樹是否相同」 | 節點 ≤ 10⁴ | Template 1 — 樹走訪;兩棵樹 → 它的成對 DFS 變形 | 一層一層 → bfs.md § Template 1 |
| 「島嶼數量」、「連通區域」、「flood fill」、「複製圖」 | 網格 ≤ 300 × 300 | Template 2 — flood fill,數啟動次數 | 兩格之間的最短路線 → bfs.md § Template 3 |
| 「根到葉的路徑和」、「路徑是否存在」、「所有路徑」 | 節點 ≤ 5,000 | Template 3 — 狀態往下傳、提早回傳 | 路徑可以從根以下開始 → 路徑上的前綴和(LC 437) |
| 「所有排列/組合/子集」 | n ≤ 10(n!)或 n ≤ 20(2ⁿ) | Template 4 — 選、遞迴、取消選 | 只要數量、且狀態會重複 → DP |
| 「刪除/插入/修剪節點並回傳根」 | BST,O(h) | Template 5 — 把回傳的子樹接回去 | 由整棵子樹的性質決定要不要剪 → Template 6 |
| 「直徑」、「是否平衡?」、「子樹和」、「最低共同祖先」、「相鄰節點間的最少移動次數」 | 節點 ≤ 10⁴ | Template 6 — 後序,回傳一個值、記錄另一個值 | 答案取決於從根下來的路徑 → Template 3 |
| 「封閉島嶼」、「被包圍的區域」、「飛地」 | 網格 ≤ 200 × 200 | Template 7 — 先淹掉邊界 | 到邊界的距離 → 多源 BFS |
| 「不同島嶼」、「平移後形狀相同」 | 網格 ≤ 50 × 50 | Template 8 — 路徑簽名 | 旋轉與鏡射也算相同 → 對 8 種變換正規化(LC 711) |
| 「一條路徑、收集最多、不能重複走同一格」、「單字搜尋」 | 網格 ≤ 15 × 15,有用的格子 ≤ 25 | Template 9 — 標記、遞迴、還原 | 格子可以重複走,或網格很大 → DP 或 BFS |
| 「除法求值」、「匯率」、「傳遞性比值」 | 變數 ≤ 40 | Template 10 — 帶權 DFS,沿路相乘 | 固定的圖上有很多查詢 → 帶權併查集 |
不在本頁 — 這些放在 dfs_advanced.md:雙網格驗證(LC 1905)、
邊方向追蹤(LC 1466)、連通分量配對計數(LC 2316)、trie + 萬用字元 DFS(LC 211,
676)、以深度為索引的堆疊 DFS(LC 388, 1233)、依距離分桶的葉節點配對(LC 1530)、N 元樹
後序彙總(LC 3965),以及 parent[] 陣列的深度攀爬(LC 4015)。尤拉路徑(LC 332, 753)
與 Tarjan 橋(LC 1192)在 graph_advanced.md;樹 ⟷ 字串編解碼
(LC 606, 536)在 tree_codec.md。依模板分組的題目在
dfs_examples.md → Index。
模板與演算法
模板比較表
| 模板 | 使用情境 | 關鍵操作 | 時間 | 空間 | 什麼時候用 |
|---|---|---|---|---|---|
| 1. 樹走訪 | 拜訪所有節點 | 遞迴/堆疊 | O(n) | O(h) | 樹的題目 |
| 2. 圖/網格 DFS | 探索圖、填色 | visited 集合/就地標記 | O(V+E) | O(V) | 圖與網格的探索 |
| 3. 路徑搜尋 | 找出特定路徑 | 追蹤路徑 | O(n) | O(h) | 路徑類題目 |
| 4. 回溯 | 嘗試所有路徑 | 撤銷選擇 | O(b^d) | O(d) | 組合類題目 |
| 5. 結構修改 | 改變結構 | 更新節點 | O(n) | O(h) | 樹的編輯 |
| 6. 由下而上 | 彙總資訊 | 後序回傳 | O(n) | O(h) | 子樹類題目 |
| 7. 兩趟 DFS | 邊界消除 | 兩階段填色 | O(m×n) | O(m×n) | 封閉/被包圍的區域 |
| 8. 路徑簽名 | 編碼形狀 | 記錄方向 | O(m×n) | O(m×n) | 計算相異形狀數 |
| 9. 網格 DFS + 回溯 | 網格中的單一最佳路徑 | 標記、遞迴、還原 | O(4^k) | O(k) | 多個起點且路徑會重疊 |
| 10. 帶權圖 DFS | 比值/除法查詢 | 累乘 | O(Q·(V+E)) | O(V+E) | 傳遞性比值計算 |
模板 1:樹走訪 — LC 94 優先度 5/5 — 必備 — 幾乎每一輪面試都會出現
辨識 — 「中序/前序/後序走訪」、「走訪每個節點」、「序列化」、「印出這棵樹」,以及在 BST 裡的 「依排序順序」(BST 的中序就是排好序的)。節點 ≤ 10⁴;遞迴深度等於樹高。
關鍵 — 三種順序是同一個遞迴,只是造訪那一行放的位置不同: 放在子節點之前(前序)、之間(中序)、之後(後序)。不管選哪種順序,每個節點都只進入一次。
# python
# LC 94 - Binary Tree Inorder Traversal
# IDEA: recurse left, visit, recurse right; move `result.append` to get pre- or post-order
# time = O(n), space = O(h) h = tree height, the recursion depth
def inorderTraversal(root):
result = []
def dfs(node):
if not node:
return
dfs(node.left)
result.append(node.val) # inorder: between the two children
dfs(node.right)
dfs(root)
return result
def inorderTraversal_iterative(root):
# the problem's follow-up: an explicit stack replaces the call stack
result, stack = [], []
node = root
while node or stack:
while node: # walk as far left as possible
stack.append(node)
node = node.left
node = stack.pop() # the leftmost node not yet visited
result.append(node.val)
node = node.right # then its right subtree
return result
// java
// LC 94 - Binary Tree Inorder Traversal
// IDEA: recurse left, visit, recurse right; move `result.add` to get pre- or post-order
// time = O(n), space = O(h)
public List<Integer> inorderTraversal(TreeNode root) {
List<Integer> result = new ArrayList<>();
dfs(root, result);
return result;
}
private void dfs(TreeNode node, List<Integer> result) {
if (node == null) {
return;
}
dfs(node.left, result);
result.add(node.val); // inorder: between the two children
dfs(node.right, result);
}
複雜度
- 時間 O(n):
dfs對每個節點跑一次,對每個空子節點(共 n + 1 個)也跑一次,每次 O(1)。 - 空間 O(h):遞迴(或
stack)只存一條從根到節點的路徑 — 平衡時 O(log n), 退化成一條鏈時 O(n)。
陷阱
return inorder(node.left) + [node.val] + inorder(node.right)在每一層都複製串列:O(n·h), 在歪斜的樹上就是 O(n²)。改成往同一個共用串列 append。- 迭代版前序要先推右子節點、再推左子節點,左邊才會先被 pop;先推左邊的話,
[1,2,3]會回傳[1,3,2]。 - 10⁵ 層深的鏈會撐爆 Python 的遞迴上限;迭代寫法沒有這個限制。
不適用時 — 要一層一層的順序 → BFS (bfs.md § Template 1)。節點的答案由子節點的答案 算出來 → Template 6。判斷條件同時需要兩個節點 → 下面的成對 DFS 變形。
練習 — LC 94 · LC 144 · LC 145 (迭代後序 = 反轉「根-右-左」的前序) · LC 100 ⭐ (成對 DFS) · LC 101 (鏡像的成對 DFS:見下方) · LC 297 ⭐ (帶 null 標記的 前序)
Symmetric Tree — LC 101
關鍵 — 一棵樹對稱的充要條件是它的兩棵子樹互為鏡像,所以 DFS 一次帶兩個游標
(a, b),檢查兩者之間的關係:值相等,接著是外側那一對(a.left, b.right)與內側那一對(a.right, b.left)。你往下遞迴時怎麼配對,就是這題本身:同向配對(a.left, b.left)+(a.right, b.right)就是 LC 100 Same Tree。
歸約到:Template 1 — 對一對節點做前序走訪,而不是一個。
來源:symmetric-tree.py。
起手式是 helper(root.left, root.right);根節點本身從頭到尾不跟任何節點比較。三個 base case,
順序如下:兩邊皆空 → True;只有一邊空 → False;值不同 → False。三關都過了才往下走,並讓
and 在第一次對不上時短路。
LC 101 trace — root = [1,2,2,null,3,null,3] helper(a, b) pairs a.left <-> b.right
a.right <-> b.left
1 helper(a=2, b=2) vals equal -> descend
/ \ |- helper(a.left=null, b.right=3) exactly one null -> FALSE
2 2 |- (never evaluated: `and` short-circuits)
\ \
3 3 answer = False
a.right b.right <- both 3's are RIGHT children, so they are not mirror partners
陷阱 — 改用同向配對並不是「大致上可行」:它回答的是另一個問題(「左子樹是否等於右子樹?」), 而且兩個方向都會答錯:
straight pairing (a.left<->b.left, a.right<->b.right) on the same two inputs:
[1,2,2,null,3,null,3] null==null, then 3==3 -> True (correct answer: False)
[1,2,2,3,4,4,3] 3 vs 4 -> False (correct answer: True)
# python
# LC 101 - Symmetric Tree
# IDEA: paired (dual-pointer) DFS; recurse on the MIRROR pairing (a.left, b.right) / (a.right, b.left)
# time = O(n), space = O(h) # h = tree height, worst O(n)
class Solution:
def isSymmetric(self, root):
if not root:
return True
# the helper takes TWO nodes -> the root is never compared to anything
return self.helper(root.left, root.right)
def helper(self, left, right):
if not left and not right: # both empty -> mirrored
return True
if not left or not right: # exactly one empty -> not mirrored
return False
if left.val != right.val:
return False
return (
self.helper(left.left, right.right) # outer pair
and
self.helper(left.right, right.left) # inner pair
)
// java
// LC 101 - Symmetric Tree
// IDEA: paired (dual-pointer) DFS; recurse on the MIRROR pairing
// time = O(n), space = O(h)
public boolean isSymmetric(TreeNode root) {
if (root == null) {
return true;
}
return helper(root.left, root.right);
}
private boolean helper(TreeNode left, TreeNode right) {
if (left == null && right == null) {
return true;
}
if (left == null || right == null) {
return false;
}
if (left.val != right.val) {
return false;
}
return helper(left.left, right.right) // outer pair
&& helper(left.right, right.left); // inner pair
}
迭代寫法 — 把配對放在堆疊上
題目自己的 follow up 就在問這個寫法,而它也是把成對 DFS 去遞迴化的通用做法:堆疊裡放的是 一對一對的節點,推入與彈出都是兩個一起。
# python
# LC 101 - Symmetric Tree (iterative — the stated follow-up)
# IDEA: same mirror pairing, kept on an explicit stack; pop two entries = pop one pair
# time = O(n), space = O(n)
def isSymmetric(root):
if not root:
return True
stack = [root.left, root.right]
while stack:
p, q = stack.pop(), stack.pop() # pops ONE pair
if not p and not q:
continue
if not p or not q or p.val != q.val:
return False
# push the two mirror pairs
stack.append(p.left)
stack.append(q.right) # outer pair
stack.append(p.right)
stack.append(q.left) # inner pair
return True
- 刻意把
None也推進去。 跟模板 1 的迭代走訪不同,這裡不可以跳過空的子節點:(None, node)這種配對一定要被彈出來比較過,才能回傳False。用if p.left:擋住推入, 會默默地把不對稱的樹判成對稱。 - 彈出時配對是反過來的 —
stack.pop(), stack.pop()拿回來的是(q.left, p.right), 也就是後推入的那個先出來。這裡無害,因為鏡像述語對它的兩個參數是對稱的;但在述語有方向性的 成對 DFS(例如「b是不是a的子樹」)裡就不是無害的,那時你必須把兩個值彈進正確的位置。 - 換成配對佇列也完全一樣 — 把堆疊換成
deque、連popleft()兩次;比較的順序會變,答案不會變。 見 bfs.md 裡的 LC 101 那一列。
類似題:
- LC 100 - Same Tree(同一個骨架,改用同向配對)
- LC 951 - Flip Equivalent Binary Trees(兩種配對都試,再
or起來) - LC 572 - Subtree of Another Tree(把 LC 100 的成對 DFS 在每個節點重新發動一次)
- LC 617 - Merge Two Binary Trees(成對 DFS,但建出一個節點而不是回傳布林值)
- LC 1612 - Check If Two Expression Trees are Equivalent(成對走訪 + 比較葉節點的多重集合)
- LC 226 - Invert Binary Tree(把鏡像當成一個變換 — 對稱就等於
isSameTree(root, invert(root)),代價是會改動原樹) - LC 872 - Leaf-Similar Trees(刻意放進來當對照:兩趟各自獨立的 DFS, 事後比較葉節點序列,因為這題允許兩棵樹的形狀不同)
模板 2:圖/網格 DFS(Flood Fill) — LC 200 優先度 5/5 — 必備 — 幾乎每一輪面試都會出現
辨識 — 「島嶼數量」、「連通區域」、「flood fill」、「最大島嶼面積」、 「複製圖」。網格 ≤ 300 × 300,或 V + E ≤ 10⁵ → 一次 O(V + E) 掃過。
關鍵 — 外層掃描第一次碰到每座島時只會碰到其中一格,而那一格的 DFS 會把相連的陸地全部 淹掉。所以每座島都恰好只在它的第一格被數一次,而每一格只被進入常數次。
# python
# LC 200 - Number of Islands
# IDEA: scan the grid; each '1' still standing starts a DFS that sinks its island -> count the launches
# time = O(m * n), space = O(m * n) the recursion is as deep as the largest island
def numIslands(grid):
rows, cols = len(grid), len(grid[0])
def sink(r, c):
if r < 0 or r >= rows or c < 0 or c >= cols or grid[r][c] != '1':
return # off the grid, water, or already sunk
grid[r][c] = '0' # mark BEFORE recursing, so no cell is entered twice
sink(r + 1, c)
sink(r - 1, c)
sink(r, c + 1)
sink(r, c - 1)
islands = 0
for r in range(rows):
for c in range(cols):
if grid[r][c] == '1':
islands += 1 # the first cell of a new island
sink(r, c)
return islands
// java
// LC 200 - Number of Islands
// IDEA: scan the grid; each '1' still standing starts a DFS that sinks its island -> count the launches
// time = O(m * n), space = O(m * n)
public int numIslands(char[][] grid) {
int islands = 0;
for (int r = 0; r < grid.length; r++) {
for (int c = 0; c < grid[0].length; c++) {
if (grid[r][c] == '1') {
islands++; // the first cell of a new island
sink(grid, r, c);
}
}
}
return islands;
}
private void sink(char[][] grid, int r, int c) {
if (r < 0 || r >= grid.length || c < 0 || c >= grid[0].length || grid[r][c] != '1') {
return;
}
grid[r][c] = '0'; // mark BEFORE recursing
sink(grid, r + 1, c);
sink(grid, r - 1, c);
sink(grid, r, c + 1);
sink(grid, r, c - 1);
}
複雜度
- 時間 O(m·n):外層掃描每格讀一次,而
sink在遞迴之前就把'1'改成'0', 所以每個陸地格只會進入一次;四個鄰居的檢查都是 O(1)。 - 空間 O(m·n):整片都是陸地時就是一座島,遞迴可以深到那個程度。
陷阱
- 在四個遞迴呼叫之後才標記:兩個陸地格會互相遞迴到永遠 —
[["1","1"]]就會撐爆堆疊。 - LC 200 的網格存的是字元
'1'和'0';拿整數1去比,會一座島都找不到。 - 300 × 300 全是陸地就是 9·10⁴ 層深的遞迴:Python 需要
sys.setrecursionlimit,或改用 BFS 寫法 (bfs.md § Template 3)。 - 把網格淹掉會破壞輸入。面試時要講出來,或改用
visited集合。
不適用時 — 有向圖問「有沒有環」→ 三狀態 DFS
(graph.md § Template 5);單純的
visited 集合會誤報環。兩格之間的最短路線 → BFS。邊一條一條進來 → 併查集。
練習 — LC 200 ⭐ · LC 733 (淹一個區域) · LC 695 (讓 sink 回傳面積) ·
LC 133 ⭐ (visited 就是 {original: copy} 這張對照表) · LC 417 ⭐ (從兩個海洋各自往內淹,
再取交集) · LC 419 (完全不用 DFS:見下方變形)
Battleships in a Board — LC 419
關鍵 — 每艘船都是一條筆直的 1×k 或 k×1 線段,所以每艘船恰好有一個左上端點:一個上方和 左方都沒有
'X'的'X'。數這些格子就等於數船 —— 一次掃描、O(1) 額外空間、不用visited集合、不改動棋盤。
歸約到:Template 2 — 數連通分量,只是「船是 直線」的保證讓你只需數每個分量的第一格,不必 flood fill 整個分量。這正是題目的 follow up (一次掃描、O(1) 空間、不改動棋盤),建立在 LC 200 的 flood fill 之上。
# python
# LC 419 - Battleships in a Board
# IDEA: count only the top-left cell of each ship -> no visited set needed
# time = O(M*N), space = O(1)
def countBattleships(board):
count = 0
for r in range(len(board)):
for c in range(len(board[0])):
if board[r][c] != 'X':
continue
if r > 0 and board[r - 1][c] == 'X':
continue
if c > 0 and board[r][c - 1] == 'X':
continue
count += 1
return count
// java
// LC 419 - Battleships in a Board
// IDEA: a cell starts a NEW ship iff it is 'X' and has no 'X' above and no 'X' to its left
// time = O(M*N), space = O(1)
public int countBattleships(char[][] board) {
int count = 0;
for (int r = 0; r < board.length; r++) {
for (int c = 0; c < board[0].length; c++) {
if (board[r][c] != 'X') continue;
if (r > 0 && board[r - 1][c] == 'X') continue; // continuation of a vertical ship
if (c > 0 && board[r][c - 1] == 'X') continue; // continuation of a horizontal ship
count++;
}
}
return count;
}
陷阱
- 這招靠的是那個保證。拿掉「船是直線」,L 形的船就會有兩個看起來像左上端點的格子;這時退回上面 LC 200 的 flood fill。
模板 3:路徑搜尋 — LC 112 優先度 5/5 — 必備 — 幾乎每一輪面試都會出現
辨識 — 「根到葉的路徑和」、「路徑是否存在」、「所有路徑」、「從 a 到 b 的 路徑」。節點 ≤ 5,000。
關鍵 — 路徑狀態(這裡是還需要的和)以參數往下傳,所以每個呼叫看到的恰好是從根到自己的 路徑。答案只在葉節點檢查,任一個子節點回傳 True 就立刻往上回傳。
# python
# LC 112 - Path Sum
# IDEA: pass the remaining sum down; at a leaf it must be exactly 0; return True as soon as a child does
# time = O(n), space = O(h)
def hasPathSum(root, targetSum):
if not root:
return False
remaining = targetSum - root.val
if not root.left and not root.right: # a leaf: the path ends here
return remaining == 0
return hasPathSum(root.left, remaining) or hasPathSum(root.right, remaining)
// java
// LC 112 - Path Sum
// IDEA: pass the remaining sum down; at a leaf it must be exactly 0; return true as soon as a child does
// time = O(n), space = O(h)
public boolean hasPathSum(TreeNode root, int targetSum) {
if (root == null) {
return false;
}
int remaining = targetSum - root.val;
if (root.left == null && root.right == null) { // a leaf: the path ends here
return remaining == 0;
}
return hasPathSum(root.left, remaining) || hasPathSum(root.right, remaining);
}
複雜度
- 時間 O(n):每個節點最多造訪一次,
or在第一個 True 就停。 - 空間 O(h):目前這條根到節點的路徑上,每個節點一個呼叫。
陷阱
- 在空的子節點而不是葉節點檢查
remaining == 0:[1,2]、目標 1 會透過 1 不存在的右子節點 回傳 True,但唯一一條根到葉的路徑和是 3。 - 空樹沒有任何路徑,目標是 0 也一樣:
hasPathSum(None, 0)是 False。 - 在圖的搜尋裡呼叫
dfs(next)卻沒有回傳它的結果:True 找到了又被丟掉 (見下方的提早回傳一節)。
不適用時 — 路徑可以在往下的任何地方開始與結束 → 沿路徑的前綴和 (LC 437)。要的是最短路徑 → BFS。路徑可以在某個節點轉彎(最大路徑和)→ Template 6。
練習 — LC 112 · LC 257 (把路徑收集成字串) · LC 113 (收集和符合的路徑: 見下方) · LC 1971 (在圖上:提早回傳) · LC 437 (任意往下的路徑:前綴和)
整個樹路徑題家族(最大路徑、連續序列、前綴和技巧), 見 tree_lca_distance.md § 4。
變形:收集每一條路徑(推入、遞迴、彈出)
# python
# LC 113 - Path Sum II
# IDEA: carry the path down; copy it at a matching leaf; pop on the way back up
# time = O(n * h), space = O(h) copying a path of length h at each matching leaf
def pathSum(root, targetSum):
result, path = [], []
def dfs(node, remaining):
if not node:
return
path.append(node.val)
remaining -= node.val
if not node.left and not node.right and remaining == 0:
result.append(path[:]) # a copy: path keeps changing
dfs(node.left, remaining)
dfs(node.right, remaining)
path.pop() # undo before returning to the parent
dfs(root, targetSum)
return result
DFS 提早回傳模式 — TRUE 要積極回傳,FALSE 要拖到最後
DFS 只要找一條路徑時,子呼叫的 true 一回來就必須立刻回傳。忽略它的話
會繼續搜尋,而函式最後還是會落到結尾的 return false。
// java
// IDEA: WRONG — the child's result is ignored, so a path that was found is thrown away
// time = O(V + E), space = O(V)
private boolean dfsPathVisitor(int node, int destination, Map<Integer, List<Integer>> map, boolean[] visited) {
if (node == destination) return true;
visited[node] = true;
for (int next : map.get(node)) {
if (!visited[next]) {
dfsPathVisitor(next, destination, map, visited); // result dropped
}
}
return false; // false for every node except the destination itself
}
// java
// IDEA: RIGHT — return true as soon as any child reaches the destination
// time = O(V + E), space = O(V)
private boolean dfsPathVisitor(int node, int destination, Map<Integer, List<Integer>> map, boolean[] visited) {
if (node == destination) return true;
visited[node] = true;
for (int next : map.get(node)) {
if (!visited[next] && dfsPathVisitor(next, destination, map, visited)) {
return true; // found one path - that is enough
}
}
return false; // only after every neighbour has failed
}
Graph: 0 -- 1 -- 2 -- 3 find a path 0 -> 3
| |
4 ------- 5
dfs(0) -> dfs(1) -> dfs(2) -> dfs(3) returns TRUE
WRONG: dfs(2) ignores it, tries 5 (a dead end), returns FALSE; so do dfs(1) and dfs(0)
RIGHT: dfs(2), dfs(1) and dfs(0) each return TRUE at once; 5 and 4 are never tried
| 忽略子呼叫的結果 | 收到 true 就回傳 |
|
|---|---|---|
| 正確性 | 路徑存在也回傳 false | 找到時回傳 true |
| 工作量 | 探索每一條分支 | 在第一條路徑就停 |
| 適用於 | 蒐集或計數所有結果(LC 257, 113, 22) | 判斷存在(LC 1971, 79, 112) |
模板 4:回溯 — LC 46
辨識 — 「所有排列/組合/子集」、「產生每一個合法的 …」、「回傳所有 方法」。n ≤ 10(n! ≈ 3.6·10⁶)或 n ≤ 20(2ⁿ ≈ 10⁶)→ 指數級的搜尋就是預期解法。
關鍵 — path 永遠存著從決策樹的根走到目前呼叫的這一路選擇,每個選擇在試下一個之前都會被撤銷
(pop、used[i] = False),所以兄弟分支都從同一個狀態出發。
# python
# LC 46 - Permutations
# IDEA: choose an unused number, recurse, un-choose; a full-length path is one permutation
# time = O(n * n!), space = O(n) n! leaves, each copied in O(n); output not counted
def permute(nums):
result, path = [], []
used = [False] * len(nums)
def backtrack():
if len(path) == len(nums):
result.append(path[:]) # copy: path is undone afterwards
return
for i in range(len(nums)):
if used[i]:
continue
used[i] = True # choose
path.append(nums[i])
backtrack()
path.pop() # un-choose, so the next i starts clean
used[i] = False
backtrack()
return result
// java
// LC 46 - Permutations
// IDEA: choose an unused number, recurse, un-choose; a full-length path is one permutation
// time = O(n * n!), space = O(n)
public List<List<Integer>> permute(int[] nums) {
List<List<Integer>> result = new ArrayList<>();
backtrack(nums, new boolean[nums.length], new ArrayList<>(), result);
return result;
}
private void backtrack(int[] nums, boolean[] used, List<Integer> path, List<List<Integer>> result) {
if (path.size() == nums.length) {
result.add(new ArrayList<>(path)); // copy: path is undone afterwards
return;
}
for (int i = 0; i < nums.length; i++) {
if (used[i]) {
continue;
}
used[i] = true; // choose
path.add(nums[i]);
backtrack(nums, used, path, result);
path.remove(path.size() - 1); // un-choose
used[i] = false;
}
}
複雜度
- 時間 O(n · n!):決策樹有 n! 個葉節點,每個以 O(n) 複製;上面的內部節點 加起來也不超過這個量。
- 空間 O(n):
path、used與遞迴深度,不計輸出。
陷阱
result.append(path)把同一個串列存了 n! 次,搜尋退回去之後它是空的:[1,2]會回傳[[], []]。- 組合(LC 39, LC 77)會傳
start索引,所以[1,2]和[2,1]不會同時產生; 排列則從 0 開始迴圈並跳過used。兩者搞混會產生重複或漏掉順序。 - 輸入有重複(LC 47):先排序,然後在
nums[i-1]未使用時跳過nums[i] == nums[i-1]。
不適用時 — 只問排列的數量,而且子狀態會重複 → DP。完整家族 (子集、組合總和、N 皇后、剪枝)在 backtrack.md。
練習 — LC 46 ⭐ · LC 78 ⭐ (子集:每個節點都記錄,不只葉節點) · LC 39 ⭐ (start 索引,可重複使用) · LC 17 ⭐ (每層一個數字) · LC 51 (用欄與對角線集合 剪枝)
模板 5:樹結構修改 — LC 450
辨識 — 「刪除/插入/修剪/剪掉節點」、「回傳修改後的樹的根」、 「轉換這棵樹」。節點 ≤ 10⁴;在 BST 裡工作量是 O(h)。
關鍵 — 函式回傳它收到那棵子樹的(可能是新的)根,每個呼叫者都把它寫回去:
node.left = f(node.left)。被刪除或被替換的節點就由它自己父節點的指派斷開,
不需要父指標,根也不用特別處理。
# python
# LC 450 - Delete Node in a BST
# IDEA: return the new subtree root and let the parent reassign it; two children -> copy the successor in
# time = O(h), space = O(h)
def deleteNode(root, key):
if not root:
return None
if key < root.val:
root.left = deleteNode(root.left, key) # reassign: that subtree's root may change
elif key > root.val:
root.right = deleteNode(root.right, key)
else:
if not root.left:
return root.right # 0 or 1 child: lift the other side
if not root.right:
return root.left
successor = root.right # 2 children: smallest node on the right
while successor.left:
successor = successor.left
root.val = successor.val
root.right = deleteNode(root.right, successor.val)
return root
// java
// LC 450 - Delete Node in a BST
// IDEA: return the new subtree root and let the parent reassign it; two children -> copy the successor in
// time = O(h), space = O(h)
public TreeNode deleteNode(TreeNode root, int key) {
if (root == null) {
return null;
}
if (key < root.val) {
root.left = deleteNode(root.left, key); // reassign: that subtree's root may change
} else if (key > root.val) {
root.right = deleteNode(root.right, key);
} else {
if (root.left == null) {
return root.right;
}
if (root.right == null) {
return root.left;
}
TreeNode successor = root.right;
while (successor.left != null) {
successor = successor.left;
}
root.val = successor.val;
root.right = deleteNode(root.right, successor.val);
}
return root;
}
複雜度
- 時間 O(h):一趟從根走到節點,再加一趟沿右子樹往下找後繼 — 平衡時 O(log n),退化成鏈時 O(n)。
- 空間 O(h):遞迴沿著同一條路徑。
陷阱
- 呼叫
deleteNode(root.left, key)卻沒有把結果接回去:父節點還指著 舊的子節點,[5,3,6]刪 key 3 之後 3 還在。 - 刪除根:呼叫者必須使用回傳值。
[5]刪 key 5 回傳None。 - 把後繼的值複製上來之後,要從右子樹刪掉後繼的值,
而不是
key—key已經不在樹裡了。
不適用時 — 這棵樹不是 BST → 兩邊都要找,O(n)。剪不剪取決於整棵 子樹的性質(「移除不含 1 的子樹」,LC 814)→ 後序 (Template 6),一樣回傳剪過的子樹。
練習 — LC 450 · LC 226 ⭐ (翻轉:把交換後的子節點接回去) · LC 701 (插入:同樣的 接回寫法) · LC 669 (修剪到一個範圍) · LC 814 (後序剪枝)
模板 6:由下而上(後序)DFS — LC 543 優先度 5/5 — 必備 — 幾乎每一輪面試都會出現
辨識 — 「直徑」、「高度」、「是否平衡」、「子樹和」、「最低共同祖先」、 「包含 … 的最小子樹」、「相鄰節點間的最少移動次數」:節點的答案需要先有 子節點的答案。節點 ≤ 10⁴。
關鍵 — 每個呼叫只回傳一個值給父節點(這裡是高度),而題目要的答案
(這裡是 left + right,在這個節點轉彎的最長路徑)是另一個
值,記在外層變數裡,從不回傳。
# python
# LC 543 - Diameter of Binary Tree
# IDEA: post-order; return the height upward, record left + right (the path through this node) globally
# time = O(n), space = O(h)
def diameterOfBinaryTree(root):
best = 0
def height(node):
nonlocal best
if not node:
return 0
left = height(node.left)
right = height(node.right)
best = max(best, left + right) # edges on the longest path that turns here
return 1 + max(left, right) # what the parent needs: one side only
height(root)
return best
// java
// LC 543 - Diameter of Binary Tree
// IDEA: post-order; return the height upward, record left + right (the path through this node) globally
// time = O(n), space = O(h)
private int best = 0;
public int diameterOfBinaryTree(TreeNode root) {
best = 0;
height(root);
return best;
}
private int height(TreeNode node) {
if (node == null) {
return 0;
}
int left = height(node.left);
int right = height(node.right);
best = Math.max(best, left + right); // edges on the longest path that turns here
return 1 + Math.max(left, right); // what the parent needs: one side only
}
複雜度
- 時間 O(n):
height對每個節點跑一次,兩個子節點回來之後只做 O(1) 的工作。 - 空間 O(h):遞迴深度。
試跑
root = [1,2,3,4,5] 1
/ \
2 3
/ \
4 5
height(4) = 1, height(5) = 1 at 2: best = 1 + 1 = 2, return 2
height(3) = 1 at 1: best = max(2, 2 + 1) = 3, return 3
answer 3 (the path 4-2-1-3 has 3 edges)
陷阱
- 往上回傳
left + right而不是1 + max(left, right):父節點會把兩個分岔 接成一個不是路徑的形狀。下面的 LC 124 是同一個陷阱,只是換成數值。 - 數節點而不是數邊:LC 543 要的是邊數,所以
[1,2]是 1,不是 2。 - 從每個祖先各自呼叫一次
height(node.left)(由上而下)在鏈上是 O(n²);一趟 後序就把每個高度回傳一次。
不適用時 — 值取決於從根下來的路徑(深度、目前為止的和)→ 把它往下傳 (Template 3)。兩個指定節點的最低共同祖先 有自己的一頁:tree_lca_distance.md。
練習 — LC 543 ⭐ · LC 104 ⭐ · LC 110 ⭐ (「不平衡」就回傳 -1) · LC 236 (回傳子樹裡 找到了哪些目標) · LC 979 (帶正負號的餘額:見下方) · LC 124 ⭐ (帶數值與截斷的 LC 543:見下方)
變形:由下而上找最低共同祖先
LCA 其實是偽裝的後序:答案是往上走時,第一個在兩側都看到目標的 節點。
- 標準 LCA(LC 236):每個呼叫回傳它在子樹裡找到的目標,或
None; 第一個左右呼叫都非空的節點就是 LCA。 - 以深度為準的 LCA(LC 865, LC 1123):目標是隱含的 — 每個最深的葉節點。回傳
(node, depth);較深的一側勝出,深度相同表示最深的幾條路徑在這裡會合。 - **「包含所有 X 的最小子樹」**和「所有 X 節點的 LCA」是同一個問題。
- 相關:LC 235(BST:依值往下走,O(h))、LC 1644(目標可能不存在)、LC 1650 (有父指標:雙指標往上走)、LC 1676(多個目標)。完整解法在 tree_lca_distance.md。
Binary Tree Maximum Path Sum — LC 124
關鍵 — Template 6 加上數值:回傳最好的單邊路徑,並在每個節點用兩邊合起來的彎折路徑更新答案。
歸約到:tree_lca_distance.md § LC 124,完整解在那裡;這裡只是指標。
Distribute Coins in Binary Tree — LC 979
關鍵 — 每條樹邊都是一座橋:切斷子樹上方的邊,剛好分成兩邊,所以必須越過它的硬幣數是
|balance(subtree)|,其中balance = node.val - 1 + balance(left) + balance(right)(自己留一枚, 其餘往上推)。流量是被迫的;答案就是每條邊上|balance|的總和,沒有東西要搜尋。
歸約到:Template 6 — 往上回傳一個有號的值 (餘額),在全域累加另一個值(它的絕對值)。
LC 979 trace — root = [0, 3, 0] balance = val - 1 + left + right
0 dfs(left 3) -> 3 - 1 + 0 + 0 = +2 moves += 2 (2 coins go UP)
/ \ dfs(right 0) -> 0 - 1 + 0 + 0 = -1 moves += 1 (1 coin goes DOWN)
3 0 dfs(root 0) -> 0 - 1 + 2 + (-1) = 0 <- always 0 at root
total moves = 2 + 1 = 3
# python
# LC 979 - Distribute Coins in Binary Tree
# IDEA: post-order DFS; each subtree returns its net balance, each edge costs |balance| moves
# time = O(n), space = O(h) # h = tree height, worst O(n)
class Solution:
def distributeCoins(self, root):
self.moves = 0
def dfs(node):
if not node:
return 0
left = dfs(node.left) # net surplus/deficit of left subtree
right = dfs(node.right) # net surplus/deficit of right subtree
balance = node.val - 1 + left + right # keep 1 coin, push the rest up
self.moves += abs(balance) # this subtree's edge to its parent
return balance # signed, never abs()
dfs(root)
return self.moves
// java
// LC 979 - Distribute Coins in Binary Tree
// IDEA: post-order DFS; each subtree returns its net balance, each edge costs |balance| moves
// time = O(n), space = O(h)
private int moves = 0;
public int distributeCoins(TreeNode root) {
moves = 0;
dfs(root);
return moves;
}
private int dfs(TreeNode node) {
if (node == null) {
return 0;
}
int left = dfs(node.left);
int right = dfs(node.right);
int balance = node.val - 1 + left + right; // keep 1 coin, push the rest up
moves += Math.abs(balance); // this subtree's edge to its parent
return balance; // signed, never Math.abs()
}
陷阱
- 回傳有號的餘額,累加絕對值。把
abs(...)往上回傳是經典 bug:-2的短缺必須保持負號, 才能在父節點抵銷兄弟的+2。 - 不要因為
node.val == 1就跳過節點:本身平衡的節點仍然要轉運子樹的流量。 - 不要在鄰接串列上用 BFS 擴散硬幣。BFS 的前緣看不到左子樹缺 3 枚、右子樹多 3 枚;只有由下往上 的餘額才給得出這個全域視角。
類似題:
- LC 979 - Distribute Coins in Binary Tree(後序餘額/流量的標準題)
- LC 2477 - Minimum Fuel Cost to Report to the Capital(同樣的邊流量計數,但每條邊是
ceil(people / seats)) - LC 1443 - Minimum Time to Collect All Apples in a Tree(後序,每條有用的邊算 2)
- LC 1339 - Maximum Product of Splitted Binary Tree(後序子樹和,再切一條邊)
- LC 508 - Most Frequent Subtree Sum(每棵子樹的值由後序往上彙總)
- LC 124 - Binary Tree Maximum Path Sum(往上回傳一個值,在全域彙總另一個值)
- LC 2049 - Count Nodes With the Highest Score(子樹大小彙總 —— 見下面的題目)
Count Nodes With the Highest Score — LC 2049
關鍵 — 移除節點
x後,每個子節點的子樹各自成為一個分量,再加上x上方的全部節點, 大小是n - size(x)。所以一趟回傳子樹大小的後序走訪就能替每個節點計分:score(x) = Π size(child) × (n - size(x)),在根節點時省略最後一項。
歸約到:Template 6 — 往上回傳子樹大小,沿途 彙總分數(以及有幾個節點達到最佳分數)。
# python
# IDEA: sizes come children-first (reverse BFS order); removing x leaves its child subtrees + n - size(x)
# time = O(n), space = O(n)
# Iterative on purpose: n is up to 10^5 and a chain would overflow Python's recursion limit.
# LC 2049 - Count Nodes With the Highest Score
from collections import deque
def countHighestScoreNodes(parents):
n = len(parents)
children = [[] for _ in range(n)]
for node in range(1, n): # parents[0] == -1: node 0 is the root
children[parents[node]].append(node)
order = [] # BFS order from the root
queue = deque([0])
while queue:
node = queue.popleft()
order.append(node)
queue.extend(children[node])
size = [1] * n
best_score = 0
best_count = 0
for node in reversed(order): # every child is finished before its parent
score = 1
for child in children[node]:
score *= size[child] # each child's subtree is one component
size[node] += size[child]
above = n - size[node] # everything outside this subtree
if above > 0: # the root has nothing above it
score *= above
if score > best_score:
best_score, best_count = score, 1
elif score == best_score:
best_count += 1
return best_count
陷阱
- 根節點上方沒有分量:
n - size(root) == 0會把根的分數歸零。這一項是 0 時就跳過。 - n ≤ 10⁵ 而且樹可能是一條鏈,遞迴的後序走訪會超過 Python 的預設上限;上面依反向 BFS 順序的迴圈,不用遞迴就得到同樣「子節點先算完」的順序。
- 三個約 3·10⁴ 節點分量的乘積約為 2.7·10¹³:在 Java 裡需要
long。
類似題 — LC 1519 (依標籤彙總子樹) · LC 508 (子樹和 + 頻率) · LC 834 (子樹大小,再換根)
模板 7:兩趟 DFS(邊界消除) — LC 1254
辨識 — 「封閉島嶼」、「被包圍的區域」、「飛地」、「被吃掉」:和 邊界相連的陸地不算。網格 ≤ 100 × 100(LC 1254)或 200 × 200(LC 130)。
關鍵 — 碰到邊界的陸地永遠不可能封閉,所以第一趟把所有和 邊界相連的格子淹掉;第一趟之後還留下的陸地恰好就是封閉的陸地,第二趟 就像 LC 200 數島嶼那樣把它數出來。
# python
# LC 1254 - Number of Closed Islands
# IDEA: pass 1 floods every island touching the border; pass 2 counts the islands left (0 = land here)
# time = O(m * n), space = O(m * n)
def closedIsland(grid):
rows, cols = len(grid), len(grid[0])
def flood(r, c):
if r < 0 or r >= rows or c < 0 or c >= cols or grid[r][c] == 1:
return
grid[r][c] = 1 # land -> water, which also marks it visited
flood(r + 1, c)
flood(r - 1, c)
flood(r, c + 1)
flood(r, c - 1)
# pass 1: remove every island that touches the border
for c in range(cols):
flood(0, c)
flood(rows - 1, c)
for r in range(rows):
flood(r, 0)
flood(r, cols - 1)
# pass 2: every island still standing is closed
count = 0
for r in range(1, rows - 1):
for c in range(1, cols - 1):
if grid[r][c] == 0:
count += 1
flood(r, c)
return count
// java
// LC 1254 - Number of Closed Islands
// IDEA: pass 1 floods every island touching the border; pass 2 counts the islands left (0 = land here)
// time = O(m * n), space = O(m * n)
public int closedIsland(int[][] grid) {
int rows = grid.length;
int cols = grid[0].length;
for (int c = 0; c < cols; c++) { // pass 1: top and bottom borders
flood(grid, 0, c);
flood(grid, rows - 1, c);
}
for (int r = 0; r < rows; r++) { // pass 1: left and right borders
flood(grid, r, 0);
flood(grid, r, cols - 1);
}
int count = 0; // pass 2: every island left is closed
for (int r = 1; r < rows - 1; r++) {
for (int c = 1; c < cols - 1; c++) {
if (grid[r][c] == 0) {
count++;
flood(grid, r, c);
}
}
}
return count;
}
private void flood(int[][] grid, int r, int c) {
if (r < 0 || r >= grid.length || c < 0 || c >= grid[0].length || grid[r][c] == 1) {
return;
}
grid[r][c] = 1; // land -> water, which also marks it visited
flood(grid, r + 1, c);
flood(grid, r - 1, c);
flood(grid, r, c + 1);
flood(grid, r, c - 1);
}
複雜度
- 時間 O(m·n):兩趟加起來每個陸地格只淹一次,因為
flood在遞迴前就把 0 改成 1;掃描對每格只讀常數次。 - 空間 O(m·n):一次淹水的遞迴深度,最壞情況是整個網格。
陷阱
- 在單一 DFS 裡判斷「封閉」,一碰到邊界就回傳:那座島剩下的部分沒被淹掉, 之後又從別的格子被數一次。若要一趟做完, DFS 必須把整座島走完才回報。
- LC 1254 用
0表示陸地、1表示水 — 和 LC 200 相反。 - LC 130 用第三個值(
'E')標記和邊界相連的區域,然後在另一趟掃描中把'O' → 'X'、'E' → 'O'; 在淹水過程中就翻轉,會改到它之後還要讀的格子。
不適用時 — 問的是到邊界的距離,而不是某格能不能到邊界 → 從邊界格出發的多源 BFS (bfs.md § Template 4)。
練習 — LC 1254 · LC 1020 (數格子而不是數島嶼) · LC 130 (把剩下的翻轉) · LC 417 ⭐ (兩次淹水,從兩個海洋各自往內,再取交集)
模板 8:路徑簽名(形狀編碼) — LC 694
辨識 — 「不同島嶼的數量」、「平移後形狀相同」、「獨特的形狀」、 「重複的子樹」。網格 ≤ 50 × 50。
關鍵 — 總是以相同順序嘗試四個方向的 DFS,走兩座同形狀的島時順序也會相同。 所以移動的字串,加上每次退回時的回退標記,在形狀相同時完全一樣、 形狀不同時就不同,把這些字串放進集合就能數出形狀數。
# python
# LC 694 - Number of Distinct Islands
# IDEA: record the move into each cell and a 'B' on the way back; equal shapes give equal strings
# time = O(m * n), space = O(m * n)
def numDistinctIslands(grid):
rows, cols = len(grid), len(grid[0])
shapes = set()
def dfs(r, c, move, path):
if r < 0 or r >= rows or c < 0 or c >= cols or grid[r][c] != 1:
return
grid[r][c] = 0 # mark visited
path.append(move)
dfs(r + 1, c, 'D', path) # a FIXED order: down, up, right, left
dfs(r - 1, c, 'U', path)
dfs(r, c + 1, 'R', path)
dfs(r, c - 1, 'L', path)
path.append('B') # back marker: this cell's branch is finished
for r in range(rows):
for c in range(cols):
if grid[r][c] == 1:
path = []
dfs(r, c, 'S', path) # 'S' = the start cell
shapes.add(''.join(path))
return len(shapes)
// java
// LC 694 - Number of Distinct Islands
// IDEA: record the move into each cell and a 'B' on the way back; equal shapes give equal strings
// time = O(m * n), space = O(m * n)
public int numDistinctIslands(int[][] grid) {
Set<String> shapes = new HashSet<>();
for (int r = 0; r < grid.length; r++) {
for (int c = 0; c < grid[0].length; c++) {
if (grid[r][c] == 1) {
StringBuilder path = new StringBuilder();
dfs(grid, r, c, 'S', path);
shapes.add(path.toString());
}
}
}
return shapes.size();
}
private void dfs(int[][] grid, int r, int c, char move, StringBuilder path) {
if (r < 0 || r >= grid.length || c < 0 || c >= grid[0].length || grid[r][c] != 1) {
return;
}
grid[r][c] = 0; // mark visited
path.append(move);
dfs(grid, r + 1, c, 'D', path); // a FIXED order: down, up, right, left
dfs(grid, r - 1, c, 'U', path);
dfs(grid, r, c + 1, 'R', path);
dfs(grid, r, c - 1, 'L', path);
path.append('B'); // back marker
}
複雜度
- 時間 O(m·n):每個陸地格只進入一次並附加兩個字元,所以建出並 雜湊所有字串和陸地總量成線性。
- 空間 O(m·n):
shapes裡的字串加上遞迴深度。
試跑 — 為什麼需要回退標記
Shape A 1 1 Shape B 1 .
1 . 1 1
order D, U, R, L from the top-left cell
A: S, D to (1,0), back, R to (0,1), back, back -> "SDBRBB"
B: S, D to (1,0), R to (1,1), back, back, back -> "SDRBBB"
without the 'B's both are "SDR"
陷阱
- 沒有回退標記:上面兩個形狀都編碼成
"SDR",被當成同一個。 - 每座島的方向順序都必須相同;對方向的
set做迴圈 無法保證這一點。 - 依造訪順序記錄的相對座標
(r - r0, c - c0)也是同樣有效的簽名,而且不需要 標記。別在同一個集合裡混用兩種編碼。
不適用時 — 旋轉與鏡射也算同一形狀(LC 711)→ 收集每座島的 格子,對 8 種變換正規化。換成子樹而不是島嶼(LC 652)→ 以後序序列化 每棵子樹,用 map 計數字串。
練習 — LC 694 · LC 652 (序列化每棵子樹) · LC 711 (8 種變換)
模板 9:網格 DFS + 還原(回溯) — LC 1219
辨識 — 「在一條路徑上收集最多黃金」、「不重複走格子的最長路徑」、 「可以從任何地方開始和結束」、「單字搜尋」。網格很小(m, n ≤ 15,有用的格子最多 25 個)→ 指數級的搜尋就是預期解法。
關鍵 — 路徑進入一格時標記它,離開時還原,所以 網格永遠剛好顯示目前路徑上的格子。正因如此,之後的路徑 — 不論是從 別的起點,還是走兄弟分支 — 才能再用這一格。
# python
# LC 1219 - Path with Maximum Gold
# IDEA: DFS from every gold cell; mark on entry, restore on exit; the guard at the top of the child
# time = O(k * 3^k), space = O(k) k = number of gold cells (k <= 25)
class Solution:
def getMaximumGold(self, grid):
self.max_gold = 0
rows, cols = len(grid), len(grid[0])
for r in range(rows):
for c in range(cols):
if grid[r][c] > 0:
self.dfs(grid, r, c, 0) # start value = 0
return self.max_gold
def dfs(self, grid, r, c, cur_gold):
rows, cols = len(grid), len(grid[0])
# GUARD: off the grid, empty (0) or on the current path (-1) -> stop
if r < 0 or r >= rows or c < 0 or c >= cols or grid[r][c] <= 0:
return
cache = grid[r][c]
cur_gold += cache # ACCUMULATE this cell
self.max_gold = max(self.max_gold, cur_gold) # UPDATE MAX on every cell entry
grid[r][c] = -1 # mark: on the current path
self.dfs(grid, r + 1, c, cur_gold) # the guard filters every direction
self.dfs(grid, r - 1, c, cur_gold)
self.dfs(grid, r, c + 1, cur_gold)
self.dfs(grid, r, c - 1, cur_gold)
grid[r][c] = cache # RESTORE: off the path again
// java
// LC 1219 - Path with Maximum Gold
// IDEA: DFS from every gold cell; mark on entry, restore on exit; the guard at the top of the child
// time = O(k * 3^k), space = O(k)
private int maxGold = 0;
public int getMaximumGold(int[][] grid) {
maxGold = 0;
for (int r = 0; r < grid.length; r++) {
for (int c = 0; c < grid[0].length; c++) {
if (grid[r][c] > 0) {
dfs(grid, r, c, 0);
}
}
}
return maxGold;
}
private void dfs(int[][] grid, int r, int c, int curGold) {
if (r < 0 || r >= grid.length || c < 0 || c >= grid[0].length || grid[r][c] <= 0) {
return; // off the grid, empty, or on the path
}
int cache = grid[r][c];
curGold += cache;
maxGold = Math.max(maxGold, curGold);
grid[r][c] = -1; // mark: on the current path
dfs(grid, r + 1, c, curGold);
dfs(grid, r - 1, c, curGold);
dfs(grid, r, c + 1, curGold);
dfs(grid, r, c - 1, curGold);
grid[r][c] = cache; // restore: off the path again
}
複雜度
- 時間 O(k · 3^k) 最壞情況:從 k 個黃金格各啟動一次,而第一步之後, 路徑上每一格最多只有 3 個還不在路徑上的鄰居。k ≤ 25 而且實際網格稀疏得多, 跑起來很快。
- 空間 O(k):遞迴深度等於最長路徑;標記直接存在網格裡。
陷阱
- 沒有還原:第一次啟動後格子一直是
-1,之後的啟動和兄弟分支都不能 再用它們。LC 1219 的第一個範例[[0,6,0],[5,8,7],[0,9,0]]會回傳 23 而不是 24。 - 每個框架只標記與取消標記一次,包住那四個呼叫 — 絕不要放在鄰居迴圈裡,那樣 同一格會被這個框架的每條分支重複標記。
- 在哪裡更新最大值,決定你需不需要 seed:在進入格子時更新(上面,以及下面的 V0-2) 每一格都自然被計入;在每個鄰居更新(V0-3)的話,起始格永遠不會被算到,除非呼叫端 先 seed。
不適用時 — 路徑可以重複走格子,或網格很大(10⁵ 格)→ 不是搜尋;用 DP 或 BFS。數區域(LC 200)→ 標記後永不還原 (Template 2)。
練習 — LC 1219 · LC 79 ⭐ (單字搜尋:把字母還原) · LC 980 (路徑必須覆蓋 每一個空格) · LC 212 ⭐ (很多單字:用 trie 剪枝 DFS)
下面三個版本都正確。差別在三個決定放在哪裡:
- 檢查 — 鄰居合法嗎(在界內 + 有黃金 + 還沒造訪)?
- 累加 —
cur_gold在哪裡加上目前這一格? - 更新最大值 — 在哪裡記錄
self.max_gold?
快速比較
| V0-1 — 在子呼叫裡檢查(上面的模板) | V0-2 — 呼叫前先檢查 | V0-3 — 在迴圈裡更新最大值 | |
|---|---|---|---|
| 鄰居迴圈 | 4 個明寫的遞迴呼叫 | for m in moves: |
for m in moves: |
| 檢查的位置 | 子呼叫開頭(base case) | 遞迴呼叫之前 | 遞迴呼叫之前 |
累加 cur_gold |
在子呼叫裡(+= grid[r][c]) |
在呼叫點(cur_gold + grid[..]) |
在呼叫點(cur_gold + grid[..]) |
| 傳入的起始值 | 0 |
grid[start] |
grid[start] |
更新 max_gold |
子呼叫開頭(每格一次) | 子呼叫開頭(每格一次) | 迴圈內(每個鄰居一次)— 需要 seed |
| 會有多餘的遞迴呼叫嗎? | 會 — 不合法的鄰居仍會呼叫後才返回 | 不會 — 只有合法鄰居才遞迴 | 不會 — 只有合法鄰居才遞迴 |
| 能處理孤立的起始格嗎? | ✅ 自動處理 | ✅ 自動處理 | ⚠️ 只能靠呼叫端的 seed |
| 結論 | ✅ 最乾淨的預設寫法 | ✅ 有效率、慣用 | ⚠️ 能動,但脆弱 — 避免 |
差異的心智模型: V0-1 把合法性檢查往下推給被呼叫者(「由子節點自己決定該不該存在」)—
所以 base case 同時兼任檢查。V0-2 / V0-3 則把它往上拉到呼叫端(「父節點只呼叫合法的子節點」)—
因此不會浪費堆疊框架,但起始格必須另外驗證(由啟動迴圈裡的 if grid[y][x] > 0 完成)。
V0-2 — 呼叫前先檢查,在呼叫點累加
# python
# LC 1219 - Path with Maximum Gold (V0-2)
# IDEA: guard BEFORE each recursive call, so no frame is spent on an invalid neighbour;
# the launch loop's `if grid[y][x] > 0` validates the START cell
# time = O(k * 3^k), space = O(k)
class Solution:
def getMaximumGold(self, grid):
self.max_gold = 0
L, W = len(grid), len(grid[0])
for y in range(L):
for x in range(W):
if grid[y][x] > 0:
self.dfs(grid, x, y, grid[y][x]) # start value = the cell itself
return self.max_gold
def dfs(self, grid, x, y, cur_gold):
L, W = len(grid), len(grid[0])
self.max_gold = max(self.max_gold, cur_gold) # (3) UPDATE MAX per cell entry — safe
cache = grid[y][x]
grid[y][x] = -1 # mark visited
moves = [[-1, 0], [1, 0], [0, 1], [0, -1]]
for dx, dy in moves:
x_, y_ = x + dx, y + dy
# (1) GUARD before recursing + (2) ACCUMULATE at the call site
if 0 <= x_ < W and 0 <= y_ < L and grid[y_][x_] > 0:
self.dfs(grid, x_, y_, cur_gold + grid[y_][x_])
grid[y][x] = cache # BACKTRACK: restore
什麼時候用: 當你想要有效率/競賽慣用的寫法時 — moves 陣列很容易擴充到八方向或
斜向題目,而且能省掉往牆裡撞的無效呼叫。因為 max_gold 仍是在進入節點時(迴圈之前)更新,
孤立的起始格不用額外程式碼就能正確計分。等你上手之後,這就是首選版本。
V0-3 — 在迴圈裡更新最大值(能動,但脆弱 — 避免)
# python
# LC 1219 - Path with Maximum Gold (V0-3)
# IDEA: same as V0-2, but max_gold is updated INSIDE the loop (on next_gold), so the entry cell is
# never scored by the DFS itself and the launch loop must SEED max_gold with grid[y][x]
# time = O(k * 3^k), space = O(k)
class Solution:
def getMaximumGold(self, grid):
self.max_gold = 0
L, W = len(grid), len(grid[0])
for y in range(L):
for x in range(W):
if grid[y][x] > 0:
self.max_gold = max(self.max_gold, grid[y][x]) # REQUIRED seed
self.dfs(grid, x, y, grid[y][x])
return self.max_gold
def dfs(self, grid, x, y, cur_gold):
L, W = len(grid), len(grid[0])
cache = grid[y][x]
grid[y][x] = -1 # mark visited (once per frame)
moves = [[-1, 0], [1, 0], [0, 1], [0, -1]]
for dx, dy in moves:
x_, y_ = x + dx, y + dy
if 0 <= x_ < W and 0 <= y_ < L and grid[y_][x_] > 0:
# mark/unmark is per cell entry, not per neighbour: do not touch grid[y][x] here
next_gold = cur_gold + grid[y_][x_]
self.max_gold = max(self.max_gold, next_gold) # (3) UPDATE MAX in loop
self.dfs(grid, x_, y_, next_gold)
grid[y][x] = cache # BACKTRACK: restore
什麼時候用: 基本上永遠不要當第一選擇。列在這裡是為了展示陷阱:把最大值的更新搬進迴圈後,
起始格對 DFS 來說就變成隱形的,只好由呼叫端補一個 seed。少寫那一行,單格(或完全孤立)的
輸入就會悄悄回傳 0。請優先選 V0-1 / V0-2。
模板 10:帶權圖 DFS(除法/比值查詢) — LC 399
辨識 — 「除法求值」、「已知 a / b = 2.0、b / c = 3.0,求 a / c」、「匯率」、 「單位換算」、「傳遞性比值」。變數 ≤ 40、查詢 ≤ 20 → 每個查詢跑一次 DFS 綽綽有餘。
關鍵 — a / b = v 是一條權重 v 的邊 a → b,也是一條權重 1/v 的邊 b → a。沿任何
路徑權重相乘、中間項互相抵銷(a/b · b/c = a/c),所以從 x 到 y 的權重乘積
就是 x / y;任何一條路徑都行,因為這些等式是一致的。
# python
# LC 399 - Evaluate Division
# IDEA: weighted graph a -> b (v) and b -> a (1/v); a DFS multiplies the weights along one path
# time = O(Q * (V + E)), space = O(V + E)
from collections import defaultdict
def calcEquation(equations, values, queries):
graph = defaultdict(dict)
for (a, b), value in zip(equations, values):
graph[a][b] = value
graph[b][a] = 1.0 / value # the reverse edge, reciprocal weight
def dfs(node, target, visited):
if node == target:
return 1.0
visited.add(node)
for nxt, weight in graph[node].items():
if nxt not in visited:
rest = dfs(nxt, target, visited)
if rest != -1.0:
return weight * rest # node/nxt * nxt/target = node/target
return -1.0
answers = []
for x, y in queries:
if x not in graph or y not in graph:
answers.append(-1.0) # an unknown variable, even for x / x
else:
answers.append(dfs(x, y, set())) # a fresh visited set per query
return answers
// java
// LC 399 - Evaluate Division
// IDEA: weighted graph a -> b (v) and b -> a (1/v); a DFS multiplies the weights along one path
// time = O(Q * (V + E)), space = O(V + E)
public double[] calcEquation(List<List<String>> equations, double[] values, List<List<String>> queries) {
Map<String, Map<String, Double>> graph = new HashMap<>();
for (int i = 0; i < equations.size(); i++) {
String a = equations.get(i).get(0);
String b = equations.get(i).get(1);
graph.computeIfAbsent(a, k -> new HashMap<>()).put(b, values[i]);
graph.computeIfAbsent(b, k -> new HashMap<>()).put(a, 1.0 / values[i]);
}
double[] answers = new double[queries.size()];
for (int i = 0; i < queries.size(); i++) {
String x = queries.get(i).get(0);
String y = queries.get(i).get(1);
if (!graph.containsKey(x) || !graph.containsKey(y)) {
answers[i] = -1.0; // an unknown variable, even for x / x
} else {
answers[i] = dfs(graph, x, y, new HashSet<>()); // a fresh visited set per query
}
}
return answers;
}
private double dfs(Map<String, Map<String, Double>> graph, String node, String target, Set<String> visited) {
if (node.equals(target)) {
return 1.0;
}
visited.add(node);
for (Map.Entry<String, Double> edge : graph.get(node).entrySet()) {
if (!visited.contains(edge.getKey())) {
double rest = dfs(graph, edge.getKey(), target, visited);
if (rest != -1.0) {
return edge.getValue() * rest; // node/next * next/target
}
}
}
return -1.0;
}
複雜度
- 時間 O(Q · (V + E)):每個查詢的 DFS 對每個變數最多造訪一次(
visited), 每條邊讀一次。 - 空間 O(V + E):圖本身,加上
visited與遞迴的 O(V)。
陷阱
- 從沒出現過的變數做
x / x是 -1.0,不是 1.0:LC 399 的查詢["x","x"]。先檢查 是否存在,再走node == target的捷徑。 - 所有查詢共用一個
visited集合:第二個查詢會發現節點已被造訪過, 回傳 -1.0。 - 只存
a → b:反方向的查詢(b / a)找不到路徑。 - 用
rest > 0而不是rest != -1.0判斷,是依賴 LC 399 保證值都是正數; 用哨兵值判斷就不需要這個前提。
不適用時 — 固定的圖上有很多查詢,或等式一個一個進來 → 帶權 併查集(存每個節點到其根的比值)。成本沿路徑是相加而且要最 便宜的 → 最短路徑(Dijkstra.md)。
練習 — LC 399 · LC 990 (相等/不相等的限制:併查集,不帶權) · LC 2307 (檢查等式之間有沒有矛盾)
練習路徑
| 階段 | 題目(由易到難) | 新增的東西 |
|---|---|---|
| 核心 ⭐ | LC 100 ok · LC 543 ok · LC 200 ok · LC 417 again · LC 124 again | 成對 DFS、後序回傳值、flood fill、從邊界出發的可達性、全域累加器 |
| 練習 | LC 94 ok · LC 112 ok · LC 101 again · LC 695 ok · LC 113 ok · LC 46 未評定 · LC 450 again · LC 1254 未評定 · LC 694 ok · LC 1219 ok · LC 399 again | 其餘每個模板各一次,順序同卡片的教學順序 |
| 延伸 | LC 979 again · LC 2049 again · LC 711 未評定 · LC 980 未評定 | 每條邊上帶正負號的流量、移除節點後的子樹大小、可旋轉的形狀、覆蓋每一格 |
總結與速查
決策流程圖
DFS Problem Analysis Flowchart:
1. Is it a tree/graph traversal problem?
├── YES → Check structure type
│ ├── Tree? → Use Tree Templates (1, 3, 5, 6)
│ │ ├── Need specific order? → Template 1 (Traversal)
│ │ ├── Need paths? → Template 3 (Path Finding)
│ │ ├── Need to modify? → Template 5 (Modification)
│ │ └── Need subtree info? → Template 6 (Bottom-up)
│ └── Graph? → Use Graph Template (2)
│ ├── Has cycles? → Add visited set
│ ├── Need all paths? → Track path
│ └── Multi-source? → Start from all sources
└── NO → Continue to 2
2. Is it a combinatorial problem?
├── YES → Use Backtracking Template (4)
│ ├── Permutations? → Swap elements
│ ├── Combinations? → Start index
│ ├── Subsets? → Include/exclude
│ └── Constraint satisfaction? → Check validity
└── NO → Continue to 3
3. Does it require exploring all possibilities?
├── YES → Use DFS with appropriate state tracking
│ ├── Grid problem? → 4-directional DFS
│ ├── String problem? → Index-based DFS
│ └── Decision tree? → Choice-based DFS
└── NO → Consider different algorithm
4. Special considerations:
├── Need shortest path? → Consider BFS instead
├── Has optimal substructure? → Consider DP
└── Need all solutions? → DFS with backtracking
解題步驟
- 辨識模式:樹、圖、回溯,還是路徑?
- 選擇模板:挑出合適的 DFS 模板
- 追蹤狀態:visited 集合、路徑串列,或全域變數
- 處理 base case:空節點、邊界、找到目標
- 測試邊界情況:空輸入、單一節點、環
常見錯誤與技巧
🚫 常見錯誤:
- 忘記 visited 集合:在圖上會無窮迴圈
- 沒有回溯:組合類題目會產生錯誤的路徑
- 走訪順序錯誤:該用後序時卻用了前序
- 邊走訪邊修改:會破壞迭代
- 沒處理 null:NullPointerException
- ⚠️ 關鍵:找到路徑時沒有立刻回傳:在 DFS 裡找路徑時,一找到就必須立刻回傳 true(見 Template 3 底下的提早回傳一節)
✅ 最佳實務:
- 在圖上使用 visited 集合:避免成環
- 複製路徑:存結果時用
path[:] - 先檢查邊界:網格類題目
- 取有意義的名字:用
visited而不是v - 考慮改成迭代:遞迴很深的時候
面試提示
- 釐清題型:是樹還是圖?可能有環嗎?
- 說明做法:「我要用 DFS,因為……」
- 討論複雜度:時間與空間分析
- 處理邊界情況:空的、單一元素、環
- 必要時最佳化:記憶化、剪枝
選模板的實用心法
- 兩趟型問題:如果得先消掉某些東西(邊界、邊),用模板 7
- 形狀比較:如果要比較結構/形狀,用模板 8(路徑簽名)
- 由下而上彙總:如果答案取決於先處理完子節點,用模板 6
- 嘗試所有可能:如果題目要「所有」解/組合,用模板 4(回溯)
- 多起點且路徑重疊:標記、遞迴,然後還原 — 模板 9
- 述語需要兩個節點:如果問題沒辦法只用一個節點講出來(「鏡像」、「兩棵樹相同」),就把一對節點帶著遞迴 — 模板 1 的成對 DFS 變化型
- 完全對不上的題目:在自創模式之前,先翻 dfs_advanced.md
相關主題
- bfs.md:需要最短路徑時
- dp.md:子問題重疊 — 把 DFS 記憶化
- backtrack.md:用於組合、且會撤銷的 DFS
- union_find.md:處理連通性的替代方案
- topology_sorting.md:DFS 在相依關係上的應用
- dfs_advanced.md:從本文件拆出去的冷門模板
- dfs_examples.md:題解與完整題目索引
面試必會題目:LC 94, 100, 101, 104, 112, 113, 124, 200, 236, 297, 399, 694 進階題目:LC 124, 297, 329, 472, 652, 694, 711 路徑簽名模式:LC 694(Distinct Islands)、LC 711(Distinct Islands II)、LC 652(Find Duplicate Subtrees)