DFS(深度優先搜尋)
範圍 — DFS 的主文件:十個核心的深度優先模板 — 樹走訪、網格填色(flood fill)、路徑搜尋、回溯、樹結構修改、後序彙總、邊界消除、形狀簽名與帶權邊走訪 — 並附上用來挑選模板的辨識表。 另見 — 從本檔案拆出去的深入主題:dfs_advanced.md — 尤拉路徑(Hierholzer)、Tarjan 找橋、字典樹 + 萬用字元 DFS、以深度為索引的堆疊 DFS、距離桶配對葉節點、N 元樹與
parent[]的彙總;dfs_examples.md — 題解存放處,以及依模式與難度整理的完整題目索引。 鄰近文件:bfs.md — 廣度優先的對應版本以及如何取捨;backtrack.md — 回程時會還原狀態的 DFS;graph.md — 圖的表示法;tree.md — 樹上的 DFS。
LeetCode 題目清單
總覽
深度優先搜尋(DFS) 是一種圖/樹的走訪演算法,沿著每個分支盡可能往深處走,走不下去才回溯。它用遞迴或堆疊來維護走訪路徑。
關鍵性質
- 時間複雜度:圖是 O(V + E),樹是 O(n)
- 空間複雜度:遞迴堆疊 O(h),其中 h = 高度
- 核心想法:先往深走,再往廣走
- 資料結構:堆疊(遞迴的隱含堆疊或顯式堆疊)
- 什麼時候用:路徑搜尋、環偵測、拓撲排序、樹走訪、回溯類題目
參考資料
題型分類
下面每個模式都只出現一次 — 以下一節的模板形式呈現。這張表是它們的索引: 先對上辨識關鍵字,再跳到對應的模板。
| # | 模式 | 辨識關鍵字 | 模板 | 代表題 | 其他 |
|---|---|---|---|---|---|
| 1 | 樹走訪、成對 DFS | “traverse”、“visit all”、“print tree”、“serialize”、“is it a mirror”、“are two trees the same” | T1 | LC 94 | 144, 145, 297, 449, 100, 101, 951 |
| 2 | 圖/網格走訪、連通分量 | “connected components”、“islands”、“cycle detection” | T2 | LC 200 | 695, 133, 207, 210, 419 |
| 3 | 路徑類問題 | “path sum”、“root to leaf”、“all paths”、“does a path exist” | T3 | LC 112 | 113, 257, 129, 1971 |
| 4 | 回溯 | “all combinations”、“permutations”、“subsets” | T4 | LC 46 | 78, 39, 17, 22, 51, 79 |
| 5 | 樹結構修改 | “delete”、“insert”、“trim”、“convert” | T5 | LC 450 | 701, 669, 538, 226, 114 |
| 6 | 子樹彙總與 LCA | “subtree sum”、“duplicate subtrees”、“LCA”、“deepest leaves”、“minimum moves between adjacent nodes” | T6 | LC 543 | 124, 236, 508, 652, 663, 979, 2049 |
| 7 | 邊界消除(兩趟) | “closed islands”、“surrounded regions”、“captured” | T7 | LC 1254 | 130, 417, 1020 |
| 8 | 路徑簽名(形狀編碼) | “distinct islands”、“unique shapes”、“same shape after translation” | T8 | LC 694 | 711, 652 |
| 9 | 網格 DFS + 回溯 | “one path”、“collect the most”、“cannot revisit a cell” | T9 | LC 1219 | 79, 329, 980 |
| 10 | 帶權邊 DFS(比值查詢) | “evaluate division”、“exchange rates”、“transitive ratios” | T10 | LC 399 | 721, 1101, 737 |
不在這份文件裡 — 以下內容住在 dfs_advanced.md:雙網格驗證(LC 1905)、
邊方向追蹤(LC 1466)、連通分量配對計數(LC 2316)、尤拉路徑(LC 332, 753)、Tarjan
找橋(LC 1192)、字典樹 + 萬用字元 DFS(LC 211, 676)、以深度為索引的堆疊 DFS(LC 388, 1233)、
距離桶配對葉節點(LC 1530)、N 元樹後序彙總(LC 3965)、樹 ⟷ 字串編解碼
(LC 606, 536),以及 parent[] 陣列往上爬深度(LC 4015)。依模式與難度整理的完整題目清單
在 dfs_examples.md → Problems by Pattern。
模板與演算法
模板比較表
| 模板 | 使用情境 | 關鍵操作 | 時間 | 空間 | 什麼時候用 |
|---|---|---|---|---|---|
| 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) | 傳遞性比值計算 |
通用 DFS 模板
def dfs(node, visited=None):
"""
Universal DFS template for trees and graphs
Can be adapted for various problems
"""
# Base case
if not node or (visited and node in visited):
return
# Mark as visited (for graphs)
if visited is not None:
visited.add(node)
# Process current node (pre-order position)
process(node)
# Recursive calls
for neighbor in get_neighbors(node):
dfs(neighbor, visited)
# Post-order processing if needed
# process_after(node)
模板 1:樹走訪 — LC 94 Priority 5 of 5 — Must know — expect it in almost every loop
- 說明:以特定順序拜訪所有節點(前序、中序、後序)
- 辨識:“Traverse”、“visit all”、“print tree”、“serialize”
- 例題:LC 94、LC 144、LC 145、LC 297、LC 449;成對 DFS — LC 100、LC 101(見下方的變化型)
# Preorder: Root -> Left -> Right
def preorder(root):
if not root:
return []
return [root.val] + preorder(root.left) + preorder(root.right)
# Inorder: Left -> Root -> Right
def inorder(root):
if not root:
return []
return inorder(root.left) + [root.val] + inorder(root.right)
# Postorder: Left -> Right -> Root
def postorder(root):
if not root:
return []
return postorder(root.left) + postorder(root.right) + [root.val]
# Iterative with Stack
def dfs_iterative(root):
if not root:
return []
stack = [root]
result = []
while stack:
node = stack.pop()
result.append(node.val)
# Add right first so left is processed first (LIFO)
if node.right:
stack.append(node.right)
if node.left:
stack.append(node.left)
return result
變化型:成對(雙指標)DFS — LC 101 Symmetric Tree
- 說明:一次帶兩個游標的 DFS。遞迴不再是從一個節點「算出」某個值, 而是檢查一對節點之間的關係,並對配好對的子節點繼續遞迴。
- 辨識:“is it a mirror of itself”、“are these two trees the same”、 “symmetric around its centre”、“same shape and same values” — 凡是述語本身就需要兩個節點才講得出來的題目。
- 關鍵技巧:helper 收的是
(a, b),不是node。而你往下遞迴時怎麼配對,就是這類題的全部:(a.left, b.left)+(a.right, b.right)→ 同向配對=「兩棵樹相同」(LC 100)(a.left, b.right)+(a.right, b.left)→ 鏡像配對=「對稱」(LC 101)
- 核心想法:
- 一棵樹對稱的充要條件是它的兩棵子樹互為鏡像,所以起手式是
helper(root.left, root.right)— 根節點本身從頭到尾不跟任何節點比較。 - 三個 base case,順序很重要:兩邊皆空 →
True;只有一邊空 →False; 值不同 →False。三關都過了才往下走。 - 依鏡像配對往下遞迴,並把兩個結果
and起來:外側那一對(a.left↔b.right) 與內側那一對(a.right↔b.left)。 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
- 最該背下來的那個 bug:在這題改用同向配對並不是「大致上可行」 — 它回答的是另一個問題(「左子樹是否等於右子樹?」),而且兩個方向都會答錯:
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
# NOTE: 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() # NOTE: 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 題目:
- 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 Priority 5 of 5 — Must know — expect it in almost every loop
- 說明:探索圖、找出連通分量、偵測環
- 辨識:“Connected components”、“islands”、“cycle detection”
- 例題:LC 200、LC 695、LC 133、LC 207、LC 210
def dfs_graph(graph, start):
"""
DFS for graph with cycle handling
"""
visited = set()
result = []
def dfs(node):
if node in visited:
return
visited.add(node)
result.append(node)
for neighbor in graph[node]:
dfs(neighbor)
dfs(start)
return result
# For detecting cycles
def has_cycle(graph):
visited = set()
rec_stack = set()
def dfs(node):
visited.add(node)
rec_stack.add(node)
for neighbor in graph[node]:
if neighbor not in visited:
if dfs(neighbor):
return True
elif neighbor in rec_stack:
return True
rec_stack.remove(node)
return False
for node in graph:
if node not in visited:
if dfs(node):
return True
return False
變體:不用 flood fill 也能數連通分量 — LC 419 Battleships in a Board
巧思:當每個連通分量都保證是筆直的 1×k / k×1 直線時,你根本不需要 DFS —
只要數出屬於某艘船左上端的格子即可,這讓額外空間降到 O(1)
(不用 visited 集合,也不用就地改動)。在 LC 200 的 flood fill 基準之上,
這正是那個經典追問 「能不能一趟掃完、O(1) 空間、且不修改盤面?」 的好答案。
// 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;
}
# 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
如果拿掉「船一定是直線」這個保證,就退回上面那套 LC 200 的網格 DFS。
模板 3:路徑搜尋 — LC 112 Priority 5 of 5 — Must know — expect it in almost every loop
- 說明:在樹/圖中找出具備特定性質的路徑
- 辨識:“Path sum”、“root to leaf”、“all paths”、“longest path”
- 例題:LC 112、LC 113、LC 257、LC 124、LC 543
📚 相關模式:想看更完整、變化更多的路徑題型(路徑和、最大路徑、連續序列、前綴和技巧),請看 bst.md 模板 7(Path Problems),那裡有 7 種路徑模式的詳細實作。
def find_paths(root, target):
"""
Find all root-to-leaf paths with sum = target
"""
def dfs(node, curr_sum, path, result):
if not node:
return
# Update current state
curr_sum += node.val
path.append(node.val)
# Check if leaf and target met
if not node.left and not node.right:
if curr_sum == target:
result.append(path[:])
# Explore children
dfs(node.left, curr_sum, path, result)
dfs(node.right, curr_sum, path, result)
# Backtrack
path.pop()
result = []
dfs(root, 0, [], result)
return result
DFS 提早回傳模式 — TRUE 要積極回傳,FALSE 要拖到最後
問題:在 DFS 裡找路徑時,下面這兩種寫法差在哪?
❌ 錯誤做法:沒有檢查回傳值
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]) {
// ❌ WRONG: Ignoring return value - continues searching even after path found!
dfsPathVisitor(next, destination, map, visited);
}
}
return false; // Will ALWAYS return false (except for direct hits)
}
✅ 正確做法:成功時立刻回傳
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]) {
// ✅ CORRECT: Return immediately when path found!
if (dfsPathVisitor(next, destination, map, visited)) {
return true;
}
}
}
return false; // Only return false if ALL paths explored
}
📊 具體範例:為什麼提早回傳很重要
測試案例:
Graph: 0 -- 1 -- 2 -- 3
| |
4 -------- 5
Adjacency List:
0: [1, 4]
1: [0, 2]
2: [1, 3, 5]
3: [2]
4: [0, 5]
5: [2, 4]
Task: Find path from 0 to 3
情境 1:❌ 錯誤(沒有提早回傳)
呼叫堆疊追蹤:
1. dfsPathVisitor(0, 3, ..., visited=[])
→ visited = [0]
→ Loop neighbors: [1, 4]
2. dfsPathVisitor(1, 3, ..., visited=[0]) // First neighbor
→ visited = [0, 1]
→ Loop neighbors: [0, 2] (skip 0, already visited)
3. dfsPathVisitor(2, 3, ..., visited=[0,1])
→ visited = [0, 1, 2]
→ Loop neighbors: [1, 3, 5] (skip 1)
4. dfsPathVisitor(3, 3, ..., visited=[0,1,2])
→ ✅ Found! Returns TRUE
← Returns TRUE to level 3
← But level 2 IGNORES the return value!
← Continues checking neighbor 5
5. dfsPathVisitor(5, 3, ..., visited=[0,1,2])
→ visited = [0, 1, 2, 5]
→ Loop neighbors: [2, 4] (both visited)
← Returns FALSE
← Level 2 finishes loop, returns FALSE
← Level 1 receives FALSE from neighbor 1
6. dfsPathVisitor(4, 3, ..., visited=[0,1,2,5]) // Second neighbor
→ visited = [0, 1, 2, 5, 4]
→ Loop neighbors: [0, 5] (both visited)
← Returns FALSE
← Level 0 finishes loop, returns FALSE
❌ FINAL RESULT: FALSE (Path exists but not detected!)
為什麼會失敗:
- 在第 4 步找到了終點(回傳 TRUE)
- 但第 3 步的上層呼叫忽略了這個 TRUE
- 於是繼續多餘地探索其他鄰居
- 最後因為其他路徑到不了終點而回傳 FALSE
情境 2:✅ 正確(有提早回傳)
呼叫堆疊追蹤:
1. dfsPathVisitor(0, 3, ..., visited=[])
→ visited = [0]
→ Loop neighbors: [1, 4]
2. dfsPathVisitor(1, 3, ..., visited=[0]) // First neighbor
→ visited = [0, 1]
→ Loop neighbors: [0, 2] (skip 0)
3. dfsPathVisitor(2, 3, ..., visited=[0,1])
→ visited = [0, 1, 2]
→ Loop neighbors: [1, 3, 5] (skip 1)
4. dfsPathVisitor(3, 3, ..., visited=[0,1,2])
→ ✅ Found! Returns TRUE
← Returns TRUE to level 3
← Level 2 checks: if (TRUE) return true; ✅
← Returns TRUE immediately (skips remaining neighbors!)
← Level 1 checks: if (TRUE) return true; ✅
← Returns TRUE immediately (skips neighbor 4!)
✅ FINAL RESULT: TRUE (Correct!)
為什麼可行:
- 在第 4 步找到了終點(回傳 TRUE)
- 第 3 步的上層呼叫檢查了回傳值
- 立刻回傳 TRUE,不再探索其他路徑
- 把 TRUE 一路傳回根節點
🎯 關鍵洞察
| 面向 | ❌ 沒有提早回傳 | ✅ 有提早回傳 |
|---|---|---|
| 正確性 | ❌ 路徑存在卻回傳 FALSE | ✅ 找到路徑就回傳 TRUE |
| 效率 | 多餘地探索所有路徑 | 一找到路徑就停 |
| 時間複雜度 | 永遠 O(V + E)(走完全部) | 最壞 O(V + E),但通常好很多 |
| 使用情境 | 蒐集所有路徑/結果 | 判斷是否存在任一路徑 |
📝 各自適用的時機
模式 1:提早回傳(檢查路徑是否存在)
// Use when: "Does path exist?" "Can we reach?" "Is there a route?"
if (dfs(next)) {
return true; // Found one path - that's enough!
}
例題: LC 1971(Path Exists)、LC 797(All Paths)、LC 79(Word Search)
模式 2:不回傳、繼續走(蒐集所有結果)
// Use when: "Find ALL paths" "Count all solutions" "Collect all combinations"
dfs(next); // Don't return early - need to explore all branches
例題: LC 257(All Root-to-Leaf Paths)、LC 113(Path Sum II)、LC 22(Generate Parentheses)
模板 4:回溯 — LC 46
- 說明:嘗試所有可能,並撤銷選擇
- 辨識:“All combinations”、“permutations”、“subsets”
- 例題:LC 46、LC 78、LC 39、LC 17
def backtrack_template(candidates, target):
"""
General backtracking template
"""
def backtrack(start, path, remaining):
# Base case - found solution
if remaining == 0:
result.append(path[:])
return
# Try all possibilities
for i in range(start, len(candidates)):
if candidates[i] > remaining:
continue
# Make choice
path.append(candidates[i])
# Recurse
backtrack(i, path, remaining - candidates[i])
# Undo choice (backtrack)
path.pop()
result = []
backtrack(0, [], target)
return result
模板 5:樹結構修改 — LC 450
- 說明:在走訪過程中修改樹的結構或數值
- 辨識:“Delete”、“insert”、“trim”、“convert”
- 例題:LC 450、LC 701、LC 669、LC 538
def modify_tree(root, condition):
"""
Modify tree structure based on condition
"""
if not root:
return None
# Recursively modify subtrees first
root.left = modify_tree(root.left, condition)
root.right = modify_tree(root.right, condition)
# Modify current node based on condition
if not condition(root):
# Example: delete node, return child
if not root.left:
return root.right
if not root.right:
return root.left
# Handle two children case
# ... (find successor/predecessor)
return root
慣用寫法:先把子樹指回去,再回傳該節點
- 把子樹指派給節點,最後再回傳更新後的節點(非常重要!!!!)
// java
// LC 199
private TreeNode _dfs(TreeNode node){
if (node == null){
return null;
}
/** NOTE !!! no need to create global node, but can define inside the method */
TreeNode root2 = node;
root2.left = this._dfs(node.left);
root2.right = this._dfs(node.right);
/** NOTE !!! we need to return root as final step */
return root2;
}
Template 6: Bottom-up (Post-Order) DFS — LC 543 Priority 5 of 5 — Must know — expect it in almost every loop
- Description: Process subtrees and aggregate results bottom-up; find the lowest common ancestor of target nodes
- Recognition: “Subtree sum”, “duplicate subtrees”, “LCA”, “smallest subtree containing”, “lowest common ancestor”, “deepest leaves”, “minimum moves between adjacent nodes”
- Examples: LC 508, LC 652, LC 236, LC 663, LC 865, LC 979, LC 1123
- When to Use LCA Approach:
- Two (or more) target nodes exist in different subtrees and you need the first node that “sees” both sides
- “Smallest subtree that contains [condition X]” — this is LCA in disguise
- Targets may be given (LC 236: find LCA of p, q) or implicit (LC 865/1123: all nodes at max depth)
- Core Idea (Post-Order / Bottom-Up):
- Recurse left and right subtrees first (post-order)
- Each subtree returns a
(node, depth/info)pair upward - At each node, compare left vs right results:
- Left deeper → answer is in the left subtree, propagate left result up
- Right deeper → answer is in the right subtree, propagate right result up
- Equal depth → current node is the LCA (deepest paths meet here), return current node
- The root of the recursion holds the final answer
- Key Variants:
- Standard LCA (LC 236): Targets p, q are given; return first node that sees both in different subtrees
- Depth-Based LCA (LC 865/1123): Targets are discovered (deepest nodes); use depth comparison to find where deepest paths converge
- Paint + Answer (LC 865 Editorial V1): Two-pass — first DFS computes all depths, second DFS finds the subtree containing all max-depth nodes
- BFS + Parent Map (LC 865 V0-4): BFS to find deepest level, then walk parents upward until all converge to one node
- Similar Classic LC Problems:
- LC 236 - Lowest Common Ancestor of a Binary Tree (standard LCA)
- LC 235 - Lowest Common Ancestor of a Binary Search Tree (BST property optimization)
- LC 865 - Smallest Subtree with all the Deepest Nodes (depth-based LCA)
- LC 1123 - Lowest Common Ancestor of Deepest Leaves (same as LC 865)
- LC 1644 - Lowest Common Ancestor of a Binary Tree II (nodes may not exist)
- LC 1650 - Lowest Common Ancestor of a Binary Tree III (with parent pointers)
- LC 1676 - Lowest Common Ancestor of a Binary Tree IV (multiple target nodes)
def bottom_up_dfs(root):
"""
Process subtrees first, then current node
Useful for subtree problems
"""
def dfs(node):
if not node:
return 0 # or base value
# Process subtrees first
left_result = dfs(node.left)
right_result = dfs(node.right)
# Process current node using subtree results
current_result = process(node, left_result, right_result)
# Update global result if needed
self.global_result = max(self.global_result, current_result)
return current_result
self.global_result = 0
dfs(root)
return self.global_result
Global-accumulator form — LC 124 Binary Tree Maximum Path Sum
Source:
binary-tree-maximum-path-sum.py
- Key Idea: a node computes two different values, and confusing them is the whole difficulty of
the problem:
- The answer candidate (
left + right + node.val) — the path that turns at this node, using both children. It is recorded into a global max and never returned. - The value returned to the parent (
max(left, right) + node.val) — a path continuing upward can only use one child, because a path is a sequence of nodes, not a fork.
- The answer candidate (
- Recognition: “path does not need to pass through the root”, “path may turn at some node”, “maximise over all paths” — anything where the best local answer is not the value the parent needs.
- Why
max(0, ...): a child subtree whose best downward sum is negative is simply dropped — attaching it can only make the path worse. Clamping to0is how “drop it” is spelled. - Why
float('-inf')and not0: the path must be non-empty, so an all-negative tree ([-3]→-3) must be allowed to win. Seeding at0silently returns0there. - Do not return
left + right + node.valupward. That is the single most common bug: it hands the parent a forked path, which the parent then forks again, producing a shape that is not a path at all.
# python
# LC 124 - Binary Tree Maximum Path Sum
# IDEA: post-order DFS; record the `turning` path globally, return the best `straight` path upward
# time = O(n), space = O(h) # h = tree height, worst O(n)
class Solution:
def maxPathSum(self, root):
self.max_sum = float('-inf')
def dfs(node):
if not node:
return 0
left = max(0, dfs(node.left)) # drop a negative subtree
right = max(0, dfs(node.right)) # drop a negative subtree
# (1) candidate: path TURNS here, uses BOTH children -> global only
self.max_sum = max(self.max_sum, left + right + node.val)
# (2) upward: path CONTINUES, so only ONE child may be kept
return max(left, right) + node.val
dfs(root)
return self.max_sum
// java
// LC 124 - Binary Tree Maximum Path Sum
// IDEA: post-order DFS; record the `turning` path globally, return the best `straight` path upward
// time = O(n), space = O(h)
private int maxSum = Integer.MIN_VALUE;
public int maxPathSum(TreeNode root) {
maxSum = Integer.MIN_VALUE;
dfs(root);
return maxSum;
}
private int dfs(TreeNode node) {
if (node == null) {
return 0;
}
int left = Math.max(0, dfs(node.left)); // drop a negative subtree
int right = Math.max(0, dfs(node.right)); // drop a negative subtree
maxSum = Math.max(maxSum, left + right + node.val); // turns here
return Math.max(left, right) + node.val; // continues upward
}
Variant: no clamp — carry the node into the negative branch instead
Equivalent form that appears in the wild: rather than clamping a negative child to 0, restart the
branch at node.val. The two branch values then each already include node.val, so the turning
candidate has to subtract it back out once.
# python
# LC 124 - Binary Tree Maximum Path Sum (no-clamp variant)
# IDEA: a negative branch restarts at root.val, so both sides carry root.val -> subtract one copy
# time = O(n), space = O(h)
def dfs(node):
if not node:
return 0
l_max, r_max = dfs(node.left), dfs(node.right)
l_max = node.val if l_max < 0 else l_max + node.val
r_max = node.val if r_max < 0 else r_max + node.val
self.maximum = max(self.maximum, l_max + r_max - node.val) # NOTE: `- node.val`
return max(l_max, r_max)
Prefer the
max(0, ...)form. It is shorter, the- node.valcorrection is easy to forget, and the clamp reads directly as the invariant “a negative subtree is never worth attaching”.
Variation: post-order balance / flow accumulation — LC 979 Distribute Coins in Binary Tree
- Description: Post-order DFS where each node returns its subtree’s surplus/deficit (
balance), while a global counter accumulates|balance|across every edge - Recognition: “move one unit between adjacent nodes”, “minimum number of moves”, “make every node have exactly one X”, “total supply equals total demand”
- Key Technique: The answer is a sum over edges, not over nodes. Every tree edge is a bridge: cutting the edge above a subtree splits the tree into exactly two components, so the coins crossing it can only be
|balance(subtree)|. The traffic is forced — there is nothing to search or optimise, only to count. - Examples: LC 979 (Distribute Coins in Binary Tree)
- Core Idea:
balance(node) = node.val - 1 + balance(left) + balance(right)— the node keeps 1 coin, and the rest of the subtree’s net excess (> 0) or shortfall (< 0) is pushed up to the parent.- Every coin crossing an edge is one move, so the edge above a subtree costs
|balance(subtree)|moves →moves += |balance|. - Only the magnitude matters: a coin flowing up and a coin flowing down cost the same, which is why
abs()is taken at accumulation time. balance(root) == 0always (the problem guaranteesΣ node.val == n) — that invariant is what makes the greedy edge count optimal.
- Two equivalent accumulation spots (both appear in the wild, same total):
- Charge from the parent:
self.moves += abs(left) + abs(right)before returning — each non-root node is charged once, as somebody’s child. - Charge from the node:
self.moves += abs(current_balance)after computing it — each node pays for its own edge to its parent; the root adds|0| = 0.
- Charge from the parent:
- Important Notes:
- Return the signed balance but accumulate the absolute one. Returning
abs(...)upward is the classic bug: a-2deficit has to stay negative so it can cancel a sibling’s+2surplus at their parent. node.val - 1is the whole trick — “every node keeps exactly one coin” turns a distribution problem into a flow-conservation problem.- Do not short-circuit on
node.val == 1; a locally balanced node is still a conduit for its subtrees’ traffic. - Do not flatten the tree into an undirected adjacency list and diffuse coins with BFS — the common wrong first instinct. A BFS frontier is local: it cannot see that the left subtree is short 3 coins while the right subtree has 3 spare, and therefore cannot know those 3 must travel up through the root and back down. It ends up shuffling coins along non-optimal paths (or looping) because it has no notion of a subtree’s net demand.
- What the post-order rollup supplies is exactly the missing global view:
balance(subtree)is the net surplus/deficit of a whole component, and it only exists bottom-up. Re-modelling the tree as a graph discards the cut structure above (every edge a bridge) that makes each edge’s cost a closed form rather than a search.
- Return the signed balance but accumulate the absolute one. Returning
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 # NOTE: 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; // NOTE: signed, never Math.abs()
}
- Similar Classic LC Problems:
- LC 979 - Distribute Coins in Binary Tree (canonical post-order balance/flow)
- LC 2477 - Minimum Fuel Cost to Report to the Capital (same edge-flow count, but
ceil(people / seats)per edge) - LC 1443 - Minimum Time to Collect All Apples in a Tree (post-order, charge 2 per useful edge)
- LC 1339 - Maximum Product of Splitted Binary Tree (post-order subtree sum, then cut one edge)
- LC 508 - Most Frequent Subtree Sum (per-subtree value rolled up post-order)
- LC 124 - Binary Tree Maximum Path Sum (return one value up, aggregate a different one globally)
- LC 2049 - Count Nodes With the Highest Score (subtree size rollup — see the variation below)
變體:子樹大小彙總(移除節點後計分) — LC 2049
- 說明:後序 DFS 回傳每個節點的子樹大小,同時算出由「移除該節點後形成的各連通分量大小」推導出的每個節點的值(分數)
- 辨識:“remove node and edges → tree splits into subtrees”、“product/sum of component sizes”、“score of a node”、“tree given as
parents[]array” - 關鍵技巧:一趟 DFS 回傳
subtree_size = 1 + Σ child_subtree_size。移除節點後,連通分量是 (a) 每個子節點的子樹,以及 (b) 上方那一側 =n - subtree_size。邊走邊彙總即可。 - 例題:LC 2049(Count Nodes With the Highest Score)
- 核心想法:
- 移除節點
x會把樹切成len(children[x])個子節點分量,再加上「上方」那個分量(x 子樹以外的所有東西)。 child component size= 每個子節點的子樹大小(由 DFS 回傳)。parent / above component size=n - subtree_size(x)(只有在> 0,也就是 x 不是根節點時才算數)。score(x) = Π(child subtree sizes) × max(1, n - subtree_size(x))— 每個子樹大小都只算一次,因此是 O(n) 時間/O(n) 空間(n ≤ 10^5 時必須如此)。
- 移除節點
- 從
parents[]建樹:對i != root做children[parents[i]].append(i);根節點是parents[i] == -1的那個索引(通常是節點 0)。 - 模式變體:
- 一趟 DFS(回傳大小的同時就地累乘/更新最大值)— 最精簡
- 兩趟(第一趟:預先算出
subtree_size[]陣列;第二趟:逐一走過節點算分數)— 把算大小和算分數拆開,比較好推理
- 重要提醒:
- 用
max(1, ...)或if remaining > 0保護上方分量 — 根節點沒有「上方」分量。 - 用以分數為鍵的
Counter/dict 來數有多少節點達到最大值,或是邊走邊追蹤(max_score, count)。 - 這個做法不限於二元樹 — 同一套 DFS 適用於任何以
parents[]/鄰接表給定的樹。
- 用
- 相似的經典 LC 題目:
- LC 2049 - Count Nodes With the Highest Score(移除節點計分的代表題)
- LC 1519 - Number of Nodes in the Sub-Tree With the Same Label(用 DFS 做子樹彙總)
- LC 508 - Most Frequent Subtree Sum(每個子樹的值 + 次數統計)
- LC 543 - Diameter of Binary Tree(由下而上的子樹指標)
- LC 124 - Binary Tree Maximum Path Sum(回傳子樹值,彙總全域最大)
- LC 834 - Sum of Distances in Tree(子樹大小 + 換根 DP,進階追問)
模板 7:兩趟 DFS(邊界消除) — LC 1254
- 說明:先消掉和邊界相連的格子,再處理內部
- 辨識:“Closed islands”、“surrounded regions”、“captured pieces”
- 例題:LC 1254、LC 130、LC 417
// java
// LC 1254
// V0
// IDEA: 2-Pass DFS (Boundary Elimination)
/**
* Algorithm:
* Pass 1: Start from all boundary cells and flood-fill to eliminate
* all islands connected to the boundary (these cannot be closed)
* Pass 2: Count remaining land cells as closed islands
*
* Time: O(m×n), Space: O(m×n) for recursion stack
*/
public int closedIsland(int[][] grid) {
if (grid == null || grid.length == 0) {
return 0;
}
int rows = grid.length;
int cols = grid[0].length;
// Pass 1: Eliminate boundary-connected islands
// Flood top and bottom borders
for (int c = 0; c < cols; c++) {
flood(grid, 0, c); // Top border
flood(grid, rows - 1, c); // Bottom border
}
// Flood left and right borders
for (int r = 0; r < rows; r++) {
flood(grid, r, 0); // Left border
flood(grid, r, cols - 1); // Right border
}
// Pass 2: Count closed islands
int count = 0;
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); // Mark entire island
}
}
}
return count;
}
private void flood(int[][] grid, int r, int c) {
int rows = grid.length;
int cols = grid[0].length;
// Base case: out of bounds or water
if (r < 0 || r >= rows || c < 0 || c >= cols || grid[r][c] == 1) {
return;
}
grid[r][c] = 1; // Mark land as water (visited)
// Flood 4-directionally
flood(grid, r + 1, c);
flood(grid, r - 1, c);
flood(grid, r, c + 1);
flood(grid, r, c - 1);
}
# python
# LC 1254
def closedIsland(grid):
"""
2-Pass DFS approach
"""
if not grid or not grid[0]:
return 0
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
flood(r + 1, c)
flood(r - 1, c)
flood(r, c + 1)
flood(r, c - 1)
# Pass 1: Eliminate boundary islands
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: Count closed islands
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
模板 8:路徑簽名(形狀編碼) — LC 694
- 說明:用唯一的路徑簽名來編碼島嶼或子樹的形狀/結構
- 辨識:“Distinct islands”、“unique shapes”、“count different structures”、“same shape after translation”
- 關鍵技巧:在 DFS 走訪過程中記錄移動方向,組出一個標準化的簽名
- 例題:LC 694、LC 711、LC 652
// Java implementation with directional encoding
public int numDistinctIslands(int[][] grid) {
if (grid == null || grid.length == 0 || grid[0].length == 0) {
return 0;
}
Set<String> uniqueIslandShapes = new HashSet<>();
int rows = grid.length;
int cols = grid[0].length;
// Iterate through every cell in the grid
for (int r = 0; r < rows; r++) {
for (int c = 0; c < cols; c++) {
// Start DFS only on unvisited land cells
if (grid[r][c] == 1) {
StringBuilder pathSignature = new StringBuilder();
// Start DFS from (r, c). 'S' marks the start
dfs(grid, r, c, pathSignature, 'S');
if (pathSignature.length() > 0) {
uniqueIslandShapes.add(pathSignature.toString());
}
}
}
}
return uniqueIslandShapes.size();
}
/**
* DFS with directional encoding
* Records the direction taken to reach each cell
* Uses 'O' delimiter when backtracking
*/
private void dfs(int[][] grid, int r, int c, StringBuilder path, char direction) {
int rows = grid.length;
int cols = grid[0].length;
// Base cases: Out of bounds or water/visited
if (r < 0 || r >= rows || c < 0 || c >= cols || grid[r][c] == 0) {
return;
}
// 1. Mark as visited by setting to 0
grid[r][c] = 0;
// 2. Record the direction taken to reach this cell
path.append(direction);
// 3. Recurse in FIXED order (Down, Up, Right, Left)
dfs(grid, r + 1, c, path, 'D'); // Down
dfs(grid, r - 1, c, path, 'U'); // Up
dfs(grid, r, c + 1, path, 'R'); // Right
dfs(grid, r, c - 1, path, 'L'); // Left
// 4. Add delimiter when backtracking
// This distinguishes different branch structures
path.append('O');
}
兩種可互換的編碼方式。 上面的 Java 版記錄的是進入每個格子時所走的方向 (
D/U/R/L,回程時再加一個O分隔符);下面的 Python 版則改為記錄每個格子的 相對座標(r-r0, c-c0)。兩者都對平移不變、但對旋轉敏感 — 挑一種用就好, 千萬別在同一個簽名裡混用。
def count_distinct_shapes(grid):
"""
Count distinct island shapes using path signatures
Key: Encode each island's shape as a unique string
"""
if not grid or not grid[0]:
return 0
rows, cols = len(grid), len(grid[0])
unique_shapes = set()
def dfs(r, c, r0, c0, path):
"""
DFS with path signature encoding
r0, c0: Starting position for relative encoding
path: StringBuilder to record the shape signature
"""
if r < 0 or r >= rows or c < 0 or c >= cols or grid[r][c] != 1:
return
# Mark as visited
grid[r][c] = 0
# Encode relative position
path.append(f"({r - r0},{c - c0})")
# Visit neighbors in FIXED order (critical for consistency)
dfs(r + 1, c, r0, c0, path) # Down
dfs(r - 1, c, r0, c0, path) # Up
dfs(r, c + 1, r0, c0, path) # Right
dfs(r, c - 1, r0, c0, path) # Left
# Iterate through grid in fixed order (top-left to bottom-right)
for r in range(rows):
for c in range(cols):
if grid[r][c] == 1:
path = []
dfs(r, c, r, c, path) # Start with (r, c) as origin
unique_shapes.add(tuple(path))
return len(unique_shapes)
路徑簽名的關鍵觀念:
-
固定的走訪順序
- 永遠以同一個固定順序檢查鄰居(例如 D、U、R、L)
- 這才能保證相同的形狀產生相同的簽名
-
起點正規化
- 以固定順序掃描網格(由上而下、由左而右)
- 遇到的第一個陸地格子就是原點
- 所有座標都相對於這個原點
-
為什麼分隔符很重要
textShape 1: 11 Shape 2: 1 1 11 Without delimiter: "SDRO" vs "SDRO" (Same - Wrong!) With delimiter: "SDOO" vs "SDRO" (Different - Correct!) -
一致性保證
- 相同形狀 → 相同簽名(永遠成立)
- 不同形狀 → 不同簽名
- 對平移不變(位置不影響結果)
- 對旋轉/鏡射敏感(這正是題目要求的)
模板 9:網格 DFS + 回溯 — 三種寫法比較(LC 1219 Path with Maximum Gold)
題目:在
m x n的網格中,沿單一條路徑蒐集最多的金子。你可以從任一個有金子的格子 出發/結束,上下左右移動,不能重複走同一格,也不能踩到值為0的格子。 因為路徑可以從任何地方開始,我們對每一個有金子的格子都啟動一次 DFS。 又因為不同起點的路徑會互相重疊,每次 DFS 結束後我們都要回溯(還原該格), 讓下一次啟動時網格是乾淨的。
三個版本都是正確的。它們的差別在於三個決策放在哪裡做:
- 檢查(Guard) — 這個鄰居合法嗎(在界內 + 有金子 + 沒走過)?
- 累加(Accumulate) —
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-1 — 在子呼叫裡檢查(建議的預設寫法)
# python — LC 1219
# GUARD lives at the top of the child → doubles as the recursion base case.
# Cleanest to reason about: you may call dfs() on ANY coordinate (even off-grid);
# the child rejects itself. Cost: every invalid neighbor still spends one call frame.
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])
# (1) GUARD: out of bounds OR empty(0) OR visited(-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 # (2) ACCUMULATE here
self.max_gold = max(self.max_gold, cur_gold) # (3) UPDATE MAX per cell entry
grid[r][c] = -1 # mark visited
# recurse into ALL 4 dirs unconditionally — guard filters at the top
self.dfs(grid, r + 1, c, cur_gold)
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 # BACKTRACK: restore
什麼時候用: 網格 DFS 的預設選擇。最不容易寫錯 — 起始格和鄰居走的是同一套檢查, 不需要任何特例處理。當清晰度優先、或起始格本身可能不合法時,就選它。
V0-2 — 呼叫前先檢查,在呼叫點累加
# python — LC 1219
# GUARD is inline BEFORE each recursive call → no wasted frames on invalid neighbors.
# The launch loop's `if grid[y][x] > 0` validates the START cell (child no longer does).
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
# Same structure as V0-2, BUT max_gold is updated INSIDE the loop (on `next_gold`),
# not at cell entry. Consequence: the entry cell is never scored by the DFS itself,
# so a lone gold cell with no gold neighbors would be missed → the launch loop must
# SEED max_gold with grid[y][x]. That extra dependency is exactly what makes it fragile.
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:
# NOTE: do NOT mark/unmark grid[y][x] here inside the loop.
# Marking is per-cell-entry, not per-neighbor: the same cell is
# explored by all 4 branches of THIS frame; re-marking each
# iteration would corrupt the shared state.
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。
注意事項(三個版本通用)
- 這題的回溯是必要的,不是可選的。一條路徑可能從很多格子出發,而且路徑會重疊;
遞迴後還原
grid[r][c] = cache,後續的啟動才能重複使用該格。 和單純的「數島嶼」(LC 200)對比 — 那裡是標記後永不還原。 - 就地標記已訪(
-1/0)省掉了額外的visited集合 — 因為我們會還原,所以沒問題。 檢查條件把空格和已訪一視同仁(<= 0),所以不需要另外的 visited 檢查。 - 每個遞迴框架恰好標記/取消標記一次,把鄰居探索包在中間 — 絕不是每個鄰居各做一次。
- 在哪裡更新最大值,決定了你需不需要 seed:在進入格子時更新(V0-1/V0-2), 每一格(包含孤立格)都自動被計入;改成每個鄰居更新(V0-3),你就欠呼叫端一個起始格的 seed。
- 複雜度(三者相同):
time = O(4^k)最壞情況,其中k ≤ 25是金子格數 (第一格之後,每格最多分岔到 3 個未訪鄰居);space = O(k)為遞迴深度。
模板 10:帶權圖 DFS(除法/比值查詢) — LC 399
- 說明:建一張帶權有向圖,邊的權重代表比值/除法結果,再用 DFS 算出任兩個連通節點之間的傳遞性比值
- 辨識:“Evaluate division”、“exchange rates”、“currency conversion”、“ratio queries”、“transitive relationships with weights”
- 關鍵技巧:把等式建模成雙向帶權圖(
Map<String, Map<String, Double>>),DFS 時沿路累乘邊權 - 例題:LC 399(Evaluate Division)、LC 1101(The Earliest Moment When Everyone Become Friends — 變體)、LC 721(Accounts Merge — 圖分群變體)
- 核心演算法想法:
- 建圖:對每個等式
a / b = val,加上權重為val的邊a → b,以及權重為1/val的邊b → a - 處理查詢:對查詢
c / d,從cDFS 到d,沿路把邊權相乘 - 累乘:在 DFS 中傳遞一個累乘值;抵達目標時,該乘積就是答案
- 替代做法:帶比值的併查集(存
node → root的比值,查詢只要 O(α(n)))
- 建圖:對每個等式
- 重要提醒:
- 雙向邊:務必同時存
a→b與b→a,權重互為倒數 - visited 集合:每次查詢都要重置,讓每條路徑的探索互相獨立
- 提早結束:若任一節點不在圖中,立刻回傳 -1.0
- 自己除自己:若
start == end且該節點存在於圖中,回傳 1.0 - 乘法 vs 加法:和最短路徑類題目不同,這裡是乘法累積
- 雙向邊:務必同時存
- 相似的經典 LC 題目:
- LC 399 - Evaluate Division(帶權圖 DFS 的代表題)
- LC 1976 - Number of Ways to Arrive at Destination(帶權圖走訪)
- LC 787 - Cheapest Flights Within K Stops(有限制的帶權圖)
- LC 743 - Network Delay Time(帶權圖探索)
- LC 1334 - Find the City With the Smallest Number of Neighbors at a Threshold Distance
# 399 Evaluate Division
# there is also an "union find" solution
class Solution:
def calcEquation(self, equations, values, queries):
from collections import defaultdict
# build graph
graph = defaultdict(dict)
for (x, y), v in zip(equations, values):
graph[x][y] = v
graph[y][x] = 1.0/v
ans = [self.dfs(x, y, graph, set()) for (x, y) in queries]
return ans
def dfs(self, x, y, graph, visited):
if not graph:
return
if x not in graph or y not in graph:
return -1
if x == y:
return 1
visited.add(x)
for n in graph[x]:
if n in visited:
continue
visited.add(n)
d = self.dfs(n, y, graph, visited)
if d > 0:
return d * graph[x][n]
return -1.0
// java
// V1
// IDEA: DFS
// https://leetcode.com/problems/evaluate-division/solutions/3543256/image-explanation-easiest-concise-comple-okpu/
public double[] calcEquation_1(List<List<String>> equations, double[] values, List<List<String>> queries) {
HashMap<String, HashMap<String, Double>> gr = buildGraph(equations, values);
double[] finalAns = new double[queries.size()];
for (int i = 0; i < queries.size(); i++) {
String dividend = queries.get(i).get(0);
String divisor = queries.get(i).get(1);
/** NOTE !!!
*
* either dividend nor divisor NOT in graph, return -1.0 directly
*/
if (!gr.containsKey(dividend) || !gr.containsKey(divisor)) {
finalAns[i] = -1.0;
} else {
/** NOTE !!!
*
* we use `vis` to check if element already visited
* (to avoid repeat accessing)
* `vis` init again in every loop
*/
HashSet<String> vis = new HashSet<>();
/**
* NOTE !!!
*
* we init `ans` and pass it to dfs method
* (but dfs method return NOTHING)
* -> `ans` is init, and pass into dfs,
* -> so `ans` value is updated during dfs recursion run
* -> and after dfs run completed, we get the result `ans` value
*/
double[] ans = { -1.0 };
double temp = 1.0;
dfs(dividend, divisor, gr, vis, ans, temp);
finalAns[i] = ans[0];
}
}
return finalAns;
}
/** NOTE !!! below dfs method */
public void dfs(String node, String dest, HashMap<String, HashMap<String, Double>> gr, HashSet<String> vis,
double[] ans, double temp) {
/** NOTE !!! we use `vis` to check if element already visited */
if (vis.contains(node))
return;
vis.add(node);
if (node.equals(dest)) {
ans[0] = temp;
return;
}
for (Map.Entry<String, Double> entry : gr.get(node).entrySet()) {
String ne = entry.getKey();
double val = entry.getValue();
/** NOTE !!! update temp as `temp * val` */
dfs(ne, dest, gr, vis, ans, temp * val);
}
}
public HashMap<String, HashMap<String, Double>> buildGraph(List<List<String>> equations, double[] values) {
HashMap<String, HashMap<String, Double>> gr = new HashMap<>();
for (int i = 0; i < equations.size(); i++) {
String dividend = equations.get(i).get(0);
String divisor = equations.get(i).get(1);
double value = values[i];
gr.putIfAbsent(dividend, new HashMap<>());
gr.putIfAbsent(divisor, new HashMap<>());
gr.get(dividend).put(divisor, value);
gr.get(divisor).put(dividend, 1.0 / value);
}
return gr;
}
總結與速查
決策流程圖
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(詳見前面的說明)
✅ 最佳實務:
- 在圖上使用 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)