DFS(深度優先搜尋)

圖論優先度 5/5 — 必備 — 幾乎每一輪面試都會出現必備 更新於 Nov 12, 2026
章節優先度優先度 5/5 — 必備 — 幾乎每一輪面試都會出現必備優先度 4/5 — 高價值 — 這裡有缺口就會掉關高價值優先度 3/5 — 值得會 — 多半是必備模式的變形值得會優先度 2/5 — 冷門 — 讀過一次即可,除非目標公司已知會問冷門只標在真正需要的章節上 —— 沒標的是背景/參考資料。

範圍 — DFS 的主文件:十個核心的深度優先模板 — 樹走訪、網格填色(flood fill)、路徑搜尋、回溯、樹結構修改、後序彙總、邊界消除、形狀簽名與帶權邊走訪 — 並附上用來挑選模板的辨識表。 另見 — 從本檔案拆出去的深入主題:dfs_advanced.md — 雙網格驗證、邊方向標記、跨連通分量配對計數、字典樹 + 萬用字元 DFS、以深度為索引的堆疊 DFS、距離桶配對葉節點、N 元樹與 parent[] 的彙總;graph_advanced.md — 尤拉路徑(Hierholzer)與 Tarjan low-link;dfs_examples.md — 經典題目,依本檔的模板分組,附索引。 鄰近文件:bfs.md — 廣度優先的對應版本以及如何取捨;backtrack.md — 回程時會還原狀態的 DFS;graph.md — 圖的表示法;tree.md — 樹上的 DFS。

LeetCode 題目清單

總覽

深度優先搜尋(DFS) 是一種圖/樹的走訪演算法,沿著每個分支盡可能往深處走,走不下去才回溯。它用遞迴或堆疊來維護走訪路徑。

關鍵性質

  • 時間複雜度:圖是 O(V + E),樹是 O(n)
  • 空間複雜度:遞迴堆疊 O(h),其中 h = 高度
  • 核心想法:先往深走,再往廣走
  • 資料結構:堆疊(遞迴的隱含堆疊或顯式堆疊)
  • 什麼時候用:路徑搜尋、環偵測、拓撲排序、樹走訪、回溯類題目

參考資料

辨識

下面每個模式都只出現一次,以卡片形式放在「模板與演算法」底下。先比對題目敘述裡的用語,再拿限制條件對照 限制條件尺規,動手寫程式前先讀最後一欄。

題目敘述裡的訊號 限制提示 該用 不適用時
「中序/前序/後序」、「走訪所有節點」、「序列化」、「是不是鏡像」、「兩棵樹是否相同」 節點 ≤ 10⁴ Template 1 — 樹走訪;兩棵樹 → 它的成對 DFS 變形 一層一層 → bfs.md § Template 1
「島嶼數量」、「連通區域」、「flood fill」、「複製圖」 網格 ≤ 300 × 300 Template 2 — flood fill,數啟動次數 兩格之間的最短路線 → bfs.md § Template 3
「根到葉的路徑和」、「路徑是否存在」、「所有路徑」 節點 ≤ 5,000 Template 3 — 狀態往下傳、提早回傳 路徑可以從根以下開始 → 路徑上的前綴和(LC 437)
「所有排列/組合/子集」 n ≤ 10(n!)或 n ≤ 20(2ⁿ) Template 4 — 選、遞迴、取消選 只要數量、且狀態會重複 → DP
「刪除/插入/修剪節點並回傳根」 BST,O(h) Template 5 — 把回傳的子樹接回去 由整棵子樹的性質決定要不要剪 → Template 6
「直徑」、「是否平衡?」、「子樹和」、「最低共同祖先」、「相鄰節點間的最少移動次數」 節點 ≤ 10⁴ Template 6 — 後序,回傳一個值、記錄另一個值 答案取決於從根下來的路徑 → Template 3
「封閉島嶼」、「被包圍的區域」、「飛地」 網格 ≤ 200 × 200 Template 7 — 先淹掉邊界 到邊界的距離 → 多源 BFS
「不同島嶼」、「平移後形狀相同」 網格 ≤ 50 × 50 Template 8 — 路徑簽名 旋轉與鏡射也算相同 → 對 8 種變換正規化(LC 711)
「一條路徑、收集最多、不能重複走同一格」、「單字搜尋」 網格 ≤ 15 × 15,有用的格子 ≤ 25 Template 9 — 標記、遞迴、還原 格子可以重複走,或網格很大 → DP 或 BFS
「除法求值」、「匯率」、「傳遞性比值」 變數 ≤ 40 Template 10 — 帶權 DFS,沿路相乘 固定的圖上有很多查詢 → 帶權併查集

不在本頁 — 這些放在 dfs_advanced.md:雙網格驗證(LC 1905)、 邊方向追蹤(LC 1466)、連通分量配對計數(LC 2316)、trie + 萬用字元 DFS(LC 211, 676)、以深度為索引的堆疊 DFS(LC 388, 1233)、依距離分桶的葉節點配對(LC 1530)、N 元樹 後序彙總(LC 3965),以及 parent[] 陣列的深度攀爬(LC 4015)。尤拉路徑(LC 332, 753) 與 Tarjan 橋(LC 1192)在 graph_advanced.md;樹 ⟷ 字串編解碼 (LC 606, 536)在 tree_codec.md。依模板分組的題目在 dfs_examples.md → Index。

模板與演算法

模板比較表

模板 使用情境 關鍵操作 時間 空間 什麼時候用
1. 樹走訪 拜訪所有節點 遞迴/堆疊 O(n) O(h) 樹的題目
2. 圖/網格 DFS 探索圖、填色 visited 集合/就地標記 O(V+E) O(V) 圖與網格的探索
3. 路徑搜尋 找出特定路徑 追蹤路徑 O(n) O(h) 路徑類題目
4. 回溯 嘗試所有路徑 撤銷選擇 O(b^d) O(d) 組合類題目
5. 結構修改 改變結構 更新節點 O(n) O(h) 樹的編輯
6. 由下而上 彙總資訊 後序回傳 O(n) O(h) 子樹類題目
7. 兩趟 DFS 邊界消除 兩階段填色 O(m×n) O(m×n) 封閉/被包圍的區域
8. 路徑簽名 編碼形狀 記錄方向 O(m×n) O(m×n) 計算相異形狀數
9. 網格 DFS + 回溯 網格中的單一最佳路徑 標記、遞迴、還原 O(4^k) O(k) 多個起點且路徑會重疊
10. 帶權圖 DFS 比值/除法查詢 累乘 O(Q·(V+E)) O(V+E) 傳遞性比值計算

模板 1:樹走訪 — LC 94 優先度 5/5 — 必備 — 幾乎每一輪面試都會出現

辨識 — 「中序/前序/後序走訪」、「走訪每個節點」、「序列化」、「印出這棵樹」,以及在 BST 裡的 「依排序順序」(BST 的中序就是排好序的)。節點 ≤ 10⁴;遞迴深度等於樹高。

關鍵 — 三種順序是同一個遞迴,只是造訪那一行放的位置不同: 放在子節點之前(前序)、之間(中序)、之後(後序)。不管選哪種順序,每個節點都只進入一次。

python
# python
# LC 94 - Binary Tree Inorder Traversal
# IDEA: recurse left, visit, recurse right; move `result.append` to get pre- or post-order
# time = O(n), space = O(h)   h = tree height, the recursion depth
def inorderTraversal(root):
    result = []

    def dfs(node):
        if not node:
            return
        dfs(node.left)
        result.append(node.val)          # inorder: between the two children
        dfs(node.right)

    dfs(root)
    return result


def inorderTraversal_iterative(root):
    # the problem's follow-up: an explicit stack replaces the call stack
    result, stack = [], []
    node = root
    while node or stack:
        while node:                      # walk as far left as possible
            stack.append(node)
            node = node.left
        node = stack.pop()               # the leftmost node not yet visited
        result.append(node.val)
        node = node.right                # then its right subtree
    return result
java
// java
// LC 94 - Binary Tree Inorder Traversal
// IDEA: recurse left, visit, recurse right; move `result.add` to get pre- or post-order
// time = O(n), space = O(h)
public List<Integer> inorderTraversal(TreeNode root) {
    List<Integer> result = new ArrayList<>();
    dfs(root, result);
    return result;
}

private void dfs(TreeNode node, List<Integer> result) {
    if (node == null) {
        return;
    }
    dfs(node.left, result);
    result.add(node.val);                // inorder: between the two children
    dfs(node.right, result);
}

複雜度

  • 時間 O(n):dfs 對每個節點跑一次,對每個空子節點(共 n + 1 個)也跑一次,每次 O(1)。
  • 空間 O(h):遞迴(或 stack)只存一條從根到節點的路徑 — 平衡時 O(log n), 退化成一條鏈時 O(n)。

