DFS — 進階模式

圖論優先度 3/5 — 值得會 — 多半是必備模式的變形值得會 更新於 Nov 12, 2026

範圍 — 第一輪複習可以先跳過的冷門 DFS 技巧:雙網格驗證、邊方向追蹤、跨連通分量的配對計數、字典樹配萬用字元搜尋、後序距離桶與 N 元樹彙總,以及以縮排或 parent[] 陣列形式給出的樹。真正的圖論(Tarjan、尤拉路徑)在 graph_advanced.md。 另見 — 母文件:dfs.md — 十個核心 DFS 模板、辨識表和模式選擇流程圖;dfs_examples.md — 建立在那些模板上的經典題。 鄰近文件:graph_advanced.md — Tarjan low-link(橋、SCC)與 Hierholzer 尤拉路徑;trie.md — 字典樹結構本身,不含會分岔的查詢;union_find.md — 連通性 DFS 的 DSU 替代方案;tree_codec.md — 把樹編碼成字串、再解析回來的 DFS;bfs.md — 廣度優先的對應版本。

LeetCode 題目清單

總覽

這些 DFS 模式在整個面試循環裡只會出現一次,不是每一關都考。每一個都是 dfs.md 的某個核心模板,再加一項額外的記帳 — 第二張網格、一個方向旗標、一個累計總數、一個會分岔的字典樹查詢、 一組距離桶、一個以深度為索引的堆疊、一份深度備忘錄 — 而且每一個都能從題目認出來,因為題目會點名那個 額外結構。先讀 dfs.md;當題目不符合它的十個模板時,再來這裡。

這裡大多數輸入都到 n = 10⁵,在路徑形狀的輸入上,遞迴的 Python DFS 會超過預設的 1,000 層上限。 所以下面只要可能發生這種情況的模板,都用明確的堆疊來寫。

先備知識

  • dfs.md § Template 2:洪水填充。 Templates 1–3 就是多帶一個值的洪水填充。
  • dfs.md § Template 6:把值回傳給父節點的 由下而上 DFS。Templates 5 和 6 回傳的是桶陣列或最小/最大值。
  • dfs.md § Template 1:Templates 7 和 8 在一棵從沒建出來的樹上 跑的走訪。
  • graph.md § Template 1: 每個連通分量一次走訪,Template 3 在它們之間計數。

辨識

題目裡的訊號 限制條件線索 該用 不適用時
「grid2 裡、在 grid1 也全是陸地的島嶼」、「子島嶼」 m, n ≤ 500 Template 1 — 洪水填充 + 一個旗標 只有一張網格 → dfs.md § Template 2
「最少反轉幾條路,讓每個城市都能到 0」 樹,n ≤ 5·10⁴ Template 2 — 無向走訪,邊上帶旗標 要每個根的答案 → 換根 DP(LC 2858)
「彼此無法到達的節點對數」 n ≤ 10⁵ Template 3 — 大小 × 累計總數 要的是分量內的對數 → Σ size·(size − 1)/2
「. 可以匹配任何字母」、「恰好差一個字元」 單字 ≤ 25 個字母,呼叫 ≤ 10⁴ 次 Template 4 — 字典樹,搜尋時分岔 沒有萬用字元 → 普通的字典樹走訪(trie.md)
「距離 ≤ d 的葉節點對」 d ≤ 10 Template 5 — 每個節點一個桶陣列 距離沒有上界 → 經過每個 LCA 的配對沒辦法分桶
以 edges 給出、根在 0 的樹,值由子節點的最小/最大值算出 n ≤ 10⁵ Template 6 — N 元樹彙總 有 .left / .right 的 TreeNode → dfs.md § Template 6
以 tab 縮排的文字、以 / 分隔的路徑 輸入 ≤ 10⁴ 個字元 Template 7 — stack[depth] 巢狀結構是括號 → 解析用堆疊(stack.md)
輸入是 parent[] / manager[],根是 -1,答案需要深度 n ≤ 10⁵ Template 8 — 記憶化往上爬 要子樹總和 → 先反轉成子節點串列,再做後序
「哪條連線是關鍵」、「每張機票恰好用一次」 V ≤ 10⁵ Tarjan / Hierholzer — graph_advanced.md —
樹轉成字串、再轉回來 n ≤ 10⁴ tree_codec.md —

網格與圖的額外記帳

Template 1:帶有效旗標的洪水填充 — LC 1905

辨識 — 兩張形狀相同的網格;「數 grid2 裡、在 grid1 也全是陸地的島嶼」(子島嶼),或任何 「只有每一格都通過檢查的分量才算數」的題目。m, n ≤ 500,所以一座島可以有 250,000 格。

關鍵 — 把 grid2 的整座島淹完,並把每一格的檢查結果 AND 進同一個旗標。就算旗標已經變成 false, 這座島也必須淹到底:沒被淹到的格子之後會被當成一座「新」島,被單獨計數。

