DFS — 實戰題解

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

範圍 — dfs.md 的題庫:它的模板能解的 DFS 題目,每題一張題目卡,歸在它所歸約到的模板底下;題目若在別份文件裡完整解過,這裡只留一列指標。 另見 — 母表:dfs.md — 十個核心模板和模式選擇流程圖,技巧本身在那裡講;dfs_advanced.md — 冷門/困難的 DFS 模板與範例。 相鄰文件:bst.md、tree_codec.md 和 tree_lca_distance.md 擁有這裡指過去的樹題;bfs_examples.md — 廣度優先的題庫;union_find.md — 連通性題目的另一種引擎。

LeetCode 題目清單

總覽

這裡收的是那些不是模板錨定題的 DFS 題目,依它們所歸約到的 dfs.md 模板分組,順序和那份文件一致。 dfs.md 放模板;這份放題目。每張卡片只講這題在模板之外多加的那一步。在別份文件裡完整解過的題目,這裡只留一列指標,所以每題都只有一個家。 在這裡沒有自己題目的模板(模板 9、模板 10)就沒有對應的章節。

索引

樹的走訪範例

關鍵 —— 網格的大小只取決於樹高,所以先算樹高,DFS 就只需要把 (row, col) 往下傳。每往下一層,到子節點的水平位移減半: 2^(height - row - 1),子樹因此不會重疊。

歸約到:dfs § 模板 1 — 前序走訪,往下傳的是節點的位置,不往上回傳任何東西。