陷阱

  • return inorder(node.left) + [node.val] + inorder(node.right) 在每一層都複製串列:O(n·h), 在歪斜的樹上就是 O(n²)。改成往同一個共用串列 append。
  • 迭代版前序要先推右子節點、再推左子節點,左邊才會先被 pop;先推左邊的話, [1,2,3] 會回傳 [1,3,2]。
  • 10⁵ 層深的鏈會撐爆 Python 的遞迴上限;迭代寫法沒有這個限制。

不適用時 — 要一層一層的順序 → BFS (bfs.md § Template 1)。節點的答案由子節點的答案 算出來 → Template 6。判斷條件同時需要兩個節點 → 下面的成對 DFS 變形。

練習 — LC 94 · LC 144 · LC 145 (迭代後序 = 反轉「根-右-左」的前序) · LC 100 ⭐ (成對 DFS) · LC 101 (鏡像的成對 DFS:見下方) · LC 297 ⭐ (帶 null 標記的 前序)

Symmetric Tree — LC 101

關鍵 — 一棵樹對稱的充要條件是它的兩棵子樹互為鏡像,所以 DFS 一次帶兩個游標 (a, b),檢查兩者之間的關係:值相等,接著是外側那一對 (a.left, b.right) 與內側那一對 (a.right, b.left)。你往下遞迴時怎麼配對,就是這題本身:同向配對 (a.left, b.left) + (a.right, b.right) 就是 LC 100 Same Tree。

歸約到:Template 1 — 對一對節點做前序走訪,而不是一個。 來源:symmetric-tree.py。

起手式是 helper(root.left, root.right);根節點本身從頭到尾不跟任何節點比較。三個 base case, 順序如下:兩邊皆空 → True;只有一邊空 → False;值不同 → False。三關都過了才往下走,並讓 and 在第一次對不上時短路。

text
LC 101 trace — root = [1,2,2,null,3,null,3]     helper(a, b) pairs  a.left <-> b.right
                                                                   a.right <-> b.left
        1               helper(a=2, b=2)                  vals equal -> descend
      /   \             |- helper(a.left=null, b.right=3)  exactly one null -> FALSE
    2       2           |- (never evaluated: `and` short-circuits)
     \       \
      3       3         answer = False
   a.right  b.right     <- both 3's are RIGHT children, so they are not mirror partners

陷阱 — 改用同向配對並不是「大致上可行」:它回答的是另一個問題(「左子樹是否等於右子樹?」), 而且兩個方向都會答錯:

text
straight pairing (a.left<->b.left, a.right<->b.right) on the same two inputs:

  [1,2,2,null,3,null,3]   null==null, then 3==3  -> True    (correct answer: False)
  [1,2,2,3,4,4,3]         3 vs 4                 -> False   (correct answer: True)
python
# python
# LC 101 - Symmetric Tree
# IDEA: paired (dual-pointer) DFS; recurse on the MIRROR pairing (a.left, b.right) / (a.right, b.left)
# time = O(n), space = O(h)   # h = tree height, worst O(n)
class Solution:
    def isSymmetric(self, root):
        if not root:
            return True
        # the helper takes TWO nodes -> the root is never compared to anything
        return self.helper(root.left, root.right)

    def helper(self, left, right):
        if not left and not right:      # both empty -> mirrored
            return True
        if not left or not right:       # exactly one empty -> not mirrored
            return False
        if left.val != right.val:
            return False
        return (
            self.helper(left.left, right.right)     # outer pair
            and
            self.helper(left.right, right.left)     # inner pair
        )
java
// java
// LC 101 - Symmetric Tree
// IDEA: paired (dual-pointer) DFS; recurse on the MIRROR pairing
// time = O(n), space = O(h)
public boolean isSymmetric(TreeNode root) {
    if (root == null) {
        return true;
    }
    return helper(root.left, root.right);
}

private boolean helper(TreeNode left, TreeNode right) {
    if (left == null && right == null) {
        return true;
    }
    if (left == null || right == null) {
        return false;
    }
    if (left.val != right.val) {
        return false;
    }
    return helper(left.left, right.right)      // outer pair
            && helper(left.right, right.left); // inner pair
}
迭代寫法 — 把配對放在堆疊上

題目自己的 follow up 就在問這個寫法,而它也是把成對 DFS 去遞迴化的通用做法:堆疊裡放的是 一對一對的節點,推入與彈出都是兩個一起。

python
# python
# LC 101 - Symmetric Tree (iterative — the stated follow-up)
# IDEA: same mirror pairing, kept on an explicit stack; pop two entries = pop one pair
# time = O(n), space = O(n)
def isSymmetric(root):
    if not root:
        return True
    stack = [root.left, root.right]
    while stack:
        p, q = stack.pop(), stack.pop()          # pops ONE pair
        if not p and not q:
            continue
        if not p or not q or p.val != q.val:
            return False
        # push the two mirror pairs
        stack.append(p.left)
        stack.append(q.right)                    # outer pair
        stack.append(p.right)
        stack.append(q.left)                     # inner pair
    return True
  • 刻意把 None 也推進去。 跟模板 1 的迭代走訪不同,這裡不可以跳過空的子節點: (None, node) 這種配對一定要被彈出來比較過,才能回傳 False。用 if p.left: 擋住推入, 會默默地把不對稱的樹判成對稱。
  • 彈出時配對是反過來的 — stack.pop(), stack.pop() 拿回來的是 (q.left, p.right), 也就是後推入的那個先出來。這裡無害,因為鏡像述語對它的兩個參數是對稱的;但在述語有方向性的 成對 DFS(例如「b 是不是 a 的子樹」)裡就不是無害的,那時你必須把兩個值彈進正確的位置。
  • 換成配對佇列也完全一樣 — 把堆疊換成 deque、連 popleft() 兩次;比較的順序會變,答案不會變。 見 bfs.md 裡的 LC 101 那一列。

類似題:

  • LC 100 - Same Tree(同一個骨架,改用同向配對)
  • LC 951 - Flip Equivalent Binary Trees(兩種配對都試,再 or 起來)
  • LC 572 - Subtree of Another Tree(把 LC 100 的成對 DFS 在每個節點重新發動一次)
  • LC 617 - Merge Two Binary Trees(成對 DFS,但建出一個節點而不是回傳布林值)
  • LC 1612 - Check If Two Expression Trees are Equivalent(成對走訪 + 比較葉節點的多重集合)
  • LC 226 - Invert Binary Tree(把鏡像當成一個變換 — 對稱就等於 isSameTree(root, invert(root)),代價是會改動原樹)
  • LC 872 - Leaf-Similar Trees(刻意放進來當對照:兩趟各自獨立的 DFS, 事後比較葉節點序列,因為這題允許兩棵樹的形狀不同)

模板 2:圖/網格 DFS(Flood Fill) — LC 200 優先度 5/5 — 必備 — 幾乎每一輪面試都會出現

辨識 — 「島嶼數量」、「連通區域」、「flood fill」、「最大島嶼面積」、 「複製圖」。網格 ≤ 300 × 300,或 V + E ≤ 10⁵ → 一次 O(V + E) 掃過。

關鍵 — 外層掃描第一次碰到每座島時只會碰到其中一格,而那一格的 DFS 會把相連的陸地全部 淹掉。所以每座島都恰好只在它的第一格被數一次,而每一格只被進入常數次。

python
# python
# LC 200 - Number of Islands
# IDEA: scan the grid; each '1' still standing starts a DFS that sinks its island -> count the launches
# time = O(m * n), space = O(m * n)   the recursion is as deep as the largest island
def numIslands(grid):
    rows, cols = len(grid), len(grid[0])

    def sink(r, c):
        if r < 0 or r >= rows or c < 0 or c >= cols or grid[r][c] != '1':
            return                        # off the grid, water, or already sunk
        grid[r][c] = '0'                  # mark BEFORE recursing, so no cell is entered twice
        sink(r + 1, c)
        sink(r - 1, c)
        sink(r, c + 1)
        sink(r, c - 1)

    islands = 0
    for r in range(rows):
        for c in range(cols):
            if grid[r][c] == '1':
                islands += 1              # the first cell of a new island
                sink(r, c)
    return islands
java
// java
// LC 200 - Number of Islands
// IDEA: scan the grid; each '1' still standing starts a DFS that sinks its island -> count the launches
// time = O(m * n), space = O(m * n)
public int numIslands(char[][] grid) {
    int islands = 0;
    for (int r = 0; r < grid.length; r++) {
        for (int c = 0; c < grid[0].length; c++) {
            if (grid[r][c] == '1') {
                islands++;                // the first cell of a new island
                sink(grid, r, c);
            }
        }
    }
    return islands;
}

private void sink(char[][] grid, int r, int c) {
    if (r < 0 || r >= grid.length || c < 0 || c >= grid[0].length || grid[r][c] != '1') {
        return;
    }
    grid[r][c] = '0';                     // mark BEFORE recursing
    sink(grid, r + 1, c);
    sink(grid, r - 1, c);
    sink(grid, r, c + 1);
    sink(grid, r, c - 1);
}