python
# python
# LC 1905 - Count Sub Islands
# IDEA: flood each island of grid2 with an explicit stack; it counts if every cell is land in grid1
# time = O(m * n), space = O(m * n)
def countSubIslands(grid1, grid2):
    rows, cols = len(grid2), len(grid2[0])
    count = 0
    for r in range(rows):
        for c in range(cols):
            if grid2[r][c] != 1:
                continue
            is_sub = True
            grid2[r][c] = 0                    # sink on push: each cell enters the stack once
            stack = [(r, c)]
            while stack:
                y, x = stack.pop()
                if grid1[y][x] == 0:
                    is_sub = False             # keep flooding: the rest of the island must sink too
                for dy, dx in ((1, 0), (-1, 0), (0, 1), (0, -1)):
                    ny, nx = y + dy, x + dx
                    if 0 <= ny < rows and 0 <= nx < cols and grid2[ny][nx] == 1:
                        grid2[ny][nx] = 0
                        stack.append((ny, nx))
            if is_sub:
                count += 1
    return count
java
// java
// LC 1905 - Count Sub Islands
// IDEA: flood each island of grid2 with an explicit stack; it counts if every cell is land in grid1
// time = O(m * n), space = O(m * n)
public int countSubIslands(int[][] grid1, int[][] grid2) {
    int rows = grid2.length, cols = grid2[0].length;
    int[][] dirs = {{1, 0}, {-1, 0}, {0, 1}, {0, -1}};
    int count = 0;
    for (int r = 0; r < rows; r++) {
        for (int c = 0; c < cols; c++) {
            if (grid2[r][c] != 1) {
                continue;
            }
            boolean isSub = true;
            grid2[r][c] = 0;
            Deque<int[]> stack = new ArrayDeque<>();
            stack.push(new int[]{r, c});
            while (!stack.isEmpty()) {
                int[] cell = stack.pop();
                if (grid1[cell[0]][cell[1]] == 0) {
                    isSub = false;                 // keep flooding
                }
                for (int[] d : dirs) {
                    int ny = cell[0] + d[0], nx = cell[1] + d[1];
                    if (ny >= 0 && ny < rows && nx >= 0 && nx < cols && grid2[ny][nx] == 1) {
                        grid2[ny][nx] = 0;
                        stack.push(new int[]{ny, nx});
                    }
                }
            }
            if (isSub) {
                count++;
            }
        }
    }
    return count;
}

複雜度

  • 時間 O(m·n):格子在推入之前就被設成 0,所以 while stack 迴圈每個陸地格子只彈出一次, 並看 4 個鄰居。
  • 空間 O(m·n):堆疊可能放得下一整座島。

陷阱

  • 寫成 return dfs(up) and dfs(down) and ... 的遞迴版本,在第一個 false 就短路, 島上其餘格子都沒被淹;那些格子之後會被當成另一座島計數。遞迴時必須寫 ok = dfs(nbr) and ok(先呼叫),四個方向都要。
  • 滿格網格上遞迴洪水填充深達 250,000 層:超過 Python 的預設上限,也很可能超過 Java 預設的 執行緒堆疊。明確的堆疊沒有這個限制。
  • 輸入會被修改(grid2 被淹掉)。呼叫端還需要它的話先複製一份。

練習 — LC 1905 ⭐ · LC 1254 (檢查條件是「從不碰到邊界」:dfs.md § Template 7) · LC 827 (替每座島標上大小,再逐一嘗試每個 0)

Template 2:在無向走訪上帶方向旗標 — LC 1466

辨識 — 一棵由單行道組成的樹;「最少要反轉幾條路,讓每個城市都能到達城市 0」。 n ≤ 5·10⁴,共 n − 1 條邊。

關鍵 — 每條路兩個方向都存,原方向標 1、反方向標 0。從城市 0 往外走:每次順著原方向 (旗標 1)走過一條路,那條路就是背離 0 的,必須反轉。在樹上每條路剛好走過一次,所以計數是精確的。

python
# python
# LC 1466 - Reorder Routes to Make All Paths Lead to the City Zero
# IDEA: undirected walk from 0; an edge crossed in its original direction must be reversed
# time = O(n), space = O(n)
def minReorder(n, connections):
    graph = [[] for _ in range(n)]
    for a, b in connections:
        graph[a].append((b, 1))      # a -> b as given: points away from 0 if walked from a
        graph[b].append((a, 0))      # the reverse direction costs nothing

    seen = [False] * n
    seen[0] = True
    stack = [0]
    reversals = 0
    while stack:
        city = stack.pop()
        for nxt, original in graph[city]:
            if not seen[nxt]:
                seen[nxt] = True
                reversals += original    # walked away from 0 along the original direction
                stack.append(nxt)
    return reversals
