DFS — 實戰題解
範圍 — dfs.md 的題庫:它的模板能解的 DFS 題目,每題一張題目卡,歸在它所歸約到的模板底下;題目若在別份文件裡完整解過,這裡只留一列指標。 另見 — 母表:dfs.md — 十個核心模板和模式選擇流程圖,技巧本身在那裡講;dfs_advanced.md — 冷門/困難的 DFS 模板與範例。 相鄰文件:bst.md、tree_codec.md 和 tree_lca_distance.md 擁有這裡指過去的樹題;bfs_examples.md — 廣度優先的題庫;union_find.md — 連通性題目的另一種引擎。
LeetCode 題目清單
總覽
這裡收的是那些不是模板錨定題的 DFS 題目,依它們所歸約到的 dfs.md 模板分組,順序和那份文件一致。 dfs.md 放模板;這份放題目。每張卡片只講這題在模板之外多加的那一步。在別份文件裡完整解過的題目,這裡只留一列指標,所以每題都只有一個家。 在這裡沒有自己題目的模板(模板 9、模板 10)就沒有對應的章節。
索引
| LC | 題目 | 歸約到 | 難度 |
|---|---|---|---|
| 655 | Print Binary Tree | 模板 1:樹的走訪 | Medium |
| 695 | Max Area of Island | 模板 2:圖/網格 DFS | Medium |
| 529 | Minesweeper | 模板 2:圖/網格 DFS | Medium |
| 737 | Sentence Similarity II | 模板 2:圖/網格 DFS | Medium |
| 990 | Satisfiability of Equality Equations | 模板 2:圖/網格 DFS | Medium |
| 472 | Concatenated Words | 模板 4:回溯 | Hard |
| 663 | Equal Tree Partition | 模板 6:由下而上的 DFS | Medium |
| 1339 | Maximum Product of Splitted Binary Tree | 模板 6:由下而上的 DFS | Medium |
| 3319 | K-th Largest Perfect Subtree Size in Binary Tree | 模板 6:由下而上的 DFS | Medium |
| 417 | Pacific Atlantic Water Flow | 模板 7:兩趟 DFS(邊界) | Medium |
樹的走訪範例
Print Binary Tree — LC 655
關鍵 —— 網格的大小只取決於樹高,所以先算樹高,DFS 就只需要把
(row, col)往下傳。每往下一層,到子節點的水平位移減半:2^(height - row - 1),子樹因此不會重疊。
歸約到:dfs § 模板 1 — 前序走訪,往下傳的是節點的位置,不往上回傳任何東西。
# python
# LC 655 - Print Binary Tree
# IDEA: height first; then pre-order DFS places each node and halves the column offset per level
# time = O(h * 2^h), space = O(h * 2^h) the output grid is (h + 1) x (2^(h+1) - 1)
def printTree(root):
def height(node):
if not node:
return -1 # a lone leaf has height 0
return 1 + max(height(node.left), height(node.right))
h = height(root)
rows, cols = h + 1, 2 ** (h + 1) - 1
grid = [[""] * cols for _ in range(rows)]
def place(node, row, col):
if not node:
return
grid[row][col] = str(node.val)
if row == h:
return
offset = 2 ** (h - row - 1) # halves on every level down
place(node.left, row + 1, col - offset)
place(node.right, row + 1, col + offset)
place(root, 0, (cols - 1) // 2) # root in the middle of the top row
return grid
陷阱
- 單一節點的高度必須是 0(null 回傳
-1)。null 回傳0的話網格會多一列:[1]會得到兩列而不是一列。
在別處解過
| LC | 題目 | 家 |
|---|---|---|
| 94 | Binary Tree Inorder Traversal | dfs § 模板 1(錨定題) |
| 100 | Same Tree | binary_tree § Template 12(錨定題) |
| 98 | Validate Binary Search Tree | bst.md — 用上下界驗證 |
| 700 | Search in a Binary Search Tree | bst.md |
| 297 | Serialize and Deserialize Binary Tree | tree_codec.md |
| 449 | Serialize and Deserialize BST | bst_examples.md |
| 662 | Maximum Width of Binary Tree | bfs_examples.md — 每層用堆積索引 |
圖/網格 DFS 範例
Max Area of Island — LC 695
關鍵 —— LC 200 裡數島嶼的那個 flood fill 也能量島嶼的大小:讓淹沒函式回傳
1 + 四個鄰居的大小, 啟動迴圈保留最大值,而不是計數。
歸約到:dfs § 模板 2 — 同一個 flood fill,只是回傳大小而不是什麼都不回傳。
# python
# LC 695 - Max Area of Island
# IDEA: each 1 still standing starts a sink that returns the island's size; keep the largest
# time = O(m * n), space = O(m * n) the recursion is as deep as the largest island
def maxAreaOfIsland(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 0
grid[r][c] = 0 # sink on entry: never counted twice
return 1 + sink(r + 1, c) + sink(r - 1, c) + sink(r, c + 1) + sink(r, c - 1)
best = 0
for r in range(rows):
for c in range(cols):
if grid[r][c] == 1:
best = max(best, sink(r, c))
return best
// java
// LC 695 - Max Area of Island
// IDEA: flood fill that returns the size of the island it sinks; keep the largest
// time = O(m * n), space = O(m * n)
class Solution {
public int maxAreaOfIsland(int[][] grid) {
int best = 0;
for (int r = 0; r < grid.length; r++) {
for (int c = 0; c < grid[0].length; c++) {
best = Math.max(best, area(grid, r, c));
}
}
return best;
}
private int area(int[][] grid, int r, int c) {
if (r < 0 || r >= grid.length || c < 0 || c >= grid[0].length || grid[r][c] == 0) {
return 0;
}
grid[r][c] = 0; // sink it so it is counted once
return 1 + area(grid, r + 1, c) + area(grid, r - 1, c)
+ area(grid, r, c + 1) + area(grid, r, c - 1);
}
}
陷阱
- 遞迴之前就要標記格子。四個呼叫之後才標記的話,兩個鄰居會互相計數, 遇到 2×2 的陸地區塊遞迴永遠不會結束。
Minesweeper — LC 529
關鍵 —— flood fill 遇到數字就停。翻開的格子若旁邊有地雷,就變成地雷數、不再擴散; 只有旁邊沒有地雷的格子會變成
'B',並遞迴到全部八個鄰居。只遞迴進'E'格子,這本身就是 visited 檢查。
歸約到:dfs § 模板 2 — 八個方向,由格子依它的鄰居決定 flood 要不要繼續。
# python
# LC 529 - Minesweeper
# IDEA: a mine click is game over; otherwise flood from the click, stopping at cells next to a mine
# time = O(m * n), space = O(m * n) each cell is revealed once; recursion as deep as the blank region
import sys
sys.setrecursionlimit(10**6) # the default 1,000 is too low: a 50 x 50 blank board recurses 2,500 deep
def updateBoard(board, click):
rows, cols = len(board), len(board[0])
r, c = click
if board[r][c] == "M":
board[r][c] = "X"
return board
dirs = [(-1, -1), (-1, 0), (-1, 1), (0, -1), (0, 1), (1, -1), (1, 0), (1, 1)]
def reveal(r, c):
if r < 0 or r >= rows or c < 0 or c >= cols or board[r][c] != "E":
return # only an unrevealed empty cell expands
mines = 0
for dr, dc in dirs:
nr, nc = r + dr, c + dc
if 0 <= nr < rows and 0 <= nc < cols and board[nr][nc] == "M":
mines += 1
if mines > 0:
board[r][c] = str(mines) # a number stops the flood
return
board[r][c] = "B"
for dr, dc in dirs:
reveal(r + dr, c + dc)
reveal(r, c)
return board
陷阱
- 守衛條件是
board[r][c] != "E",不是「是不是地雷」。'B'和數字格都已經翻開了; 少了這個守衛,兩個空白格會互相遞迴到天荒地老。
Sentence Similarity II — LC 737
關鍵 —— 相似具有遞移性,所以兩個字相似,正好等於它們在「以相似字對為邊」的圖裡屬於同一個連通分量。 每一組對齊的字對就是一次可達性查詢。(LC 734 沒有遞移性,查一下集合就好。)
歸約到:dfs § 模板 2 — 在一張明確的字詞圖上,每個查詢跑一次。
# python
# LC 737 - Sentence Similarity II
# IDEA: undirected graph of similar pairs; each differing word pair must be reachable
# time = O(n * (V + E)), space = O(V + E) one DFS per aligned pair, n = sentence length
from collections import defaultdict
def areSentencesSimilarTwo(sentence1, sentence2, similarPairs):
if len(sentence1) != len(sentence2):
return False
graph = defaultdict(set)
for a, b in similarPairs:
graph[a].add(b)
graph[b].add(a) # similarity is symmetric: both directions
def connected(src, dst):
stack, seen = [src], {src}
while stack:
word = stack.pop()
if word == dst:
return True
for nxt in graph[word]:
if nxt not in seen:
seen.add(nxt)
stack.append(nxt)
return False
for w1, w2 in zip(sentence1, sentence2):
if w1 != w2 and not connected(w1, w2):
return False
return True
陷阱
- 一個字就算沒出現在任何字對裡,也跟自己相似:先檢查
w1 != w2再搜尋。 - 查詢很多時,先一次把連通分量標好(像下面的 LC 990),或改用 union-find; 每次查詢都跑一次 DFS 會重複走同一段路。
Satisfiability of Equality Equations — LC 990
關鍵 ——
==是等價關係:==的邊把變數分成幾個連通分量,除非某個!=連到同一個分量裡的兩個變數, 否則方程式都能滿足。所以先一次把每個分量標好,之後每個!=都是 O(1) 檢查。所有==都要在任何!=之前讀完。
歸約到:dfs § 模板 2 — 寫入分量編號、而不是把格子淹掉的 flood fill。
# python
# LC 990 - Satisfiability of Equality Equations
# IDEA: DFS labels each == component; a != inside one component is a contradiction
# time = O(n), space = O(1) n = equations; at most 26 variables
from collections import defaultdict
def equationsPossible(equations):
graph = defaultdict(list)
for eq in equations:
if eq[1] == "=":
graph[eq[0]].append(eq[3])
graph[eq[3]].append(eq[0]) # == is symmetric: store both directions
group = {}
def label(var, gid):
group[var] = gid
for nxt in graph[var]:
if nxt not in group:
label(nxt, gid)
for var in list(graph):
if var not in group:
label(var, var) # one DFS per component
for eq in equations:
if eq[1] == "!":
a, b = eq[0], eq[3]
if a == b or (a in group and group.get(b) == group[a]):
return False
return True
陷阱
a!=a本身就無法滿足,而且a可能沒出現在任何==裡。先測a == b。- 單向的圖會讓遞移性檢查出錯:從
a==b, b==c來看,從c出發的搜尋找不到任何邊。 兩個方向都要加。 - 併查集是另一個常見答案,同樣 O(n):先 union 每個
==,再拒絕兩個字母同根的!=。這張卡是它的家,union_find_examples.md指向這裡。
在別處解過
| LC | 題目 | 家 |
|---|---|---|
| 200 | Number of Islands | dfs § 模板 2(錨定題) |
| 133 | Clone Graph | graph_examples.md |
路徑搜尋範例
這組題目裡每一題根到葉的路徑題,都在別份文件裡解過。
| LC | 題目 | 家 |
|---|---|---|
| 112 | Path Sum | dfs § 模板 3(錨定題) |
| 113 | Path Sum II | dfs § 模板 3 — 收集每一條路徑 |
| 129 | Sum Root to Leaf Numbers | tree_lca_distance.md — 根到葉的路徑 |
| 257 | Binary Tree Paths | tree_lca_distance.md — 根到葉的路徑 |
| 1448 | Count Good Nodes in Binary Tree | tree_examples.md |
回溯範例
Concatenated Words — LC 472
關鍵 —— 一個字能拆成兩個以上的字典字才算數。試每一個字典前綴、對剩下的部分遞迴; 把「這個後綴能不能拆」記憶化,指數級的搜尋就變成 word-break DP,因為同一個後綴會被很多種切法走到。
歸約到:dfs § 模板 4 — 選一個前綴、對剩下的遞迴;再加上記憶化,因為選擇會重疊。
# python
# LC 472 - Concatenated Words
# IDEA: a word counts if a proper dictionary prefix leaves a suffix that splits into dictionary words
# time = O(n * L^3), space = O(n * L) n words of length L: L suffixes, L cuts each, O(L) slicing
def findAllConcatenatedWordsInADict(words):
word_set = set(words)
memo = {}
def splits(word):
# can `word` be cut into one or more dictionary words?
if word in memo:
return memo[word]
for i in range(1, len(word) + 1):
prefix = word[:i]
if prefix in word_set and (i == len(word) or splits(word[i:])):
memo[word] = True
return True
memo[word] = False
return False
result = []
for word in words:
if not word:
continue
# two or more pieces: the first piece is a PROPER prefix
for i in range(1, len(word)):
if word[:i] in word_set and splits(word[i:]):
result.append(word)
break
return result
陷阱
- 一個字也在它自己的字典裡。要求第一段是真前綴(
i < len(word)),才能擋住每個字都跟自己配對。 - 沒有記憶化的話,
"aaaa…ab"配上["a", "aa", "aaa"]會走遍每一種組合:指數級。
樹的修改範例
這組題目裡每一題樹的修改題,都在別份文件裡解過。
| LC | 題目 | 家 |
|---|---|---|
| 450 | Delete Node in a BST | dfs § 模板 5(錨定題) |
| 701 | Insert into a Binary Search Tree | bst.md |
| 669 | Trim a Binary Search Tree | bst.md |
| 538 | Convert BST to Greater Tree | bst_examples.md — 反向中序 |
| 776 | Split BST | bst_examples.md |
| 623 | Add One Row to Tree | bfs_examples.md — 逐層 BFS,旁邊附 DFS 倒數版本 |
由下而上(後序)DFS 範例
Equal Tree Partition — LC 663
關鍵 —— 拿掉一條邊,會留下它底下的子樹和其餘部分。一趟後序走訪收集每個子樹和; 只要有某個不是整棵樹的子樹和等於
total / 2,這棵樹就能平分。
歸約到:dfs § 模板 6 — 每個節點把子樹和往上回傳,同時記下來。
# python
# LC 663 - Equal Tree Partition
# IDEA: post-order collects every subtree sum; look for total / 2 among the proper subtrees
# time = O(n), space = O(n)
def checkEqualTree(root):
sums = []
def subtree_sum(node):
if not node:
return 0
s = node.val + subtree_sum(node.left) + subtree_sum(node.right)
sums.append(s)
return s
total = subtree_sum(root)
sums.pop() # the whole tree is the last sum: not a cut
return total % 2 == 0 and total // 2 in sums
陷阱
- 搜尋前先拿掉根自己的和。
[0, -1, 1]的總和是 0,整棵樹的和也是 0, 但沒有任何一條邊拿掉後能得到兩半各為 0。 - 總和是奇數就不可能平分;在
//之前先檢查。
Maximum Product of Splitted Binary Tree — LC 1339
關鍵 —— 跟 LC 663 同一趟子樹和:在和為
s的子樹上方切開,得到s * (total - s)。一趟拿到每個s,再取最好的乘積。
歸約到:dfs § 模板 6 — 子樹和往上回傳,再拿來跟總和算分。
# python
# LC 1339 - Maximum Product of Splitted Binary Tree
# IDEA: post-order records every subtree sum s; each is one cut worth s * (total - s)
# time = O(n), space = O(n)
def maxProduct(root):
sums = []
def subtree_sum(node):
if not node:
return 0
s = node.val + subtree_sum(node.left) + subtree_sum(node.right)
sums.append(s)
return s
total = subtree_sum(root)
best = 0
for s in sums:
best = max(best, s * (total - s))
return best % (10 ** 9 + 7) # take the max BEFORE the modulo
陷阱
- 取模只做一次,而且是對最大值做。拿已經對 10⁹+7 取模的乘積互相比較, 會挑到錯的切法。
K-th Largest Perfect Subtree Size in Binary Tree — LC 3319
關鍵 —— 子樹是完美的,正好等於兩個子節點都完美而且高度相同。往上回傳
(height, is_perfect); 高度為h的完美子樹有2^h − 1個節點,所以大小不必另外計數。
歸約到:dfs § 模板 6 — 節點依子節點回傳的那一對值做決定。
# python
# LC 3319 - K-th Largest Perfect Subtree Size in Binary Tree
# IDEA: post-order returns (height, is_perfect); record 2^height - 1 for every perfect subtree
# time = O(n log n), space = O(n) the sort of the recorded sizes dominates
import sys
sys.setrecursionlimit(10**6) # the default 1,000 is too low: a 2,000-node chain recurses 2,000 deep
def kthLargestPerfectSubtree(root, k):
sizes = []
def dfs(node):
if not node:
return 0, True
left_h, left_ok = dfs(node.left)
right_h, right_ok = dfs(node.right)
if left_ok and right_ok and left_h == right_h:
sizes.append(2 ** (left_h + 1) - 1)
return left_h + 1, True
return max(left_h, right_h) + 1, False
dfs(root)
if len(sizes) < k:
return -1
sizes.sort(reverse=True)
return sizes[k - 1]
陷阱
- 「兩個子節點都完美」還不夠:左子是一片葉子、右子是高度 2 的完美子樹的節點, 並不完美。高度必須相同。
在別處解過
| LC | 題目 | 家 |
|---|---|---|
| 543 | Diameter of Binary Tree | dfs § 模板 6(錨定題) |
| 236 | Lowest Common Ancestor of a Binary Tree | tree_lca_distance.md |
| 508 | Most Frequent Subtree Sum | hashing.md — 用雜湊表計數子樹和 |
兩趟 DFS(邊界消去)範例
Pacific Atlantic Water Flow — LC 417
關鍵 —— 對每個格子問「這裡的水流得到海洋嗎?」是 O((mn)²)。 反過來做:從每個海洋的邊界出發往上坡爬(
next >= current)。一次 flood 走得到的格子, 水都能流進那個海洋;答案就是兩次 flood 都走得到的格子。
歸約到:dfs § 模板 7 — flood 從邊界出發,只是跑兩次(每個海洋一次),結果取交集而不是相減。
# python
# LC 417 - Pacific Atlantic Water Flow
# IDEA: flood uphill from each ocean's border; answer = cells reached by both floods
# time = O(m * n), space = O(m * n) each flood visits a cell at most once
import sys
sys.setrecursionlimit(10**6) # the default 1,000 is too low: a flat 200 x 200 grid recurses 40,000 deep
def pacificAtlantic(heights):
rows, cols = len(heights), len(heights[0])
pacific, atlantic = set(), set()
def climb(r, c, reached):
reached.add((r, c))
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 (nr, nc) not in reached
and heights[nr][nc] >= heights[r][c]): # uphill: its water flows down to us
climb(nr, nc, reached)
for r in range(rows):
climb(r, 0, pacific) # left edge
climb(r, cols - 1, atlantic) # right edge
for c in range(cols):
climb(0, c, pacific) # top edge
climb(rows - 1, c, atlantic) # bottom edge
result = []
for r in range(rows):
for c in range(cols):
if (r, c) in pacific and (r, c) in atlantic:
result.append([r, c])
return result
陷阱
- 比較方向是反的:flood 走向較高或等高的鄰居,因為水會從那裡流下來到目前的格子。
用
<找到的是水流往哪裡去。 - 已到達的集合同時也是 visited 集合;少了它,兩個等高的格子會互相遞迴到天荒地老。
在別處解過
| LC | 題目 | 家 |
|---|---|---|
| 1254 | Number of Closed Islands | dfs § 模板 7(錨定題) |
路徑簽名範例
| LC | 題目 | 家 |
|---|---|---|
| 694 | Number of Distinct Islands | dfs § 模板 8(錨定題) |
| 652 | Find Duplicate Subtrees | tree_codec.md — 後序子樹簽名 + 雜湊表計數 |