複雜度

  • 時間 O(m·n):外層掃描每格讀一次,而 sink 在遞迴之前就把 '1' 改成 '0', 所以每個陸地格只會進入一次;四個鄰居的檢查都是 O(1)。
  • 空間 O(m·n):整片都是陸地時就是一座島,遞迴可以深到那個程度。

陷阱

  • 在四個遞迴呼叫之後才標記:兩個陸地格會互相遞迴到永遠 — [["1","1"]] 就會撐爆堆疊。
  • LC 200 的網格存的是字元 '1' 和 '0';拿整數 1 去比,會一座島都找不到。
  • 300 × 300 全是陸地就是 9·10⁴ 層深的遞迴:Python 需要 sys.setrecursionlimit,或改用 BFS 寫法 (bfs.md § Template 3)。
  • 把網格淹掉會破壞輸入。面試時要講出來,或改用 visited 集合。

不適用時 — 有向圖問「有沒有環」→ 三狀態 DFS (graph.md § Template 5);單純的 visited 集合會誤報環。兩格之間的最短路線 → BFS。邊一條一條進來 → 併查集。

練習 — LC 200 ⭐ · LC 733 (淹一個區域) · LC 695 (讓 sink 回傳面積) · LC 133 ⭐ (visited 就是 {original: copy} 這張對照表) · LC 417 ⭐ (從兩個海洋各自往內淹, 再取交集) · LC 419 (完全不用 DFS:見下方變形)

Battleships in a Board — LC 419

關鍵 — 每艘船都是一條筆直的 1×k 或 k×1 線段,所以每艘船恰好有一個左上端點:一個上方和 左方都沒有 'X' 的 'X'。數這些格子就等於數船 —— 一次掃描、O(1) 額外空間、不用 visited 集合、不改動棋盤。

歸約到:Template 2 — 數連通分量,只是「船是 直線」的保證讓你只需數每個分量的第一格,不必 flood fill 整個分量。這正是題目的 follow up (一次掃描、O(1) 空間、不改動棋盤),建立在 LC 200 的 flood fill 之上。

python
# python
# LC 419 - Battleships in a Board
# IDEA: count only the top-left cell of each ship -> no visited set needed
# time = O(M*N), space = O(1)
def countBattleships(board):
    count = 0
    for r in range(len(board)):
        for c in range(len(board[0])):
            if board[r][c] != 'X':
                continue
            if r > 0 and board[r - 1][c] == 'X':
                continue
            if c > 0 and board[r][c - 1] == 'X':
                continue
            count += 1
    return count
java
// java
// LC 419 - Battleships in a Board
// IDEA: a cell starts a NEW ship iff it is 'X' and has no 'X' above and no 'X' to its left
// time = O(M*N), space = O(1)
public int countBattleships(char[][] board) {
    int count = 0;
    for (int r = 0; r < board.length; r++) {
        for (int c = 0; c < board[0].length; c++) {
            if (board[r][c] != 'X') continue;
            if (r > 0 && board[r - 1][c] == 'X') continue;   // continuation of a vertical ship
            if (c > 0 && board[r][c - 1] == 'X') continue;   // continuation of a horizontal ship
            count++;
        }
    }
    return count;
}

陷阱

  • 這招靠的是那個保證。拿掉「船是直線」,L 形的船就會有兩個看起來像左上端點的格子;這時退回上面 LC 200 的 flood fill。

模板 3:路徑搜尋 — LC 112 優先度 5/5 — 必備 — 幾乎每一輪面試都會出現

辨識 — 「根到葉的路徑和」、「路徑是否存在」、「所有路徑」、「從 a 到 b 的 路徑」。節點 ≤ 5,000。

關鍵 — 路徑狀態(這裡是還需要的和)以參數往下傳,所以每個呼叫看到的恰好是從根到自己的 路徑。答案只在葉節點檢查,任一個子節點回傳 True 就立刻往上回傳。

python
# python
# LC 112 - Path Sum
# IDEA: pass the remaining sum down; at a leaf it must be exactly 0; return True as soon as a child does
# time = O(n), space = O(h)
def hasPathSum(root, targetSum):
    if not root:
        return False
    remaining = targetSum - root.val
    if not root.left and not root.right:      # a leaf: the path ends here
        return remaining == 0
    return hasPathSum(root.left, remaining) or hasPathSum(root.right, remaining)
java
// java
// LC 112 - Path Sum
// IDEA: pass the remaining sum down; at a leaf it must be exactly 0; return true as soon as a child does
// time = O(n), space = O(h)
public boolean hasPathSum(TreeNode root, int targetSum) {
    if (root == null) {
        return false;
    }
    int remaining = targetSum - root.val;
    if (root.left == null && root.right == null) {   // a leaf: the path ends here
        return remaining == 0;
    }
    return hasPathSum(root.left, remaining) || hasPathSum(root.right, remaining);
}

複雜度

  • 時間 O(n):每個節點最多造訪一次,or 在第一個 True 就停。
  • 空間 O(h):目前這條根到節點的路徑上,每個節點一個呼叫。

陷阱

  • 在空的子節點而不是葉節點檢查 remaining == 0:[1,2]、目標 1 會透過 1 不存在的右子節點 回傳 True,但唯一一條根到葉的路徑和是 3。
  • 空樹沒有任何路徑,目標是 0 也一樣:hasPathSum(None, 0) 是 False。
  • 在圖的搜尋裡呼叫 dfs(next) 卻沒有回傳它的結果:True 找到了又被丟掉 (見下方的提早回傳一節)。

不適用時 — 路徑可以在往下的任何地方開始與結束 → 沿路徑的前綴和 (LC 437)。要的是最短路徑 → BFS。路徑可以在某個節點轉彎(最大路徑和)→ Template 6。

練習 — LC 112 · LC 257 (把路徑收集成字串) · LC 113 (收集和符合的路徑: 見下方) · LC 1971 (在圖上:提早回傳) · LC 437 (任意往下的路徑:前綴和)

整個樹路徑題家族(最大路徑、連續序列、前綴和技巧), 見 tree_lca_distance.md § 4。

變形:收集每一條路徑(推入、遞迴、彈出)

python
# python
# LC 113 - Path Sum II
# IDEA: carry the path down; copy it at a matching leaf; pop on the way back up
# time = O(n * h), space = O(h)   copying a path of length h at each matching leaf
def pathSum(root, targetSum):
    result, path = [], []

    def dfs(node, remaining):
        if not node:
            return
        path.append(node.val)
        remaining -= node.val
        if not node.left and not node.right and remaining == 0:
            result.append(path[:])               # a copy: path keeps changing
        dfs(node.left, remaining)
        dfs(node.right, remaining)
        path.pop()                               # undo before returning to the parent

    dfs(root, targetSum)
    return result

DFS 提早回傳模式 — TRUE 要積極回傳,FALSE 要拖到最後

DFS 只要找一條路徑時,子呼叫的 true 一回來就必須立刻回傳。忽略它的話 會繼續搜尋,而函式最後還是會落到結尾的 return false。

java
// java
// IDEA: WRONG — the child's result is ignored, so a path that was found is thrown away
// time = O(V + E), space = O(V)
private boolean dfsPathVisitor(int node, int destination, Map<Integer, List<Integer>> map, boolean[] visited) {
    if (node == destination) return true;
    visited[node] = true;
    for (int next : map.get(node)) {
        if (!visited[next]) {
            dfsPathVisitor(next, destination, map, visited);   // result dropped
        }
    }
    return false;   // false for every node except the destination itself
}
java
// java
// IDEA: RIGHT — return true as soon as any child reaches the destination
// time = O(V + E), space = O(V)
private boolean dfsPathVisitor(int node, int destination, Map<Integer, List<Integer>> map, boolean[] visited) {
    if (node == destination) return true;
    visited[node] = true;
    for (int next : map.get(node)) {
        if (!visited[next] && dfsPathVisitor(next, destination, map, visited)) {
            return true;    // found one path - that is enough
        }
    }
    return false;           // only after every neighbour has failed
}
text
Graph: 0 -- 1 -- 2 -- 3        find a path 0 -> 3
       |         |
       4 ------- 5

dfs(0) -> dfs(1) -> dfs(2) -> dfs(3) returns TRUE
WRONG: dfs(2) ignores it, tries 5 (a dead end), returns FALSE; so do dfs(1) and dfs(0)
RIGHT: dfs(2), dfs(1) and dfs(0) each return TRUE at once; 5 and 4 are never tried
忽略子呼叫的結果 收到 true 就回傳
正確性 路徑存在也回傳 false 找到時回傳 true
工作量 探索每一條分支 在第一條路徑就停
適用於 蒐集或計數所有結果(LC 257, 113, 22) 判斷存在(LC 1971, 79, 112)