java
// java
// LC 1466 - Reorder Routes to Make All Paths Lead to the City Zero
// IDEA: undirected walk from 0; an edge crossed in its original direction must be reversed
// time = O(n), space = O(n)
public int minReorder(int n, int[][] connections) {
    List<List<int[]>> graph = new ArrayList<>();
    for (int i = 0; i < n; i++) {
        graph.add(new ArrayList<>());
    }
    for (int[] c : connections) {
        graph.get(c[0]).add(new int[]{c[1], 1});
        graph.get(c[1]).add(new int[]{c[0], 0});
    }
    boolean[] seen = new boolean[n];
    seen[0] = true;
    Deque<Integer> stack = new ArrayDeque<>();
    stack.push(0);
    int reversals = 0;
    while (!stack.isEmpty()) {
        int city = stack.pop();
        for (int[] edge : graph.get(city)) {
            if (!seen[edge[0]]) {
                seen[edge[0]] = true;
                reversals += edge[1];
                stack.push(edge[0]);
            }
        }
    }
    return reversals;
}

複雜度

  • 時間 O(n):每個城市只推入一次(推入時就設 seen),它的雙向串列只讀一次; 總共 2(n − 1) 個項目。
  • 空間 O(n):加倍的鄰接表、seen 和堆疊。

試跑 — n = 6,道路 0→1, 1→3, 2→3, 4→0, 4→5:

text
pop  crosses           flag  reversals
0    0->1 (original)    1    1
0    0->4 (reverse)     0    1
4    4->5 (original)    1    2
1    1->3 (original)    1    3
3    3->2 (reverse)     0    3
answer 3

陷阱

  • 從 0 出發只沿著原本的有向邊走,在 [[1,0],[2,0]] 上什麼都到不了;圖必須雙向存。
  • 改成數旗標 0 的邊會得到 n − 1 − 答案。寫迴圈之前先確定旗標標的是哪個方向。

不適用時 — 要的是每一個可能的根的反轉次數(LC 2858):用這個模板算出根為 0 的答案, 再換根,根沿著一條邊移動時,次數變化 ±1。

練習 — LC 1466 ⭐ · LC 2858 (每個根:換根 DP)

Template 3:跨連通分量的配對計數 — LC 2316

辨識 — 「彼此無法到達的節點對數」、「分屬不同群組的配對」。n ≤ 10⁵ 排除了逐對檢查; 只有分量大小重要。

關鍵 — 新分量的每個節點,都和之前已看過的分量裡每個節點配成一對,所以加上 size × seen_so_far,然後再讓 seen_so_far 變大。每個跨分量的配對只在找到它的第二個分量時被計一次。

python
# python
# LC 2316 - Count Unreachable Pairs of Nodes in an Undirected Graph
# IDEA: component sizes by flood fill; each new component pairs with every node seen before it
# time = O(n + E), space = O(n + E)
def countPairs(n, edges):
    graph = [[] for _ in range(n)]
    for a, b in edges:
        graph[a].append(b)
        graph[b].append(a)

    seen = [False] * n
    pairs = 0
    seen_so_far = 0                 # nodes in the components already counted
    for start in range(n):
        if seen[start]:
            continue
        seen[start] = True
        stack = [start]
        size = 0
        while stack:
            node = stack.pop()
            size += 1
            for nxt in graph[node]:
                if not seen[nxt]:
                    seen[nxt] = True
                    stack.append(nxt)
        pairs += size * seen_so_far
        seen_so_far += size
    return pairs
java
// java
// LC 2316 - Count Unreachable Pairs of Nodes in an Undirected Graph
// IDEA: component sizes by flood fill; each new component pairs with every node seen before it
// time = O(n + E), space = O(n + E)
public long countPairs(int n, int[][] edges) {
    List<List<Integer>> graph = new ArrayList<>();
    for (int i = 0; i < n; i++) {
        graph.add(new ArrayList<>());
    }
    for (int[] e : edges) {
        graph.get(e[0]).add(e[1]);
        graph.get(e[1]).add(e[0]);
    }
    boolean[] seen = new boolean[n];
    long pairs = 0, seenSoFar = 0;           // long: up to ~5 * 10^9 pairs
    for (int start = 0; start < n; start++) {
        if (seen[start]) {
            continue;
        }
        seen[start] = true;
        Deque<Integer> stack = new ArrayDeque<>();
        stack.push(start);
        long size = 0;
        while (!stack.isEmpty()) {
            int node = stack.pop();
            size++;
            for (int nxt : graph.get(node)) {
                if (!seen[nxt]) {
                    seen[nxt] = true;
                    stack.push(nxt);
                }
            }
        }
        pairs += size * seenSoFar;
        seenSoFar += size;
    }
    return pairs;
}

複雜度

  • 時間 O(n + E):外層 for start 每個分量發動一次洪水填充,每個節點只推入一次。
  • 空間 O(n + E):鄰接表、seen 和堆疊。

陷阱

  • Java 的 int 會溢位:沒有邊且 n = 10⁵ 時答案約 5·10⁹。pairs、size 和 seenSoFar 都要用 long — 兩個 int 相乘的 size * seenSoFar 會在轉型之前就溢位。
  • 對每個分量加總 size × (n − size) 會把每對算兩次;要除以 2,或改用累計總數。
  • 併查集也能得到同樣的大小(全部 union 完再數根),而且在邊一條一條加入時更合適。

練習 — LC 2316 ⭐ · LC 323 (只數分量個數) · LC 2492 (節點 1 所在分量裡的 最小邊)

