DFS — 進階模式
範圍 — 第一輪複習可以先跳過的冷門 DFS 技巧:雙網格驗證、邊方向追蹤、跨連通分量的配對計數、字典樹配萬用字元搜尋、後序距離桶與 N 元樹彙總,以及以縮排或
parent[]陣列形式給出的樹。真正的圖論(Tarjan、尤拉路徑)在 graph_advanced.md。 另見 — 母文件:dfs.md — 十個核心 DFS 模板、辨識表和模式選擇流程圖;dfs_examples.md — 建立在那些模板上的經典題。 鄰近文件:graph_advanced.md — Tarjan low-link(橋、SCC)與 Hierholzer 尤拉路徑;trie.md — 字典樹結構本身,不含會分岔的查詢;union_find.md — 連通性 DFS 的 DSU 替代方案;tree_codec.md — 把樹編碼成字串、再解析回來的 DFS;bfs.md — 廣度優先的對應版本。
LeetCode 題目清單
總覽
這些 DFS 模式在整個面試循環裡只會出現一次,不是每一關都考。每一個都是 dfs.md 的某個核心模板,再加一項額外的記帳 — 第二張網格、一個方向旗標、一個累計總數、一個會分岔的字典樹查詢、 一組距離桶、一個以深度為索引的堆疊、一份深度備忘錄 — 而且每一個都能從題目認出來,因為題目會點名那個 額外結構。先讀 dfs.md;當題目不符合它的十個模板時,再來這裡。
這裡大多數輸入都到 n = 10⁵,在路徑形狀的輸入上,遞迴的 Python DFS 會超過預設的 1,000 層上限。 所以下面只要可能發生這種情況的模板,都用明確的堆疊來寫。
先備知識
- dfs.md § Template 2:洪水填充。 Templates 1–3 就是多帶一個值的洪水填充。
- dfs.md § Template 6:把值回傳給父節點的 由下而上 DFS。Templates 5 和 6 回傳的是桶陣列或最小/最大值。
- dfs.md § Template 1:Templates 7 和 8 在一棵從沒建出來的樹上 跑的走訪。
- graph.md § Template 1: 每個連通分量一次走訪,Template 3 在它們之間計數。
辨識
| 題目裡的訊號 | 限制條件線索 | 該用 | 不適用時 |
|---|---|---|---|
「grid2 裡、在 grid1 也全是陸地的島嶼」、「子島嶼」 |
m, n ≤ 500 | Template 1 — 洪水填充 + 一個旗標 | 只有一張網格 → dfs.md § Template 2 |
| 「最少反轉幾條路,讓每個城市都能到 0」 | 樹,n ≤ 5·10⁴ | Template 2 — 無向走訪,邊上帶旗標 | 要每個根的答案 → 換根 DP(LC 2858) |
| 「彼此無法到達的節點對數」 | n ≤ 10⁵ | Template 3 — 大小 × 累計總數 | 要的是分量內的對數 → Σ size·(size − 1)/2 |
「. 可以匹配任何字母」、「恰好差一個字元」 |
單字 ≤ 25 個字母,呼叫 ≤ 10⁴ 次 | Template 4 — 字典樹,搜尋時分岔 | 沒有萬用字元 → 普通的字典樹走訪(trie.md) |
| 「距離 ≤ d 的葉節點對」 | d ≤ 10 | Template 5 — 每個節點一個桶陣列 | 距離沒有上界 → 經過每個 LCA 的配對沒辦法分桶 |
以 edges 給出、根在 0 的樹,值由子節點的最小/最大值算出 |
n ≤ 10⁵ | Template 6 — N 元樹彙總 | 有 .left / .right 的 TreeNode → dfs.md § Template 6 |
以 tab 縮排的文字、以 / 分隔的路徑 |
輸入 ≤ 10⁴ 個字元 | Template 7 — stack[depth] |
巢狀結構是括號 → 解析用堆疊(stack.md) |
輸入是 parent[] / manager[],根是 -1,答案需要深度 |
n ≤ 10⁵ | Template 8 — 記憶化往上爬 | 要子樹總和 → 先反轉成子節點串列,再做後序 |
| 「哪條連線是關鍵」、「每張機票恰好用一次」 | V ≤ 10⁵ | Tarjan / Hierholzer — graph_advanced.md | — |
| 樹轉成字串、再轉回來 | n ≤ 10⁴ | tree_codec.md | — |
網格與圖的額外記帳
Template 1:帶有效旗標的洪水填充 — LC 1905
辨識 — 兩張形狀相同的網格;「數 grid2 裡、在 grid1 也全是陸地的島嶼」(子島嶼),或任何
「只有每一格都通過檢查的分量才算數」的題目。m, n ≤ 500,所以一座島可以有 250,000 格。
關鍵 — 把 grid2 的整座島淹完,並把每一格的檢查結果 AND 進同一個旗標。就算旗標已經變成 false,
這座島也必須淹到底:沒被淹到的格子之後會被當成一座「新」島,被單獨計數。
# python
# LC 1905 - Count Sub Islands
# IDEA: flood each island of grid2 with an explicit stack; it counts if every cell is land in grid1
# time = O(m * n), space = O(m * n)
def countSubIslands(grid1, grid2):
rows, cols = len(grid2), len(grid2[0])
count = 0
for r in range(rows):
for c in range(cols):
if grid2[r][c] != 1:
continue
is_sub = True
grid2[r][c] = 0 # sink on push: each cell enters the stack once
stack = [(r, c)]
while stack:
y, x = stack.pop()
if grid1[y][x] == 0:
is_sub = False # keep flooding: the rest of the island must sink too
for dy, dx in ((1, 0), (-1, 0), (0, 1), (0, -1)):
ny, nx = y + dy, x + dx
if 0 <= ny < rows and 0 <= nx < cols and grid2[ny][nx] == 1:
grid2[ny][nx] = 0
stack.append((ny, nx))
if is_sub:
count += 1
return count
// java
// LC 1905 - Count Sub Islands
// IDEA: flood each island of grid2 with an explicit stack; it counts if every cell is land in grid1
// time = O(m * n), space = O(m * n)
public int countSubIslands(int[][] grid1, int[][] grid2) {
int rows = grid2.length, cols = grid2[0].length;
int[][] dirs = {{1, 0}, {-1, 0}, {0, 1}, {0, -1}};
int count = 0;
for (int r = 0; r < rows; r++) {
for (int c = 0; c < cols; c++) {
if (grid2[r][c] != 1) {
continue;
}
boolean isSub = true;
grid2[r][c] = 0;
Deque<int[]> stack = new ArrayDeque<>();
stack.push(new int[]{r, c});
while (!stack.isEmpty()) {
int[] cell = stack.pop();
if (grid1[cell[0]][cell[1]] == 0) {
isSub = false; // keep flooding
}
for (int[] d : dirs) {
int ny = cell[0] + d[0], nx = cell[1] + d[1];
if (ny >= 0 && ny < rows && nx >= 0 && nx < cols && grid2[ny][nx] == 1) {
grid2[ny][nx] = 0;
stack.push(new int[]{ny, nx});
}
}
}
if (isSub) {
count++;
}
}
}
return count;
}
複雜度
- 時間 O(m·n):格子在推入之前就被設成 0,所以
while stack迴圈每個陸地格子只彈出一次, 並看 4 個鄰居。 - 空間 O(m·n):堆疊可能放得下一整座島。
陷阱
- 寫成
return dfs(up) and dfs(down) and ...的遞迴版本,在第一個 false 就短路, 島上其餘格子都沒被淹;那些格子之後會被當成另一座島計數。遞迴時必須寫ok = dfs(nbr) and ok(先呼叫),四個方向都要。 - 滿格網格上遞迴洪水填充深達 250,000 層:超過 Python 的預設上限,也很可能超過 Java 預設的 執行緒堆疊。明確的堆疊沒有這個限制。
- 輸入會被修改(
grid2被淹掉)。呼叫端還需要它的話先複製一份。
練習 — LC 1905 ⭐ · LC 1254 (檢查條件是「從不碰到邊界」:dfs.md § Template 7) · LC 827 (替每座島標上大小,再逐一嘗試每個 0)
Template 2:在無向走訪上帶方向旗標 — LC 1466
辨識 — 一棵由單行道組成的樹;「最少要反轉幾條路,讓每個城市都能到達城市 0」。 n ≤ 5·10⁴,共 n − 1 條邊。
關鍵 — 每條路兩個方向都存,原方向標 1、反方向標 0。從城市 0 往外走:每次順著原方向
(旗標 1)走過一條路,那條路就是背離 0 的,必須反轉。在樹上每條路剛好走過一次,所以計數是精確的。
# python
# LC 1466 - Reorder Routes to Make All Paths Lead to the City Zero
# IDEA: undirected walk from 0; an edge crossed in its original direction must be reversed
# time = O(n), space = O(n)
def minReorder(n, connections):
graph = [[] for _ in range(n)]
for a, b in connections:
graph[a].append((b, 1)) # a -> b as given: points away from 0 if walked from a
graph[b].append((a, 0)) # the reverse direction costs nothing
seen = [False] * n
seen[0] = True
stack = [0]
reversals = 0
while stack:
city = stack.pop()
for nxt, original in graph[city]:
if not seen[nxt]:
seen[nxt] = True
reversals += original # walked away from 0 along the original direction
stack.append(nxt)
return reversals
// java
// LC 1466 - Reorder Routes to Make All Paths Lead to the City Zero
// IDEA: undirected walk from 0; an edge crossed in its original direction must be reversed
// time = O(n), space = O(n)
public int minReorder(int n, int[][] connections) {
List<List<int[]>> graph = new ArrayList<>();
for (int i = 0; i < n; i++) {
graph.add(new ArrayList<>());
}
for (int[] c : connections) {
graph.get(c[0]).add(new int[]{c[1], 1});
graph.get(c[1]).add(new int[]{c[0], 0});
}
boolean[] seen = new boolean[n];
seen[0] = true;
Deque<Integer> stack = new ArrayDeque<>();
stack.push(0);
int reversals = 0;
while (!stack.isEmpty()) {
int city = stack.pop();
for (int[] edge : graph.get(city)) {
if (!seen[edge[0]]) {
seen[edge[0]] = true;
reversals += edge[1];
stack.push(edge[0]);
}
}
}
return reversals;
}
複雜度
- 時間 O(n):每個城市只推入一次(推入時就設
seen),它的雙向串列只讀一次; 總共 2(n − 1) 個項目。 - 空間 O(n):加倍的鄰接表、
seen和堆疊。
試跑 — n = 6,道路 0→1, 1→3, 2→3, 4→0, 4→5:
pop crosses flag reversals
0 0->1 (original) 1 1
0 0->4 (reverse) 0 1
4 4->5 (original) 1 2
1 1->3 (original) 1 3
3 3->2 (reverse) 0 3
answer 3
陷阱
- 從 0 出發只沿著原本的有向邊走,在
[[1,0],[2,0]]上什麼都到不了;圖必須雙向存。 - 改成數旗標
0的邊會得到 n − 1 − 答案。寫迴圈之前先確定旗標標的是哪個方向。
不適用時 — 要的是每一個可能的根的反轉次數(LC 2858):用這個模板算出根為 0 的答案, 再換根,根沿著一條邊移動時,次數變化 ±1。
練習 — LC 1466 ⭐ · LC 2858 (每個根:換根 DP)
Template 3:跨連通分量的配對計數 — LC 2316
辨識 — 「彼此無法到達的節點對數」、「分屬不同群組的配對」。n ≤ 10⁵ 排除了逐對檢查; 只有分量大小重要。
關鍵 — 新分量的每個節點,都和之前已看過的分量裡每個節點配成一對,所以加上
size × seen_so_far,然後再讓 seen_so_far 變大。每個跨分量的配對只在找到它的第二個分量時被計一次。
# python
# LC 2316 - Count Unreachable Pairs of Nodes in an Undirected Graph
# IDEA: component sizes by flood fill; each new component pairs with every node seen before it
# time = O(n + E), space = O(n + E)
def countPairs(n, edges):
graph = [[] for _ in range(n)]
for a, b in edges:
graph[a].append(b)
graph[b].append(a)
seen = [False] * n
pairs = 0
seen_so_far = 0 # nodes in the components already counted
for start in range(n):
if seen[start]:
continue
seen[start] = True
stack = [start]
size = 0
while stack:
node = stack.pop()
size += 1
for nxt in graph[node]:
if not seen[nxt]:
seen[nxt] = True
stack.append(nxt)
pairs += size * seen_so_far
seen_so_far += size
return pairs
// java
// LC 2316 - Count Unreachable Pairs of Nodes in an Undirected Graph
// IDEA: component sizes by flood fill; each new component pairs with every node seen before it
// time = O(n + E), space = O(n + E)
public long countPairs(int n, int[][] edges) {
List<List<Integer>> graph = new ArrayList<>();
for (int i = 0; i < n; i++) {
graph.add(new ArrayList<>());
}
for (int[] e : edges) {
graph.get(e[0]).add(e[1]);
graph.get(e[1]).add(e[0]);
}
boolean[] seen = new boolean[n];
long pairs = 0, seenSoFar = 0; // long: up to ~5 * 10^9 pairs
for (int start = 0; start < n; start++) {
if (seen[start]) {
continue;
}
seen[start] = true;
Deque<Integer> stack = new ArrayDeque<>();
stack.push(start);
long size = 0;
while (!stack.isEmpty()) {
int node = stack.pop();
size++;
for (int nxt : graph.get(node)) {
if (!seen[nxt]) {
seen[nxt] = true;
stack.push(nxt);
}
}
}
pairs += size * seenSoFar;
seenSoFar += size;
}
return pairs;
}
複雜度
- 時間 O(n + E):外層
for start每個分量發動一次洪水填充,每個節點只推入一次。 - 空間 O(n + E):鄰接表、
seen和堆疊。
陷阱
- Java 的
int會溢位:沒有邊且 n = 10⁵ 時答案約 5·10⁹。pairs、size和seenSoFar都要用long— 兩個int相乘的size * seenSoFar會在轉型之前就溢位。 - 對每個分量加總
size × (n − size)會把每對算兩次;要除以 2,或改用累計總數。 - 併查集也能得到同樣的大小(全部 union 完再數根),而且在邊一條一條加入時更合適。
練習 — LC 2316 ⭐ · LC 323 (只數分量個數) · LC 2492 (節點 1 所在分量裡的 最小邊)
以字典樹為後盾的搜尋
Template 4:查詢會分岔的字典樹 DFS — LC 211
辨識 — 一本單字字典,加上「. 可以匹配任何字母」、「恰好差一個字元」、「魔法字典」。
插入就是普通的字典樹迴圈;只有搜尋會分岔。單字 ≤ 25 個字母,LC 211 每個查詢最多 2 個點。
關鍵 — dfs(node, i) 回答「word[i:] 能不能從這個字典樹節點開始匹配」。字母只走一個子節點;
. 嘗試每個子節點,第一次成功就回傳。查詢走完時,答案是有沒有單字在那個節點結束,而不是 True。
# python
# LC 211 - Design Add and Search Words Data Structure
# IDEA: trie; a '.' in the query branches the DFS into every child, a letter follows one
# time = O(L) per addWord; search O(L) without '.', O(26^d * L) with d dots, space = O(total chars)
class WordDictionary:
def __init__(self):
self.root = {}
def addWord(self, word):
node = self.root
for ch in word:
if ch not in node:
node[ch] = {}
node = node[ch]
node['$'] = True # end-of-word marker
def search(self, word):
def dfs(node, i):
if i == len(word):
return '$' in node # a word must END here
ch = word[i]
if ch == '.':
for key, child in node.items():
if key != '$' and dfs(child, i + 1):
return True # first match is enough
return False
if ch not in node:
return False
return dfs(node[ch], i + 1)
return dfs(self.root, 0)
// java
// LC 211 - Design Add and Search Words Data Structure
// IDEA: trie; a '.' in the query branches the DFS into every child, a letter follows one
// time = O(L) per addWord; search O(L) without '.', O(26^d * L) with d dots, space = O(total chars)
class WordDictionary {
private final WordDictionary[] children = new WordDictionary[26];
private boolean isWord = false;
public void addWord(String word) {
WordDictionary node = this;
for (char c : word.toCharArray()) {
if (node.children[c - 'a'] == null) {
node.children[c - 'a'] = new WordDictionary();
}
node = node.children[c - 'a'];
}
node.isWord = true;
}
public boolean search(String word) {
return dfs(word, 0, this);
}
private boolean dfs(String word, int i, WordDictionary node) {
if (node == null) {
return false;
}
if (i == word.length()) {
return node.isWord; // must end a word, not just be a prefix
}
char c = word.charAt(i);
if (c == '.') {
for (WordDictionary child : node.children) {
if (dfs(word, i + 1, child)) {
return true;
}
}
return false;
}
return dfs(word, i + 1, node.children[c - 'a']);
}
}
複雜度
- 時間:
addWord走 L 個字母。search每個字母只走一個子節點,但每個.會讓for key, child迴圈跑過最多 26 個子節點,所以 d 個點在最壞情況是 O(26^d · L)。 - 空間 O(總字元數):每個不同的前綴一個字典樹節點;遞迴深度 L。
陷阱
- 查詢走完就回傳
True,會讓"ba"匹配到"bad"的前綴。要檢查結尾標記。 - 用 dict 實作字典樹時,
'$'標記也是其中一個鍵:.迭代子節點時要跳過它, 否則 DFS 會遞迴進True。 - 第一個成功的分支沒有立刻回傳,會讓 26^d 的最壞情況變成每次都發生。
不適用時 — 查詢沒有萬用字元:那就是普通的字典樹走訪(trie.md), 或乾脆用單字的雜湊集合。
練習 — LC 211 ⭐ · LC 208 (普通的字典樹) · LC 676 (見下) · LC 212 (由字典樹驅動的 網格 DFS)
Implement Magic Dictionary — LC 676
關鍵 — 不是在已知位置放萬用字元,而是沿著字典樹往下帶一個不匹配額度 1。 子節點的字母和查詢不同就花掉它;查詢走完時額度必須剛好是 0,因為沒改過的單字不算數。
歸約到:Template 4 — 分岔方式從「遇到點就走每個子節點」 變成「每個子節點都走,字母不同就付 1」。
# python
# LC 676 - Implement Magic Dictionary
# IDEA: trie DFS carrying a mismatch budget of 1; it must be fully spent at the word's end
# time = O(26 * L^2) per search, space = O(total chars)
class MagicDictionary:
def __init__(self):
self.root = {}
def buildDict(self, dictionary):
for word in dictionary:
node = self.root
for ch in word:
if ch not in node:
node[ch] = {}
node = node[ch]
node['$'] = True
def search(self, searchWord):
def dfs(node, i, budget):
if i == len(searchWord):
return budget == 0 and '$' in node # exactly one change, and a whole word
for ch, child in node.items():
if ch == '$':
continue
cost = 0 if ch == searchWord[i] else 1
if cost <= budget and dfs(child, i + 1, budget - cost):
return True
return False
return dfs(self.root, 0, 1)
陷阱
- 結尾用
budget <= 1而不是== 0,會接受字典裡的原字:字典有"hello"時,search("hello")必須是False。 - 額度花完後 DFS 只會沿著相同的字母走,所以一次搜尋最多有 26 · L 個地方能花掉額度, 每個之後只走一條最多 L 步的路徑:O(26 · L²),不是指數級。
後序彙總
Template 5:後序距離桶 — LC 1530
辨識 — 「最短路徑不超過 distance 的葉節點對」,上界很小(distance ≤ 10),樹最多 2¹⁰ 個節點。
關鍵 — 每個節點回傳 cnt[d] = 它下面恰好 d 條邊遠的葉節點數。左邊深度 d1 的葉節點和右邊深度
d2 的葉節點,經過這個節點相距 d1 + d2 + 2,而這個節點就是它們的最低共同祖先,所以每一對恰好在
這裡被計一次。
# python
# LC 1530 - Number of Good Leaf Nodes Pairs
# IDEA: post-order; each node returns leaf counts by depth below it and pairs left x right at the LCA
# time = O(N * distance^2), space = O(N + distance * height)
def countPairs(root, distance):
pairs = [0]
def leaves_by_depth(node):
cnt = [0] * (distance + 1) # cnt[d] = leaves exactly d edges below node
if node is None:
return cnt
if node.left is None and node.right is None:
cnt[0] = 1 # a leaf is 0 edges from itself
return cnt
left = leaves_by_depth(node.left)
right = leaves_by_depth(node.right)
for d1 in range(distance + 1): # join a left leaf and a right leaf through node
for d2 in range(distance + 1):
if d1 + d2 + 2 <= distance:
pairs[0] += left[d1] * right[d2]
for d in range(distance): # one edge farther for the parent; deeper leaves drop off
cnt[d + 1] = left[d] + right[d]
return cnt
leaves_by_depth(root)
return pairs[0]
// java
// LC 1530 - Number of Good Leaf Nodes Pairs
// IDEA: post-order; each node returns leaf counts by depth below it and pairs left x right at the LCA
// time = O(N * distance^2), space = O(N + distance * height)
class Solution {
private int pairs = 0;
public int countPairs(TreeNode root, int distance) {
pairs = 0; // a reused Solution must not carry counts over
leavesByDepth(root, distance);
return pairs;
}
// cnt[d] = number of leaves exactly d edges below node
private int[] leavesByDepth(TreeNode node, int distance) {
int[] cnt = new int[distance + 1];
if (node == null) return cnt;
if (node.left == null && node.right == null) {
cnt[0] = 1; // a leaf is 0 edges from itself
return cnt;
}
int[] left = leavesByDepth(node.left, distance);
int[] right = leavesByDepth(node.right, distance);
for (int d1 = 0; d1 <= distance; d1++) { // join a left leaf and a right leaf through node
for (int d2 = 0; d2 <= distance; d2++) {
if (d1 + d2 + 2 <= distance) pairs += left[d1] * right[d2];
}
}
for (int d = 0; d < distance; d++) { // one edge farther for the parent
cnt[d + 1] = left[d] + right[d];
}
return cnt;
}
}
複雜度
- 時間 O(N·distance²):N 個節點每個都跑一次
d1/d2雙層迴圈。對right做累計前綴和, 可以讓合併變成 O(distance),總共 O(N·distance)。 - 空間 O(N + distance·height):遞迴深度,加上每個尚未結束的呼叫各一個桶。
陷阱
- 桶往上平移時會丟掉比
distance更深的葉節點(是range(distance),不是range(distance + 1));在d = distance時寫cnt[d + 1]會越界。 - 在每個祖先都合併、而不是只在左右分岔處合併,會重複計數;這一對只屬於它們的 LCA。
不適用時 — 距離上界很大:每個節點的桶都變成 O(N)。把樹轉成圖、從每個葉節點跑有界的 BFS 是比較慢的備案(O(L·N)),這些筆記裡沒有寫出它。
練習 — LC 1530 · LC 543 (在每個節點合併深度) · LC 124 (合併最佳向下總和) · LC 687 · LC 1530 ⭐
Template 6:N 元樹後序彙總 — LC 3965
辨識 — 以 edges 給出、「u 是 v 的父節點」、根在 0 的樹;節點的值是子節點值(最小、最大、總和)
加上自己權重的某個公式;只問根的值。n ≤ 10⁵。
關鍵 — 節點要等所有子節點都算完才能算。把 DFS 順序反過來彈出,每個子節點都會比它的父節點先被處理,
所以一個存放完成值的陣列就能取代遞迴。LC 3965 的公式:own = (latest − earliest) + baseTime[node]、
finish = latest + own,其中 latest / earliest 取自子節點。
# python
# LC 3965 - Finish Time of Tasks I
# IDEA: build children lists; compute nodes in reverse DFS pre-order so children finish first
# time = O(n), space = O(n)
def finishTime(n, edges, baseTime):
children = [[] for _ in range(n)]
for parent, child in edges: # directed: parent -> child
children[parent].append(child)
order = [] # DFS pre-order from the root
stack = [0]
while stack:
node = stack.pop()
order.append(node)
for child in children[node]:
stack.append(child)
finish = [0] * n
for node in reversed(order): # every child appears after its parent in `order`
if not children[node]:
finish[node] = baseTime[node] # leaf
continue
earliest = min(finish[c] for c in children[node])
latest = max(finish[c] for c in children[node])
own_duration = (latest - earliest) + baseTime[node]
finish[node] = latest + own_duration
return finish[0]
// java
// LC 3965 - Finish Time of Tasks I
// IDEA: build children lists; compute nodes in reverse DFS pre-order so children finish first
// time = O(n), space = O(n)
public long finishTime(int n, int[][] edges, int[] baseTime) {
List<List<Integer>> children = new ArrayList<>();
for (int i = 0; i < n; i++) {
children.add(new ArrayList<>());
}
for (int[] e : edges) {
children.get(e[0]).add(e[1]);
}
int[] order = new int[n];
int size = 0;
Deque<Integer> stack = new ArrayDeque<>();
stack.push(0);
while (!stack.isEmpty()) {
int node = stack.pop();
order[size++] = node;
for (int c : children.get(node)) {
stack.push(c);
}
}
long[] finish = new long[n]; // long: values compound with depth
for (int i = n - 1; i >= 0; i--) {
int node = order[i];
if (children.get(node).isEmpty()) {
finish[node] = baseTime[node];
continue;
}
long earliest = Long.MAX_VALUE, latest = Long.MIN_VALUE;
for (int c : children.get(node)) {
earliest = Math.min(earliest, finish[c]);
latest = Math.max(latest, finish[c]);
}
finish[node] = latest + (latest - earliest) + baseTime[node];
}
return finish[0];
}
複雜度
- 時間 O(n):每個節點只推入一次,
for node in reversed(order)那一趟每個子節點串列只讀一次。 - 空間 O(n):子節點串列、
order、finish和堆疊。
陷阱
- 把圖建成無向的會破壞葉節點判斷:每個非根節點都會把父節點當成鄰居,沒有節點看起來像葉子。
- Java 的
int會溢位:節點的值接近它最慢子節點的兩倍,隨深度不斷累積。題目只保證答案 < 2⁵³;要用long。 - 遞迴的
dfs(child)寫法最容易解釋,但在一條鏈上深達 10⁵ 層;說明這一點,然後寫以順序為基礎的版本。
不適用時 — 樹是二元的 TreeNode:同樣的彙總就是
dfs.md § Template 6,遞迴深度是樹高。
練習 — LC 3965 ⭐ · LC 3967 (同樣的彙總,要回答很多個查詢) · LC 559 (1 + 子節點最大深度) · LC 1376 (子節點最大值 + 自己的時間)
編碼在輸入裡的樹
Template 7:以深度為索引的堆疊 — LC 388
辨識 — 輸入字串本身就是一棵樹:以 tab 縮排的行、以 / 分隔的路徑、每個 token 前面有一個深度
前綴。答案是根到葉的某種彙總,例如最長路徑。
關鍵 — stack[d] 存放深度 d 那個尚未關閉的祖先的彙總值。處理深度為 d 的一行之前,
先彈到 len(stack) == d + 1:那些彈出就是 DFS 往上返回,而 stack[d] 這時正好是這一行的父前綴。
從頭到尾都不用建樹。
# python
# LC 388 - Longest Absolute File Path
# IDEA: stack[d] = path length up to the open directory at depth d; popping == returning up the DFS
# time = O(N), space = O(D) N = input length, D = deepest nesting
def lengthLongestPath(input):
longest = 0
stack = [0] # depth 0 has an empty prefix
for line in input.split('\n'):
name = line.lstrip('\t')
depth = len(line) - len(name) # leading tabs = depth
while len(stack) > depth + 1: # close the directories this line is not inside
stack.pop()
if '.' in name: # a file is a leaf: score it, never push it
longest = max(longest, stack[depth] + len(name))
else: # a directory: its prefix plus the name plus '/'
stack.append(stack[depth] + len(name) + 1)
return longest
// java
// LC 388 - Longest Absolute File Path
// IDEA: stack[d] = path length up to the open directory at depth d; popping == returning up the DFS
// time = O(N), space = O(D)
public int lengthLongestPath(String input) {
int longest = 0;
Deque<Integer> stack = new ArrayDeque<>();
stack.push(0);
for (String line : input.split("\n")) {
int depth = 0;
while (depth < line.length() && line.charAt(depth) == '\t') {
depth++;
}
String name = line.substring(depth);
while (stack.size() > depth + 1) {
stack.pop();
}
if (name.contains(".")) {
longest = Math.max(longest, stack.peek() + name.length());
} else {
stack.push(stack.peek() + name.length() + 1);
}
}
return longest;
}
複雜度
- 時間 O(N):
split和lstrip每個字元只讀一次;每個目錄最多被while len(stack) > depth + 1迴圈推入、彈出各一次。 - 空間 O(D):堆疊為每個尚未關閉的目錄存一個項目。
陷阱
- 把檔案推入堆疊會弄壞所有更深的前綴;檔案是葉子。
- 完全沒有檔案時回傳 0(
"a"→ 0),不是最長的目錄路徑。 + 1是目錄貢獻的/,所以最上層的檔案以stack[0] = 0計分,前面沒有斜線。
練習 — LC 388 ⭐ · LC 1233 (見下) · LC 1028 (深度由破折號表示:把樹重建出來)
Remove Sub-Folders from the Filesystem — LC 1233
關鍵 — 把每條路徑拆成各段、存進字典樹,並在資料夾結束的節點做標記。從根開始的 DFS 收集到被標記的節點就停止往下:存放的資料夾底下的所有東西,依定義都是子資料夾。
歸約到:Template 7 — 一樣把「路徑的各段當成深度層級」來讀, 只是因為路徑沒有排序,所以存在字典樹裡。
# python
# LC 1233 - Remove Sub-Folders from the Filesystem
# IDEA: path trie; DFS collects the first stored folder on each branch and cuts below it
# time = O(total path chars), space = O(total path chars)
def removeSubfolders(folder):
root = {}
for path in folder:
node = root
for part in path.split('/')[1:]: # [0] is the empty string before the first '/'
if part not in node:
node[part] = {}
node = node[part]
node['$'] = path # the full path ends here
kept = []
stack = [root]
while stack:
node = stack.pop()
if '$' in node:
kept.append(node['$'])
continue # cut: anything deeper is a sub-folder
for child in node.values():
stack.append(child)
return kept
陷阱
- 比較原始字串前綴會說
/a/bc在/a/b裡面。以/拆開比較的是完整名稱; 如果改用排序,要檢查path.startswith(kept[-1] + '/')。
Template 8:在 parent 陣列上記憶化往上爬 — LC 4015
辨識 — 樹以 parent[](或 manager[])給出,parent[root] = -1,而答案需要每個節點的深度或
樹高。n ≤ 10⁵,而且編號不保證 parent[i] < i。
關鍵 — parent[] 本身就是往上的邊,所以 depth[x] = depth[parent[x]] + 1 不需要任何其他結構。
把它記憶化:每個節點的深度只算一次,每次往上爬都停在第一個已知的祖先,所以全部的爬升加起來是 O(n)。
深度從 1 開始,所以 depth[x] == 0 代表「還沒算」。
# python
# LC 4015 - Weighted Sum of a Tree
# IDEA: memoized climb up parent[] (explicit stack, no recursion); height = max depth
# time = O(n), space = O(n)
def weightedSum(parent, nums):
n = len(parent)
depth = [0] * n # 0 = not computed yet; depths are 1-based
for start in range(n):
chain = []
x = start
while depth[x] == 0: # climb until a computed node, or past the root
if parent[x] == -1:
depth[x] = 1
break
chain.append(x)
x = parent[x]
d = depth[x]
while chain: # unwind: fill every node climbed through
d += 1
depth[chain.pop()] = d
height = max(depth)
total = 0
for i in range(n):
total += nums[i] * (height - depth[i] + 1)
return total
// java
// LC 4015 - Weighted Sum of a Tree
// IDEA: memoized climb up parent[] with an explicit stack; height = max depth
// time = O(n), space = O(n)
class Solution {
public long weightedSum(int[] parent, int[] nums) {
int n = parent.length;
int[] depth = new int[n]; // 0 = not computed yet; depths are 1-based
java.util.ArrayDeque<Integer> chain = new java.util.ArrayDeque<>();
for (int start = 0; start < n; start++) {
int x = start;
while (depth[x] == 0) { // climb until a computed node, or past the root
if (parent[x] == -1) {
depth[x] = 1;
break;
}
chain.push(x);
x = parent[x];
}
int d = depth[x];
while (!chain.isEmpty()) { // unwind: fill every node climbed through
d++;
depth[chain.pop()] = d;
}
}
int height = 0;
for (int dep : depth) height = Math.max(height, dep);
long total = 0;
for (int i = 0; i < n; i++) {
total += (long) nums[i] * (height - depth[i] + 1);
}
return total;
}
}
複雜度
- 時間 O(n):節點只在
depth[x] == 0時才被加入chain,並在鏈展開時被填好, 所以在所有起點加起來,每個節點只被爬過一次。 - 空間 O(n):
depth和一條chain,在路徑形狀的樹上最長 n。
試跑 — parent = [-1,0,0,0,2,2],nums = [5,2,3,1,4,6]:
start climb depths filled
0 root depth0 = 1
1 1 -> 0 (known) depth1 = 2
2 2 -> 0 depth2 = 2
3 3 -> 0 depth3 = 2
4 4 -> 2 (known) depth4 = 3
5 5 -> 2 depth5 = 3
height 3: 5*3 + 2*2 + 3*2 + 1*2 + 4*1 + 6*1 = 37
陷阱
- 沒有備忘錄時,路徑形狀的樹要花 1 + 2 + … + n = O(n²)。
- 只掃一次
i = 0 … n − 1、寫depth[i] = depth[parent[i]] + 1,只有在parent[i] < i時才對; LC 4015 不保證這點,所以這樣掃會讀到還沒填的項目。 - 遞迴往上爬在一條鏈上深達 n 層;n = 10⁵ 時需要明確的堆疊。
不適用時 — 答案需要子樹彙總(大小、總和、子節點最小/最大):把 parent[] 反轉成子節點串列,
改用 Template 6。
往上爬看起來也很像併查集的 find,但這裡沒有 union;用 DSU 毫無好處。
練習 — LC 4015 ⭐ · LC 1376 (沿著 manager 鏈累加時間) · LC 1483 (把往上爬以 2^k 步預先算好: binary lifting) · LC 1650 (兩條鏈一起往上爬:兩串列交點)
何時不要用
| 技巧 | 通常就夠用的簡單卡片 | 什麼時候就夠 |
|---|---|---|
| Template 1(有效旗標) | dfs.md § Template 2 | 只有一張網格,每個分量都算數 |
| Template 2(方向旗標) | 普通的有向 DFS / BFS | 問題只是沿著給定方向能不能到達 |
| Template 3(配對計數) | graph.md § Template 1 | 只要分量的個數 |
| Template 4(字典樹 DFS) | 單字的雜湊集合,或普通的字典樹走訪 | 查詢沒有萬用字元,或字典小到可以逐字比較 |
| Template 5(距離桶) | dfs.md § Template 6 | 答案是一條最佳路徑(直徑、最大路徑和),不是配對數 |
| Template 6(N 元樹彙總) | 遞迴後序 | n 很小或樹很淺;遞迴比較好解釋 |
| Template 7(以深度為索引的堆疊) | 先建樹,再對它 DFS | 面試官要的是樹本身,或格式不是一個節點一行 |
| Template 8(記憶化往上爬) | 在反轉後的子節點串列上從根做 BFS | 編號保證 parent[i] < i(那就掃一次),或反正也需要子樹資料 |
總結
| 模板 | 額外記帳 | 時間 | 空間 |
|---|---|---|---|
| 1. 有效旗標 | 每次洪水填充一個布林值 | O(m·n) | O(m·n) |
| 2. 方向旗標 | 每條存起來的邊上的 0/1 |
O(n) | O(n) |
| 3. 配對計數 | 已看過節點的累計總數 | O(n + E) | O(n + E) |
| 4. 字典樹 DFS | 遇到 . 分岔/不匹配額度 |
每次搜尋 O(26^d · L) | O(總字元數) |
| 5. 距離桶 | 每個節點的 cnt[d] |
O(N·d²) | O(N) |
| 6. N 元樹彙總 | 用反向前序取代遞迴 | O(n) | O(n) |
| 7. 以深度為索引的堆疊 | 每個尚未關閉的祖先一個 stack[d] |
O(N) | O(D) |
| 8. 記憶化往上爬 | depth[] 備忘錄,0 = 還沒算 |
O(n) | O(n) |
這份文件特有的陷阱
- 結果是一個旗標的洪水填充,仍然要把整個分量淹完。
- 字典樹 DFS 要檢查單字結尾標記,迭代子節點時又要跳過它。
- 以深度為索引的堆疊從不推入葉子。
- n 到 10⁵ 且輸入可能是路徑形狀:Templates 1、2、3、6 和 8 因此都用明確的堆疊來寫。
語言備註
- Python 遞迴:預設上限是 1,000 層。
sys.setrecursionlimit(10**6)可以調高,但很深的遞迴仍可能 耗盡 C 堆疊;明確的堆疊才是安全的寫法。 - Java 遞迴:LeetCode 解答沒辦法設定執行緒堆疊大小;數萬層就可能丟出
StackOverflowError。 - Java 共享狀態:傳進遞迴呼叫的
StringBuilder、List或int[],在每一層都是同一個物件, 所以呼叫返回後 append 的內容還在 — dfs.md § Template 8 就是這樣建出路徑簽名的。int或String參數是副本;改用回傳值。
相關主題
- graph_advanced.md:Tarjan 找橋/SCC 與 Hierholzer 尤拉路徑。
- union_find.md:Template 3 有 DSU 寫法;Template 8 看起來像 DSU,但沒有
union()。 - trie.md:Template 4 搜尋的結構。
- tree_codec.md:回傳字串的 DFS,以及把它讀回來的解析器。
- bfs.md:這裡每個洪水填充的廣度優先版本。