模板 4:回溯 — LC 46

辨識 — 「所有排列/組合/子集」、「產生每一個合法的 …」、「回傳所有 方法」。n ≤ 10(n! ≈ 3.6·10⁶)或 n ≤ 20(2ⁿ ≈ 10⁶)→ 指數級的搜尋就是預期解法。

關鍵 — path 永遠存著從決策樹的根走到目前呼叫的這一路選擇,每個選擇在試下一個之前都會被撤銷 (pop、used[i] = False),所以兄弟分支都從同一個狀態出發。

python
# python
# LC 46 - Permutations
# IDEA: choose an unused number, recurse, un-choose; a full-length path is one permutation
# time = O(n * n!), space = O(n)   n! leaves, each copied in O(n); output not counted
def permute(nums):
    result, path = [], []
    used = [False] * len(nums)

    def backtrack():
        if len(path) == len(nums):
            result.append(path[:])            # copy: path is undone afterwards
            return
        for i in range(len(nums)):
            if used[i]:
                continue
            used[i] = True                    # choose
            path.append(nums[i])
            backtrack()
            path.pop()                        # un-choose, so the next i starts clean
            used[i] = False

    backtrack()
    return result
java
// java
// LC 46 - Permutations
// IDEA: choose an unused number, recurse, un-choose; a full-length path is one permutation
// time = O(n * n!), space = O(n)
public List<List<Integer>> permute(int[] nums) {
    List<List<Integer>> result = new ArrayList<>();
    backtrack(nums, new boolean[nums.length], new ArrayList<>(), result);
    return result;
}

private void backtrack(int[] nums, boolean[] used, List<Integer> path, List<List<Integer>> result) {
    if (path.size() == nums.length) {
        result.add(new ArrayList<>(path));    // copy: path is undone afterwards
        return;
    }
    for (int i = 0; i < nums.length; i++) {
        if (used[i]) {
            continue;
        }
        used[i] = true;                       // choose
        path.add(nums[i]);
        backtrack(nums, used, path, result);
        path.remove(path.size() - 1);         // un-choose
        used[i] = false;
    }
}

複雜度

  • 時間 O(n · n!):決策樹有 n! 個葉節點,每個以 O(n) 複製;上面的內部節點 加起來也不超過這個量。
  • 空間 O(n):path、used 與遞迴深度,不計輸出。

陷阱

  • result.append(path) 把同一個串列存了 n! 次,搜尋退回去之後它是空的: [1,2] 會回傳 [[], []]。
  • 組合(LC 39, LC 77)會傳 start 索引,所以 [1,2] 和 [2,1] 不會同時產生; 排列則從 0 開始迴圈並跳過 used。兩者搞混會產生重複或漏掉順序。
  • 輸入有重複(LC 47):先排序,然後在 nums[i-1] 未使用時跳過 nums[i] == nums[i-1]。

不適用時 — 只問排列的數量,而且子狀態會重複 → DP。完整家族 (子集、組合總和、N 皇后、剪枝)在 backtrack.md。

練習 — LC 46 ⭐ · LC 78 ⭐ (子集:每個節點都記錄,不只葉節點) · LC 39 ⭐ (start 索引,可重複使用) · LC 17 ⭐ (每層一個數字) · LC 51 (用欄與對角線集合 剪枝)

模板 5:樹結構修改 — LC 450

辨識 — 「刪除/插入/修剪/剪掉節點」、「回傳修改後的樹的根」、 「轉換這棵樹」。節點 ≤ 10⁴;在 BST 裡工作量是 O(h)。

關鍵 — 函式回傳它收到那棵子樹的(可能是新的)根,每個呼叫者都把它寫回去: node.left = f(node.left)。被刪除或被替換的節點就由它自己父節點的指派斷開, 不需要父指標,根也不用特別處理。

python
# python
# LC 450 - Delete Node in a BST
# IDEA: return the new subtree root and let the parent reassign it; two children -> copy the successor in
# time = O(h), space = O(h)
def deleteNode(root, key):
    if not root:
        return None
    if key < root.val:
        root.left = deleteNode(root.left, key)     # reassign: that subtree's root may change
    elif key > root.val:
        root.right = deleteNode(root.right, key)
    else:
        if not root.left:
            return root.right                      # 0 or 1 child: lift the other side
        if not root.right:
            return root.left
        successor = root.right                     # 2 children: smallest node on the right
        while successor.left:
            successor = successor.left
        root.val = successor.val
        root.right = deleteNode(root.right, successor.val)
    return root
java
// java
// LC 450 - Delete Node in a BST
// IDEA: return the new subtree root and let the parent reassign it; two children -> copy the successor in
// time = O(h), space = O(h)
public TreeNode deleteNode(TreeNode root, int key) {
    if (root == null) {
        return null;
    }
    if (key < root.val) {
        root.left = deleteNode(root.left, key);    // reassign: that subtree's root may change
    } else if (key > root.val) {
        root.right = deleteNode(root.right, key);
    } else {
        if (root.left == null) {
            return root.right;
        }
        if (root.right == null) {
            return root.left;
        }
        TreeNode successor = root.right;
        while (successor.left != null) {
            successor = successor.left;
        }
        root.val = successor.val;
        root.right = deleteNode(root.right, successor.val);
    }
    return root;
}

複雜度

  • 時間 O(h):一趟從根走到節點,再加一趟沿右子樹往下找後繼 — 平衡時 O(log n),退化成鏈時 O(n)。
  • 空間 O(h):遞迴沿著同一條路徑。

陷阱

  • 呼叫 deleteNode(root.left, key) 卻沒有把結果接回去:父節點還指著 舊的子節點,[5,3,6] 刪 key 3 之後 3 還在。
  • 刪除根:呼叫者必須使用回傳值。[5] 刪 key 5 回傳 None。
  • 把後繼的值複製上來之後,要從右子樹刪掉後繼的值, 而不是 key — key 已經不在樹裡了。

不適用時 — 這棵樹不是 BST → 兩邊都要找,O(n)。剪不剪取決於整棵 子樹的性質(「移除不含 1 的子樹」,LC 814)→ 後序 (Template 6),一樣回傳剪過的子樹。

練習 — LC 450 · LC 226 ⭐ (翻轉:把交換後的子節點接回去) · LC 701 (插入:同樣的 接回寫法) · LC 669 (修剪到一個範圍) · LC 814 (後序剪枝)

模板 6:由下而上(後序)DFS — LC 543 優先度 5/5 — 必備 — 幾乎每一輪面試都會出現

辨識 — 「直徑」、「高度」、「是否平衡」、「子樹和」、「最低共同祖先」、 「包含 … 的最小子樹」、「相鄰節點間的最少移動次數」:節點的答案需要先有 子節點的答案。節點 ≤ 10⁴。

關鍵 — 每個呼叫只回傳一個值給父節點(這裡是高度),而題目要的答案 (這裡是 left + right,在這個節點轉彎的最長路徑)是另一個 值,記在外層變數裡,從不回傳。

python
# python
# LC 543 - Diameter of Binary Tree
# IDEA: post-order; return the height upward, record left + right (the path through this node) globally
# time = O(n), space = O(h)
def diameterOfBinaryTree(root):
    best = 0

    def height(node):
        nonlocal best
        if not node:
            return 0
        left = height(node.left)
        right = height(node.right)
        best = max(best, left + right)        # edges on the longest path that turns here
        return 1 + max(left, right)           # what the parent needs: one side only

    height(root)
    return best
java
// java
// LC 543 - Diameter of Binary Tree
// IDEA: post-order; return the height upward, record left + right (the path through this node) globally
// time = O(n), space = O(h)
private int best = 0;

public int diameterOfBinaryTree(TreeNode root) {
    best = 0;
    height(root);
    return best;
}

private int height(TreeNode node) {
    if (node == null) {
        return 0;
    }
    int left = height(node.left);
    int right = height(node.right);
    best = Math.max(best, left + right);      // edges on the longest path that turns here
    return 1 + Math.max(left, right);         // what the parent needs: one side only
}

複雜度

  • 時間 O(n):height 對每個節點跑一次,兩個子節點回來之後只做 O(1) 的工作。
  • 空間 O(h):遞迴深度。

試跑

text
root = [1,2,3,4,5]          1
                           / \
                          2   3
                         / \
                        4   5
height(4) = 1, height(5) = 1     at 2: best = 1 + 1 = 2, return 2
height(3) = 1                    at 1: best = max(2, 2 + 1) = 3, return 3
answer 3   (the path 4-2-1-3 has 3 edges)

陷阱

  • 往上回傳 left + right 而不是 1 + max(left, right):父節點會把兩個分岔 接成一個不是路徑的形狀。下面的 LC 124 是同一個陷阱,只是換成數值。
  • 數節點而不是數邊:LC 543 要的是邊數,所以 [1,2] 是 1,不是 2。
  • 從每個祖先各自呼叫一次 height(node.left)(由上而下)在鏈上是 O(n²);一趟 後序就把每個高度回傳一次。