以字典樹為後盾的搜尋

Template 4:查詢會分岔的字典樹 DFS — LC 211

辨識 — 一本單字字典,加上「. 可以匹配任何字母」、「恰好差一個字元」、「魔法字典」。 插入就是普通的字典樹迴圈;只有搜尋會分岔。單字 ≤ 25 個字母,LC 211 每個查詢最多 2 個點。

關鍵 — dfs(node, i) 回答「word[i:] 能不能從這個字典樹節點開始匹配」。字母只走一個子節點; . 嘗試每個子節點,第一次成功就回傳。查詢走完時,答案是有沒有單字在那個節點結束,而不是 True。

python
# python
# LC 211 - Design Add and Search Words Data Structure
# IDEA: trie; a '.' in the query branches the DFS into every child, a letter follows one
# time = O(L) per addWord; search O(L) without '.', O(26^d * L) with d dots, space = O(total chars)
class WordDictionary:
    def __init__(self):
        self.root = {}

    def addWord(self, word):
        node = self.root
        for ch in word:
            if ch not in node:
                node[ch] = {}
            node = node[ch]
        node['$'] = True                       # end-of-word marker

    def search(self, word):
        def dfs(node, i):
            if i == len(word):
                return '$' in node             # a word must END here
            ch = word[i]
            if ch == '.':
                for key, child in node.items():
                    if key != '$' and dfs(child, i + 1):
                        return True            # first match is enough
                return False
            if ch not in node:
                return False
            return dfs(node[ch], i + 1)

        return dfs(self.root, 0)
java
// java
// LC 211 - Design Add and Search Words Data Structure
// IDEA: trie; a '.' in the query branches the DFS into every child, a letter follows one
// time = O(L) per addWord; search O(L) without '.', O(26^d * L) with d dots, space = O(total chars)
class WordDictionary {
    private final WordDictionary[] children = new WordDictionary[26];
    private boolean isWord = false;

    public void addWord(String word) {
        WordDictionary node = this;
        for (char c : word.toCharArray()) {
            if (node.children[c - 'a'] == null) {
                node.children[c - 'a'] = new WordDictionary();
            }
            node = node.children[c - 'a'];
        }
        node.isWord = true;
    }

    public boolean search(String word) {
        return dfs(word, 0, this);
    }

    private boolean dfs(String word, int i, WordDictionary node) {
        if (node == null) {
            return false;
        }
        if (i == word.length()) {
            return node.isWord;                       // must end a word, not just be a prefix
        }
        char c = word.charAt(i);
        if (c == '.') {
            for (WordDictionary child : node.children) {
                if (dfs(word, i + 1, child)) {
                    return true;
                }
            }
            return false;
        }
        return dfs(word, i + 1, node.children[c - 'a']);
    }
}

複雜度

  • 時間:addWord 走 L 個字母。search 每個字母只走一個子節點,但每個 . 會讓 for key, child 迴圈跑過最多 26 個子節點,所以 d 個點在最壞情況是 O(26^d · L)。
  • 空間 O(總字元數):每個不同的前綴一個字典樹節點;遞迴深度 L。

陷阱

  • 查詢走完就回傳 True,會讓 "ba" 匹配到 "bad" 的前綴。要檢查結尾標記。
  • 用 dict 實作字典樹時,'$' 標記也是其中一個鍵:. 迭代子節點時要跳過它, 否則 DFS 會遞迴進 True。
  • 第一個成功的分支沒有立刻回傳,會讓 26^d 的最壞情況變成每次都發生。

不適用時 — 查詢沒有萬用字元:那就是普通的字典樹走訪(trie.md), 或乾脆用單字的雜湊集合。

練習 — LC 211 ⭐ · LC 208 (普通的字典樹) · LC 676 (見下) · LC 212 (由字典樹驅動的 網格 DFS)

Implement Magic Dictionary — LC 676

關鍵 — 不是在已知位置放萬用字元,而是沿著字典樹往下帶一個不匹配額度 1。 子節點的字母和查詢不同就花掉它;查詢走完時額度必須剛好是 0,因為沒改過的單字不算數。

歸約到:Template 4 — 分岔方式從「遇到點就走每個子節點」 變成「每個子節點都走,字母不同就付 1」。

python
# python
# LC 676 - Implement Magic Dictionary
# IDEA: trie DFS carrying a mismatch budget of 1; it must be fully spent at the word's end
# time = O(26 * L^2) per search, space = O(total chars)
class MagicDictionary:
    def __init__(self):
        self.root = {}

    def buildDict(self, dictionary):
        for word in dictionary:
            node = self.root
            for ch in word:
                if ch not in node:
                    node[ch] = {}
                node = node[ch]
            node['$'] = True

    def search(self, searchWord):
        def dfs(node, i, budget):
            if i == len(searchWord):
                return budget == 0 and '$' in node     # exactly one change, and a whole word
            for ch, child in node.items():
                if ch == '$':
                    continue
                cost = 0 if ch == searchWord[i] else 1
                if cost <= budget and dfs(child, i + 1, budget - cost):
                    return True
            return False

        return dfs(self.root, 0, 1)

