DFS — 進階模式

GraphsPriority 3 of 5 — Worth knowing — usually a variant of a must-know patternWorth knowing 更新於 Sep 19, 2026
Section priorityPriority 5 of 5 — Must know — expect it in almost every loopMust knowPriority 4 of 5 — High value — a gap here costs you roundsHigh valuePriority 3 of 5 — Worth knowing — usually a variant of a must-know patternWorth knowingPriority 2 of 5 — Niche — read once, revisit only if a company is known to askNicheMarked on the sections that carry it — unmarked sections are background/reference.

範圍 — 第一輪複習可以先跳過的冷門硬 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 的寫法把驗證結果累積起來
    • 標記走訪過的格子:在走訪用的那張網格上標記,避免重複走訪
    • 短路最佳化:驗證失敗時可以提早回傳來加速
    • 兩張網格的比對:一張決定走訪結構,另一張決定驗證條件
java
/**
 * 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 實作:

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

text
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

布林傳遞為什麼可行:

java
// 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 包住
  • 統計符合特定性質的合法子分量
  • 兩張網格的比對問題

主要變形:

  1. 提早終止:只要有一個格子不合格,整個分量就標成不合法
  2. 反向驗證:檢查 grid2 的格子在 grid1 裡不存在(反過來的問題)
  3. 多張網格:拿多張參考網格一起驗證
  4. 加權驗證:走訪時把值加總,再檢查是否超過門檻

類似題目:

  • 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 一律從目標節點(所有路徑該通往的那個節點)開始
java
/**
 * 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 實作:

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. 建雙向圖

    • 每條邊都加兩個方向
    • 原方向的旗標是 1(需要反轉)
    • 反方向的旗標是 0(已經對了)
  2. 為什麼這樣可行

    text
    Example: 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
    
  3. 方向旗標的邏輯

    • 旗標 = 1:邊是原方向(current->neighbor)
      • 代表我們正在用一條「背離根」的邊
      • 必須反轉
    • 旗標 = 0:邊是反方向(neighbor->current)
      • 代表原本那條邊就是朝著根的
      • 已經是對的
  4. 樹的性質

    • 對樹狀結構(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 或併查集都可以找出所有分量
    • 累計追蹤:維護已處理節點的滾動總和,才能高效算出配對數
java
/**
 * 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 實作:

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

關鍵觀念:

  1. 兩種計數方式

    text
    Forward 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
    
  2. 為什麼這樣可行

    • 不同分量裡的節點永遠互相到不了
    • 來自不同分量的每一對節點 = 一組不可達配對
    • 用乘法就能高效數出所有這種跨分量配對
    • 追蹤累計數量,避免 O(n²) 的暴力法
  3. 視覺化

    text
    Example: 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 ✓
    
  4. 常見陷阱

    • 重複計數:每一對只能算一次
    • 找出所有分量:一定要走訪所有節點才找得到全部分量
    • 溢位: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)

變形:

  1. 加權配對:用節點權重計數,而不是單純數個數
  2. 條件配對:只計算滿足額外限制的配對
  3. 動態分量:加/刪邊之後增量更新計數
  4. 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
// 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
# 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
# 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

什麼時候用「移掉哪條邊會讓圖斷開?」/找出所有的(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
// 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
# 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
// 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
# 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
# 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
// 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
# 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
# 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 的葉子有幾個」

在每個節點你要做兩件事:

  1. 把左右子樹合起來算配對數。 左子樹裡深度 d1 的葉子,和右子樹裡深度 d2 的葉子,是透過這個節點連起來的,所以路徑長是 d1 + d2 + 2。只要 d1 + d2 + 2 ≤ distance,就把 left[d1] * right[d2] 加進全域答案。
  2. 往上位移並合併,交給父節點。 回傳 cur[d+1] = left[d] + right[d] — 每個葉子相對父節點都比相對這個節點多一條邊。

關鍵洞見:每一對只會在它們的最近共同祖先被數到一次 — 也就是「一個葉子在左子樹、另一個在右子樹」的那個唯一節點。不需要除以 2(不像 BFS 解法)。

b. 模式

python
# 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) and latest = 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:

text
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:

  1. Build an adjacency list graph[parent] = [child, ...] from the edges array — you loop for child in graph[node], not node.left / node.right.
  2. edges[i] = [u, v] means u is the parent of v → append v to graph[u] (direction matters; don’t build it undirected).

b. Pattern

python
# 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
// 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 a TreeNode with .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. n is up to 10^5 and 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. Either sys.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 an int. A node whose children’s times are far apart returns latest + (latest - earliest) + base, i.e. close to twice its slowest child, so the value compounds with depth — accumulate in long in 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.
  • defaultdict while iterating. if not graph[node] inserts an empty list for a leaf. Harmless here, but do not also iterate graph elsewhere 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())的遞迴下降解析器。
    python
    def 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)))或會標記 null1,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:

text
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:

  1. Depth is 1-based, so depth[x] == 0 doubles as “not computed yet” → no separate visited array and no None sentinel.
  2. Recursion terminates on the root’s -1, not on a node count — a valid parent[] 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
# 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_i finishes it — useful when the weights are queried repeatedly and only h changes.

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
# 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 -1 marking 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.)
  • n up to 10^5 with 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, and parent[] even looks like a DSU array — but there is no union(), 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
// 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 當堆疊、鄰接對照表、遞迴上限

python
# 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 是參考型別(不是原始型別)。把它傳進函式後,函式內做的修改在回傳之後依然存在。

java
// 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
}

為什麼這樣可行:

text
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!)

與原始型別的對照:

java
// ❌ 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 ✅ 可以(appendsetCharAtdeleteCharAt 逐步建構字串
List<T> ✅ 可以(addremoveset 收集結果或路徑
int[] / char[] ✅ 可以(arr[i] = value 修改陣列元素
Map<K, V> ✅ 可以(putremove 追蹤次數/狀態
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²)。
  • n10^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」替代解法,也是這裡任何深遞迴模板 比較安全的寫法。