不適用時 — 值取決於從根下來的路徑(深度、目前為止的和)→ 把它往下傳 (Template 3)。兩個指定節點的最低共同祖先 有自己的一頁:tree_lca_distance.md。

練習 — LC 543 ⭐ · LC 104 ⭐ · LC 110 ⭐ (「不平衡」就回傳 -1) · LC 236 (回傳子樹裡 找到了哪些目標) · LC 979 (帶正負號的餘額:見下方) · LC 124 ⭐ (帶數值與截斷的 LC 543:見下方)

變形:由下而上找最低共同祖先

LCA 其實是偽裝的後序:答案是往上走時,第一個在兩側都看到目標的 節點。

  • 標準 LCA(LC 236):每個呼叫回傳它在子樹裡找到的目標,或 None; 第一個左右呼叫都非空的節點就是 LCA。
  • 以深度為準的 LCA(LC 865, LC 1123):目標是隱含的 — 每個最深的葉節點。回傳 (node, depth);較深的一側勝出,深度相同表示最深的幾條路徑在這裡會合。
  • **「包含所有 X 的最小子樹」**和「所有 X 節點的 LCA」是同一個問題。
  • 相關:LC 235(BST:依值往下走,O(h))、LC 1644(目標可能不存在)、LC 1650 (有父指標:雙指標往上走)、LC 1676(多個目標)。完整解法在 tree_lca_distance.md。

Binary Tree Maximum Path Sum — LC 124

關鍵 — Template 6 加上數值:回傳最好的單邊路徑,並在每個節點用兩邊合起來的彎折路徑更新答案。

歸約到:tree_lca_distance.md § LC 124,完整解在那裡;這裡只是指標。

Distribute Coins in Binary Tree — LC 979

關鍵 — 每條樹邊都是一座橋:切斷子樹上方的邊,剛好分成兩邊,所以必須越過它的硬幣數是 |balance(subtree)|,其中 balance = node.val - 1 + balance(left) + balance(right)(自己留一枚, 其餘往上推)。流量是被迫的;答案就是每條邊上 |balance| 的總和,沒有東西要搜尋。

歸約到:Template 6 — 往上回傳一個有號的值 (餘額),在全域累加另一個值(它的絕對值)。

text
LC 979 trace — root = [0, 3, 0]        balance = val - 1 + left + right

        0                 dfs(left  3) -> 3 - 1 + 0 + 0 = +2   moves += 2   (2 coins go UP)
       / \                dfs(right 0) -> 0 - 1 + 0 + 0 = -1   moves += 1   (1 coin  goes DOWN)
      3   0               dfs(root  0) -> 0 - 1 + 2 + (-1) = 0            <- always 0 at root

  total moves = 2 + 1 = 3
python
# python
# LC 979 - Distribute Coins in Binary Tree
# IDEA: post-order DFS; each subtree returns its net balance, each edge costs |balance| moves
# time = O(n), space = O(h)   # h = tree height, worst O(n)
class Solution:
    def distributeCoins(self, root):
        self.moves = 0

        def dfs(node):
            if not node:
                return 0
            left = dfs(node.left)                 # net surplus/deficit of left subtree
            right = dfs(node.right)               # net surplus/deficit of right subtree
            balance = node.val - 1 + left + right # keep 1 coin, push the rest up
            self.moves += abs(balance)            # this subtree's edge to its parent
            return balance                        # signed, never abs()

        dfs(root)
        return self.moves
java
// java
// LC 979 - Distribute Coins in Binary Tree
// IDEA: post-order DFS; each subtree returns its net balance, each edge costs |balance| moves
// time = O(n), space = O(h)
private int moves = 0;

public int distributeCoins(TreeNode root) {
    moves = 0;
    dfs(root);
    return moves;
}

private int dfs(TreeNode node) {
    if (node == null) {
        return 0;
    }
    int left = dfs(node.left);
    int right = dfs(node.right);
    int balance = node.val - 1 + left + right;  // keep 1 coin, push the rest up
    moves += Math.abs(balance);                 // this subtree's edge to its parent
    return balance;                             // signed, never Math.abs()
}

陷阱

  • 回傳有號的餘額,累加絕對值。把 abs(...) 往上回傳是經典 bug:-2 的短缺必須保持負號, 才能在父節點抵銷兄弟的 +2。
  • 不要因為 node.val == 1 就跳過節點:本身平衡的節點仍然要轉運子樹的流量。
  • 不要在鄰接串列上用 BFS 擴散硬幣。BFS 的前緣看不到左子樹缺 3 枚、右子樹多 3 枚;只有由下往上 的餘額才給得出這個全域視角。

類似題:

  • LC 979 - Distribute Coins in Binary Tree(後序餘額/流量的標準題)
  • LC 2477 - Minimum Fuel Cost to Report to the Capital(同樣的邊流量計數,但每條邊是 ceil(people / seats))
  • LC 1443 - Minimum Time to Collect All Apples in a Tree(後序,每條有用的邊算 2)
  • LC 1339 - Maximum Product of Splitted Binary Tree(後序子樹和,再切一條邊)
  • LC 508 - Most Frequent Subtree Sum(每棵子樹的值由後序往上彙總)
  • LC 124 - Binary Tree Maximum Path Sum(往上回傳一個值,在全域彙總另一個值)
  • LC 2049 - Count Nodes With the Highest Score(子樹大小彙總 —— 見下面的題目)

Count Nodes With the Highest Score — LC 2049

關鍵 — 移除節點 x 後,每個子節點的子樹各自成為一個分量,再加上 x 上方的全部節點, 大小是 n - size(x)。所以一趟回傳子樹大小的後序走訪就能替每個節點計分: score(x) = Π size(child) × (n - size(x)),在根節點時省略最後一項。

歸約到:Template 6 — 往上回傳子樹大小,沿途 彙總分數(以及有幾個節點達到最佳分數)。

python
# python
# IDEA: sizes come children-first (reverse BFS order); removing x leaves its child subtrees + n - size(x)
# time = O(n), space = O(n)
# Iterative on purpose: n is up to 10^5 and a chain would overflow Python's recursion limit.
# LC 2049 - Count Nodes With the Highest Score
from collections import deque

def countHighestScoreNodes(parents):
    n = len(parents)
    children = [[] for _ in range(n)]
    for node in range(1, n):                 # parents[0] == -1: node 0 is the root
        children[parents[node]].append(node)

    order = []                               # BFS order from the root
    queue = deque([0])
    while queue:
        node = queue.popleft()
        order.append(node)
        queue.extend(children[node])

    size = [1] * n
    best_score = 0
    best_count = 0
    for node in reversed(order):             # every child is finished before its parent
        score = 1
        for child in children[node]:
            score *= size[child]             # each child's subtree is one component
            size[node] += size[child]
        above = n - size[node]               # everything outside this subtree
        if above > 0:                        # the root has nothing above it
            score *= above
        if score > best_score:
            best_score, best_count = score, 1
        elif score == best_score:
            best_count += 1
    return best_count

陷阱

  • 根節點上方沒有分量:n - size(root) == 0 會把根的分數歸零。這一項是 0 時就跳過。
  • n ≤ 10⁵ 而且樹可能是一條鏈,遞迴的後序走訪會超過 Python 的預設上限;上面依反向 BFS 順序的迴圈,不用遞迴就得到同樣「子節點先算完」的順序。
  • 三個約 3·10⁴ 節點分量的乘積約為 2.7·10¹³:在 Java 裡需要 long。

類似題 — LC 1519 (依標籤彙總子樹) · LC 508 (子樹和 + 頻率) · LC 834 (子樹大小,再換根)

模板 7:兩趟 DFS(邊界消除) — LC 1254

辨識 — 「封閉島嶼」、「被包圍的區域」、「飛地」、「被吃掉」:和 邊界相連的陸地不算。網格 ≤ 100 × 100(LC 1254)或 200 × 200(LC 130)。

關鍵 — 碰到邊界的陸地永遠不可能封閉,所以第一趟把所有和 邊界相連的格子淹掉;第一趟之後還留下的陸地恰好就是封閉的陸地,第二趟 就像 LC 200 數島嶼那樣把它數出來。

python
# python
# LC 1254 - Number of Closed Islands
# IDEA: pass 1 floods every island touching the border; pass 2 counts the islands left (0 = land here)
# time = O(m * n), space = O(m * n)
def closedIsland(grid):
    rows, cols = len(grid), len(grid[0])

    def flood(r, c):
        if r < 0 or r >= rows or c < 0 or c >= cols or grid[r][c] == 1:
            return
        grid[r][c] = 1                        # land -> water, which also marks it visited
        flood(r + 1, c)
        flood(r - 1, c)
        flood(r, c + 1)
        flood(r, c - 1)

    # pass 1: remove every island that touches the border
    for c in range(cols):
        flood(0, c)
        flood(rows - 1, c)
    for r in range(rows):
        flood(r, 0)
        flood(r, cols - 1)

    # pass 2: every island still standing is closed
    count = 0
    for r in range(1, rows - 1):
        for c in range(1, cols - 1):
            if grid[r][c] == 0:
                count += 1
                flood(r, c)
    return count