python
# python
# LC 655 - Print Binary Tree
# IDEA: height first; then pre-order DFS places each node and halves the column offset per level
# time = O(h * 2^h), space = O(h * 2^h)   the output grid is (h + 1) x (2^(h+1) - 1)
def printTree(root):
    def height(node):
        if not node:
            return -1                      # a lone leaf has height 0
        return 1 + max(height(node.left), height(node.right))

    h = height(root)
    rows, cols = h + 1, 2 ** (h + 1) - 1
    grid = [[""] * cols for _ in range(rows)]

    def place(node, row, col):
        if not node:
            return
        grid[row][col] = str(node.val)
        if row == h:
            return
        offset = 2 ** (h - row - 1)        # halves on every level down
        place(node.left, row + 1, col - offset)
        place(node.right, row + 1, col + offset)

    place(root, 0, (cols - 1) // 2)        # root in the middle of the top row
    return grid

陷阱

  • 單一節點的高度必須是 0(null 回傳 -1)。null 回傳 0 的話網格會多一列: [1] 會得到兩列而不是一列。

在別處解過

LC 題目 家
94 Binary Tree Inorder Traversal dfs § 模板 1(錨定題)
100 Same Tree binary_tree § Template 12(錨定題)
98 Validate Binary Search Tree bst.md — 用上下界驗證
700 Search in a Binary Search Tree bst.md
297 Serialize and Deserialize Binary Tree tree_codec.md
449 Serialize and Deserialize BST bst_examples.md
662 Maximum Width of Binary Tree bfs_examples.md — 每層用堆積索引

圖/網格 DFS 範例

Max Area of Island — LC 695

關鍵 —— LC 200 裡數島嶼的那個 flood fill 也能量島嶼的大小:讓淹沒函式回傳 1 + 四個鄰居的大小, 啟動迴圈保留最大值,而不是計數。

歸約到:dfs § 模板 2 — 同一個 flood fill,只是回傳大小而不是什麼都不回傳。

python
# python
# LC 695 - Max Area of Island
# IDEA: each 1 still standing starts a sink that returns the island's size; keep the largest
# time = O(m * n), space = O(m * n)   the recursion is as deep as the largest island
def maxAreaOfIsland(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 0
        grid[r][c] = 0                     # sink on entry: never counted twice
        return 1 + sink(r + 1, c) + sink(r - 1, c) + sink(r, c + 1) + sink(r, c - 1)

    best = 0
    for r in range(rows):
        for c in range(cols):
            if grid[r][c] == 1:
                best = max(best, sink(r, c))
    return best
java
// java
// LC 695 - Max Area of Island
// IDEA: flood fill that returns the size of the island it sinks; keep the largest
// time = O(m * n), space = O(m * n)
class Solution {
    public int maxAreaOfIsland(int[][] grid) {
        int best = 0;
        for (int r = 0; r < grid.length; r++) {
            for (int c = 0; c < grid[0].length; c++) {
                best = Math.max(best, area(grid, r, c));
            }
        }
        return best;
    }

    private int area(int[][] grid, int r, int c) {
        if (r < 0 || r >= grid.length || c < 0 || c >= grid[0].length || grid[r][c] == 0) {
            return 0;
        }
        grid[r][c] = 0;                           // sink it so it is counted once
        return 1 + area(grid, r + 1, c) + area(grid, r - 1, c)
                 + area(grid, r, c + 1) + area(grid, r, c - 1);
    }
}

陷阱

  • 遞迴之前就要標記格子。四個呼叫之後才標記的話,兩個鄰居會互相計數, 遇到 2×2 的陸地區塊遞迴永遠不會結束。

Minesweeper — LC 529

關鍵 —— flood fill 遇到數字就停。翻開的格子若旁邊有地雷,就變成地雷數、不再擴散; 只有旁邊沒有地雷的格子會變成 'B',並遞迴到全部八個鄰居。只遞迴進 'E' 格子,這本身就是 visited 檢查。

歸約到:dfs § 模板 2 — 八個方向,由格子依它的鄰居決定 flood 要不要繼續。

python
# python
# LC 529 - Minesweeper
# IDEA: a mine click is game over; otherwise flood from the click, stopping at cells next to a mine
# time = O(m * n), space = O(m * n)   each cell is revealed once; recursion as deep as the blank region
import sys
sys.setrecursionlimit(10**6)   # the default 1,000 is too low: a 50 x 50 blank board recurses 2,500 deep

def updateBoard(board, click):
    rows, cols = len(board), len(board[0])
    r, c = click
    if board[r][c] == "M":
        board[r][c] = "X"
        return board
    dirs = [(-1, -1), (-1, 0), (-1, 1), (0, -1), (0, 1), (1, -1), (1, 0), (1, 1)]

    def reveal(r, c):
        if r < 0 or r >= rows or c < 0 or c >= cols or board[r][c] != "E":
            return                         # only an unrevealed empty cell expands
        mines = 0
        for dr, dc in dirs:
            nr, nc = r + dr, c + dc
            if 0 <= nr < rows and 0 <= nc < cols and board[nr][nc] == "M":
                mines += 1
        if mines > 0:
            board[r][c] = str(mines)       # a number stops the flood
            return
        board[r][c] = "B"
        for dr, dc in dirs:
            reveal(r + dr, c + dc)

    reveal(r, c)
    return board

陷阱

  • 守衛條件是 board[r][c] != "E",不是「是不是地雷」。'B' 和數字格都已經翻開了; 少了這個守衛,兩個空白格會互相遞迴到天荒地老。

Sentence Similarity II — LC 737

關鍵 —— 相似具有遞移性,所以兩個字相似,正好等於它們在「以相似字對為邊」的圖裡屬於同一個連通分量。 每一組對齊的字對就是一次可達性查詢。(LC 734 沒有遞移性,查一下集合就好。)

歸約到:dfs § 模板 2 — 在一張明確的字詞圖上,每個查詢跑一次。

python
# python
# LC 737 - Sentence Similarity II
# IDEA: undirected graph of similar pairs; each differing word pair must be reachable
# time = O(n * (V + E)), space = O(V + E)   one DFS per aligned pair, n = sentence length
from collections import defaultdict

def areSentencesSimilarTwo(sentence1, sentence2, similarPairs):
    if len(sentence1) != len(sentence2):
        return False
    graph = defaultdict(set)
    for a, b in similarPairs:
        graph[a].add(b)
        graph[b].add(a)                    # similarity is symmetric: both directions

    def connected(src, dst):
        stack, seen = [src], {src}
        while stack:
            word = stack.pop()
            if word == dst:
                return True
            for nxt in graph[word]:
                if nxt not in seen:
                    seen.add(nxt)
                    stack.append(nxt)
        return False

    for w1, w2 in zip(sentence1, sentence2):
        if w1 != w2 and not connected(w1, w2):
            return False
    return True

陷阱

  • 一個字就算沒出現在任何字對裡,也跟自己相似:先檢查 w1 != w2 再搜尋。
  • 查詢很多時,先一次把連通分量標好(像下面的 LC 990),或改用 union-find; 每次查詢都跑一次 DFS 會重複走同一段路。

Satisfiability of Equality Equations — LC 990

關鍵 —— == 是等價關係:== 的邊把變數分成幾個連通分量,除非某個 != 連到同一個分量裡的兩個變數, 否則方程式都能滿足。所以先一次把每個分量標好,之後每個 != 都是 O(1) 檢查。所有 == 都要在任何 != 之前讀完。

歸約到:dfs § 模板 2 — 寫入分量編號、而不是把格子淹掉的 flood fill。

python
# python
# LC 990 - Satisfiability of Equality Equations
# IDEA: DFS labels each == component; a != inside one component is a contradiction
# time = O(n), space = O(1)   n = equations; at most 26 variables
from collections import defaultdict

def equationsPossible(equations):
    graph = defaultdict(list)
    for eq in equations:
        if eq[1] == "=":
            graph[eq[0]].append(eq[3])
            graph[eq[3]].append(eq[0])     # == is symmetric: store both directions

    group = {}
    def label(var, gid):
        group[var] = gid
        for nxt in graph[var]:
            if nxt not in group:
                label(nxt, gid)

    for var in list(graph):
        if var not in group:
            label(var, var)                # one DFS per component

    for eq in equations:
        if eq[1] == "!":
            a, b = eq[0], eq[3]
            if a == b or (a in group and group.get(b) == group[a]):
                return False
    return True

陷阱

  • a!=a 本身就無法滿足,而且 a 可能沒出現在任何 == 裡。先測 a == b。
  • 單向的圖會讓遞移性檢查出錯:從 a==b, b==c 來看,從 c 出發的搜尋找不到任何邊。 兩個方向都要加。
  • 併查集是另一個常見答案,同樣 O(n):先 union 每個 ==,再拒絕兩個字母同根的 !=。這張卡是它的家,union_find_examples.md 指向這裡。

在別處解過

LC 題目 家
200 Number of Islands dfs § 模板 2(錨定題)
133 Clone Graph graph_examples.md

路徑搜尋範例

這組題目裡每一題根到葉的路徑題,都在別份文件裡解過。

LC 題目 家
112 Path Sum dfs § 模板 3(錨定題)
113 Path Sum II dfs § 模板 3 — 收集每一條路徑
129 Sum Root to Leaf Numbers tree_lca_distance.md — 根到葉的路徑
257 Binary Tree Paths tree_lca_distance.md — 根到葉的路徑
1448 Count Good Nodes in Binary Tree tree_examples.md

回溯範例

Concatenated Words — LC 472

關鍵 —— 一個字能拆成兩個以上的字典字才算數。試每一個字典前綴、對剩下的部分遞迴; 把「這個後綴能不能拆」記憶化,指數級的搜尋就變成 word-break DP,因為同一個後綴會被很多種切法走到。

歸約到:dfs § 模板 4 — 選一個前綴、對剩下的遞迴;再加上記憶化,因為選擇會重疊。

python
# python
# LC 472 - Concatenated Words
# IDEA: a word counts if a proper dictionary prefix leaves a suffix that splits into dictionary words
# time = O(n * L^3), space = O(n * L)   n words of length L: L suffixes, L cuts each, O(L) slicing
def findAllConcatenatedWordsInADict(words):
    word_set = set(words)
    memo = {}

    def splits(word):
        # can `word` be cut into one or more dictionary words?
        if word in memo:
            return memo[word]
        for i in range(1, len(word) + 1):
            prefix = word[:i]
            if prefix in word_set and (i == len(word) or splits(word[i:])):
                memo[word] = True
                return True
        memo[word] = False
        return False

    result = []
    for word in words:
        if not word:
            continue
        # two or more pieces: the first piece is a PROPER prefix
        for i in range(1, len(word)):
            if word[:i] in word_set and splits(word[i:]):
                result.append(word)
                break
    return result

陷阱

  • 一個字也在它自己的字典裡。要求第一段是真前綴(i < len(word)),才能擋住每個字都跟自己配對。
  • 沒有記憶化的話,"aaaa…ab" 配上 ["a", "aa", "aaa"] 會走遍每一種組合:指數級。

樹的修改範例

這組題目裡每一題樹的修改題,都在別份文件裡解過。

LC 題目 家
450 Delete Node in a BST dfs § 模板 5(錨定題)
701 Insert into a Binary Search Tree bst.md
669 Trim a Binary Search Tree bst.md
538 Convert BST to Greater Tree bst_examples.md — 反向中序
776 Split BST bst_examples.md
623 Add One Row to Tree bfs_examples.md — 逐層 BFS,旁邊附 DFS 倒數版本

由下而上(後序)DFS 範例

Equal Tree Partition — LC 663

關鍵 —— 拿掉一條邊,會留下它底下的子樹和其餘部分。一趟後序走訪收集每個子樹和; 只要有某個不是整棵樹的子樹和等於 total / 2,這棵樹就能平分。

歸約到:dfs § 模板 6 — 每個節點把子樹和往上回傳,同時記下來。

python
# python
# LC 663 - Equal Tree Partition
# IDEA: post-order collects every subtree sum; look for total / 2 among the proper subtrees
# time = O(n), space = O(n)
def checkEqualTree(root):
    sums = []

    def subtree_sum(node):
        if not node:
            return 0
        s = node.val + subtree_sum(node.left) + subtree_sum(node.right)
        sums.append(s)
        return s

    total = subtree_sum(root)
    sums.pop()                             # the whole tree is the last sum: not a cut
    return total % 2 == 0 and total // 2 in sums

陷阱

  • 搜尋前先拿掉根自己的和。[0, -1, 1] 的總和是 0,整棵樹的和也是 0, 但沒有任何一條邊拿掉後能得到兩半各為 0。
  • 總和是奇數就不可能平分;在 // 之前先檢查。

Maximum Product of Splitted Binary Tree — LC 1339

關鍵 —— 跟 LC 663 同一趟子樹和:在和為 s 的子樹上方切開,得到 s * (total - s)。一趟拿到每個 s,再取最好的乘積。

歸約到:dfs § 模板 6 — 子樹和往上回傳,再拿來跟總和算分。

python
# python
# LC 1339 - Maximum Product of Splitted Binary Tree
# IDEA: post-order records every subtree sum s; each is one cut worth s * (total - s)
# time = O(n), space = O(n)
def maxProduct(root):
    sums = []

    def subtree_sum(node):
        if not node:
            return 0
        s = node.val + subtree_sum(node.left) + subtree_sum(node.right)
        sums.append(s)
        return s

    total = subtree_sum(root)
    best = 0
    for s in sums:
        best = max(best, s * (total - s))
    return best % (10 ** 9 + 7)            # take the max BEFORE the modulo

陷阱

  • 取模只做一次,而且是對最大值做。拿已經對 10⁹+7 取模的乘積互相比較, 會挑到錯的切法。

K-th Largest Perfect Subtree Size in Binary Tree — LC 3319

關鍵 —— 子樹是完美的,正好等於兩個子節點都完美而且高度相同。往上回傳 (height, is_perfect); 高度為 h 的完美子樹有 2^h − 1 個節點,所以大小不必另外計數。

歸約到:dfs § 模板 6 — 節點依子節點回傳的那一對值做決定。

python
# python
# LC 3319 - K-th Largest Perfect Subtree Size in Binary Tree
# IDEA: post-order returns (height, is_perfect); record 2^height - 1 for every perfect subtree
# time = O(n log n), space = O(n)   the sort of the recorded sizes dominates
import sys
sys.setrecursionlimit(10**6)   # the default 1,000 is too low: a 2,000-node chain recurses 2,000 deep

def kthLargestPerfectSubtree(root, k):
    sizes = []

    def dfs(node):
        if not node:
            return 0, True
        left_h, left_ok = dfs(node.left)
        right_h, right_ok = dfs(node.right)
        if left_ok and right_ok and left_h == right_h:
            sizes.append(2 ** (left_h + 1) - 1)
            return left_h + 1, True
        return max(left_h, right_h) + 1, False

    dfs(root)
    if len(sizes) < k:
        return -1
    sizes.sort(reverse=True)
    return sizes[k - 1]

陷阱

  • 「兩個子節點都完美」還不夠:左子是一片葉子、右子是高度 2 的完美子樹的節點, 並不完美。高度必須相同。

在別處解過

LC 題目 家
543 Diameter of Binary Tree dfs § 模板 6(錨定題)
236 Lowest Common Ancestor of a Binary Tree tree_lca_distance.md
508 Most Frequent Subtree Sum hashing.md — 用雜湊表計數子樹和

兩趟 DFS(邊界消去)範例

Pacific Atlantic Water Flow — LC 417

關鍵 —— 對每個格子問「這裡的水流得到海洋嗎?」是 O((mn)²)。 反過來做:從每個海洋的邊界出發往上坡爬(next >= current)。一次 flood 走得到的格子, 水都能流進那個海洋;答案就是兩次 flood 都走得到的格子。

歸約到:dfs § 模板 7 — flood 從邊界出發,只是跑兩次(每個海洋一次),結果取交集而不是相減。

python
# python
# LC 417 - Pacific Atlantic Water Flow
# IDEA: flood uphill from each ocean's border; answer = cells reached by both floods
# time = O(m * n), space = O(m * n)   each flood visits a cell at most once
import sys
sys.setrecursionlimit(10**6)   # the default 1,000 is too low: a flat 200 x 200 grid recurses 40,000 deep

def pacificAtlantic(heights):
    rows, cols = len(heights), len(heights[0])
    pacific, atlantic = set(), set()

    def climb(r, c, reached):
        reached.add((r, c))
        for dr, dc in ((1, 0), (-1, 0), (0, 1), (0, -1)):
            nr, nc = r + dr, c + dc
            if (0 <= nr < rows and 0 <= nc < cols and (nr, nc) not in reached
                    and heights[nr][nc] >= heights[r][c]):   # uphill: its water flows down to us
                climb(nr, nc, reached)

    for r in range(rows):
        climb(r, 0, pacific)               # left edge
        climb(r, cols - 1, atlantic)       # right edge
    for c in range(cols):
        climb(0, c, pacific)               # top edge
        climb(rows - 1, c, atlantic)       # bottom edge

    result = []
    for r in range(rows):
        for c in range(cols):
            if (r, c) in pacific and (r, c) in atlantic:
                result.append([r, c])
    return result

陷阱

  • 比較方向是反的:flood 走向較高或等高的鄰居,因為水會從那裡流下來到目前的格子。 用 < 找到的是水流往哪裡去。
  • 已到達的集合同時也是 visited 集合;少了它,兩個等高的格子會互相遞迴到天荒地老。

在別處解過

LC 題目 家
1254 Number of Closed Islands dfs § 模板 7(錨定題)

路徑簽名範例

LC 題目 家
694 Number of Distinct Islands dfs § 模板 8(錨定題)
652 Find Duplicate Subtrees tree_codec.md — 後序子樹簽名 + 雜湊表計數