DFS — 進階模式
範圍 — 第一輪複習可以先跳過的冷門硬 DFS 技巧:雙網格驗證、邊方向追蹤、跨連通分量的配對計數、尤拉路徑、Tarjan low-link 找橋、字典樹配萬用字元搜尋、以深度為索引的堆疊 DFS,以及在 N 元樹與 parent 陣列樹上的後序彙總。 另見 — 母文件:dfs.md — 十個核心 DFS 模板、辨識表和模式選擇流程圖;dfs_examples.md — 核心模板的解題存檔。 鄰近文件:trie.md — 字典樹結構本身,不含會分岔的查詢;union_find.md — 連通性 DFS 的 DSU 替代方案;graph.md — 圖的表示法與走訪順序;tree_codec.md — 樹 ⟷ 字串編解碼的完整說明;bfs.md — 廣度優先的對應版本。
LeetCode 題目清單
總覽
這些是整個面試循環裡只會出現一次的 DFS 模式,不是每一關都會考。每一個都只是在 dfs.md 的某個核心模板上,疊一個可辨識的小把戲 — 先讀那份文件,等到某題不符合 它的十個模板時,再回來這裡。
關鍵性質
- 複雜度:依模板而定 — 見下方的模板比較表
- 核心想法:這裡每個模式都是「普通 DFS 加上一項額外的記帳」— 多一張網格、一個方向旗標、 一個發現時間戳、一份已消耗邊的集合、一個以深度為索引的堆疊、一組距離桶
- 什麼時候用:只有當題目本身就點名了那個額外結構(兩張網格、邊的方向、「關鍵」邊、
「每條邊只用一次」、縮排、一個
parent[]陣列) - 先備知識:dfs.md 裡的核心模板
Problem Categories
| # | Pattern | Recognition keywords | Canonical LC | Also |
|---|---|---|---|---|
| 1 | DFS with Validation (sub-component detection) | “sub-islands”, “subset validation”, “inclusion checking” | LC 1905 | 827, 463 |
| 2 | Bidirectional graph with direction tracking | “reorder edges”, “reverse routes”, “make all paths lead to” | LC 1466 | 1568, 1579 |
| 3 | Component pair counting | “unreachable pairs”, “pairs in different components” | LC 2316 | 323, 547 |
| 4 | Euler path / Hierholzer | “use every edge exactly once”, “reconstruct itinerary” | LC 332 | 753 |
| 5 | Tarjan bridge finding (low-link) | “critical connection”, “which edge disconnects the graph” | LC 1192 | 1568 |
| 6 | Trie + DFS wildcard search | “. matches any letter”, “one edit away”, “magic dictionary” |
LC 211 | 676 |
| 7 | Depth-indexed stack DFS | tab-indented input, /-separated paths, “longest absolute path” |
LC 388 | 1233 |
| 8 | Post-order distance-bucket aggregation | “good leaf pairs”, “distance between leaves ≤ k” | LC 1530 | 124, 543, 687 |
| 9 | N-ary post-order child min/max rollup | tree as edges rooted at 0, answer only for the root |
LC 3965 | 3967, 559, 590, 1376 |
| 10 | Tree ⟷ string codec | DFS returns a string / parses a nesting string | LC 606 / LC 536 | 297, 449, 331, 652 |
| 11 | Parent-array tree, memoized upward depth | input is parent[] / manager[] with -1 at the root |
LC 4015 | 1376, 1483, 1650 |
模板與演算法
模板比較表
| 模板 | 額外要記的東西 | 時間 | 空間 | 什麼時候用 |
|---|---|---|---|---|
| 1. DFS 驗證 | 第二張參考網格 + 一個布林旗標 | O(m×n) | O(m×n) | 一個分量必須被另一個包住 |
| 2. 方向追蹤 | 每條雙向邊配一個 0/1 旗標 |
O(V+E) | O(V+E) | 數出方向不對的邊 |
| 3. 分量配對計數 | 已處理節點數的滾動總和 | O(V+E) | O(V) | 不用 O(n²) 就數出跨分量的配對 |
| 4. Hierholzer | 標記已消耗的邊、後序 append 再反轉 | O(E log E) | O(E) | 必須恰好走過每一條邊 |
| 5. Tarjan low-link | disc[] / low[] 時間戳 |
O(V+E) | O(V+E) | 一趟就找出橋/關節點 |
| 6. 字典樹 + DFS | 遇到萬用字元就往所有子節點分岔 | O(26^d · L) | O(total chars) | 查詢會分岔的前綴搜尋 |
| 7. 以深度為索引的堆疊 | stack[d] = 深度 d 那個祖先的前綴值 |
O(N) | O(D) | 輸入字串本身就是那棵樹 |
| 8. 距離桶 | 每個節點回傳一個 cnt[d] 陣列 |
O(N·d²) | O(N) | 依樹上距離統計節點配對 |
| 9. N 元樹彙總 | 用鄰接串列取代 .left/.right |
O(N) | O(N) | 值只由子節點的值決定 |
| 10. 編解碼 | 共用的解析游標/格式模板 | O(N) | O(N) | 把樹編成字串再還原回來 |
| 11. 記憶化向上爬 | depth[] 記憶表,0 兼作「尚未計算」 |
O(N) | O(N) | 從 parent[] 陣列求深度/高度 |
模板 1:帶驗證的 DFS(子分量偵測) — LC 1905
- 說明:走訪一個網格/圖結構的同時,拿另一個參考結構做驗證
- 辨識:「Sub-islands」、「子集驗證」、「分量比對」、「包含關係檢查」
- 關鍵技巧:DFS 走訪時帶一個布林旗標,追蹤是否所有格子都滿足條件
- 例題:LC 1905(Count Sub Islands)
- 重點提醒:
- 布林旗標的傳遞:用
res = dfs(...) && res的寫法把驗證結果累積起來 - 標記走訪過的格子:在走訪用的那張網格上標記,避免重複走訪
- 短路最佳化:驗證失敗時可以提早回傳來加速
- 兩張網格的比對:一張決定走訪結構,另一張決定驗證條件
- 布林旗標的傳遞:用
/**
* Pattern: DFS traversal on one grid while validating against another grid
* Use case: Count sub-islands, validate subset components, inclusion checking
* Key insight: Use boolean flag propagation to track whether ALL cells satisfy condition
*
* Time: O(m × n) - visit each cell once
* Space: O(m × n) - recursion stack + visited set
*/
public int countSubComponents(int[][] grid1, int[][] grid2) {
if (grid2 == null || grid2.length == 0) {
return 0;
}
int rows = grid2.length;
int cols = grid2[0].length;
Set<Integer> visited = new HashSet<>();
int count = 0;
// Iterate through grid2 to find all components
for (int r = 0; r < rows; r++) {
for (int c = 0; c < cols; c++) {
int flatCoord = r * cols + c;
// Start DFS on unvisited land cells in grid2
if (grid2[r][c] == 1 && !visited.contains(flatCoord)) {
// DFS returns true if ALL cells in this component exist in grid1
if (dfsValidate(grid1, grid2, r, c, visited)) {
count++;
}
}
}
}
return count;
}
/**
* DFS with validation: Check if entire component in grid2 is subset of grid1
* Returns true only if ALL cells in the component satisfy the condition
*/
private boolean dfsValidate(int[][] grid1, int[][] grid2, int r, int c, Set<Integer> visited) {
int rows = grid2.length;
int cols = grid2[0].length;
int flatCoord = r * cols + c;
// Base cases
if (r < 0 || r >= rows || c < 0 || c >= cols
|| grid2[r][c] == 0 || visited.contains(flatCoord)) {
return true; // Empty/visited cells don't violate the condition
}
// Mark as visited
visited.add(flatCoord);
// Initialize result as true
boolean isValid = true;
// Check condition: Does this cell exist in grid1?
if (grid1[r][c] == 0) {
isValid = false; // Found a cell in grid2 that's NOT in grid1
}
// CRITICAL: Use && with res to propagate validation through entire component
// Must visit ALL neighbors even if isValid is false (to mark them as visited)
isValid = dfsValidate(grid1, grid2, r - 1, c, visited) && isValid;
isValid = dfsValidate(grid1, grid2, r + 1, c, visited) && isValid;
isValid = dfsValidate(grid1, grid2, r, c - 1, visited) && isValid;
isValid = dfsValidate(grid1, grid2, r, c + 1, visited) && isValid;
return isValid;
}
Python 實作:
def count_sub_components(grid1, grid2):
"""
Count components in grid2 that are completely contained in grid1
"""
if not grid2 or not grid2[0]:
return 0
rows, cols = len(grid2), len(grid2[0])
visited = set()
count = 0
def dfs(r, c):
"""
DFS with validation
Returns True if entire component is valid
"""
# Base cases
if (r < 0 or r >= rows or c < 0 or c >= cols
or grid2[r][c] == 0 or (r, c) in visited):
return True
visited.add((r, c))
# Check condition
is_valid = True
if grid1[r][c] == 0:
is_valid = False
# Visit all neighbors (must visit ALL even if invalid)
is_valid = dfs(r - 1, c) and is_valid
is_valid = dfs(r + 1, c) and is_valid
is_valid = dfs(r, c - 1) and is_valid
is_valid = dfs(r, c + 1) and is_valid
return is_valid
# Main loop
for r in range(rows):
for c in range(cols):
if grid2[r][c] == 1 and (r, c) not in visited:
if dfs(r, c):
count += 1
return count
具體例子:LC 1905 - Count Sub Islands
Problem: Count islands in grid2 that are completely contained in grid1
grid1: [[1,1,1,0,0], grid2: [[1,1,1,0,0],
[0,1,1,1,1], [0,0,1,0,0],
[0,0,0,0,0], [0,1,0,0,0],
[1,0,0,0,0], [1,0,1,1,0],
[1,1,0,1,1]] [0,1,0,1,0]]
Analysis:
- Island 1 in grid2 (top-left): Cells (0,0), (0,1), (0,2), (1,2)
→ Check grid1: All exist? YES → Count it ✓
- Island 2 in grid2 (middle): Cells (2,1)
→ Check grid1: (2,1) = 0 → NOT a sub-island ✗
- Island 3 in grid2 (bottom): Cells (3,0), (3,2), (3,3), (4,1), (4,3)
→ Check grid1: (3,0) = 1, but (4,1) = 1... complex shape
→ Some cells don't match → NOT a sub-island ✗
Result: 1 sub-island (only the first one)
Key Insight:
- Must traverse ENTIRE island in grid2
- Check EVERY cell against grid1
- Return true only if ALL cells pass validation
布林傳遞為什麼可行:
// CORRECT: Visit all neighbors, accumulate results
res = dfs(r - 1, c) && res;
res = dfs(r + 1, c) && res;
res = dfs(r, c - 1) && res;
res = dfs(r, c + 1) && res;
// WRONG: Short-circuits, doesn't visit all cells
if (!dfs(r - 1, c)) return false; // Stops early, leaves cells unvisited!
模式特徵:
- 兩份資料來源:一份決定結構(grid2),一份決定驗證(grid1)
- 必須走完整個分量:不能短路提早結束
- 布林累積:用
res = dfs(...) && res的寫法 - visited 追蹤:這是避免無窮迴圈與重複計數的關鍵
- 總時間:O(m × n) — 每個格子只走訪一次
- 總空間:O(m × n) — 遞迴堆疊 + visited 集合
什麼時候用這個模式:
- 驗證某個分量是不是另一個的子集
- 檢查結構 A 是否完全被結構 B 包住
- 統計符合特定性質的合法子分量
- 兩張網格的比對問題
主要變形:
- 提早終止:只要有一個格子不合格,整個分量就標成不合法
- 反向驗證:檢查 grid2 的格子在 grid1 裡不存在(反過來的問題)
- 多張網格:拿多張參考網格一起驗證
- 加權驗證:走訪時把值加總,再檢查是否超過門檻
類似題目:
- LC 1905: Count Sub Islands(兩張網格、子集驗證)
- LC 200: Number of Islands(單張網格、基本 DFS)
- LC 695: Max Area of Island(單張網格、數格子)
- LC 463: Island Perimeter(單張網格、數邊)
- LC 827: Making A Large Island(修改網格、求最大面積)
模板 2:雙向圖 + 方向追蹤 — LC 1466
- 說明:把一張有向圖建成無向圖表示,DFS 走訪時追蹤原本的邊方向
- 辨識:「重新排列邊」、「反轉路線」、「讓路徑通往」、「最少反轉幾條邊」、「替邊定向」
- 關鍵技巧:在雙向鄰接串列裡替每條邊存方向資訊(旗標),DFS 時數出需要反轉的邊
- 例題:LC 1466(Reorder Routes to Make All Paths Lead to the City Zero)
- 重點提醒:
- 建雙向圖:每條邊都加兩個方向,但用旗標標出原本的方向
- 方向旗標:原方向用 1,反方向用 0
- 走訪時計數:走到旗標為 1 的邊(方向錯的那種)就把計數器加一
- 樹的性質:對樹狀結構特別好用(n 個節點 n-1 條邊)
- 從根出發:DFS 一律從目標節點(所有路徑該通往的那個節點)開始
/**
* Pattern: Build bidirectional graph with direction flags, count edge reversals via DFS
* Use case: Reorder edges, reverse routes, make all paths lead to a target node
* Key insight: Treat directed graph as undirected for traversal, but track original directions
*
* Time: O(V + E) - visit each node and edge once
* Space: O(V + E) - adjacency list + visited array
*/
public int minReorder(int n, int[][] connections) {
// Build bidirectional adjacency list with direction flags
// Map: city -> List of [neighbor, direction_flag]
// direction_flag: 1 if original direction (needs reversal)
// direction_flag: 0 if reverse direction (correct direction)
Map<Integer, List<int[]>> adj = new HashMap<>();
for (int i = 0; i < n; i++) {
adj.put(i, new ArrayList<>());
}
for (int[] c : connections) {
int from = c[0];
int to = c[1];
// Original direction: from -> to (flag = 1, needs reversal)
adj.get(from).add(new int[]{to, 1});
// Reverse direction: to -> from (flag = 0, correct direction)
adj.get(to).add(new int[]{from, 0});
}
boolean[] visited = new boolean[n];
int[] count = {0}; // Use array to pass by reference
// Start DFS from target node (city 0)
dfsCountReversals(0, adj, visited, count);
return count[0];
}
/**
* DFS to count edges that need reversal
* Increment count when traversing edge with flag=1 (wrong direction)
*/
private void dfsCountReversals(int node, Map<Integer, List<int[]>> adj,
boolean[] visited, int[] count) {
visited[node] = true;
for (int[] edge : adj.get(node)) {
int neighbor = edge[0];
int directionFlag = edge[1];
if (!visited[neighbor]) {
// If flag = 1, edge points away from target (needs reversal)
if (directionFlag == 1) {
count[0]++;
}
dfsCountReversals(neighbor, adj, visited, count);
}
}
}
Python 實作:
def min_reorder(n, connections):
"""
Count minimum edge reversals to make all paths lead to node 0
"""
# Build bidirectional graph with direction flags
adj = {i: [] for i in range(n)}
for src, dst in connections:
# Original direction: src -> dst (flag=1, needs reversal)
adj[src].append((dst, 1))
# Reverse direction: dst -> src (flag=0, correct)
adj[dst].append((src, 0))
visited = set()
count = [0]
def dfs(node):
visited.add(node)
for neighbor, flag in adj[node]:
if neighbor not in visited:
# If flag=1, edge points away from 0 (needs reversal)
if flag == 1:
count[0] += 1
dfs(neighbor)
dfs(0) # Start from target node
return count[0]
關鍵觀念:
-
建雙向圖
- 每條邊都加兩個方向
- 原方向的旗標是 1(需要反轉)
- 反方向的旗標是 0(已經對了)
-
為什麼這樣可行
textExample: connections = [[0,1],[1,3],[2,3],[4,0],[4,5]] Original directed graph (edges point away from 0): 0 -> 1 -> 3 2 -> 3 4 -> 0, 4 -> 5 Need to reverse: 0->1, 1->3, 4->5 (3 reversals) During DFS from 0: - Visit 1: used edge 0->1 (flag=1) → count++ - Visit 3: used edge 1->3 (flag=1) → count++ - Visit 2: used edge 2->3 (flag=0) → no count - Visit 4: used edge 4->0 (flag=0) → no count - Visit 5: used edge 4->5 (flag=1) → count++ Total = 3 -
方向旗標的邏輯
- 旗標 = 1:邊是原方向(current->neighbor)
- 代表我們正在用一條「背離根」的邊
- 必須反轉
- 旗標 = 0:邊是反方向(neighbor->current)
- 代表原本那條邊就是朝著根的
- 已經是對的
- 旗標 = 1:邊是原方向(current->neighbor)
-
樹的性質
- 對樹狀結構(n-1 條邊)完全適用
- 從根出發每個節點都可達
- 不用擔心有環
模式特徵:
- 圖的種類:樹或有向圖
- 關鍵技巧:帶額外資訊的雙向表示法
- DFS 起點:一律是目標節點
- 計數條件:旗標為 1 的邊需要反轉
- visited 追蹤:走訪樹時不可或缺
- 時間複雜度:O(V + E) — 線性
- 空間複雜度:O(V + E) — 鄰接串列
什麼時候用這個模式:
- 「重新排列路線/邊,讓所有路徑都通往 X」
- 「最少反轉幾條邊才能讓所有節點連到根」
- 「替邊定向,讓所有節點都到得了目標」
- 需要改變邊方向的樹/圖問題
- 統計必要的邊方向修改次數
類似題目:
- LC 1466: Reorder Routes to Make All Paths Lead to the City Zero
- LC 1568: Minimum Number of Days to Disconnect Island(相關的圖修改題)
- LC 1579: Remove Max Number of Edges to Keep Graph Fully Traversable(替邊定向)
模板 3:連通分量配對計數(不可達的配對) — LC 2316
- 說明:在一張有多個不連通分量的圖裡,統計互相到不了的節點配對數
- 辨識:「不可達的配對」、「數不連通的配對」、「不同分量之間的配對」、「孤立節點配對」
- 關鍵技巧:先用 DFS/併查集找出所有分量,再用累乘的方式數出跨分量的配對
- 例題:LC 2316(Count Unreachable Pairs of Nodes in an Undirected Graph)
- 重點提醒:
- 兩種計數方式:
- 往前算:
componentSize × nodesProcessed(已經看過的節點) - 往後算:
componentSize × (n - componentSize - processed)(剩下的節點)
- 往前算:
- 避免重複計數:不同分量之間的配對只能算一次
- 數學上的最佳化:O(components) 而不是 O(n²) 的暴力法
- 找出分量:DFS 或併查集都可以找出所有分量
- 累計追蹤:維護已處理節點的滾動總和,才能高效算出配對數
- 兩種計數方式:
/**
* Pattern: Count pairs of nodes that cannot reach each other across different components
* Use case: Count unreachable/disconnected pairs, isolated node pairs
* Key insight: For each component, multiply its size by nodes in OTHER components
*
* Time: O(V + E) - DFS to find all components
* Space: O(V) - visited array + adjacency list
*/
// Approach 1: DFS with Forward Counting (count against already processed)
public long countUnreachablePairs_DFS_Forward(int n, int[][] edges) {
// Build adjacency list
List<Integer>[] adj = new ArrayList[n];
for (int i = 0; i < n; i++) {
adj[i] = new ArrayList<>();
}
for (int[] edge : edges) {
adj[edge[0]].add(edge[1]);
adj[edge[1]].add(edge[0]);
}
boolean[] visited = new boolean[n];
long totalUnreachablePairs = 0;
long nodesProcessed = 0; // Track nodes in components already processed
// Find each component and count pairs
for (int i = 0; i < n; i++) {
if (!visited[i]) {
// DFS to find component size
long componentSize = dfs(i, adj, visited);
/**
* KEY TRICK: Forward counting
* Each node in current component is unreachable from
* ALL nodes in previous components
*
* Formula: componentSize × nodesProcessed
* - componentSize: nodes in current component
* - nodesProcessed: nodes in all previous components
*/
totalUnreachablePairs += componentSize * nodesProcessed;
// Update processed count
nodesProcessed += componentSize;
}
}
return totalUnreachablePairs;
}
private long dfs(int node, List<Integer>[] adj, boolean[] visited) {
visited[node] = true;
long count = 1;
for (int neighbor : adj[node]) {
if (!visited[neighbor]) {
count += dfs(neighbor, adj, visited);
}
}
return count;
}
// Approach 2: Union-Find with Backward Counting (count against remaining unprocessed)
public long countUnreachablePairs_UnionFind_Backward(int n, int[][] edges) {
// Initialize Union-Find
int[] parent = new int[n];
int[] rank = new int[n];
for (int i = 0; i < n; i++) {
parent[i] = i;
}
// Union all edges
for (int[] edge : edges) {
union(edge[0], edge[1], parent, rank);
}
// Count component sizes
Map<Integer, Integer> sizeMap = new HashMap<>();
for (int i = 0; i < n; i++) {
int root = find(i, parent);
sizeMap.put(root, sizeMap.getOrDefault(root, 0) + 1);
}
long result = 0;
long processed = 0;
/**
* KEY TRICK: Backward counting
* For each component, count pairs with ALL remaining unprocessed nodes
*
* Formula: size × (n - size - processed)
* - size: nodes in current component
* - n: total nodes
* - processed: nodes in components already counted
* - (n - size - processed): nodes in OTHER components not yet counted
*
* This avoids double counting by only counting forward to remaining components
*/
for (int size : sizeMap.values()) {
result += size * (n - size - processed);
processed += size;
}
return result;
}
private int find(int x, int[] parent) {
if (parent[x] != x) {
parent[x] = find(parent[x], parent); // Path compression
}
return parent[x];
}
private void union(int x, int y, int[] parent, int[] rank) {
int rootX = find(x, parent);
int rootY = find(y, parent);
if (rootX != rootY) {
// Union by rank
if (rank[rootX] < rank[rootY]) {
parent[rootX] = rootY;
} else if (rank[rootX] > rank[rootY]) {
parent[rootY] = rootX;
} else {
parent[rootY] = rootX;
rank[rootX]++;
}
}
}
// Approach 3: Alternative - Count total pairs minus reachable pairs
public long countUnreachablePairs_Alternative(int n, int[][] edges) {
// Build adjacency list
List<List<Integer>> adj = new ArrayList<>();
for (int i = 0; i < n; i++) {
adj.add(new ArrayList<>());
}
for (int[] edge : edges) {
adj.get(edge[0]).add(edge[1]);
adj.get(edge[1]).add(edge[0]);
}
/**
* Total possible pairs = n × (n-1) / 2
* Reachable pairs = sum of (componentSize × (componentSize-1) / 2) for each component
* Unreachable pairs = Total - Reachable
*/
long totalPairs = (long) n * (n - 1) / 2;
boolean[] visited = new boolean[n];
for (int i = 0; i < n; i++) {
if (!visited[i]) {
long size = dfsCount(i, adj, visited);
// Subtract reachable pairs within this component
totalPairs -= (size * (size - 1)) / 2;
}
}
return totalPairs;
}
private long dfsCount(int node, List<List<Integer>> adj, boolean[] visited) {
visited[node] = true;
long count = 1;
for (int neighbor : adj.get(node)) {
if (!visited[neighbor]) {
count += dfsCount(neighbor, adj, visited);
}
}
return count;
}
Python 實作:
def count_unreachable_pairs_dfs(n, edges):
"""
Count unreachable pairs using DFS with forward counting
"""
# Build adjacency list
adj = [[] for _ in range(n)]
for u, v in edges:
adj[u].append(v)
adj[v].append(u)
visited = [False] * n
total_pairs = 0
processed = 0
def dfs(node):
"""DFS to count component size"""
visited[node] = True
count = 1
for neighbor in adj[node]:
if not visited[neighbor]:
count += dfs(neighbor)
return count
# Find each component
for i in range(n):
if not visited[i]:
component_size = dfs(i)
# Key trick: multiply by already processed nodes
total_pairs += component_size * processed
processed += component_size
return total_pairs
def count_unreachable_pairs_uf(n, edges):
"""
Count unreachable pairs using Union-Find with backward counting
"""
# Initialize Union-Find
parent = list(range(n))
def find(x):
if parent[x] != x:
parent[x] = find(parent[x])
return parent[x]
def union(x, y):
root_x, root_y = find(x), find(y)
if root_x != root_y:
parent[root_x] = root_y
# Union all edges
for u, v in edges:
union(u, v)
# Count component sizes
from collections import Counter
size_map = Counter(find(i) for i in range(n))
result = 0
processed = 0
# Key trick: count against remaining unprocessed nodes
for size in size_map.values():
result += size * (n - size - processed)
processed += size
return result
關鍵觀念:
-
兩種計數方式
textForward Counting (Approach 1): - Component 1 (size=3): 3 × 0 = 0 - Component 2 (size=2): 2 × 3 = 6 - Component 3 (size=4): 4 × 5 = 20 - Total: 26 Backward Counting (Approach 2): - Component 1 (size=3): 3 × (9-3-0) = 18 - Component 2 (size=2): 2 × (9-2-3) = 8 - Component 3 (size=4): 4 × (9-4-5) = 0 - Total: 26 Both give same result, different order of calculation -
為什麼這樣可行
- 不同分量裡的節點永遠互相到不了
- 來自不同分量的每一對節點 = 一組不可達配對
- 用乘法就能高效數出所有這種跨分量配對
- 追蹤累計數量,避免 O(n²) 的暴力法
-
視覺化
textExample: n=7, components=[3,2,2] Component A: {0,1,2} (size=3) Component B: {3,4} (size=2) Component C: {5,6} (size=2) Unreachable pairs: - A-B: 3×2 = 6 pairs - A-C: 3×2 = 6 pairs - B-C: 2×2 = 4 pairs Total: 16 pairs Forward: 3×0 + 2×3 + 2×5 = 0+6+10 = 16 ✓ Backward: 3×4 + 2×2 + 2×0 = 12+4+0 = 16 ✓ -
常見陷阱
- 重複計數:每一對只能算一次
- 找出所有分量:一定要走訪所有節點才找得到全部分量
- 溢位:n 很大時要用
long(最多 10^5 個節點 → 約 10^10 組配對) - 邊界情況:只有一個分量(回傳 0)、沒有任何邊(回傳 n×(n-1)/2)
模式特徵:
- 圖的種類:有多個分量的無向圖
- 關鍵洞見:不可達 = 不同分量
- 最佳化手法:用累乘取代巢狀迴圈
- 找分量:DFS、BFS 或併查集都行
- 時間複雜度:O(V + E) — 對圖的大小是線性的
- 空間複雜度:O(V) — visited 追蹤或 parent 陣列
什麼時候用這個模式:
- 「數出互相到不了的節點配對」
- 「不可達/不連通的節點配對數」
- 「來自不同分量的配對」
- 需要對「孤立群組」做配對計數
- 圖的連通性搭配計數需求
類似題目:
- LC 2316: Count Unreachable Pairs of Nodes in an Undirected Graph
- LC 323: Number of Connected Components in an Undirected Graph(數分量)
- LC 547: Number of Provinces(類似的分量偵測)
- LC 684: Redundant Connection(併查集搭配分量)
- LC 1135: Connecting Cities With Minimum Cost(考慮分量的 MST)
變形:
- 加權配對:用節點權重計數,而不是單純數個數
- 條件配對:只計算滿足額外限制的配對
- 動態分量:加/刪邊之後增量更新計數
- K 大小分量的配對:只數大小恰為 k 的分量之間的配對
模板 4:尤拉路徑 — Hierholzer 演算法(LC 332 Reconstruct Itinerary) Priority 4 of 5 — High value — a gap here costs you rounds
什麼時候用:「每條邊恰好用一次」(不是每個節點)。單純的 DFS + 回溯在這裡是指數級的; Hierholzer 是線性的。
核心想法:一路貪婪往前走並消耗邊,直到走不動為止,然後把卡住的那個節點 append 到答案裡再退回去。 因為是後序 append、最後再整個反轉,所以你第一個撞到的死路一定是行程的最後一站。 永遠不要標記節點已走訪 — 要標記的是邊已被消耗(同一個機場可以被造訪很多次)。
| 普通 DFS/回溯 | Hierholzer | |
|---|---|---|
| 標記什麼 | 節點已走訪 | 邊已消耗 |
| 走到死路時 | 復原並改試另一條分支 | 留著它 — append 節點然後彈出 |
| 時間 | 指數 | O(E log E)(只花在排序) |
// java
// LC 332 - Reconstruct Itinerary
// IDEA: Hierholzer — greedy walk consuming edges, append node on dead end, reverse at the end
// time = O(E log E) PriorityQueue ordering; each edge is consumed exactly once
// space = O(E) adjacency map + explicit stack + route
public List<String> findItinerary(List<List<String>> tickets) {
// min-heap per airport -> always take the smallest lexical destination first
Map<String, PriorityQueue<String>> graph = new HashMap<>();
for (List<String> t : tickets) {
graph.computeIfAbsent(t.get(0), k -> new PriorityQueue<>()).add(t.get(1));
}
LinkedList<String> route = new LinkedList<>();
Deque<String> stack = new ArrayDeque<>();
stack.push("JFK");
while (!stack.isEmpty()) {
PriorityQueue<String> pq = graph.get(stack.peek());
if (pq != null && !pq.isEmpty()) {
stack.push(pq.poll()); // consume an edge, walk forward
} else {
route.addFirst(stack.pop()); // dead end -> finalize (post-order + reverse in one step)
}
}
return route;
}
# python
# LC 332 - Reconstruct Itinerary
# IDEA: Hierholzer — greedy walk consuming edges, append node on dead end, reverse at the end
# time = O(E log E) sorting the tickets; each edge is consumed exactly once
# space = O(E) adjacency lists + explicit stack + route
from collections import defaultdict
def findItinerary(tickets):
graph = defaultdict(list)
# sort DESC so list.pop() (from the tail) always yields the smallest airport
for src, dst in sorted(tickets, reverse=True):
graph[src].append(dst)
route, stack = [], ["JFK"]
while stack:
# walk forward until the current airport has no unused ticket
while graph[stack[-1]]:
stack.append(graph[stack[-1]].pop())
# dead end -> this airport is finalized, append in POST-ORDER
route.append(stack.pop())
return route[::-1]
容易踩到的坑
- 不要維護機場的
visited集合 — 同一個機場被造訪很多次是完全合理的。 - 答案是倒著建起來的;忘記最後那次反轉(或
addFirst)是最經典的 bug。 - 遞迴版是同一個想法:
for nxt in sorted(graph[u]): consume; dfs(nxt),然後在迴圈之後 才route.append(u)。
變形:在 de Bruijn 圖上的尤拉迴路 — LC 753 Cracking the Safe
變化點:這張圖是隱式的。節點是長度 (n-1) 的前綴,邊是 k^n 種可能的密碼;
走一趟尤拉迴路就會經過每個密碼一次,得到最短的包含字串。
# python
# LC 753 - Cracking the Safe
# IDEA: Hierholzer on the de Bruijn graph — node = last (n-1) digits, edge = a full n-digit code
# time = O(k^n), space = O(k^n)
def crackSafe(n, k):
start = "0" * (n - 1)
seen, out = set(), []
def dfs(node):
for d in map(str, range(k)):
edge = node + d
if edge not in seen:
seen.add(edge) # consume the EDGE (the code), not the node
dfs(edge[1:])
out.append(d) # post-order append, same as LC 332
dfs(start)
return "".join(out) + start
模板 5:Tarjan 找橋(low-link DFS) — LC 1192 Critical Connections Priority 5 of 5 — Must know — expect it in almost every loop
什麼時候用:「移掉哪條邊會讓圖斷開?」/找出所有的橋(critical connection)或關節點。
暴力法(逐條移邊、重跑連通性檢查)是 O(E*(V+E));Tarjan 只要一趟 DFS。
核心想法:跑出一棵 DFS 樹,每個節點記兩個時間戳。
disc[u]—u第一次被發現的時間(之後永遠不變)。low[u]— 從u的子樹出發,走樹邊加上最多一條反向邊,能到達的最小disc。
橋的判定條件:對一條樹邊 u -> v,它是橋的充要條件是 low[v] > disc[u] — 也就是 v 的
整棵子樹沒有任何反向邊能爬到 u 或更上面,所以剪掉 u-v 就會把它孤立。
// java
// LC 1192 - Critical Connections in a Network
// IDEA: Tarjan low-link — bridge iff low[child] > disc[parent]
// time = O(V + E) single DFS pass
// space = O(V + E) adjacency list + disc/low arrays + recursion depth
private List<List<Integer>> graph;
private int[] disc, low;
private int timer = 0;
private List<List<Integer>> bridges = new ArrayList<>();
public List<List<Integer>> criticalConnections(int n, List<List<Integer>> connections) {
graph = new ArrayList<>();
for (int i = 0; i < n; i++) graph.add(new ArrayList<>());
for (List<Integer> e : connections) {
graph.get(e.get(0)).add(e.get(1));
graph.get(e.get(1)).add(e.get(0));
}
disc = new int[n];
low = new int[n];
Arrays.fill(disc, -1); // -1 = unvisited
timer = 0;
bridges = new ArrayList<>();
for (int i = 0; i < n; i++) {
if (disc[i] == -1) dfs(i, -1); // loop handles a disconnected graph too
}
return bridges;
}
private void dfs(int u, int parent) {
disc[u] = low[u] = timer++;
for (int v : graph.get(u)) {
if (v == parent) continue; // don't walk straight back up the tree edge
if (disc[v] == -1) {
dfs(v, u);
low[u] = Math.min(low[u], low[v]); // pull the child's reach up
if (low[v] > disc[u]) {
bridges.add(Arrays.asList(u, v)); // no back edge bypasses u-v => bridge
}
} else {
low[u] = Math.min(low[u], disc[v]); // back edge: use disc[v], NOT low[v]
}
}
}
# python
# LC 1192 - Critical Connections in a Network
# IDEA: Tarjan low-link — bridge iff low[child] > disc[parent]
# time = O(V + E) single DFS pass
# space = O(V + E) adjacency list + disc/low arrays + recursion depth
def criticalConnections(n, connections):
graph = [[] for _ in range(n)]
for a, b in connections:
graph[a].append(b)
graph[b].append(a)
disc = [-1] * n # discovery time, -1 = unvisited
low = [0] * n # lowest disc reachable from u's subtree via <= 1 back edge
timer = [0]
res = []
def dfs(u, parent):
disc[u] = low[u] = timer[0]
timer[0] += 1
for v in graph[u]:
if v == parent:
continue # never go straight back up the tree edge
if disc[v] == -1:
dfs(v, u)
low[u] = min(low[u], low[v])
if low[v] > disc[u]: # v's subtree cannot reach u or above
res.append([u, v])
else:
low[u] = min(low[u], disc[v]) # back edge
for i in range(n):
if disc[i] == -1:
dfs(i, -1)
return res
容易踩到的坑
- 走反向邊時要用
disc[v],不是low[v]— 用low[v]會錯誤地把跨子樹的可達性混在一起。 v == parent的跳過寫法假設沒有平行邊(LC 1192 成立)。有重邊時要改成用邊的 id 跳過, 否則一條重複的邊會被誤判成橋。n可以到10^5— 在 Python 要把sys.setrecursionlimit(10 ** 6)調高,或改成顯式堆疊。- 合理性檢查:任何在環裡的邊都不會是橋;一棵樹的每條邊都是橋。
模板 6:字典樹 + DFS 萬用字元搜尋 — LC 211 Design Add and Search Words Priority 4 of 5 — High value — a gap here costs you rounds
什麼時候用:前綴資料結構,但查詢會分岔 — . 配任何字母,或是「只差一個編輯」。
插入還是一般迴圈;只有搜尋變成 DFS,當目前的查詢字元是萬用字元時就往 26 個子節點全部分岔。
核心想法:dfs(node, i) = 「從字典樹的 node 出發,配得出 word[i:] 嗎?」
基底情況 i == len(word) 要回傳該節點的「單字結尾」旗標(不是 True — "b." 不該只因為
它是 "bad" 的前綴就配上,除非真的有單字結束在那裡)。
// java
// LC 211 - Design Add and Search Words Data Structure
// IDEA: trie; '.' in a query branches the DFS into every non-null child
// time = O(L) per addWord; search O(L) with no '.', O(26^d * L) worst case with d dots
// space = O(total chars) for the trie, O(L) recursion depth
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()) {
int i = c - 'a';
if (node.children[i] == null) node.children[i] = new WordDictionary();
node = node.children[i];
}
node.isWord = true;
}
public boolean search(String word) {
return dfs(word, 0, this);
}
private boolean dfs(String word, int idx, WordDictionary node) {
if (node == null) return false; // guard inside the child
if (idx == word.length()) return node.isWord; // NOT `true` — must end a word
char c = word.charAt(idx);
if (c == '.') {
for (WordDictionary child : node.children) {
if (dfs(word, idx + 1, child)) return true; // early return on first hit
}
return false;
}
return dfs(word, idx + 1, node.children[c - 'a']);
}
}
# python
# LC 211 - Design Add and Search Words Data Structure
# IDEA: dict-based trie; '.' in a query branches the DFS into every child
# time = O(L) per addWord; search O(L) with no '.', O(26^d * L) worst case with d dots
# space = O(total chars) for the trie, O(L) recursion depth
class WordDictionary:
def __init__(self):
self.root = {}
def addWord(self, word):
node = self.root
for ch in word:
node = node.setdefault(ch, {})
node['$'] = True # end-of-word marker
def search(self, word):
def dfs(node, i):
if i == len(word):
return '$' in node # NOT True — must end a word
ch = word[i]
if ch == '.':
# branch into EVERY child -> this is the DFS part
for k, child in node.items():
if k != '$' and dfs(child, i + 1):
return True
return False
return ch in node and dfs(node[ch], i + 1)
return dfs(self.root, 0)
容易踩到的坑
- 用 dict 實作的字典樹,走訪子節點時一定要跳過
'$'這個哨兵 — 否則.會「配上」結尾標記, 然後你就遞迴進True裡了。 - 只要有一條分支成功就立刻回傳(見 dfs.md → DFS Early Return Pattern);
把 26 個子節點全跑完卻不回傳,會讓
O(26^d)從最差情況變成必然發生。
變形:恰好一處不匹配的 DFS — LC 676 Implement Magic Dictionary
變化點:不是在已知位置放萬用字元,而是把一份不匹配額度往下帶進遞迴,並要求最後剛好用完
(budget == 0)。
# python
# LC 676 - Implement Magic Dictionary
# IDEA: trie DFS carrying a mismatch budget; must be fully spent at the word end
# time = O(26^1 * L) practically (one mismatch), space = O(total chars)
class MagicDictionary:
def __init__(self):
self.root = {}
def buildDict(self, dictionary):
self.root = {}
for w in dictionary:
node = self.root
for ch in w:
node = node.setdefault(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 required
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)
模板 7:以深度為索引的堆疊 DFS(從縮排/路徑得到的隱式樹) — LC 388 Priority 4 of 5 — High value — a gap here costs you rounds
什麼時候用:輸入本身就編碼了一棵樹(tab 縮排的文字、用 / 分隔的路徑、巢狀的 token),
而你要求一個從根到葉的彙總值。不要真的把樹建出來 — 一個索引就是深度的堆疊,就能 O(1)
拿到「到父節點為止的路徑前綴」。
核心想法:stack[d] 存的是深度 d 那個目錄的累積值。
處理深度為 d 的那一行之前,先一直彈出直到 len(stack) == d + 1 — 那些彈出動作就是 DFS 的
「從遞迴返回」步驟;此時 stack[d] 剛好就是目前節點的父層前綴。
// java
// LC 388 - Longest Absolute File Path
// IDEA: stack indexed by depth holds the path length up to each ancestor; popping == returning up
// time = O(N) N = input length; every char is scanned a constant number of times
// space = O(D) D = max nesting depth
public int lengthLongestPath(String input) {
int maxLen = 0;
Deque<Integer> stack = new ArrayDeque<>();
stack.push(0); // depth 0 has an empty prefix
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(); // unwind to this node's parent
if (name.contains(".")) {
maxLen = Math.max(maxLen, stack.peek() + name.length()); // file -> a leaf, score it
} else {
stack.push(stack.peek() + name.length() + 1); // dir -> +1 for the '/'
}
}
return maxLen;
}
# python
# LC 388 - Longest Absolute File Path
# IDEA: stack indexed by depth holds the path length up to each ancestor; popping == returning up
# time = O(N) N = len(input); every char is scanned a constant number of times
# space = O(D) D = max nesting depth
def lengthLongestPath(input):
max_len = 0
stack = [0] # stack[d] = prefix length of the dir at depth d
for line in input.split('\n'):
name = line.lstrip('\t')
depth = len(line) - len(name) # number of leading tabs == depth
while len(stack) > depth + 1: # pop back up to this node's parent
stack.pop()
if '.' in name:
max_len = max(max_len, stack[depth] + len(name)) # file: leaf, no '/' suffix
else:
stack.append(stack[depth] + len(name) + 1) # dir: +1 for the '/'
return max_len
容易踩到的坑
- 檔案是葉子:算分就好,永遠不要 push。把檔案 push 進去會汙染所有更深層的前綴。
- 完全沒有檔案時要回傳
0("a"->0),不是最長的目錄路徑。 - 那個
+1是目錄貢獻的'/'分隔字元,所以最上層的檔案("file1.txt")是拿stack[0] == 0來算分,前面不會多一個斜線。
變形:前綴樹 DFS 搭配提早剪枝 — LC 1233 Remove Sub-Folders from the Filesystem
變化點:一樣是「把路徑切成一層一層的深度」,但這次真的建一棵字典樹,而且一碰到已存的資料夾就 停止往下走 — 它底下的東西依定義全是子資料夾。
# python
# LC 1233 - Remove Sub-Folders from the Filesystem
# IDEA: build a path trie, then DFS and cut the branch at the first stored folder
# time = O(total path chars), space = O(total path chars)
def removeSubfolders(folder):
root = {}
for f in folder:
node = root
for part in f.split('/')[1:]: # [0] is the empty string before the leading '/'
node = node.setdefault(part, {})
node['$'] = f # store the full path at its terminal node
res = []
def dfs(node):
if '$' in node:
res.append(node['$'])
return # CUT: anything deeper is a sub-folder
for k, child in node.items():
dfs(child)
dfs(root)
return res
模板 8:後序距離桶彙總(葉子配對計數) — LC 1530
a. 核心想法
不必把樹轉成圖再從每個葉子跑 BFS(O(N²)),單趟後序 DFS 就能在 O(N) 內數出葉子配對。
每個節點回傳一個小小的桶陣列 cnt[d] = 「我的子樹裡,剛好在我下方距離 d 的葉子有幾個」。
在每個節點你要做兩件事:
- 把左右子樹合起來算配對數。 左子樹裡深度
d1的葉子,和右子樹裡深度d2的葉子,是透過這個節點連起來的,所以路徑長是d1 + d2 + 2。只要d1 + d2 + 2 ≤ distance,就把left[d1] * right[d2]加進全域答案。 - 往上位移並合併,交給父節點。 回傳
cur[d+1] = left[d] + right[d]— 每個葉子相對父節點都比相對這個節點多一條邊。
關鍵洞見:每一對只會在它們的最近共同祖先被數到一次 — 也就是「一個葉子在左子樹、另一個在右子樹」的那個唯一節點。不需要除以 2(不像 BFS 解法)。
b. 模式
# python — Post-order distance-bucket aggregation (LC 1530)
# time = O(N * distance^2) distance^2 from the d1/d2 double loop per node
# space = O(N) recursion depth + O(distance) bucket per frame
class Solution:
def countPairs(self, root, distance):
self.ans = 0
def post_order(node):
# cnt[d] = number of leaves exactly d edges below `node`
if not node:
return [0] * (distance + 1)
if not node.left and not node.right: # leaf: distance 0 to itself
base = [0] * (distance + 1)
base[0] = 1
return base
left = post_order(node.left)
right = post_order(node.right)
# (1) join a left-leaf and a right-leaf THROUGH this node (their LCA)
for d1 in range(distance + 1):
for d2 in range(distance + 1):
if d1 + d2 + 2 <= distance: # +2 for the two edges via node
self.ans += left[d1] * right[d2]
# (2) shift up by 1 edge for the parent's view
cur = [0] * (distance + 1)
for d in range(distance): # d+1 must stay in bounds
cur[d + 1] = left[d] + right[d]
return cur
post_order(root)
return self.ans
最佳化(前綴和計數,LC 1530 官方解 V2-3): 把 O(distance²) 的雙層迴圈換成滾動前綴和,每個節點只要 O(distance) 就能數完配對 → 整體 O(N * distance)。想法一樣,只是合併那一步更便宜。
辨識訊號
- 要對受樹上距離限制的葉子(或節點)配對做計數/彙總。
- 距離很小而且有上界(
distance ≤ 10)→ 每個節點配一個固定大小的桶陣列很便宜。 - 你想要接近 O(N) 而且不想建圖 — 配對會自然發生在每個 LCA 上。
與 BFS 解法的對照: BFS 把樹轉成無向圖,然後從每個葉子跑一次有界 BFS(O(L·N),每對會被數兩次)。後序 DFS 保留樹結構,每對只在它的 LCA 被數一次,通常也是面試官比較想聽的答案。
c. 類似題目
| 題目 | LC # | 和這個模式的關聯 |
|---|---|---|
| Number of Good Leaf Nodes Pairs | 1530 | 後序距離桶彙總的代表題 |
| Binary Tree Maximum Path Sum | 124 | 回傳最佳的往下值,在節點處把左右合併(LCA 合併) |
| Diameter of Binary Tree | 543 | 回傳子樹深度,在節點處合併 left_depth + right_depth |
| Longest Univalue Path | 687 | 回傳單邊長度,在每個節點把兩邊合起來 |
| Count Nodes With the Highest Score | 2049 | 後序算子樹大小,在每個節點彙總(dfs.md Template 6) |
| Sum of Distances in Tree | 834 | 後序算子樹計數 + 換根 DP(進階延伸) |
Template 9: N-ary Tree Post-Order Value Aggregation (Child Min/Max Rollup) — LC 3965 Priority 4 of 5 — High value — a gap here costs you rounds
a. Core idea
Compute a value for the root of an N-ary tree where each node’s value depends only on aggregates of its children’s computed values (typically min / max), never on the node’s own left/right. A single post-order DFS returns each node’s value up to its parent:
- Leaf → return its base value directly (base case: no children).
- Non-leaf → recurse into all children, track
earliest = min(child values)andlatest = max(child values), then combine with the node’s own base value via the problem’s formula and return that up.
For LC 3965 the formula is:
ownDuration = (latest - earliest) + baseTime[node]
finishTime = latest + ownDuration
Read it as: a parent cannot start until its slowest child lands (latest), and it is penalised by
how badly its children disagree (latest - earliest, the idle spread). A node with one child, or with
all children finishing together, has spread 0 and costs exactly its own baseTime.
Two things that make this an N-ary (not binary) tree pattern:
- Build an adjacency list
graph[parent] = [child, ...]from theedgesarray — you loopfor child in graph[node], notnode.left / node.right. edges[i] = [u, v]means u is the parent of v → appendvtograph[u](direction matters; don’t build it undirected).
b. Pattern
# python — N-ary tree post-order child min/max rollup (LC 3965)
# time = O(N) visit each node once
# space = O(N) adjacency list + recursion depth
from collections import defaultdict
class Solution:
def finishTime(self, n, edges, baseTime):
graph = defaultdict(list)
for u, v in edges: # u is PARENT of v
graph[u].append(v)
def dfs(node):
# base case: leaf = no children in the graph
if not graph[node]:
return baseTime[node]
earliest, latest = float('inf'), float('-inf')
for child in graph[node]: # loop ALL children (N-ary)
t = dfs(child) # value bubbles up from child
earliest = min(earliest, t)
latest = max(latest, t)
own_duration = (latest - earliest) + baseTime[node]
return latest + own_duration # return THIS node's value to parent
return dfs(0) # tree rooted at task 0
// java — same rollup, written ITERATIVELY (see the depth gotcha below)
// 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]); // e[0] is PARENT of e[1]
}
// push pre-order, drain in reverse -> every child settles before its parent
Deque<Integer> stack = new ArrayDeque<>();
Deque<Integer> order = new ArrayDeque<>();
stack.push(0);
while (!stack.isEmpty()) {
int cur = stack.pop();
order.push(cur);
for (int c : children.get(cur)) {
stack.push(c);
}
}
long[] finish = new long[n]; // long, NOT int — see the overflow gotcha
while (!order.isEmpty()) {
int cur = order.pop();
if (children.get(cur).isEmpty()) {
finish[cur] = baseTime[cur];
continue;
}
long earliest = Long.MAX_VALUE, latest = Long.MIN_VALUE;
for (int c : children.get(cur)) {
earliest = Math.min(earliest, finish[c]);
latest = Math.max(latest, finish[c]);
}
long ownDuration = (latest - earliest) + baseTime[cur];
finish[cur] = latest + ownDuration;
}
return finish[0];
}
Recognition signals
- Tree given as
edges+ rooted at 0 (N-ary / general tree), not aTreeNodewith.left/.right. - A node’s answer is a pure function of its children’s returned values (min/max/sum) plus its own weight → classic bottom-up post-order.
- You only need the root’s result → let DFS return the value; no global variable needed.
Gotchas that decide the submission
- Recursion depth.
nis up to10^5and the tree may be a chain, so the recursive form above is the explanatory one — it overflows the default stack on a worst-case input. Eithersys.setrecursionlimit(2 * 10**5)in Python, or switch to the two-stack iterative form (the Java block above), which is the safe default in Java where the limit is not tunable from the solution. - Overflow. Finish times are only guaranteed
< 2^53, which does not fit anint. A node whose children’s times are far apart returnslatest + (latest - earliest) + base, i.e. close to twice its slowest child, so the value compounds with depth — accumulate inlongin Java. Python ints are arbitrary precision, so this bites Java only. - Leaf test. “Leaf” is no children, which is
graph[node]being empty — not “degree 1”. Building the graph undirected breaks exactly this check, since every non-root node then has its parent as a neighbour. defaultdictwhile iterating.if not graph[node]inserts an empty list for a leaf. Harmless here, but do not also iterategraphelsewhere in the same pass.
Contrast with binary bottom-up (Pattern 6 / 15): same “return a value up, combine at parent” shape, but children are an arbitrary-length list from an adjacency list rather than fixed
left/right. Watch the edge direction when building the graph.
c. Similar LC
| Problem | LC # | Link to this pattern |
|---|---|---|
| Finish Time of Tasks I | 3965 | canonical N-ary post-order min/max child rollup |
| Finish Time of Tasks II | 3967 | same rollup, made queryable — direct follow-up to 3965 |
| Maximum Depth of N-ary Tree | 559 | 1 + max(child depths) — N-ary post-order max rollup |
| N-ary Tree Postorder Traversal | 590 | canonical post-order visit of an N-ary tree |
| Time Needed to Inform All Employees | 1376 | rooted tree via manager array, max(child times) + own |
| Sum of Nodes with Even-Valued Grandparent | 1315 | post-order over tree, aggregate from descendants |
| Count Nodes With the Highest Score | 2049 | post-order subtree aggregation (dfs.md Template 6) |
| Binary Tree Maximum Path Sum | 124 | binary sibling of the same shape: combine children’s returned values at the node |
模板 10:會回傳/消耗字串的 DFS(樹 ⟷ 字串編解碼) — LC 606 / LC 536
a. 核心想法
兩個互為鏡像的 DFS 形狀。兩者都是前序;差別只在遞迴回傳什麼:
- 編碼(樹 → 字串):DFS 回傳自己這棵子樹的字串;父節點把子節點的字串黏進一個格式模板裡。python
def encode(node): if not node: return NULL # "" for parens, "#" for comma format return FMT.format(node.val, encode(node.left), encode(node.right)) - 解碼(字串 → 樹):DFS 回傳節點,外加它消耗了多少字串 —
也就是一個帶共用游標(
int[]/實例欄位/iter())的遞迴下降解析器。pythondef decode(i): val, i = read_value(s, i) # digit loop; handle '-' node = TreeNode(val) if s[i] == '(': # left first node.left, i = decode(i + 1); i += 1 if s[i] == '(': node.right, i = decode(i + 1); i += 1 return node, i
辨識訊號
- 回傳型別是
String(不是int/void)→ 你在編碼那一半。 - 輸入是一個會巢狀的字串(
4(2(3)(1))(6(5)))或會標記 null(1,2,#,#,3,#,#)→ 解碼那一半。 - 決定任何這種格式的三個問題:分隔符、null 怎麼表示(顯式標記 vs 用巢狀結構表達)、走訪順序 — 編碼器和解碼器必須講好。
常見陷阱
- ❌ 用
int(s[i])而不是while isdigit()迴圈 → 多位數就爆掉;也要處理'-'。 - ❌ 每次呼叫都重新切片字串 → O(N²);改成只用一個游標。
- ❌ 用天真的
+=串字串 → O(N²);改用StringBuilder/ list-join。 - ❌ LC 606:只有右子節點時卻把空的左
()省掉 →1(3)會被解讀成左子節點,映射就不再是一對一了。
c. 類似題目
| 題目 | LC # | 和這個模式的關聯 |
|---|---|---|
| Construct String from Binary Tree | 606 | 編碼的代表題:"{}({})({})" + 省略規則 |
| Construct Binary Tree from String | 536 | 解碼的代表題:遞迴下降 + 游標 |
| Serialize and Deserialize Binary Tree | 297 | 兩半都要;逗號分隔 + # 當 null 標記 |
| Serialize and Deserialize BST | 449 | BST 的順序性讓你可以省掉 null 標記 |
| Verify Preorder Serialization | 331 | 不建樹就驗證這個編碼 |
| Find Duplicate Subtrees | 652 | 用後序編碼當成雜湊表的 key |
| Recover a Tree From Preorder Traversal | 1028 | 用深度前綴當分隔符 + 堆疊解碼 |
完整說明(編/解碼的對稱表、LC 606 的情況分析 + 視覺追蹤、LC 536 的兩種解析器寫法、Java 版本):
tree_codec.md→ Tree ⟷ String Codec Pattern
Template 11: Parent-Array Tree — Memoized Upward Depth — LC 4015
a. Core idea
The tree arrives as a parent[] array (parent[root] = -1), not as a TreeNode and not as an
edges list. You are asked for something that depends on each node’s depth (and often the tree
height = max(depth)).
You have two directions to choose from, and the array picks one for you:
| Direction | What it needs | Cost |
|---|---|---|
| Top-down (root → leaves) | first invert parent[] into a children adjacency list, then DFS/BFS from the root |
O(N) + an extra O(N) structure |
| Bottom-up climb (node → root) | nothing — parent[] already is the up-edge |
O(N) with a memo, O(N²) without |
The climb is the pattern worth memorising, because parent[] is literally a pointer to the parent:
depth[x] = 1 if parent[x] == -1 (root)
depth[x] = depth[parent[x]] + 1 otherwise
Run that from every node and memoize. The memo is the whole trick — each edge is then walked
exactly once amortized, so the total is O(N). Without it, a path-shaped tree (0←1←2←…←n-1) costs
1 + 2 + … + N = O(N²).
Two details that make the code shorter than it looks:
- Depth is 1-based, so
depth[x] == 0doubles as “not computed yet” → no separatevisitedarray and noNonesentinel. - Recursion terminates on the root’s
-1, not on a node count — a validparent[]is acyclic by the problem’s guarantee, so no cycle guard is needed.
⚠️ Why you cannot just sweep i = 0 … n-1 in one pass: that only works when the input guarantees
parent[i] < i (parent always appears before its child). LC 4015 does not — it only guarantees
0 <= parent[i] <= n-1 — so depth[i] = depth[parent[i]] + 1 in index order reads a not-yet-filled
entry. Memoized recursion (or BFS from the root) handles arbitrary labelling.
b. Pattern
# python — parent-array tree: memoized depth climb (LC 4015)
# time = O(N) each node's depth is computed once; each up-edge walked once amortized
# space = O(N) depth memo + recursion depth (worst case a path-shaped tree)
class Solution:
def weightedSum(self, parent, nums):
n = len(parent)
depth = [0] * n # 0 == "not computed" (depths are 1-based)
def get_depth(x):
if depth[x]: # memo hit -> stop climbing
return depth[x]
if parent[x] == -1: # root
depth[x] = 1
else:
depth[x] = get_depth(parent[x]) + 1
return depth[x]
for i in range(n): # fill the whole memo
get_depth(i)
h = max(depth) # height = deepest depth
return sum(nums[i] * (h - depth[i] + 1) for i in range(n))
Algebraic shortcut:
Σ nums[i]·(h − d_i + 1)=(h+1)·Σ nums[i] − Σ nums[i]·d_i, so a single pass accumulatingΣ nums[i]andΣ nums[i]·d_ifinishes it — useful when the weights are queried repeatedly and onlyhchanges.
Iterative climb — the recursion is O(N) deep on a path-shaped tree, which blows Python’s
default 1000-frame limit at n = 10^5. Push the chain onto an explicit stack and unwind it:
# python — same memo, no recursion
# time = O(N), space = O(N)
def get_depth(x, parent, depth):
stack = []
while depth[x] == 0: # climb until a computed node (or the root)
if parent[x] == -1:
depth[x] = 1
break
stack.append(x)
x = parent[x]
d = depth[x]
while stack: # unwind: fill every node on the climbed chain
d += 1
depth[stack.pop()] = d
return d
Recognition signals
- Input is
parent/manager/parents— an array of ancestors, with-1marking the root. - The answer needs depth, height, or an ancestor — not subtree aggregates. (Needing subtree sums or child min/max flips you back to top-down: invert to a children list, then Template 9.)
nup to10^5with a possible path-shaped tree → the memo is required, and in Python so is the iterative form.
Contrast with Union-Find: the climb-and-memo is structurally the same walk as DSU
find()with path compression, andparent[]even looks like a DSU array — but there is nounion(), no merging, and the tree is fixed. Reaching for a DSU here addsα(N)bookkeeping for nothing. See union_find.md → When NOT to use Union Find.
c. Similar LC
| Problem | LC # | Link to this pattern |
|---|---|---|
| Weighted Sum of a Tree | 4015 | canonical — memoized depth climb + height = max(depth) |
| Time Needed to Inform All Employees | 1376 | manager[] parent array; memoize the accumulated time up the chain |
| Kth Ancestor of a Tree Node | 1483 | parent array + binary lifting — the climb pre-computed at 2^k strides |
| LCA of a Binary Tree III | 1650 | climb both parent chains → reduces to “intersection of two linked lists” |
| All Nodes Distance K in Binary Tree | 863 | build a parent map first, then the tree is walkable upward too |
| Number of Nodes in the Sub-Tree With the Same Label | 1519 | the top-down alternative: invert edges to children, post-order aggregate |
| Smallest Missing Genetic Value After Subtree Queries | 2003 | parents[] rooted tree; climb the ancestor chain from the value-1 node |
語言筆記:Java 與 Python 的 DFS 慣用寫法
Java:迭代堆疊與鄰接串列 DFS
// Java DFS with Stack
Stack<TreeNode> stack = new Stack<>();
stack.push(root);
while (!stack.isEmpty()) {
TreeNode node = stack.pop();
// Process node
if (node.right != null) stack.push(node.right);
if (node.left != null) stack.push(node.left);
}
// Graph DFS with adjacency list
void dfs(int node, boolean[] visited, List<List<Integer>> adj) {
visited[node] = true;
for (int neighbor : adj.get(node)) {
if (!visited[neighbor]) {
dfs(neighbor, visited, adj);
}
}
}
Python:用 deque 當堆疊、鄰接對照表、遞迴上限
# Using collections.deque as stack
from collections import deque
stack = deque([root])
while stack:
node = stack.pop() # pop() for stack behavior
# Process node
# Graph representation
graph = defaultdict(list) # Adjacency list
visited = set() # Track visited nodes
# Recursion limit for deep trees
import sys
sys.setrecursionlimit(10000)
Java:用 StringBuilder 以傳參考的方式記錄路徑
dfs.md Template 8(Path Signature)會用到:
簽章是累積到同一個共用的 StringBuilder 裡,而不是一層層往上回傳。
關鍵洞見:StringBuilder 是參考型別(不是原始型別)。把它傳進函式後,函式內做的修改在回傳之後依然存在。
// Pattern: Create placeholder → Pass to DFS → Use modified result
Set<String> uniqueIslands = new HashSet<>();
for (int r = 0; r < rows; r++) {
for (int c = 0; c < cols; c++) {
if (grid[r][c] == 1) {
// 1. Create empty StringBuilder placeholder
StringBuilder pathSignature = new StringBuilder();
// 2. Pass to DFS — DFS will modify it in place
dfs(grid, r, c, pathSignature, 'S');
// 3. After DFS returns, pathSignature is populated
// Add the modified result to set
if (pathSignature.length() > 0) {
uniqueIslands.add(pathSignature.toString());
}
}
}
}
private void dfs(int[][] grid, int r, int c, StringBuilder path, char direction) {
// Base case
if (r < 0 || r >= rows || c < 0 || c >= cols || grid[r][c] == 0) {
return;
}
// Mark as visited
grid[r][c] = 0;
// ✅ MODIFY the reference: append to StringBuilder
// This change persists in the caller's pathSignature object
path.append(direction);
// Explore neighbors in fixed order
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
// Backtrack: remove the character added in this call
path.append('O'); // Backtrack marker
}
為什麼這樣可行:
Memory Model:
Main stack frame:
├── pathSignature = StringBuilder{} (heap object at address 0x1000)
└── call dfs(..., pathSignature, 'S')
DFS stack frame 1:
├── path = reference to 0x1000 (SAME object!)
├── path.append('S') → 0x1000 now contains "S"
└── call dfs(..., path, 'D')
DFS stack frame 2:
├── path = reference to 0x1000 (still SAME object!)
├── path.append('D') → 0x1000 now contains "SD"
└── return
Back in frame 1:
├── path.append('O') → 0x1000 now contains "SDO"
└── return
Back in main:
└── pathSignature = StringBuilder{"SDO"} ✅ (modified!)
與原始型別的對照:
// ❌ WRONG: Primitive won't persist changes
private void dfs(int curSum) {
curSum++; // Only affects local copy
}
int mySum = 5;
dfs(mySum);
System.out.println(mySum); // Still 5, NOT 6!
// ✅ CORRECT: Use reference type or return value
private void dfs(StringBuilder path) {
path.append('D'); // Affects original StringBuilder
}
StringBuilder myPath = new StringBuilder();
dfs(myPath);
System.out.println(myPath); // Modified! ✅
這個模式常用的參考型別:
| 型別 | 可修改? | 使用情境 |
|---|---|---|
StringBuilder |
✅ 可以(append、setCharAt、deleteCharAt) |
逐步建構字串 |
List<T> |
✅ 可以(add、remove、set) |
收集結果或路徑 |
int[] / char[] |
✅ 可以(arr[i] = value) |
修改陣列元素 |
Map<K, V> |
✅ 可以(put、remove) |
追蹤次數/狀態 |
int / long(原始型別) |
❌ 不行 | 只有傳值 |
String |
❌ 不行(不可變) | 改用 StringBuilder |
總結與速查
| 題目如果這樣講… | 就拿出 | 模板 |
|---|---|---|
「grid2 裡同時也是 grid1 的島」 |
雙網格布林傳遞,res = dfs(...) && res |
1 |
| 「最少反轉幾條邊,讓每個節點都到得了 X」 | 無向圖 + 方向旗標,從 X 開始 DFS | 2 |
| 「有多少對節點互相到不了」 | 分量大小 + 累乘 | 3 |
| 「每張機票/每條邊恰好用一次」 | Hierholzer:消耗邊、走到死路就 append、最後反轉 | 4 |
| 「哪些連線是關鍵的」 | Tarjan low[child] > disc[parent] |
5 |
「. 可以配任何字母」 |
搜尋會在萬用字元處分岔的字典樹 | 6 |
tab 縮排的文字或 a/b/c 路徑 |
以深度為索引的堆疊,彈出 == 往上返回 | 7 |
「距離 d 以內的 good leaf pairs」 |
後序桶陣列,在 LCA 處合併 | 8 |
樹以 edges 給定、根為 0 |
鄰接串列 + 後序 min/max 彙總 | 9 |
| 樹 ⟷ 字串,雙向都要 | 往下用格式模板,往上用解析游標 | 10 |
輸入是 parent[],根為 -1 |
記憶化攀爬;depth[x] == 0 代表「還沒算」 |
11 |
這份文件獨有的陷阱
- Hierholzer 標記的是邊,永遠不是節點 — 同一個機場被重複造訪是合理的。
- Tarjan 在反向邊上用
disc[v],絕不是low[v]。 - 用 dict 實作的字典樹在走訪子節點時必須跳過
'$'這個單字結尾哨兵。 - 以深度為索引的堆疊絕不能 push 葉子(檔案),只能 push 內部節點(目錄)。
- 記憶化的深度攀爬,少了記憶化在長條形的樹上就是 O(N²)。
n到10^5再加上遞迴,代表 Python 在模板 5、8、11 需要sys.setrecursionlimit(...)或改成顯式堆疊。
相關主題
- union_find.md:模板 1 和 3 都有 DSU 版本;模板 11 看起來像 DSU,
但沒有
union()。 - trie.md:模板 6 要搜尋的那個結構。
- topology_sorting.md:另一個經典的「帶時間戳的 DFS」演算法。
- bfs.md:模板 8 的「從每個葉子跑 BFS」替代解法,也是這裡任何深遞迴模板 比較安全的寫法。