陷阱

  • 結尾用 budget <= 1 而不是 == 0,會接受字典裡的原字:字典有 "hello" 時, search("hello") 必須是 False。
  • 額度花完後 DFS 只會沿著相同的字母走,所以一次搜尋最多有 26 · L 個地方能花掉額度, 每個之後只走一條最多 L 步的路徑:O(26 · L²),不是指數級。

後序彙總

Template 5:後序距離桶 — LC 1530

辨識 — 「最短路徑不超過 distance 的葉節點對」,上界很小(distance ≤ 10),樹最多 2¹⁰ 個節點。

關鍵 — 每個節點回傳 cnt[d] = 它下面恰好 d 條邊遠的葉節點數。左邊深度 d1 的葉節點和右邊深度 d2 的葉節點,經過這個節點相距 d1 + d2 + 2,而這個節點就是它們的最低共同祖先,所以每一對恰好在 這裡被計一次。

python
# python
# LC 1530 - Number of Good Leaf Nodes Pairs
# IDEA: post-order; each node returns leaf counts by depth below it and pairs left x right at the LCA
# time = O(N * distance^2), space = O(N + distance * height)
def countPairs(root, distance):
    pairs = [0]

    def leaves_by_depth(node):
        cnt = [0] * (distance + 1)        # cnt[d] = leaves exactly d edges below node
        if node is None:
            return cnt
        if node.left is None and node.right is None:
            cnt[0] = 1                    # a leaf is 0 edges from itself
            return cnt
        left = leaves_by_depth(node.left)
        right = leaves_by_depth(node.right)
        for d1 in range(distance + 1):    # join a left leaf and a right leaf through node
            for d2 in range(distance + 1):
                if d1 + d2 + 2 <= distance:
                    pairs[0] += left[d1] * right[d2]
        for d in range(distance):         # one edge farther for the parent; deeper leaves drop off
            cnt[d + 1] = left[d] + right[d]
        return cnt

    leaves_by_depth(root)
    return pairs[0]
java
// java
// LC 1530 - Number of Good Leaf Nodes Pairs
// IDEA: post-order; each node returns leaf counts by depth below it and pairs left x right at the LCA
// time = O(N * distance^2), space = O(N + distance * height)
class Solution {
    private int pairs = 0;

    public int countPairs(TreeNode root, int distance) {
        pairs = 0;                                        // a reused Solution must not carry counts over
        leavesByDepth(root, distance);
        return pairs;
    }

    // cnt[d] = number of leaves exactly d edges below node
    private int[] leavesByDepth(TreeNode node, int distance) {
        int[] cnt = new int[distance + 1];
        if (node == null) return cnt;
        if (node.left == null && node.right == null) {
            cnt[0] = 1;                                   // a leaf is 0 edges from itself
            return cnt;
        }
        int[] left = leavesByDepth(node.left, distance);
        int[] right = leavesByDepth(node.right, distance);
        for (int d1 = 0; d1 <= distance; d1++) {          // join a left leaf and a right leaf through node
            for (int d2 = 0; d2 <= distance; d2++) {
                if (d1 + d2 + 2 <= distance) pairs += left[d1] * right[d2];
            }
        }
        for (int d = 0; d < distance; d++) {              // one edge farther for the parent
            cnt[d + 1] = left[d] + right[d];
        }
        return cnt;
    }
}

複雜度

  • 時間 O(N·distance²):N 個節點每個都跑一次 d1 / d2 雙層迴圈。對 right 做累計前綴和, 可以讓合併變成 O(distance),總共 O(N·distance)。
  • 空間 O(N + distance·height):遞迴深度,加上每個尚未結束的呼叫各一個桶。

陷阱

  • 桶往上平移時會丟掉比 distance 更深的葉節點(是 range(distance),不是 range(distance + 1));在 d = distance 時寫 cnt[d + 1] 會越界。
  • 在每個祖先都合併、而不是只在左右分岔處合併,會重複計數;這一對只屬於它們的 LCA。

不適用時 — 距離上界很大:每個節點的桶都變成 O(N)。把樹轉成圖、從每個葉節點跑有界的 BFS 是比較慢的備案(O(L·N)),這些筆記裡沒有寫出它。

練習 — LC 1530 · LC 543 (在每個節點合併深度) · LC 124 (合併最佳向下總和) · LC 687 · LC 1530 ⭐

Template 6:N 元樹後序彙總 — LC 3965

辨識 — 以 edges 給出、「u 是 v 的父節點」、根在 0 的樹;節點的值是子節點值(最小、最大、總和) 加上自己權重的某個公式;只問根的值。n ≤ 10⁵。

關鍵 — 節點要等所有子節點都算完才能算。把 DFS 順序反過來彈出,每個子節點都會比它的父節點先被處理, 所以一個存放完成值的陣列就能取代遞迴。LC 3965 的公式:own = (latest − earliest) + baseTime[node]、 finish = latest + own,其中 latest / earliest 取自子節點。