java
// java
// LC 1254 - Number of Closed Islands
// IDEA: pass 1 floods every island touching the border; pass 2 counts the islands left (0 = land here)
// time = O(m * n), space = O(m * n)
public int closedIsland(int[][] grid) {
    int rows = grid.length;
    int cols = grid[0].length;
    for (int c = 0; c < cols; c++) {          // pass 1: top and bottom borders
        flood(grid, 0, c);
        flood(grid, rows - 1, c);
    }
    for (int r = 0; r < rows; r++) {          // pass 1: left and right borders
        flood(grid, r, 0);
        flood(grid, r, cols - 1);
    }
    int count = 0;                            // pass 2: every island left is closed
    for (int r = 1; r < rows - 1; r++) {
        for (int c = 1; c < cols - 1; c++) {
            if (grid[r][c] == 0) {
                count++;
                flood(grid, r, c);
            }
        }
    }
    return count;
}

private void flood(int[][] grid, int r, int c) {
    if (r < 0 || r >= grid.length || c < 0 || c >= grid[0].length || grid[r][c] == 1) {
        return;
    }
    grid[r][c] = 1;                           // land -> water, which also marks it visited
    flood(grid, r + 1, c);
    flood(grid, r - 1, c);
    flood(grid, r, c + 1);
    flood(grid, r, c - 1);
}

複雜度

  • 時間 O(m·n):兩趟加起來每個陸地格只淹一次,因為 flood 在遞迴前就把 0 改成 1;掃描對每格只讀常數次。
  • 空間 O(m·n):一次淹水的遞迴深度,最壞情況是整個網格。

陷阱

  • 在單一 DFS 裡判斷「封閉」,一碰到邊界就回傳:那座島剩下的部分沒被淹掉, 之後又從別的格子被數一次。若要一趟做完, DFS 必須把整座島走完才回報。
  • LC 1254 用 0 表示陸地、1 表示水 — 和 LC 200 相反。
  • LC 130 用第三個值('E')標記和邊界相連的區域,然後在另一趟掃描中把 'O' → 'X'、'E' → 'O'; 在淹水過程中就翻轉,會改到它之後還要讀的格子。

不適用時 — 問的是到邊界的距離,而不是某格能不能到邊界 → 從邊界格出發的多源 BFS (bfs.md § Template 4)。

練習 — LC 1254 · LC 1020 (數格子而不是數島嶼) · LC 130 (把剩下的翻轉) · LC 417 ⭐ (兩次淹水,從兩個海洋各自往內,再取交集)

模板 8:路徑簽名(形狀編碼) — LC 694

辨識 — 「不同島嶼的數量」、「平移後形狀相同」、「獨特的形狀」、 「重複的子樹」。網格 ≤ 50 × 50。

關鍵 — 總是以相同順序嘗試四個方向的 DFS,走兩座同形狀的島時順序也會相同。 所以移動的字串,加上每次退回時的回退標記,在形狀相同時完全一樣、 形狀不同時就不同,把這些字串放進集合就能數出形狀數。

python
# python
# LC 694 - Number of Distinct Islands
# IDEA: record the move into each cell and a 'B' on the way back; equal shapes give equal strings
# time = O(m * n), space = O(m * n)
def numDistinctIslands(grid):
    rows, cols = len(grid), len(grid[0])
    shapes = set()

    def dfs(r, c, move, path):
        if r < 0 or r >= rows or c < 0 or c >= cols or grid[r][c] != 1:
            return
        grid[r][c] = 0                        # mark visited
        path.append(move)
        dfs(r + 1, c, 'D', path)              # a FIXED order: down, up, right, left
        dfs(r - 1, c, 'U', path)
        dfs(r, c + 1, 'R', path)
        dfs(r, c - 1, 'L', path)
        path.append('B')                      # back marker: this cell's branch is finished

    for r in range(rows):
        for c in range(cols):
            if grid[r][c] == 1:
                path = []
                dfs(r, c, 'S', path)          # 'S' = the start cell
                shapes.add(''.join(path))
    return len(shapes)
java
// java
// LC 694 - Number of Distinct Islands
// IDEA: record the move into each cell and a 'B' on the way back; equal shapes give equal strings
// time = O(m * n), space = O(m * n)
public int numDistinctIslands(int[][] grid) {
    Set<String> shapes = new HashSet<>();
    for (int r = 0; r < grid.length; r++) {
        for (int c = 0; c < grid[0].length; c++) {
            if (grid[r][c] == 1) {
                StringBuilder path = new StringBuilder();
                dfs(grid, r, c, 'S', path);
                shapes.add(path.toString());
            }
        }
    }
    return shapes.size();
}

private void dfs(int[][] grid, int r, int c, char move, StringBuilder path) {
    if (r < 0 || r >= grid.length || c < 0 || c >= grid[0].length || grid[r][c] != 1) {
        return;
    }
    grid[r][c] = 0;                           // mark visited
    path.append(move);
    dfs(grid, r + 1, c, 'D', path);           // a FIXED order: down, up, right, left
    dfs(grid, r - 1, c, 'U', path);
    dfs(grid, r, c + 1, 'R', path);
    dfs(grid, r, c - 1, 'L', path);
    path.append('B');                         // back marker
}

複雜度

  • 時間 O(m·n):每個陸地格只進入一次並附加兩個字元,所以建出並 雜湊所有字串和陸地總量成線性。
  • 空間 O(m·n):shapes 裡的字串加上遞迴深度。

試跑 — 為什麼需要回退標記

text
Shape A   1 1        Shape B   1 .
          1 .                  1 1

order D, U, R, L from the top-left cell
A: S, D to (1,0), back, R to (0,1), back, back  ->  "SDBRBB"
B: S, D to (1,0), R to (1,1), back, back, back  ->  "SDRBBB"
without the 'B's both are "SDR"

陷阱

  • 沒有回退標記:上面兩個形狀都編碼成 "SDR",被當成同一個。
  • 每座島的方向順序都必須相同;對方向的 set 做迴圈 無法保證這一點。
  • 依造訪順序記錄的相對座標 (r - r0, c - c0) 也是同樣有效的簽名,而且不需要 標記。別在同一個集合裡混用兩種編碼。

不適用時 — 旋轉與鏡射也算同一形狀(LC 711)→ 收集每座島的 格子,對 8 種變換正規化。換成子樹而不是島嶼(LC 652)→ 以後序序列化 每棵子樹,用 map 計數字串。

練習 — LC 694 · LC 652 (序列化每棵子樹) · LC 711 (8 種變換)

模板 9:網格 DFS + 還原(回溯) — LC 1219

來源:path-with-maximum-gold.py

辨識 — 「在一條路徑上收集最多黃金」、「不重複走格子的最長路徑」、 「可以從任何地方開始和結束」、「單字搜尋」。網格很小(m, n ≤ 15,有用的格子最多 25 個)→ 指數級的搜尋就是預期解法。

關鍵 — 路徑進入一格時標記它,離開時還原,所以 網格永遠剛好顯示目前路徑上的格子。正因如此,之後的路徑 — 不論是從 別的起點,還是走兄弟分支 — 才能再用這一格。

python
# python
# LC 1219 - Path with Maximum Gold
# IDEA: DFS from every gold cell; mark on entry, restore on exit; the guard at the top of the child
# time = O(k * 3^k), space = O(k)   k = number of gold cells (k <= 25)
class Solution:
    def getMaximumGold(self, grid):
        self.max_gold = 0
        rows, cols = len(grid), len(grid[0])
        for r in range(rows):
            for c in range(cols):
                if grid[r][c] > 0:
                    self.dfs(grid, r, c, 0)            # start value = 0
        return self.max_gold

    def dfs(self, grid, r, c, cur_gold):
        rows, cols = len(grid), len(grid[0])
        # GUARD: off the grid, empty (0) or on the current path (-1) -> stop
        if r < 0 or r >= rows or c < 0 or c >= cols or grid[r][c] <= 0:
            return
        cache = grid[r][c]
        cur_gold += cache                              # ACCUMULATE this cell
        self.max_gold = max(self.max_gold, cur_gold)   # UPDATE MAX on every cell entry
        grid[r][c] = -1                                # mark: on the current path
        self.dfs(grid, r + 1, c, cur_gold)             # the guard filters every direction
        self.dfs(grid, r - 1, c, cur_gold)
        self.dfs(grid, r, c + 1, cur_gold)
        self.dfs(grid, r, c - 1, cur_gold)
        grid[r][c] = cache                             # RESTORE: off the path again