python
# python
# LC 3965 - Finish Time of Tasks I
# IDEA: build children lists; compute nodes in reverse DFS pre-order so children finish first
# time = O(n), space = O(n)
def finishTime(n, edges, baseTime):
    children = [[] for _ in range(n)]
    for parent, child in edges:              # directed: parent -> child
        children[parent].append(child)

    order = []                               # DFS pre-order from the root
    stack = [0]
    while stack:
        node = stack.pop()
        order.append(node)
        for child in children[node]:
            stack.append(child)

    finish = [0] * n
    for node in reversed(order):             # every child appears after its parent in `order`
        if not children[node]:
            finish[node] = baseTime[node]    # leaf
            continue
        earliest = min(finish[c] for c in children[node])
        latest = max(finish[c] for c in children[node])
        own_duration = (latest - earliest) + baseTime[node]
        finish[node] = latest + own_duration
    return finish[0]
java
// java
// LC 3965 - Finish Time of Tasks I
// IDEA: build children lists; compute nodes in reverse DFS pre-order so children finish first
// time = O(n), space = O(n)
public long finishTime(int n, int[][] edges, int[] baseTime) {
    List<List<Integer>> children = new ArrayList<>();
    for (int i = 0; i < n; i++) {
        children.add(new ArrayList<>());
    }
    for (int[] e : edges) {
        children.get(e[0]).add(e[1]);
    }
    int[] order = new int[n];
    int size = 0;
    Deque<Integer> stack = new ArrayDeque<>();
    stack.push(0);
    while (!stack.isEmpty()) {
        int node = stack.pop();
        order[size++] = node;
        for (int c : children.get(node)) {
            stack.push(c);
        }
    }
    long[] finish = new long[n];             // long: values compound with depth
    for (int i = n - 1; i >= 0; i--) {
        int node = order[i];
        if (children.get(node).isEmpty()) {
            finish[node] = baseTime[node];
            continue;
        }
        long earliest = Long.MAX_VALUE, latest = Long.MIN_VALUE;
        for (int c : children.get(node)) {
            earliest = Math.min(earliest, finish[c]);
            latest = Math.max(latest, finish[c]);
        }
        finish[node] = latest + (latest - earliest) + baseTime[node];
    }
    return finish[0];
}

複雜度

  • 時間 O(n):每個節點只推入一次,for node in reversed(order) 那一趟每個子節點串列只讀一次。
  • 空間 O(n):子節點串列、order、finish 和堆疊。

陷阱

  • 把圖建成無向的會破壞葉節點判斷:每個非根節點都會把父節點當成鄰居,沒有節點看起來像葉子。
  • Java 的 int 會溢位:節點的值接近它最慢子節點的兩倍,隨深度不斷累積。題目只保證答案 < 2⁵³;要用 long。
  • 遞迴的 dfs(child) 寫法最容易解釋,但在一條鏈上深達 10⁵ 層;說明這一點,然後寫以順序為基礎的版本。

不適用時 — 樹是二元的 TreeNode:同樣的彙總就是 dfs.md § Template 6,遞迴深度是樹高。

練習 — LC 3965 ⭐ · LC 3967 (同樣的彙總,要回答很多個查詢) · LC 559 (1 + 子節點最大深度) · LC 1376 (子節點最大值 + 自己的時間)

編碼在輸入裡的樹

Template 7:以深度為索引的堆疊 — LC 388

辨識 — 輸入字串本身就是一棵樹:以 tab 縮排的行、以 / 分隔的路徑、每個 token 前面有一個深度 前綴。答案是根到葉的某種彙總,例如最長路徑。

關鍵 — stack[d] 存放深度 d 那個尚未關閉的祖先的彙總值。處理深度為 d 的一行之前, 先彈到 len(stack) == d + 1:那些彈出就是 DFS 往上返回,而 stack[d] 這時正好是這一行的父前綴。 從頭到尾都不用建樹。

python
# python
# LC 388 - Longest Absolute File Path
# IDEA: stack[d] = path length up to the open directory at depth d; popping == returning up the DFS
# time = O(N), space = O(D)   N = input length, D = deepest nesting
def lengthLongestPath(input):
    longest = 0
    stack = [0]                                # depth 0 has an empty prefix
    for line in input.split('\n'):
        name = line.lstrip('\t')
        depth = len(line) - len(name)          # leading tabs = depth
        while len(stack) > depth + 1:          # close the directories this line is not inside
            stack.pop()
        if '.' in name:                        # a file is a leaf: score it, never push it
            longest = max(longest, stack[depth] + len(name))
        else:                                  # a directory: its prefix plus the name plus '/'
            stack.append(stack[depth] + len(name) + 1)
    return longest
java
// java
// LC 388 - Longest Absolute File Path
// IDEA: stack[d] = path length up to the open directory at depth d; popping == returning up the DFS
// time = O(N), space = O(D)
public int lengthLongestPath(String input) {
    int longest = 0;
    Deque<Integer> stack = new ArrayDeque<>();
    stack.push(0);
    for (String line : input.split("\n")) {
        int depth = 0;
        while (depth < line.length() && line.charAt(depth) == '\t') {
            depth++;
        }
        String name = line.substring(depth);
        while (stack.size() > depth + 1) {
            stack.pop();
        }
        if (name.contains(".")) {
            longest = Math.max(longest, stack.peek() + name.length());
        } else {
            stack.push(stack.peek() + name.length() + 1);
        }
    }
    return longest;
}

複雜度

  • 時間 O(N):split 和 lstrip 每個字元只讀一次;每個目錄最多被 while len(stack) > depth + 1 迴圈推入、彈出各一次。
  • 空間 O(D):堆疊為每個尚未關閉的目錄存一個項目。

陷阱

  • 把檔案推入堆疊會弄壞所有更深的前綴;檔案是葉子。
  • 完全沒有檔案時回傳 0("a" → 0),不是最長的目錄路徑。
  • + 1 是目錄貢獻的 /,所以最上層的檔案以 stack[0] = 0 計分,前面沒有斜線。

練習 — LC 388 ⭐ · LC 1233 (見下) · LC 1028 (深度由破折號表示:把樹重建出來)

Remove Sub-Folders from the Filesystem — LC 1233

關鍵 — 把每條路徑拆成各段、存進字典樹,並在資料夾結束的節點做標記。從根開始的 DFS 收集到被標記的節點就停止往下:存放的資料夾底下的所有東西,依定義都是子資料夾。

歸約到:Template 7 — 一樣把「路徑的各段當成深度層級」來讀, 只是因為路徑沒有排序,所以存在字典樹裡。

python
# python
# LC 1233 - Remove Sub-Folders from the Filesystem
# IDEA: path trie; DFS collects the first stored folder on each branch and cuts below it
# time = O(total path chars), space = O(total path chars)
def removeSubfolders(folder):
    root = {}
    for path in folder:
        node = root
        for part in path.split('/')[1:]:       # [0] is the empty string before the first '/'
            if part not in node:
                node[part] = {}
            node = node[part]
        node['$'] = path                       # the full path ends here

    kept = []
    stack = [root]
    while stack:
        node = stack.pop()
        if '$' in node:
            kept.append(node['$'])
            continue                           # cut: anything deeper is a sub-folder
        for child in node.values():
            stack.append(child)
    return kept

陷阱

  • 比較原始字串前綴會說 /a/bc 在 /a/b 裡面。以 / 拆開比較的是完整名稱; 如果改用排序,要檢查 path.startswith(kept[-1] + '/')。

Template 8:在 parent 陣列上記憶化往上爬 — LC 4015

辨識 — 樹以 parent[](或 manager[])給出,parent[root] = -1,而答案需要每個節點的深度或 樹高。n ≤ 10⁵,而且編號不保證 parent[i] < i。

關鍵 — parent[] 本身就是往上的邊,所以 depth[x] = depth[parent[x]] + 1 不需要任何其他結構。 把它記憶化:每個節點的深度只算一次,每次往上爬都停在第一個已知的祖先,所以全部的爬升加起來是 O(n)。 深度從 1 開始,所以 depth[x] == 0 代表「還沒算」。

python
# python
# LC 4015 - Weighted Sum of a Tree
# IDEA: memoized climb up parent[] (explicit stack, no recursion); height = max depth
# time = O(n), space = O(n)
def weightedSum(parent, nums):
    n = len(parent)
    depth = [0] * n                    # 0 = not computed yet; depths are 1-based
    for start in range(n):
        chain = []
        x = start
        while depth[x] == 0:           # climb until a computed node, or past the root
            if parent[x] == -1:
                depth[x] = 1
                break
            chain.append(x)
            x = parent[x]
        d = depth[x]
        while chain:                   # unwind: fill every node climbed through
            d += 1
            depth[chain.pop()] = d

    height = max(depth)
    total = 0
    for i in range(n):
        total += nums[i] * (height - depth[i] + 1)
    return total
java
// java
// LC 4015 - Weighted Sum of a Tree
// IDEA: memoized climb up parent[] with an explicit stack; height = max depth
// time = O(n), space = O(n)
class Solution {
    public long weightedSum(int[] parent, int[] nums) {
        int n = parent.length;
        int[] depth = new int[n];                         // 0 = not computed yet; depths are 1-based
        java.util.ArrayDeque<Integer> chain = new java.util.ArrayDeque<>();
        for (int start = 0; start < n; start++) {
            int x = start;
            while (depth[x] == 0) {                       // climb until a computed node, or past the root
                if (parent[x] == -1) {
                    depth[x] = 1;
                    break;
                }
                chain.push(x);
                x = parent[x];
            }
            int d = depth[x];
            while (!chain.isEmpty()) {                    // unwind: fill every node climbed through
                d++;
                depth[chain.pop()] = d;
            }
        }
        int height = 0;
        for (int dep : depth) height = Math.max(height, dep);
        long total = 0;
        for (int i = 0; i < n; i++) {
            total += (long) nums[i] * (height - depth[i] + 1);
        }
        return total;
    }
}