java
// java
// LC 1219 - Path with Maximum Gold
// IDEA: DFS from every gold cell; mark on entry, restore on exit; the guard at the top of the child
// time = O(k * 3^k), space = O(k)
private int maxGold = 0;

public int getMaximumGold(int[][] grid) {
    maxGold = 0;
    for (int r = 0; r < grid.length; r++) {
        for (int c = 0; c < grid[0].length; c++) {
            if (grid[r][c] > 0) {
                dfs(grid, r, c, 0);
            }
        }
    }
    return maxGold;
}

private void dfs(int[][] grid, int r, int c, int curGold) {
    if (r < 0 || r >= grid.length || c < 0 || c >= grid[0].length || grid[r][c] <= 0) {
        return;                                        // off the grid, empty, or on the path
    }
    int cache = grid[r][c];
    curGold += cache;
    maxGold = Math.max(maxGold, curGold);
    grid[r][c] = -1;                                   // mark: on the current path
    dfs(grid, r + 1, c, curGold);
    dfs(grid, r - 1, c, curGold);
    dfs(grid, r, c + 1, curGold);
    dfs(grid, r, c - 1, curGold);
    grid[r][c] = cache;                                // restore: off the path again
}

複雜度

  • 時間 O(k · 3^k) 最壞情況:從 k 個黃金格各啟動一次,而第一步之後, 路徑上每一格最多只有 3 個還不在路徑上的鄰居。k ≤ 25 而且實際網格稀疏得多, 跑起來很快。
  • 空間 O(k):遞迴深度等於最長路徑;標記直接存在網格裡。

陷阱

  • 沒有還原:第一次啟動後格子一直是 -1,之後的啟動和兄弟分支都不能 再用它們。LC 1219 的第一個範例 [[0,6,0],[5,8,7],[0,9,0]] 會回傳 23 而不是 24。
  • 每個框架只標記與取消標記一次,包住那四個呼叫 — 絕不要放在鄰居迴圈裡,那樣 同一格會被這個框架的每條分支重複標記。
  • 在哪裡更新最大值,決定你需不需要 seed:在進入格子時更新(上面,以及下面的 V0-2) 每一格都自然被計入;在每個鄰居更新(V0-3)的話,起始格永遠不會被算到,除非呼叫端 先 seed。

不適用時 — 路徑可以重複走格子,或網格很大(10⁵ 格)→ 不是搜尋;用 DP 或 BFS。數區域(LC 200)→ 標記後永不還原 (Template 2)。

練習 — LC 1219 · LC 79 ⭐ (單字搜尋:把字母還原) · LC 980 (路徑必須覆蓋 每一個空格) · LC 212 ⭐ (很多單字:用 trie 剪枝 DFS)

下面三個版本都正確。差別在三個決定放在哪裡:

  1. 檢查 — 鄰居合法嗎(在界內 + 有黃金 + 還沒造訪)?
  2. 累加 — cur_gold 在哪裡加上目前這一格?
  3. 更新最大值 — 在哪裡記錄 self.max_gold?

快速比較

V0-1 — 在子呼叫裡檢查(上面的模板) V0-2 — 呼叫前先檢查 V0-3 — 在迴圈裡更新最大值
鄰居迴圈 4 個明寫的遞迴呼叫 for m in moves: for m in moves:
檢查的位置 子呼叫開頭(base case) 遞迴呼叫之前 遞迴呼叫之前
累加 cur_gold 在子呼叫裡(+= grid[r][c]) 在呼叫點(cur_gold + grid[..]) 在呼叫點(cur_gold + grid[..])
傳入的起始值 0 grid[start] grid[start]
更新 max_gold 子呼叫開頭(每格一次) 子呼叫開頭(每格一次) 迴圈內(每個鄰居一次)— 需要 seed
會有多餘的遞迴呼叫嗎? 會 — 不合法的鄰居仍會呼叫後才返回 不會 — 只有合法鄰居才遞迴 不會 — 只有合法鄰居才遞迴
能處理孤立的起始格嗎? ✅ 自動處理 ✅ 自動處理 ⚠️ 只能靠呼叫端的 seed
結論 ✅ 最乾淨的預設寫法 ✅ 有效率、慣用 ⚠️ 能動,但脆弱 — 避免

差異的心智模型: V0-1 把合法性檢查往下推給被呼叫者(「由子節點自己決定該不該存在」)— 所以 base case 同時兼任檢查。V0-2 / V0-3 則把它往上拉到呼叫端(「父節點只呼叫合法的子節點」)— 因此不會浪費堆疊框架,但起始格必須另外驗證(由啟動迴圈裡的 if grid[y][x] > 0 完成)。

V0-2 — 呼叫前先檢查,在呼叫點累加

python
# python
# LC 1219 - Path with Maximum Gold (V0-2)
# IDEA: guard BEFORE each recursive call, so no frame is spent on an invalid neighbour;
#       the launch loop's `if grid[y][x] > 0` validates the START cell
# time = O(k * 3^k), space = O(k)
class Solution:
    def getMaximumGold(self, grid):
        self.max_gold = 0
        L, W = len(grid), len(grid[0])
        for y in range(L):
            for x in range(W):
                if grid[y][x] > 0:
                    self.dfs(grid, x, y, grid[y][x])   # start value = the cell itself
        return self.max_gold

    def dfs(self, grid, x, y, cur_gold):
        L, W = len(grid), len(grid[0])
        self.max_gold = max(self.max_gold, cur_gold)   # (3) UPDATE MAX per cell entry — safe
        cache = grid[y][x]
        grid[y][x] = -1                                # mark visited
        moves = [[-1, 0], [1, 0], [0, 1], [0, -1]]
        for dx, dy in moves:
            x_, y_ = x + dx, y + dy
            # (1) GUARD before recursing  +  (2) ACCUMULATE at the call site
            if 0 <= x_ < W and 0 <= y_ < L and grid[y_][x_] > 0:
                self.dfs(grid, x_, y_, cur_gold + grid[y_][x_])
        grid[y][x] = cache                             # BACKTRACK: restore

什麼時候用: 當你想要有效率/競賽慣用的寫法時 — moves 陣列很容易擴充到八方向或 斜向題目,而且能省掉往牆裡撞的無效呼叫。因為 max_gold 仍是在進入節點時(迴圈之前)更新, 孤立的起始格不用額外程式碼就能正確計分。等你上手之後,這就是首選版本。

V0-3 — 在迴圈裡更新最大值(能動,但脆弱 — 避免)

python
# python
# LC 1219 - Path with Maximum Gold (V0-3)
# IDEA: same as V0-2, but max_gold is updated INSIDE the loop (on next_gold), so the entry cell is
#       never scored by the DFS itself and the launch loop must SEED max_gold with grid[y][x]
# time = O(k * 3^k), space = O(k)
class Solution:
    def getMaximumGold(self, grid):
        self.max_gold = 0
        L, W = len(grid), len(grid[0])
        for y in range(L):
            for x in range(W):
                if grid[y][x] > 0:
                    self.max_gold = max(self.max_gold, grid[y][x])  # REQUIRED seed
                    self.dfs(grid, x, y, grid[y][x])
        return self.max_gold

    def dfs(self, grid, x, y, cur_gold):
        L, W = len(grid), len(grid[0])
        cache = grid[y][x]
        grid[y][x] = -1                                # mark visited (once per frame)
        moves = [[-1, 0], [1, 0], [0, 1], [0, -1]]
        for dx, dy in moves:
            x_, y_ = x + dx, y + dy
            if 0 <= x_ < W and 0 <= y_ < L and grid[y_][x_] > 0:
                # mark/unmark is per cell entry, not per neighbour: do not touch grid[y][x] here
                next_gold = cur_gold + grid[y_][x_]
                self.max_gold = max(self.max_gold, next_gold)   # (3) UPDATE MAX in loop
                self.dfs(grid, x_, y_, next_gold)
        grid[y][x] = cache                             # BACKTRACK: restore

什麼時候用: 基本上永遠不要當第一選擇。列在這裡是為了展示陷阱:把最大值的更新搬進迴圈後, 起始格對 DFS 來說就變成隱形的,只好由呼叫端補一個 seed。少寫那一行,單格(或完全孤立)的 輸入就會悄悄回傳 0。請優先選 V0-1 / V0-2。

模板 10:帶權圖 DFS(除法/比值查詢) — LC 399

辨識 — 「除法求值」、「已知 a / b = 2.0、b / c = 3.0,求 a / c」、「匯率」、 「單位換算」、「傳遞性比值」。變數 ≤ 40、查詢 ≤ 20 → 每個查詢跑一次 DFS 綽綽有餘。