複雜度

  • 時間 O(n):節點只在 depth[x] == 0 時才被加入 chain,並在鏈展開時被填好, 所以在所有起點加起來,每個節點只被爬過一次。
  • 空間 O(n):depth 和一條 chain,在路徑形狀的樹上最長 n。

試跑 — parent = [-1,0,0,0,2,2],nums = [5,2,3,1,4,6]:

text
start  climb                 depths filled
0      root                  depth0 = 1
1      1 -> 0 (known)        depth1 = 2
2      2 -> 0                depth2 = 2
3      3 -> 0                depth3 = 2
4      4 -> 2 (known)        depth4 = 3
5      5 -> 2                depth5 = 3
height 3: 5*3 + 2*2 + 3*2 + 1*2 + 4*1 + 6*1 = 37

陷阱

  • 沒有備忘錄時,路徑形狀的樹要花 1 + 2 + … + n = O(n²)。
  • 只掃一次 i = 0 … n − 1、寫 depth[i] = depth[parent[i]] + 1,只有在 parent[i] < i 時才對; LC 4015 不保證這點,所以這樣掃會讀到還沒填的項目。
  • 遞迴往上爬在一條鏈上深達 n 層;n = 10⁵ 時需要明確的堆疊。

不適用時 — 答案需要子樹彙總(大小、總和、子節點最小/最大):把 parent[] 反轉成子節點串列, 改用 Template 6。 往上爬看起來也很像併查集的 find,但這裡沒有 union;用 DSU 毫無好處。

練習 — LC 4015 ⭐ · LC 1376 (沿著 manager 鏈累加時間) · LC 1483 (把往上爬以 2^k 步預先算好: binary lifting) · LC 1650 (兩條鏈一起往上爬:兩串列交點)

何時不要用

技巧 通常就夠用的簡單卡片 什麼時候就夠
Template 1(有效旗標) dfs.md § Template 2 只有一張網格,每個分量都算數
Template 2(方向旗標) 普通的有向 DFS / BFS 問題只是沿著給定方向能不能到達
Template 3(配對計數) graph.md § Template 1 只要分量的個數
Template 4(字典樹 DFS) 單字的雜湊集合,或普通的字典樹走訪 查詢沒有萬用字元,或字典小到可以逐字比較
Template 5(距離桶) dfs.md § Template 6 答案是一條最佳路徑(直徑、最大路徑和),不是配對數
Template 6(N 元樹彙總) 遞迴後序 n 很小或樹很淺;遞迴比較好解釋
Template 7(以深度為索引的堆疊) 先建樹,再對它 DFS 面試官要的是樹本身,或格式不是一個節點一行
Template 8(記憶化往上爬) 在反轉後的子節點串列上從根做 BFS 編號保證 parent[i] < i(那就掃一次),或反正也需要子樹資料

總結

模板 額外記帳 時間 空間
1. 有效旗標 每次洪水填充一個布林值 O(m·n) O(m·n)
2. 方向旗標 每條存起來的邊上的 0/1 O(n) O(n)
3. 配對計數 已看過節點的累計總數 O(n + E) O(n + E)
4. 字典樹 DFS 遇到 . 分岔/不匹配額度 每次搜尋 O(26^d · L) O(總字元數)
5. 距離桶 每個節點的 cnt[d] O(N·d²) O(N)
6. N 元樹彙總 用反向前序取代遞迴 O(n) O(n)
7. 以深度為索引的堆疊 每個尚未關閉的祖先一個 stack[d] O(N) O(D)
8. 記憶化往上爬 depth[] 備忘錄,0 = 還沒算 O(n) O(n)

這份文件特有的陷阱

  • 結果是一個旗標的洪水填充,仍然要把整個分量淹完。
  • 字典樹 DFS 要檢查單字結尾標記,迭代子節點時又要跳過它。
  • 以深度為索引的堆疊從不推入葉子。
  • n 到 10⁵ 且輸入可能是路徑形狀:Templates 1、2、3、6 和 8 因此都用明確的堆疊來寫。

語言備註

  • Python 遞迴:預設上限是 1,000 層。sys.setrecursionlimit(10**6) 可以調高,但很深的遞迴仍可能 耗盡 C 堆疊;明確的堆疊才是安全的寫法。
  • Java 遞迴:LeetCode 解答沒辦法設定執行緒堆疊大小;數萬層就可能丟出 StackOverflowError。
  • Java 共享狀態:傳進遞迴呼叫的 StringBuilder、List 或 int[],在每一層都是同一個物件, 所以呼叫返回後 append 的內容還在 — dfs.md § Template 8 就是這樣建出路徑簽名的。int 或 String 參數是副本;改用回傳值。

相關主題

  • graph_advanced.md:Tarjan 找橋/SCC 與 Hierholzer 尤拉路徑。
  • union_find.md:Template 3 有 DSU 寫法;Template 8 看起來像 DSU,但沒有 union()。
  • trie.md:Template 4 搜尋的結構。
  • tree_codec.md:回傳字串的 DFS,以及把它讀回來的解析器。
  • bfs.md:這裡每個洪水填充的廣度優先版本。