關鍵 — a / b = v 是一條權重 v 的邊 a → b,也是一條權重 1/v 的邊 b → a。沿任何 路徑權重相乘、中間項互相抵銷(a/b · b/c = a/c),所以從 x 到 y 的權重乘積 就是 x / y;任何一條路徑都行,因為這些等式是一致的。

python
# python
# LC 399 - Evaluate Division
# IDEA: weighted graph a -> b (v) and b -> a (1/v); a DFS multiplies the weights along one path
# time = O(Q * (V + E)), space = O(V + E)
from collections import defaultdict

def calcEquation(equations, values, queries):
    graph = defaultdict(dict)
    for (a, b), value in zip(equations, values):
        graph[a][b] = value
        graph[b][a] = 1.0 / value               # the reverse edge, reciprocal weight

    def dfs(node, target, visited):
        if node == target:
            return 1.0
        visited.add(node)
        for nxt, weight in graph[node].items():
            if nxt not in visited:
                rest = dfs(nxt, target, visited)
                if rest != -1.0:
                    return weight * rest        # node/nxt * nxt/target = node/target
        return -1.0

    answers = []
    for x, y in queries:
        if x not in graph or y not in graph:
            answers.append(-1.0)                # an unknown variable, even for x / x
        else:
            answers.append(dfs(x, y, set()))    # a fresh visited set per query
    return answers
java
// java
// LC 399 - Evaluate Division
// IDEA: weighted graph a -> b (v) and b -> a (1/v); a DFS multiplies the weights along one path
// time = O(Q * (V + E)), space = O(V + E)
public double[] calcEquation(List<List<String>> equations, double[] values, List<List<String>> queries) {
    Map<String, Map<String, Double>> graph = new HashMap<>();
    for (int i = 0; i < equations.size(); i++) {
        String a = equations.get(i).get(0);
        String b = equations.get(i).get(1);
        graph.computeIfAbsent(a, k -> new HashMap<>()).put(b, values[i]);
        graph.computeIfAbsent(b, k -> new HashMap<>()).put(a, 1.0 / values[i]);
    }
    double[] answers = new double[queries.size()];
    for (int i = 0; i < queries.size(); i++) {
        String x = queries.get(i).get(0);
        String y = queries.get(i).get(1);
        if (!graph.containsKey(x) || !graph.containsKey(y)) {
            answers[i] = -1.0;                  // an unknown variable, even for x / x
        } else {
            answers[i] = dfs(graph, x, y, new HashSet<>());   // a fresh visited set per query
        }
    }
    return answers;
}

private double dfs(Map<String, Map<String, Double>> graph, String node, String target, Set<String> visited) {
    if (node.equals(target)) {
        return 1.0;
    }
    visited.add(node);
    for (Map.Entry<String, Double> edge : graph.get(node).entrySet()) {
        if (!visited.contains(edge.getKey())) {
            double rest = dfs(graph, edge.getKey(), target, visited);
            if (rest != -1.0) {
                return edge.getValue() * rest;  // node/next * next/target
            }
        }
    }
    return -1.0;
}

複雜度

  • 時間 O(Q · (V + E)):每個查詢的 DFS 對每個變數最多造訪一次(visited), 每條邊讀一次。
  • 空間 O(V + E):圖本身,加上 visited 與遞迴的 O(V)。

陷阱

  • 從沒出現過的變數做 x / x 是 -1.0,不是 1.0:LC 399 的查詢 ["x","x"]。先檢查 是否存在,再走 node == target 的捷徑。
  • 所有查詢共用一個 visited 集合:第二個查詢會發現節點已被造訪過, 回傳 -1.0。
  • 只存 a → b:反方向的查詢(b / a)找不到路徑。
  • 用 rest > 0 而不是 rest != -1.0 判斷,是依賴 LC 399 保證值都是正數; 用哨兵值判斷就不需要這個前提。

不適用時 — 固定的圖上有很多查詢,或等式一個一個進來 → 帶權 併查集(存每個節點到其根的比值)。成本沿路徑是相加而且要最 便宜的 → 最短路徑(Dijkstra.md)。

練習 — LC 399 · LC 990 (相等/不相等的限制:併查集,不帶權) · LC 2307 (檢查等式之間有沒有矛盾)

練習路徑

階段 題目(由易到難) 新增的東西
核心 ⭐ LC 100 ok · LC 543 ok · LC 200 ok · LC 417 again · LC 124 again 成對 DFS、後序回傳值、flood fill、從邊界出發的可達性、全域累加器
練習 LC 94 ok · LC 112 ok · LC 101 again · LC 695 ok · LC 113 ok · LC 46 未評定 · LC 450 again · LC 1254 未評定 · LC 694 ok · LC 1219 ok · LC 399 again 其餘每個模板各一次,順序同卡片的教學順序
延伸 LC 979 again · LC 2049 again · LC 711 未評定 · LC 980 未評定 每條邊上帶正負號的流量、移除節點後的子樹大小、可旋轉的形狀、覆蓋每一格

總結與速查

決策流程圖

text
DFS Problem Analysis Flowchart:

1. Is it a tree/graph traversal problem?
   ├── YES → Check structure type
   │   ├── Tree? → Use Tree Templates (1, 3, 5, 6)
   │   │   ├── Need specific order? → Template 1 (Traversal)
   │   │   ├── Need paths? → Template 3 (Path Finding)
   │   │   ├── Need to modify? → Template 5 (Modification)
   │   │   └── Need subtree info? → Template 6 (Bottom-up)
   │   └── Graph? → Use Graph Template (2)
   │       ├── Has cycles? → Add visited set
   │       ├── Need all paths? → Track path
   │       └── Multi-source? → Start from all sources
   └── NO → Continue to 2

2. Is it a combinatorial problem?
   ├── YES → Use Backtracking Template (4)
   │   ├── Permutations? → Swap elements
   │   ├── Combinations? → Start index
   │   ├── Subsets? → Include/exclude
   │   └── Constraint satisfaction? → Check validity
   └── NO → Continue to 3

3. Does it require exploring all possibilities?
   ├── YES → Use DFS with appropriate state tracking
   │   ├── Grid problem? → 4-directional DFS
   │   ├── String problem? → Index-based DFS
   │   └── Decision tree? → Choice-based DFS
   └── NO → Consider different algorithm

4. Special considerations:
   ├── Need shortest path? → Consider BFS instead
   ├── Has optimal substructure? → Consider DP
   └── Need all solutions? → DFS with backtracking

解題步驟

  1. 辨識模式:樹、圖、回溯,還是路徑?
  2. 選擇模板:挑出合適的 DFS 模板
  3. 追蹤狀態:visited 集合、路徑串列,或全域變數
  4. 處理 base case:空節點、邊界、找到目標
  5. 測試邊界情況:空輸入、單一節點、環

常見錯誤與技巧

🚫 常見錯誤:

  • 忘記 visited 集合:在圖上會無窮迴圈
  • 沒有回溯:組合類題目會產生錯誤的路徑
  • 走訪順序錯誤:該用後序時卻用了前序
  • 邊走訪邊修改:會破壞迭代
  • 沒處理 null:NullPointerException
  • ⚠️ 關鍵:找到路徑時沒有立刻回傳:在 DFS 裡找路徑時,一找到就必須立刻回傳 true(見 Template 3 底下的提早回傳一節)

✅ 最佳實務:

  • 在圖上使用 visited 集合:避免成環
  • 複製路徑:存結果時用 path[:]
  • 先檢查邊界:網格類題目
  • 取有意義的名字:用 visited 而不是 v
  • 考慮改成迭代:遞迴很深的時候

面試提示

  1. 釐清題型:是樹還是圖?可能有環嗎?
  2. 說明做法:「我要用 DFS,因為……」
  3. 討論複雜度:時間與空間分析
  4. 處理邊界情況:空的、單一元素、環
  5. 必要時最佳化:記憶化、剪枝

選模板的實用心法

  • 兩趟型問題:如果得先消掉某些東西(邊界、邊),用模板 7
  • 形狀比較:如果要比較結構/形狀,用模板 8(路徑簽名)
  • 由下而上彙總:如果答案取決於先處理完子節點,用模板 6
  • 嘗試所有可能:如果題目要「所有」解/組合,用模板 4(回溯)
  • 多起點且路徑重疊:標記、遞迴,然後還原 — 模板 9
  • 述語需要兩個節點:如果問題沒辦法只用一個節點講出來(「鏡像」、「兩棵樹相同」),就把一對節點帶著遞迴 — 模板 1 的成對 DFS 變化型
  • 完全對不上的題目:在自創模式之前,先翻 dfs_advanced.md

相關主題


面試必會題目:LC 94, 100, 101, 104, 112, 113, 124, 200, 236, 297, 399, 694 進階題目:LC 124, 297, 329, 472, 652, 694, 711 路徑簽名模式:LC 694(Distinct Islands)、LC 711(Distinct Islands II)、LC 652(Find Duplicate Subtrees)