樹的 LCA、距離與路徑問題

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

範圍 — 最近共同祖先、節點間距離、父節點表(雙向)走訪,以及根到葉的路徑模板——所有答案是一條路徑或一個交會點、而不是某種形狀的樹問題。 另見:tree.md — 這些東西所依賴的模式目錄與走訪模板;tree_examples.md — 其餘的樹題詳解;tree_backtrack.md — 回程要復原狀態的根→葉路徑;bst.md — 有序樹上的 LCA(LC 235);binary_tree.md — 深度(往下傳)vs 高度(往上回傳),這裡每個距離模板組合的就是這兩個量。

LeetCode 題目清單

概觀

這個家族被三個問題主宰:兩個節點在哪裡交會(LCA)、它們相隔多遠(距離), 以及哪些根到葉的路徑滿足某個性質(路徑和/路徑長度)。三者都是一次 DFS—— 差別只在那個數字是當參數往下走,還是當回傳值往上冒,以及你能不能往上走到父節點。

關鍵性質

  • 複雜度:這裡所有模板都是 O(N) 時間;遞迴空間 O(H),一旦建出父節點表或圖就是 O(N)
  • 核心想法:LCA 就是分岔點 — dist(p, q) = dist(lca, p) + dist(lca, q)
  • 什麼時候用:答案是一條路徑、一段距離或一個交會點,而不是某個子樹性質
  • 哨兵規則:距離的輔助函式在「找不到」時必須回傳 -1(不能是 0),因為 0 是一個合法的距離

辨識

限制條件的線索來自 complexity_cheatsheet.md 裡的尺規;這裡每一列要的都是一次 O(N) 的走訪。

題目敘述裡的訊號 限制條件線索 該拿出來的 不是它,如果
「p 和 q 的最近共同祖先」,有給根 節點 ≤ 10⁵ Template 1 後序 LCA 樹是 BST → 依值往下走,O(h)(bst.md)
「LCA」,但函式簽名沒有根、節點帶 parent 節點 ≤ 10⁵ LC 1650 變形 — 兩個指標沿父節點鏈往上 有給根 → Template 1
「包含所有最深節點的最小子樹」 節點 ≤ 500 LC 865 變形 — 回傳 (depth, node) 目標有明確給出 → Template 1
「與 target 距離為 k 的所有節點」、「離節點 k 最近的葉子」 節點 ≤ 500 Template 2 父節點表+BFS 只需要從已知祖先往下的距離 → Template 3
「值為 p 與 q 的兩個節點之間的邊數」 節點 ≤ 10⁴ Template 3 LCA + 兩次 get_dist 要所有距離 k 的節點 → Template 2
「所有總和為 targetSum 的根到葉路徑」、「回傳每一條路徑」 節點 ≤ 5,000 Template 4 前序+回溯 路徑可以從根以下開始 → Template 5
「總和為 targetSum 的路徑數量」,路徑「不必從根開始、也不必在葉結束」 節點 ≤ 1,000,有負值 Template 5 前綴和 map 同一個問題但在陣列上 → prefix_sum_advanced.md
「任意兩節點之間的最長路徑」、「最大路徑和」 節點 ≤ 3 · 10⁴ 後序高度+全域最大值(LC 124 卡片、binary_tree.md) 路徑必須是根 → 葉 → Template 4

題型分類

類別 它回答的問題 模板 例題
LCA(有 root) p 和 q 在哪裡交會? 後序 DFS,回傳第一個同時看到兩者的節點 LC 236, 1644, 1676, 865, 1123
LCA(沒有 root) 同樣的問題,但手上只有 p、q 和 parent 指標 往上走兩條父鏈 — 其實就是換皮的 LC 160 LC 1650
LCA(有序樹) 同樣的問題,但樹是 BST 只要兩個目標都在同一側就往下走 LC 235
距離(往下) 從某個祖先到目標有幾條邊? 前序 DFS 帶著 depth,用 -1 當哨兵 LC 1740
距離(任意方向) 哪些節點距離為 k,不限方向? 父節點表 → 無向圖 → BFS LC 863, 742
根到葉的路徑 哪些路徑的和/長相符合 X? 前序 DFS+回溯 LC 112, 113, 257
任意到任意的向下路徑 有幾條子路徑的和是 X? 前序 DFS+前綴和 HashMap LC 437
最長路徑 通過某節點的最長路徑有多長? 後序 DFS 回傳高度,配一個全域最大值 LC 543, 687

模板與演算法

Template 1: LCA by Returned Presence — LC 236

辨識 — 「兩個節點 p 和 q 的最近共同祖先」、「同時以兩者為子孫的最深節點(節點可以是自己的子孫)」,有給根,而且保證兩個目標都存在。節點 ≤ 10⁵ → 一次 O(N) 走訪。

關鍵 — 如果 p 或 q 在 node 的子樹裡,dfs(node) 就回傳它(兩個都在時回傳它們的 LCA),否則回傳 None。第一個兩邊呼叫都回傳非空的節點就是分岔點,所以它就是 LCA。當一個目標是另一個的祖先時,碰到它就提早 return root 已經是答案:另一個目標就在它下面某處。

python
# python
# LC 236 - Lowest Common Ancestor of a Binary Tree
# IDEA: post-order; each call reports "a target (or their LCA) is below me";
#       the first node that hears it from both sides is the LCA
# time = O(N), space = O(H)
class Solution(object):
    def lowestCommonAncestor(self, root, p, q):
        if not root or root == p or root == q:
            return root                      # a target, or fell off the tree
        left = self.lowestCommonAncestor(root.left, p, q)
        right = self.lowestCommonAncestor(root.right, p, q)
        if left and right:                   # p and q split here -> this node is the LCA
            return root
        return left if left else right       # both on one side: pass that side's answer up
java
// java
// LC 236 - Lowest Common Ancestor of a Binary Tree
// IDEA: post-order; return the target (or the LCA) found below me, else null
// time = O(N), space = O(H)
public TreeNode lowestCommonAncestor(TreeNode root, TreeNode p, TreeNode q) {
    if (root == null || root == p || root == q) {
        return root;
    }
    TreeNode left = lowestCommonAncestor(root.left, p, q);
    TreeNode right = lowestCommonAncestor(root.right, p, q);
    if (left != null && right != null) {
        return root;                          // the split point
    }
    return left != null ? left : right;
}

複雜度

  • 時間 O(N):兩個遞迴呼叫每個節點最多拜訪一次;提早 return root 只會讓走訪更早結束。
  • 空間 O(H):遞迴堆疊,每層一個 frame — 斜樹時 H = N。

試跑 — root = [3,5,1,6,2,0,8,null,null,7,4]

text
p = 5, q = 1
dfs(3)   not a target
  dfs(5) -> 5 is p, return 5        (5's subtree is never searched)
  dfs(1) -> 1 is q, return 1
  left = 5, right = 1  -> both non-null -> LCA = 3

p = 5, q = 4
dfs(3)
  dfs(5) -> return 5 at once        (4 is below it, unseen)
  dfs(1) -> None
  left = 5, right = None -> pass 5 up -> LCA = 5

陷阱

  • 只要任一邊非空就回傳 root:p = 6, q = 4 會讓 dfs(3) 回傳 3,而不是把從左邊拿到的 5 往上傳。
  • 提早回傳的前提是兩個目標都存在。q 不存在時(LC 1644),p = 5, q = 99 仍會回傳 5;要改成數命中次數 — 見下面 LC 1644 的程式碼。
  • 比 root.val == p.val 而不是比節點:值唯一時沒問題,一旦值重複就錯。要比節點本身。

不適用時 — 樹是 BST:依值往下走,O(h),永遠不用進兩邊子樹(bst.md,以及下面 LC 235 的程式碼)。沒有根而每個節點都有 parent:改成往上走(LC 1650 變形)。

練習 — LC 236 · LC 235 (BST:往下走) · LC 1650 (沒有根:兩個指標往上) · LC 1644 (目標可能不存在:數命中次數) · LC 1676 (一組目標) · LC 865 (最深的節點就是隱含的目標)

LCA 家族 — 用手上有什麼來挑模板 優先度 5/5 — 必備 — 幾乎每一輪面試都會出現

這五題都在講「最近共同祖先」。但上面那個後序模板只適用第一列;其餘各改掉一個前提, 而那個前提就決定了解法的形狀。

LC 跟 236 差在哪 對應的形狀 複雜度
236 — (基準題:給 root,p、q 保證存在) 後序,回傳「找到或 null」 O(N) / O(H)
235 樹是 BST 不用兩邊都遞迴 — 只要 p、q 在同一側就往下走 O(H) / O(1)
1644 p、q 可能不存在於樹中 一樣的後序,但還要數看到幾個目標;只有 count == 2 才算答案 O(N) / O(H)
1650 沒有 root — 每個節點有 parent 指標 往上走而不是往下 → 這就是 LC 160(兩條鏈結串列) O(H) / O(1)
1676 目標有 N 個而不是 2 個 一樣的後序,只是比對一個目標 set O(N) / O(H)

挑模板只要問一個問題:我能不能從某個 root 走到 p 和 q? 能 → 往下遞迴(236 / 1644 / 1676)。如果我是站在 p、q 上、手上只有 parent 指標 → 往上走(1650);這時根本沒有一棵樹可以搜。

LCA 變形 — 有父指標、沒有 root(LC 1650) 優先度 4/5 — 高價值 — 這裡有缺口就會掉關

1. 核心想法

關鍵洞察:lowestCommonAncestor(p, q) — 注意 root 不見了。你沒有把手,就搜不了這棵樹。 但 p.parent.parent… 是一條以 root 結尾的鏈結串列,所以兩個節點給你兩條串列, 而它們合流的地方剛好就是 LCA。這就是 LC 160, Intersection of Two Linked Lists, 只是穿了一件樹的外衣。

text
root = [3,5,1,6,2,0,8,null,null,7,4],  p = 4,  q = 1

  path(4) : 4 -> 2 -> 5 -> 3      (len 4)
  path(1) : 1 -> 3                (len 2)
                     ^
                     the lists merge here -> LCA = 3

所以能用的兩種形狀,就是 LC 160 的那兩種:把一條路徑放進 set,或是雙指標。

2. 模板 A — 祖先 set(面試先講這個)
python
# python
# LC 1650 - Lowest Common Ancestor of a Binary Tree III
# IDEA: SET + walk UP (p has a parent chain, so collect it, then re-walk from q)
# time = O(h), space = O(h)
class Solution(object):
    def lowestCommonAncestor(self, p, q):
        # walk UP via `parent`, there is no root to walk down from
        ancestors = set()
        node = p
        while node:
            ancestors.add(node)
            node = node.parent

        # the FIRST hit going up from q is the LOWEST one
        node = q
        while node:
            if node in ancestors:
                return node
            node = node.parent

        return None

為什麼第一個命中的就是最低的:從 q 往上走,會按高度遞增的順序拜訪它的祖先, 所以最早遇到的共同節點就是最深的共同節點。這也是為什麼「插入有序的 dict」那個變體 同樣成立。 要 hash 節點,不是 node.val — val 只是剛好唯一(題目條件這樣寫); 節點物件本身則永遠唯一。

3. 模板 B — 雙指標「換尾巴」(O(1) 空間) ⭐⭐⭐⭐⭐

兩條父鏈的長度不一樣,所以兩邊同步往上走會錯過交會點。修法就是 LC 160 那招: 指標一走出頂端就把它重新放到另一個起點。這樣每個指標都剛好走 len(path(p)) + len(path(q)) 步,於是兩者會在同一輪抵達共同尾巴的每一個節點。

python
# python
# LC 1650 - Lowest Common Ancestor of a Binary Tree III
# IDEA: 2 POINTERS ON THE PARENT CHAIN ("swap the tails") -- same trick as LC 160
# time = O(h), space = O(1)
class Solution(object):
    def lowestCommonAncestor(self, p, q):
        # edge
        if not p or not q:
            return None

        a, b = p, q
        while a != b:
            # `if a else q`, NOT `a.parent or q`
            a = a.parent if a else q
            b = b.parent if b else p
        return a
java
// java
// LC 1650 - Lowest Common Ancestor of a Binary Tree III
// IDEA: 2 pointers on the parent chain; swap tails on hitting null
// time = O(h), space = O(1)
public Node lowestCommonAncestor(Node p, Node q) {
    // edge
    if (p == null || q == null) {
        return null;
    }

    Node a = p, b = q;
    while (a != b) {
        // consume the null step itself, so both pointers walk the same distance
        a = (a == null) ? q : a.parent;
        b = (b == null) ? p : b.parent;
    }
    return a;
}

視覺追蹤(p = 4、q = 1,用上面那棵樹):

text
step :  0    1    2     3     4     5    6
a    :  4    2    5     3     null  1    3
b    :  1    3    null  4     2     5    3   -> a == b == 3  ✓

常見坑:

  • ❌ a = a.parent or q / a = (a.parent != null) ? a.parent : q — 這會跳掉 null 那一步。 把 null 也走掉,才能讓兩邊的總步數相等;跳掉就永遠差一步,兩個指標會一直錯身而過。
  • ❌ 用 a.val != b.val 而不是 a != b — 這題剛好可行,但你要的是節點的身分, 值一旦重複就爆了。
  • ❌ 以為 p 和 q 一定同根。若它們在不同的樹裡,兩個指標會在同一步一起落到 null, 於是 a == b == null,迴圈結束回傳 null — 雙指標寫法白送你這個終止條件, 單純同步往上的那種寫法沒有。
  • ❌ 反射動作去拿後序模板。這裡根本沒有 root 參數可以傳給它。
4. 一次看完其他變形

LC 1644 — p / q 可能不在樹裡。 236 的模板只要碰到 p 就回傳 p, 所以就算 q 不存在,它也會很開心地報一個「LCA」給你。修法:不要提早 return, 並且數你真正看到幾個目標。

python
# python
# LC 1644 - Lowest Common Ancestor of a Binary Tree II
# IDEA: 236's post-order, but do NOT return early -- count the hits, then validate
# time = O(N), space = O(H)
class Solution(object):
    def lowestCommonAncestor(self, root, p, q):
        self.found = 0

        def dfs(node):
            if not node:
                return None
            # recurse FIRST, even if node is p or q (no early return)
            left = dfs(node.left)
            right = dfs(node.right)
            if node == p or node == q:
                self.found += 1
                return node
            if left and right:
                return node
            return left if left else right

        lca = dfs(root)
        # only trust the answer if BOTH targets really exist
        return lca if self.found == 2 else None

LC 1676 — 目標有 N 個而不是 2 個。 把那兩個 == 比對換成 set 成員檢查;其他一行都不用動。

python
# python
# LC 1676 - Lowest Common Ancestor of a Binary Tree IV
# IDEA: 236's post-order against a SET of targets (all nodes guaranteed to exist)
# time = O(N), space = O(H + k)
class Solution(object):
    def lowestCommonAncestor(self, root, nodes):
        targets = set(nodes)

        def dfs(node):
            if not node or node in targets:
                return node
            left = dfs(node.left)
            right = dfs(node.right)
            if left and right:
                return node
            return left if left else right

        return dfs(root)

LC 235 — 樹是 BST。 有序性讓你不必看兩邊子樹:只要兩個目標都落在 node.val 的同一側, LCA 就在那一側,直接走過去。

python
# python
# LC 235 - Lowest Common Ancestor of a Binary Search Tree
# IDEA: walk DOWN while p, q are on the same side; the first split point is the LCA
# time = O(h), space = O(1)
class Solution(object):
    def lowestCommonAncestor(self, root, p, q):
        node = root
        while node:
            if p.val < node.val and q.val < node.val:
                node = node.left
            elif p.val > node.val and q.val > node.val:
                node = node.right
            else:
                # split point (or node IS p or q) -> LCA
                return node
        return None

LCA 變形 — 含所有最深節點的最小子樹(LC 865 / LC 1123) 優先度 4/5 — 高價值 — 這裡有缺口就會掉關

1. 核心想法

關鍵洞見:這題其實是換皮的 LCA。它沒有直接給你目標節點 p 和 q,目標是隱含的——所有位於最大深度的節點。

text
Standard LCA (LC 236)                 Deepest Subtree LCA (LC 865)
-----------------------               --------------------------------
Targets p, q are GIVEN                Targets = nodes at max depth (discovered)
Find where p and q paths meet         Find where left/right deepest paths meet

竅門在於一次後序走訪同時算出兩件事:你得走完整棵樹才知道哪些節點最深, 但同時又需要那些節點的 LCA。所以每次遞迴呼叫都回傳一個配對:

text
dfs(node) -> (depth, lca_candidate)
             ^^^^^  ^^^^^^^^^^^^^^^
             max depth      the answer for THIS subtree only
             below node

depth 那一半就是 LC 104(max depth)。lca_candidate 那一半跟著它一起往上帶, 並在每個節點都依兩邊深度重新決定一次。

三種情況(這個比較本身就是整個演算法):

情況 意義 回傳
left.depth > right.depth 最深的節點全在左邊 (left.depth + 1, left.node) — 把左邊的答案往上冒
right.depth > left.depth 最深的節點全在右邊 (right.depth + 1, right.node) — 把右邊的答案往上冒
left.depth == right.depth 兩邊都有最深節點 (left.depth + 1, node) — 當前節點就是 LCA

情況 3 為什麼對:兩邊都在同一個深度觸底,代表最深節點集橫跨當前節點, 所以沒有任何一個子節點能全部包住——當前節點就是能包住它們的最小子樹。 而且只要往上的路上又出現深度打平,答案就會被覆寫,所以最後根呼叫拿到的是最低的那一個。

情況 1/2 為什麼對:如果有一邊嚴格更深,那較淺的那邊根本沒有最深節點, 所以答案完全落在較深的子樹裡——原封不動往上傳就好(不要換成當前節點)。

base case:dfs(null) -> (0, None)。null 子節點深度為 0,會讓葉子透過情況 3 回傳 (1, leaf)——一個葉子當然是它自己的 LCA。

2. 模式:後序 DFS 回傳 (metric, payload)

這是它的一般化形狀——由下往上聚合,同時帶著一個候選答案。 只要題目說*「滿足…的最小子樹」或「兩側極端交會的那個節點」*,就掏這招出來。

text
# pattern skeleton
def dfs(node):
    if not node:
        return (BASE_METRIC, None)

    l_metric, l_ans = dfs(node.left)      # post-order: children FIRST
    r_metric, r_ans = dfs(node.right)

    if l_metric > r_metric:               # one side dominates -> pass its answer up
        return (l_metric + 1, l_ans)
    if r_metric > l_metric:
        return (r_metric + 1, r_ans)
    return (l_metric + 1, node)           # tie -> current node is the meeting point

模式檢查清單:

  • 走訪順序:後序(必須先知道兩個子節點才能決定)
  • 回傳型別:tuple/輔助類別——一個純量指標+一個節點參照
  • 決策依據:比較兩個子節點的指標,絕不靠全域狀態
  • 不做第二趟:不要先算最大深度再重掃一次;一趟就夠了
  • time = O(N)、space = O(H) — H = 樹高(遞迴堆疊)

和「全域變數」風格的對比(LC 543 / LC 124):那類題目只回傳一個純量, 答案藏在成員變數裡。這裡我們把答案放在 tuple 裡回傳,因為答案是在往上的路上 被挑選出來的,不是被取最大值取出來的。

常見陷阱:

  • ❌ 情況 1/2 也回傳 node → 你永遠只會拿回根節點
  • ❌ 打平那個情況用 >= 而不是 == → 錯誤地把情況 1 併進情況 3
  • ❌ 走兩趟(先找最大深度,再找該深度所有節點的 LCA)→ 可行,但多花 O(N) 空間、程式碼也長得多
java
// java
// LC 865 / LC 1123 — Smallest Subtree with All the Deepest Nodes
// IDEA: post-order DFS returning (lca candidate, depth below); equal depths -> this node
// Same as: LCA of the deepest leaves

// Helper class carries both the LCA candidate and its max depth below
class Result {
    TreeNode node;
    int dist;
    Result(TreeNode node, int dist) {
        this.node = node;
        this.dist = dist;
    }
}

/**
 * time = O(N)
 * space = O(H)  — recursion stack; O(log N) balanced, O(N) skewed
 */
public TreeNode subtreeWithAllDeepest(TreeNode root) {
    return dfs(root).node;
}

private Result dfs(TreeNode node) {
    if (node == null) {
        return new Result(null, 0);
    }

    Result left  = dfs(node.left);
    Result right = dfs(node.right);

    // Case 1: left subtree is deeper — LCA is buried there
    if (left.dist > right.dist) {
        return new Result(left.node, left.dist + 1);
    }

    // Case 2: right subtree is deeper — LCA is buried there
    if (right.dist > left.dist) {
        return new Result(right.node, right.dist + 1);
    }

    // Case 3: equal depth — current node is the LCA of all deepest nodes
    return new Result(node, left.dist + 1);
}
python
# python
# LC 865 / LC 1123 — Smallest Subtree with All the Deepest Nodes
# IDEA: post-order DFS returning (depth, lca_node)
# time = O(N), space = O(H)
class Solution(object):
    def subtreeWithAllDeepest(self, root):
        return self.helper(root)[1]

    def helper(self, node):
        # base case: null has depth 0 and no LCA
        if not node:
            return (0, None)

        # post-order — children resolved BEFORE the decision
        left_depth, left_node = self.helper(node.left)
        right_depth, right_node = self.helper(node.right)

        # case 1) left deeper -> all deepest nodes on left, keep left's answer
        if left_depth > right_depth:
            return (left_depth + 1, left_node)

        # case 2) right deeper -> all deepest nodes on right, keep right's answer
        if right_depth > left_depth:
            return (right_depth + 1, right_node)

        # case 3) SAME depth -> deepest nodes on both sides -> current node is LCA
        return (left_depth + 1, node)

視覺化:

text
        [3]          ← left.dist(3) == right.dist(2)? No → left wins
       /   \
     [5]   [1]       ← left.dist(2) == right.dist(1)? No → left wins
    /   \
  [6]  [2]           ← left.dist(0) == right.dist(1)? No → right wins
       /  \
      [7] [4]        ← both null, dist=0 → node [2] is LCA ✓

小訣竅:只要題目問「包含 [條件 X] 的最小子樹」,就想到後序 DFS+LCA 邏輯。

3. 相似的 LC 題目

完全同一題(同樣的程式碼,換個說法):

題目 LC # 說明
Smallest Subtree with all the Deepest Nodes 865 就是這題
Lowest Common Ancestor of Deepest Leaves 1123 字面上就是同一題 — 解答直接複製貼上

同一個模式 — 後序 DFS 回傳 (metric, node)/交會點邏輯:

題目 LC # 回傳的指標 關鍵差異
Lowest Common Ancestor of a Binary Tree 236 node(找到或 null) 目標 p、q 是給定的;打平情況 = 兩個子節點都非 null
LCA of a BST 235 — BST 性質讓你能用 O(H) 直接往下走,不需要後序
LCA of a Binary Tree II 1644 (node, count) p/q 可能不存在 → 還得回傳找到幾個
LCA of a Binary Tree III 1650 — 有父指標、沒有 root → 變成「兩條鏈結串列的交點」;見上面的變形
LCA of a Binary Tree IV 1676 node 目標節點有 N 個,不是 2 個
Find Distance in a Binary Tree 1740 depth 先找 LCA,再算 d(root,p) + d(root,q) - 2*d(root,lca)

同樣的「深度那一半」 — 只取 (depth, …) 這個部分:

題目 LC # 差在哪
Maximum Depth of Binary Tree 104 只回傳深度 — 就是這題扣掉 payload
Balanced Binary Tree 110 回傳深度+一個 bool;abs(l - r) > 1 就短路
Find Bottom Left Tree Value 513 一樣找最深節點,但要最左邊的 → 用 BFS 層序更簡單
Find Leaves of Binary Tree 366 依高度把節點分組,而不是挑一個出來
Maximum Depth of N-ary Tree 559 迴圈跑 children,不是 left/right

同樣的「在每個節點合併左右」後序形狀(但走全域變數風格):

題目 LC # 合併出來的值
Diameter of Binary Tree 543 left + right 條邊,答案存在成員變數
Binary Tree Maximum Path Sum 124 left + right + node.val,負的夾成 0
Longest Univalue Path 687 只有 child.val == node.val 時才往左/右延伸
Count Good Nodes in Binary Tree 1448 改成前序 — 資訊往下流,不是往上

判斷提示:

text
"smallest subtree containing X"      -> post-order (metric, node)   [LC 865]
"LCA of given nodes p, q"            -> post-order found-or-null     [LC 236]
"LCA but I have no root, only parent" -> walk up both chains (LC 160)  [LC 1650]
"longest/max path through any node"  -> post-order + global var      [LC 543, 124]
"deepest / leftmost / level info"    -> BFS level-order              [LC 513, 199]

Template 2: Parent Map + BFS — LC 863

辨識 — 「與 target 距離為 k 的所有節點」、「離節點 k 最近的葉子」、「感染擴散到每個節點要幾分鐘」 — 任何除了往下、也得往上走到父節點的答案。節點 ≤ 500(LC 863)、≤ 10⁵(LC 2385)→ O(N)。

關鍵 — 每個節點都知道自己的父節點之後,它最多只有三個鄰居(左、右、父),樹就成了一張無向圖。從 target 開始的 BFS 依邊距離的順序抵達各節點,所以跑完 k 輪後,佇列裡剛好就是相隔 k 條邊的節點;visited 則讓走訪不會沿原路走回去。

python
# python
# LC 863 - All Nodes Distance K in Binary Tree
# IDEA: record every node's parent, then BFS outward from target over left / right / parent;
#       the queue after k rounds is the answer
# time = O(N), space = O(N)
from collections import deque

class Solution(object):
    def distanceK(self, root, target, k):
        parent = {root: None}                # node -> parent, keyed by node, not by value
        stack = [root]
        while stack:
            node = stack.pop()
            for child in (node.left, node.right):
                if child:
                    parent[child] = node
                    stack.append(child)

        visited = {target}
        queue = deque([target])
        dist = 0
        while queue and dist < k:            # one ring per round
            for _ in range(len(queue)):
                node = queue.popleft()
                for nxt in (node.left, node.right, parent[node]):
                    if nxt and nxt not in visited:
                        visited.add(nxt)
                        queue.append(nxt)
            dist += 1
        return [node.val for node in queue]  # empty if the tree ran out before k
java
// java
// LC 863 - All Nodes Distance K in Binary Tree
// IDEA: DFS builds node -> parent; BFS from target over left / right / parent, k rounds
// time = O(N), space = O(N)
public List<Integer> distanceK(TreeNode root, TreeNode target, int k) {
    Map<TreeNode, TreeNode> parent = new HashMap<>();
    buildParentMap(root, null, parent);

    Set<TreeNode> visited = new HashSet<>();
    Deque<TreeNode> queue = new ArrayDeque<>();
    visited.add(target);
    queue.offer(target);
    int dist = 0;
    while (!queue.isEmpty() && dist < k) {
        int size = queue.size();
        for (int i = 0; i < size; i++) {
            TreeNode node = queue.poll();
            TreeNode[] neighbours = {node.left, node.right, parent.get(node)};
            for (TreeNode next : neighbours) {
                if (next != null && visited.add(next)) {   // add() is false if already seen
                    queue.offer(next);
                }
            }
        }
        dist++;
    }
    List<Integer> res = new ArrayList<>();
    for (TreeNode node : queue) {
        res.add(node.val);
    }
    return res;
}

private void buildParentMap(TreeNode node, TreeNode par, Map<TreeNode, TreeNode> parent) {
    if (node == null) {
        return;
    }
    parent.put(node, par);                    // the root maps to null
    buildParentMap(node.left, node, parent);
    buildParentMap(node.right, node, parent);
}

複雜度

  • 時間 O(N):建父節點表那一趟每個節點拜訪一次,而 visited 讓 BFS 每個節點最多入佇列一次。
  • 空間 O(N):父節點表和 visited 各存了所有節點;佇列只存一圈。

試跑 — root = [3,5,1,6,2,0,8,null,null,7,4]、target = 5、k = 2

text
round 0   queue [5]
round 1   queue [6, 2, 3]     5's left, right and parent
round 2   queue [7, 4, 1]     6 adds nothing; 2 adds 7, 4; 3 adds 1 (5 is visited)
dist == k -> [7, 4, 1]

陷阱

  • 沒有 visited 集合:從 5 出發的 BFS 爬到 3 又直接走回 5,於是 5 被當成距離 2 回報,而且一圈圈永遠停不下來。
  • 用 node.val 當表的 key:只有在值唯一時才對;要用節點本身當 key。
  • 根的父節點是 None:少了 if nxt 這個守衛,None 會被放進佇列,下一次 pop 就在 None.left 上出錯。
  • k = 0 必須回傳 [target.val],所以迴圈條件是 dist < k,而且在每一輪之前檢查。

不適用時 — 距離只需要從手上已有的某個節點(根或 LCA)往下量:不需要這張表,用 Template 3 的 get_dist。邊有權重 → 那是圖的最短路徑問題,不是這個。

練習 — LC 863 · LC 742 (pop 到第一個葉子就停) · LC 2385 (數到佇列清空要幾輪) · LC 1740 (也是距離,但 Template 3 不需要表)

模式比較:標準樹走訪 vs Move Parent

面向 標準樹走訪 Move Parent 模式
方向 單向(只能往下) 雙向(往下+往上)
前處理 不需要 建父節點表(O(N))
空間複雜度 O(h) 遞迴堆疊 O(N) 父節點表+visited
要記 visited 嗎 通常不用 非常關鍵,用來防環
走訪方式 遞迴 DFS DFS(建表)+BFS(探索)
適用場景 標準樹題 距離、路徑、多方向
圖的轉換 樹還是樹 樹 → 無向圖

Template 3: Distance Between Nodes via LCA — LC 1740

辨識 — 「值為 p 與 q 的兩個節點之間路徑上的邊數」,有給根,值不重複。節點 ≤ 10⁴ → O(N)。

關鍵 — 每一條 p … q 路徑都會經過它們的 LCA,所以 dist(p, q) = dist(lca, p) + dist(lca, q)。get_dist 把 depth 當參數往下帶,命中時原樣回傳;-1(絕不是 0)代表「不在這棵子樹裡」,因為 0 是真實的距離(目標就是起點)。

python
# python
# LC 1740 - Find Distance in a Binary Tree
# IDEA: find the split point (LCA), then measure each target's depth below it;
#       get_dist is pre-order, carrying depth down, -1 = "not in this branch"
# time = O(N), space = O(H)
class Solution(object):
    def findDistance(self, root, p, q):
        if p == q:
            return 0
        lca = self.get_lca(root, p, q)
        return self.get_dist(lca, p, 0) + self.get_dist(lca, q, 0)

    def get_lca(self, node, p, q):
        if not node or node.val == p or node.val == q:   # p, q are VALUES here
            return node
        left = self.get_lca(node.left, p, q)
        right = self.get_lca(node.right, p, q)
        if left and right:
            return node
        return left if left else right

    def get_dist(self, node, target, depth):
        if not node:
            return -1                        # dead end: the target is not on this path
        if node.val == target:
            return depth                     # edges from the start node to here
        left = self.get_dist(node.left, target, depth + 1)
        if left != -1:
            return left                      # found on the left: skip the right subtree
        return self.get_dist(node.right, target, depth + 1)
java
// java
// LC 1740 - Find Distance in a Binary Tree
// IDEA: LCA by value, then depth of p and of q below it; -1 means "not in this subtree"
// time = O(N), space = O(H)
public int findDistance(TreeNode root, int p, int q) {
    if (p == q) {
        return 0;
    }
    TreeNode lca = getLca(root, p, q);
    return getDist(lca, p, 0) + getDist(lca, q, 0);
}

private TreeNode getLca(TreeNode node, int p, int q) {
    if (node == null || node.val == p || node.val == q) {
        return node;
    }
    TreeNode left = getLca(node.left, p, q);
    TreeNode right = getLca(node.right, p, q);
    if (left != null && right != null) {
        return node;
    }
    return left != null ? left : right;
}

private int getDist(TreeNode node, int target, int depth) {
    if (node == null) {
        return -1;
    }
    if (node.val == target) {
        return depth;
    }
    int left = getDist(node.left, target, depth + 1);
    if (left != -1) {
        return left;                          // short-circuit: the right side is never walked
    }
    return getDist(node.right, target, depth + 1);
}

複雜度

  • 時間 O(N):get_lca 走整棵樹一趟,每次 get_dist 最多把 LCA 的子樹走一趟。
  • 空間 O(H):只有遞迴深度;沒有建任何額外結構。

試跑 — root = [3,5,1,6,2,0,8,null,null,7,4]、p = 5、q = 0(逐次呼叫的追蹤在下面的視覺化裡)

text
get_lca          -> 3     5 is on the left, 0 on the right
get_dist(3, 5)   -> 1     3 -> 5
get_dist(3, 0)   -> 2     the left subtree returns -1; then 3 -> 1 -> 0
answer           1 + 2 = 3

陷阱

  • None 回傳 0:「找不到」和「就在起點找到」看起來一樣,死路的左子樹也會回報一個距離。p = 5, q = 0 就不再回傳 3。
  • 從根而不是從 LCA 量:p = 6, q = 4 會得到 2 + 3 = 5;真正的路徑 6 - 5 - 2 - 4 是 3 條邊,要從 LCA 5 量。
  • 在帶 depth 的版本裡,回程又 + 1,每條邊都被算兩次;往下時 depth 已經加過了。
  • p == q:答案是 0。在搜尋之前就回傳,不要靠 get_lca 裡的提早回傳剛好讓兩個距離都是 0。

不適用時 — 要的是所有方向上距離 k 的每個節點 → Template 2。手上拿著兩個節點而且它們有 parent 指標 → 根本不用搜尋,往上走(LC 1650 變形)。

練習 — LC 1740 · LC 236 (單獨練 LCA 那一半) · LC 2096 (把路徑寫成 U/L/R 步驟:先 LCA,再兩條根路徑) · LC 863 (所有方向的距離)

get_dist — 為什麼用 -1,以及為什麼加總永遠正確

「找不到」為什麼要用 -1 而不是 0?

回傳值 意義
0 合法 — 目標就在當前節點(相隔 0 條邊)
dist > 0 合法 — 目標在起點下方 dist 條邊處
-1 哨兵 — 目標不在這一支裡(死路)

如果「找不到」回傳 0,那就沒辦法區分*「就在這裡,距離 0」和「找不到」*。所以 0 被保留給真正的距離,-1 是唯一安全的「找不到」旗標。

合法距離是怎麼往上冒的(前序 DFS):

  1. 碰到 None → 回傳 -1(這條路是死路)。
  2. 碰到目標 → 回傳累積的 dist(一個 ≥ 0 的邊數)。
  3. 否則帶著 dist + 1 遞迴 left/right。
    • left != -1 → 目標在左子樹,立刻把那個距離往上傳(短路,跳過右邊)。
    • 否則回傳 right(不是右子樹的合法距離,就是兩邊都失敗的 -1)。

為什麼 dist_p + dist_q 永遠正確 — 從 LCA 出發只有兩種形狀:

text
case 1: p and q are in different subtrees      case 2: one target IS the LCA
                                                        (ancestor of the other)
          LCA                                        p (= LCA)
         /   \                                          \
        p     q                                          ...
                                                           q
   dist = dist_p + dist_q                        dist_p = 0, so dist = dist_q

兩種情況下,get_dist(lca, p) + get_dist(lca, q) 都剛好等於路徑 p … q 上的邊數。

視覺化 — get_dist 實際上是怎麼走這棵樹的

用 LC 1740 例子裡的樹:root = [3,5,1,6,2,0,8,null,null,7,4]、p = 5、q = 0 → 答案 3。

text
                      3          <- depth 0  (this is also the LCA of 5 and 0)
                   /     \
                  5       1      <- depth 1
                /   \    /  \
               6     2  0    8   <- depth 2
                    / \
                   7   4         <- depth 3

第 1 步 — get_lca(root, 5, 0) → 節點 3(5 在左子樹、0 在右子樹 → 它們在 3 分岔)。

第 2 步 — get_dist(3, target=5, depth=0)

text
get_dist(3, 5, 0)                 3 != 5  -> recurse left with depth+1
└── get_dist(5, 5, 1)             5 == 5  -> RETURN 1  ✅
        (right subtree never visited — short-circuited by `if left != -1`)

=> dist_p = 1

第 3 步 — get_dist(3, target=0, depth=0) — 這步比較有意思,因為左半邊是死路:

text
get_dist(3, 0, 0)                       3 != 0
│
├── get_dist(5, 0, 1)                   5 != 0
│   ├── get_dist(6, 0, 2)               6 != 0
│   │   ├── get_dist(None, 0, 3) -> -1      ❌ dead end
│   │   └── get_dist(None, 0, 3) -> -1      ❌ dead end
│   │   RETURN -1                            ❌ bubbles up
│   └── get_dist(2, 0, 2)               2 != 0
│       ├── get_dist(7, 0, 3) -> -1 (both children None)   ❌
│       └── get_dist(4, 0, 3) -> -1 (both children None)   ❌
│       RETURN -1                            ❌
│   RETURN -1   <- whole LEFT subtree of 3 says "not here"
│
│   (left == -1, so we DO NOT short-circuit — we must try right)
│
└── get_dist(1, 0, 1)                   1 != 0
    └── get_dist(0, 0, 2)               0 == 0  -> RETURN 2  ✅
    RETURN 2  <- passed up UNCHANGED (no `+1`!)
RETURN 2

=> dist_q = 2

答案: dist_p + dist_q = 1 + 2 = 3 ✅(路徑 5 - 3 - 1 - 0,3 條邊)

這段追蹤裡要注意的兩件事:

text
1) depth grows going DOWN      2) the found value flows UP untouched
   (as an argument)               (no accumulation on the way back)

     get_dist(.., depth=0)          RETURN 2  ▲
            │  depth+1                        │  same 2
            ▼                        RETURN 2 ▲
     get_dist(.., depth=1)                    │  same 2
            │  depth+1              RETURN 2  ▲
            ▼                                 │
     get_dist(.., depth=2)  ==  target  ->  emit `depth` (= 2)

因為目標值是唯一的,最多只有一支會回傳非 -1 的值——所以往上的路上根本沒有東西要 max() 或相加。這正是它和高度/深度類問題最關鍵的差別:

數字的流動方向 合併步驟
get_height(LC 104) 由下往上算出來 1 + max(left, right)
get_dist(這個模式) 由上往下帶,再原樣回傳上去 沒有 — 只是把非 -1 的值往上傳

哨兵傳遞速查:

text
            left      right     ->  return        meaning
            ----      -----         ------        -------
            -1        -1        ->  -1            target in NEITHER subtree
            d≥0       (skipped) ->  d             found left (short-circuit)
            -1        d≥0       ->  d             found right

get_dist 的各種變形 — 計數器住在哪裡 優先度 5/5 — 必備 — 幾乎每一輪面試都會出現

以下全部回傳同樣的邊數(目標不在子樹裡時回傳 -1),差別只在計數器住在哪裡。 標準的由上往下版本是 Template 3 的 get_dist;下面只寫出那兩個不只是換個寫法的變形。

# 變形 簽名 計數器位置 會短路嗎? 說明
V1 由上往下+明確守衛 (node, target, depth) 當參數往下傳 ✅ 會(if left != -1) Template 3 的版本 — 最明確
V2 由上往下+max() 小技巧 (node, target, depth) 當參數往下傳 ❌ 不會 就是 V1 把 != -1 守衛換成 return max(left, right):目標唯一,所以最多一邊會回傳 >= 0,max 會把它冒上來(兩邊都失敗時剛好是 -1)。程式碼最短,但它一定會掃兩邊子樹 — 嚴格來說比 V1 多做事,所以優先用 V1
V3 由下往上(回傳時 +1) (node, target) 在回程路上往上累積 ✅ 會 不需要 depth 參數 — 下面寫出來
V4 迭代式 BFS (root, target) 存在佇列裡 ✅ 會(提早回傳) 沒有遞迴 → 沒有堆疊爆掉的風險 — 下面寫出來
V3 — 由下往上的 DFS(不需要 depth 參數)

核心想法:不把計數器往下帶,而是碰到目標時從 0 開始,回程每經過一條邊就加 1。這樣函式只需要兩個參數。

python
# python
# IDEA: bottom-up -- return 0 at the target, add 1 per edge on the way back up
# time = O(N), space = O(H)
def get_dist(self, node, target):
    if not node:
        return -1
    if node.val == target:
        return 0                 # distance to itself is 0

    _left  = self.get_dist(node.left,  target)
    _right = self.get_dist(node.right, target)

    # If found on the left, add 1 for the current edge and return it
    if _left >= 0:
        return _left + 1

    # If found on the right, add 1 for the current edge and return it
    if _right >= 0:
        return _right + 1

    return -1

方向對比(V1 vs V3),以 get_dist(3, target=0) 為例:

text
        V1 (top-down)                      V3 (bottom-up)
        depth flows DOWN                   +1 flows UP

   3   get_dist(3, .., depth=0)       3   return 1 + 1 = 2   ▲
   |            │                     |                      │  +1
   1   get_dist(1, .., depth=1)       1   return 0 + 1 = 1   ▲
   |            │                     |                      │  +1
   0   match -> return depth = 2      0   match -> return 0  ▲

   answer emitted at the BOTTOM       answer assembled on the WAY BACK

⚠️ 陷阱:這裡千萬別寫 1 + max(_left, _right) — 兩邊都是 -1 時會得到 0,變成謊報*「就在這個節點找到」*。+1 一定要擋在 >= 0 的檢查後面(或者像 LeetCode-CA 那個版本,在 1 + max(...) 之前先檢查 if left == right == -1: return -1)。

V4 — 迭代式 BFS(層序,不用遞迴)

核心想法:把 (node, dist) 配對推進佇列;節點所在的層數就是它離起點的距離。樹很深、遞迴有爆堆疊風險時很好用。

python
# python
# IDEA: BFS carrying (node, dist); the level a node is popped on is its distance
# time = O(N), space = O(W)  -- W = max tree width
from collections import deque

def get_dist(self, root, target):
    if not root:
        return -1

    q = deque([(root, 0)])   # [node, current_distance]

    while q:
        node, dist = q.popleft()

        if node.val == target:
            return dist      # first hit IS the answer (level == distance)

        if node.left:
            q.append((node.left, dist + 1))
        if node.right:
            q.append((node.right, dist + 1))

    return -1                # scanned everything, target not below `root`

佇列追蹤 — 在範例樹上跑 get_dist(3, target=0):

text
                  3(0)
                /      \
             5(1)      1(1)
            /   \     /   \
          6(2) 2(2) 0(2)  8(2)      <- (n) = dist stored alongside the node

 pop        queue after push                        check
 ----       -------------------                     -----
 (3,0)      [(5,1), (1,1)]                          3 != 0
 (5,1)      [(1,1), (6,2), (2,2)]                   5 != 0
 (1,1)      [(6,2), (2,2), (0,2), (8,2)]            1 != 0
 (6,2)      [(2,2), (0,2), (8,2)]                   6 != 0
 (2,2)      [(0,2), (8,2), (7,3), (4,3)]            2 != 0
 (0,2)      -                                       0 == 0  -> RETURN 2  ✅

⚠️ 注意:這裡是在推入前就擋掉 None 子節點,所以迴圈內部不需要 -1 哨兵——哨兵就是佇列清空後那一行 return -1。

該用哪一個?
  • 面試/講清楚 → V1(面試官想看的就是你怎麼推理哨兵的處理)
  • 想寫得短、一行漂亮解 → V2(記得主動說出你放棄了短路)
  • 參數少一點、想要「在目標處回傳 0」這個慣例 → V3(由下往上的 getDistance 用的就是這個慣例)
  • 樹很深/很斜,遞迴深度真的有風險 → V4

四種都是 O(N) 時間。空間:V1–V3 是 O(H)(遞迴堆疊,斜樹最壞 H = N),V4 是 O(W)(佇列寬度)。

常見錯誤:

  • ❌ None 回傳 0 而不是 -1 → 沒辦法區分「就在這個節點找到」和「找不到」
  • ❌ 漏了 if left != -1: return left 這個守衛 → 回傳了右子樹的 -1,把找到的距離弄丟
  • ❌ 在傳 depth 的版本裡又加 +1 → 重複計數(往下的路上已經加過了)
  • ❌ 從 root 而不是從 lca 呼叫 get_dist → 量到的是錯的路徑

這個模式還會在哪裡出現:

題目 LC # get_dist 怎麼被用
Find Distance in a Binary Tree 1740 基本款 — get_lca + get_dist × 2
All Nodes Distance K 863 從目標往下的距離;往上的部分交給父節點表處理(Template 2)
Maximum Depth 104 同樣的 DFS 形狀,但改成由下往上的 1 + max(...),沒有哨兵
Path Sum 112 一模一樣的由上往下累積,只是帶的是 remaining_sum 而不是 depth
Smallest Subtree w/ Deepest Nodes 865/1123 LCA 和深度合併成一個 (depth, node) 回傳

參考: leetcode_python/Tree/find-distance-in-a-binary-tree.py

模式辨識 — 這是不是一題 get_dist?

辨識條件:

  • ✅ 要的是邊數,不是節點數(深度 0 的節點相隔 0 條邊)
  • ✅ 目標在樹裡是唯一的(值不重複)
  • ✅ 距離是從某個已知起點(根或 LCA)往下量的
  • ❌ 如果兩個節點可能在互不相干的子樹裡 → 先找 LCA(Template 1)
  • ❌ 如果需要所有方向的距離(包含往上)→ 用 Template 2,也就是父節點表

Template 4: Root-to-Leaf Path with Backtracking — LC 113

辨識 — 「所有值的總和等於 targetSum 的根到葉路徑」、「回傳每一條路徑」、「葉子是沒有子節點的節點」。節點 ≤ 5,000 → 一次 O(N) 走訪,加上複製留下來那些路徑的成本。

關鍵 — path 永遠剛好存著從根到當前節點的那些節點:進入時 append、離開時 pop,所以葉子那裡的檢查看到的是完整的根到葉路徑,兄弟子樹也永遠看不到彼此的節點。

python
# python
# LC 113 - Path Sum II
# IDEA: pre-order DFS; push the node, record a COPY at a matching leaf, pop on the way back
# time = O(N * H), space = O(H)   (output not counted; each kept path costs an O(H) copy)
class Solution(object):
    def pathSum(self, 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(list(path))    # a copy: `path` keeps changing
            else:
                dfs(node.left, remaining)
                dfs(node.right, remaining)
            path.pop()                       # backtrack: the parent's path again

        dfs(root, targetSum)
        return result
java
// java
// LC 113 - Path Sum II
// IDEA: pre-order DFS with one shared list; add on entry, copy at a matching leaf, remove on exit
// time = O(N * H), space = O(H)
public List<List<Integer>> pathSum(TreeNode root, int targetSum) {
    List<List<Integer>> result = new ArrayList<>();
    dfs(root, targetSum, new ArrayList<>(), result);
    return result;
}

private void dfs(TreeNode node, int remaining, List<Integer> path, List<List<Integer>> result) {
    if (node == null) {
        return;
    }
    path.add(node.val);
    remaining -= node.val;
    if (node.left == null && node.right == null && remaining == 0) {
        result.add(new ArrayList<>(path));    // save a COPY
    } else {
        dfs(node.left, remaining, path, result);
        dfs(node.right, remaining, path, result);
    }
    path.remove(path.size() - 1);             // backtrack
}

複雜度

  • 時間 O(N · H):DFS 每個節點拜訪一次(O(N));每個符合的葉子要複製一條最多 H 個值的路徑。平衡樹上是 O(N log N);斜樹只有一個葉子。
  • 空間 O(H):遞迴堆疊和 path 都長到樹的深度;輸出不算在內。

試跑 — root = [5,4,8,11,null,13,4,7,2,null,null,5,1]、targetSum = 22

text
path [5, 4, 11, 7]    remaining -5 at leaf 7     no
pop 7, push 2         [5, 4, 11, 2]  remaining 0 at a leaf  -> record
pop 2, 11, 4          [5]
...                   [5, 8, 4, 5]   remaining 0 at a leaf  -> record
answer [[5, 4, 11, 2], [5, 8, 4, 5]]

陷阱

  • result.append(path) 沒有複製:每一筆都是同一個 list,走完時是空的 — 這個例子會回傳 [[], []]。
  • 在 None 而不是在葉子檢查總和:只有一個子節點的節點會被當成路徑終點,所以 LC 112 的 root = [1,2]、targetSum = 1 會回傳 true;但 1 不是葉子。
  • 漏了 pop:右子樹會繼承左子樹的節點 — [1,2,3]、targetSum = 4 會記下 [1, 2, 3]。
  • remaining < 0 就提早停:值可能是負的,後面的節點可以把它拉回來。

不適用時 — 路徑可以在一條往下的線上任意起訖 → Template 5。路徑可以在某個節點轉彎(LC 124、LC 543)→ 後序高度加全域最大值,見 LC 124 卡片。只問是或否(LC 112)→ 完全不需要 list,回傳 left or right。

練習 — LC 113 · LC 112 (是或否,不用路徑 list) · LC 257 (路徑寫成字串) · LC 129 (往下帶一個數字而不是 list) · LC 988 (比較反轉後的路徑)

路徑更新策略:不可變字串 vs 可變 List+回溯

DFS 過程中追蹤路徑狀態有兩種做法。選對了,程式碼會簡單非常多。

策略 1:不可變字串 — 在 DFS 呼叫裡直接把更新後的路徑傳下去(不需要回溯)

當你把 path + "->" + node.val 直接當參數傳下去,每一層遞迴拿到的都是自己的那份字串副本。父層的 path 從沒被改過,所以完全不需要顯式回溯。

python
# python
# LC 257 - Binary Tree Paths
# IDEA: immutable string path -- the update happens in the call, so nothing to undo
# time = O(N * H), space = O(H^2): each live frame holds its own prefix string (output not counted)
def binaryTreePaths(root):
    res = []

    def dfs(node, path):
        if not node.left and not node.right:
            res.append(path)
            return
        if node.left:
            dfs(node.left, path + "->" + str(node.left.val))    # a new string
        if node.right:
            dfs(node.right, path + "->" + str(node.right.val))

    if root:
        dfs(root, str(root.val))
    return res
java
// java
// LC 257 - Binary Tree Paths
// IDEA: String path, updated inside the DFS call -> each frame keeps its own copy, no backtrack
// time = O(N * H), space = O(H^2): each live frame holds its own prefix string (output not counted)
public List<String> binaryTreePaths(TreeNode root) {
    List<String> res = new ArrayList<>();
    if (root == null) {
        return res;
    }
    dfs(root, String.valueOf(root.val), res);
    return res;
}

private void dfs(TreeNode node, String path, List<String> res) {
    if (node.left == null && node.right == null) {
        res.add(path);                        // the path is complete
        return;
    }
    if (node.left != null) {
        dfs(node.left, path + "->" + node.left.val, res);    // `path` here is unchanged
    }
    if (node.right != null) {
        dfs(node.right, path + "->" + node.right.val, res);
    }
}

策略 2:可變 List — 原地修改,然後回溯

路徑是可變結構(list、List<Integer>、StringBuilder)時,同一個物件會被所有呼叫共用,所以每次修改都要在呼叫返回時還原。那正是上面 Template 4 的程式碼:遞迴前 path.append/path.add,遞迴後 pop/remove。

比較:

面向 不可變字串 可變 List+回溯
路徑在哪裡更新 在 DFS 呼叫的參數裡 在 DFS 呼叫之前
需要回溯嗎? 不用(每層都有自己的副本) 要(必須還原修改)
記憶體 每條路徑產生 O(N) 個新字串 O(N),共用同一個重複利用的 list
最適合 字串路徑(LC 257) 數值路徑(LC 113、112)
出 bug 的風險 低(沒有共用狀態) 中(容易忘記回溯)

經驗法則:

  • 不可變(String、int) → 在呼叫裡把更新後的值傳下去 → 不用回溯
  • 可變(List、StringBuilder) → 呼叫前修改 → 呼叫後回溯

Template 5: Downward Path Count with a Prefix-Sum Map — LC 437

辨識 — 「值的總和為 targetSum 的路徑數量;路徑不必從根開始、也不必在葉子結束,但必須往下走」。節點 ≤ 1,000 而且值可能是負的,所以會縮的視窗行不通 → 用 map 做到 O(N)。

關鍵 — prefix 只記當前根路徑上那些節點的「根到節點」總和的次數。一條在這個節點結束的往下路徑總和等於 target,若且唯若 cur - target 是其中之一 — 這就是樹上的 LC 560 — 而且回程時把計數還原,所以兄弟分支永遠不會和這一支配對。

python
# python
# LC 437 - Path Sum III
# IDEA: pre-order DFS carrying the root-to-node sum; count earlier prefixes equal to cur - target,
#       add cur for the children, remove it on the way back
# time = O(N), space = O(N)
from collections import defaultdict

class Solution(object):
    def pathSum(self, root, targetSum):
        prefix = defaultdict(int)
        prefix[0] = 1                        # the empty prefix: paths that start at the root

        def dfs(node, cur):
            if not node:
                return 0
            cur += node.val
            count = prefix[cur - targetSum]  # downward paths that end here
            prefix[cur] += 1                 # visible to my descendants only
            count += dfs(node.left, cur) + dfs(node.right, cur)
            prefix[cur] -= 1                 # leave this branch
            return count

        return dfs(root, 0)
java
// java
// LC 437 - Path Sum III
// IDEA: 2-sum on the root path: curSum - targetSum seen before -> a sub-path ends here
// time = O(N), space = O(N)
private final Map<Long, Integer> prefix = new HashMap<>();

public int pathSum(TreeNode root, int targetSum) {
    prefix.put(0L, 1);                        // the empty path has sum 0
    return dfs(root, 0L, targetSum);
}

private int dfs(TreeNode node, long cur, int targetSum) {
    if (node == null) {
        return 0;
    }
    cur += node.val;                          // long: the sums overflow int
    int count = prefix.getOrDefault(cur - targetSum, 0);
    prefix.merge(cur, 1, Integer::sum);
    count += dfs(node.left, cur, targetSum) + dfs(node.right, cur, targetSum);
    prefix.merge(cur, -1, Integer::sum);      // backtrack
    return count;
}

複雜度

  • 時間 O(N):每個節點拜訪一次,做期望 O(1) 的 map 操作。
  • 空間 O(N):map 對每個看過的不同前綴和留一個 key(只有當前路徑上的計數非零),再加上 O(H) 的遞迴。

試跑 — 鏈 1 → 2 → 3,targetSum = 3

text
node 1   cur 1   look up -2 -> 0              prefix {0:1, 1:1}
node 2   cur 3   look up  0 -> 1  (1 -> 2)    prefix {0:1, 1:1, 3:1}
node 3   cur 6   look up  3 -> 1  (3 alone)   answer 2

陷阱

  • 漏了 prefix[0] = 1:從根開始的路徑永遠算不到 — 上面那條鏈會回傳 1。
  • 回程沒有減回去:左子樹的前綴和會和右邊的節點配對,把一條在父節點轉彎的路徑也算進去。root = [0,1,3]、targetSum = 2 會回傳 1,而不是 0。
  • 先插入 cur 再查:targetSum = 0 時,每個節點都會把自己算成一條空路徑。
  • Java 的 int 總和:LC 437 的值可達 ±10⁹,累加會溢位;map 的 key 要用 long。

不適用時 — 路徑必須從根開始、在葉子結束 → Template 4,不需要 map。輸入是陣列而不是樹 → LC 560 的前綴和,沒有東西要還原。這個樹版本更長的說明,包含 base case 的兩種寫法,在 prefix_sum_advanced.md Template 14。

練習 — LC 437 · LC 112 (只要根到葉:不需要 map) · LC 560 (同一招用在陣列上) · LC 1171 (鏈結串列裡總和為零的連續段:同一張前綴 map)

和根到葉回溯的關鍵差異:

模式 路徑限制 資料結構 回溯什麼?
DFS+路徑 list+回溯 只能根 → 葉 List<Integer> path 移除最後一個元素
DFS+前綴和+回溯 任意節點 → 任意節點 ↓ Map<Long, Integer> 把 map 的計數減一

LC 範例

不是任何模板錨點題的路徑題,做成問題卡放在這裡。LC 543(直徑)是 binary_tree.md § Template 5 的錨點題,在那裡講解;下面的 LC 124 和 LC 687 就是同一個模板,只是「一條臂」的定義不同。

Path Sum — LC 112

關鍵 — 題目只問有沒有,所以往下走時唯一要記的就是還欠多少:把 targetSum - node.val 傳給子節點,而且只有葉子能結清這筆帳。

歸約到:Template 4 — 不用複製或還原路徑 list,收集結果改成 left or right。

python
# python
# LC 112 - Path Sum
# IDEA: pre-order DFS carrying the remaining sum; a leaf answers, an inner node ORs its children
# time = O(N), space = O(H)
def hasPathSum(root, targetSum):
    if not root:
        return False
    if not root.left and not root.right:          # only a leaf can end the path
        return root.val == targetSum
    remaining = targetSum - root.val
    return hasPathSum(root.left, remaining) or hasPathSum(root.right, remaining)
java
// java
// LC 112 - Path Sum
// IDEA: carry the remaining sum down; a leaf compares, an inner node ORs its children
// time = O(N), space = O(H)
public boolean hasPathSum(TreeNode root, int targetSum) {
    if (root == null) {
        return false;
    }
    if (root.left == null && root.right == null) {
        return root.val == targetSum;
    }
    int remaining = targetSum - root.val;
    return hasPathSum(root.left, remaining) || hasPathSum(root.right, remaining);
}

陷阱

  • 在 None 而不是在葉子判斷:root = [1,2]、targetSum = 1 會回傳 true,但節點 1 有子節點,不是葉子。
  • 空樹配 targetSum = 0 是 false:根本沒有路徑。

Sum Root to Leaf Numbers — LC 129

關鍵 — 往下帶的狀態是目前路徑拼出來的數字:number * 10 + node.val。它是 int,每次呼叫都拿到自己的一份,沒有東西要還原;葉子回傳它的數字,每個內部節點回傳兩個子節點的總和。

歸約到:Template 4 — 帶的是一個數字而不是 list,也就是上面「路徑更新策略」裡不可變狀態的情況。

python
# python
# LC 129 - Sum Root to Leaf Numbers
# IDEA: pre-order DFS carrying the number built so far; a leaf returns it, a parent adds both sides
# time = O(N), space = O(H)
def sumNumbers(root):
    def dfs(node, number):
        if not node:
            return 0
        number = number * 10 + node.val           # append this digit
        if not node.left and not node.right:
            return number
        return dfs(node.left, number) + dfs(node.right, number)

    return dfs(root, 0)

陷阱

  • 在 None 回傳 number,會把只有一個子節點的節點算兩次:[1,2,3] 要維持 25,必須在葉子回傳數字、在 None 回傳 0。

Binary Tree Maximum Path Sum — LC 124

關鍵 — 和直徑一樣是「回傳一條臂、記錄轉彎」的形狀,只是把高度換成總和。值可能是負的,所以會拉低總和的臂要夾到 0(不取),而全域值從 -inf 開始,因為最佳路徑可能只是單一個負數節點。

歸約到:binary_tree § Template 5 — 兩邊各加上 max(0, arm),每個節點都加上 node.val。

python
# python
# LC 124 - Binary Tree Maximum Path Sum
# IDEA: return the best downward arm starting at me; record the path that bends at me in a global
# time = O(N), space = O(H)
def maxPathSum(root):
    best = float("-inf")

    def gain(node):
        nonlocal best
        if not node:
            return 0
        left = max(0, gain(node.left))            # a negative arm is dropped, not taken
        right = max(0, gain(node.right))
        best = max(best, node.val + left + right) # the path that bends at node
        return node.val + max(left, right)        # only one arm continues upward

    gain(root)
    return best
java
// java
// LC 124 - Binary Tree Maximum Path Sum
// IDEA: return the best arm, clamped at 0; record node + left + right in a field
// time = O(N), space = O(H)
private int best;

public int maxPathSum(TreeNode root) {
    best = Integer.MIN_VALUE;
    gain(root);
    return best;
}

private int gain(TreeNode node) {
    if (node == null) {
        return 0;
    }
    int left = Math.max(0, gain(node.left));
    int right = Math.max(0, gain(node.right));
    best = Math.max(best, node.val + left + right);
    return node.val + Math.max(left, right);
}

試跑 — root = [-10,9,20,null,null,15,7]

text
gain(9)    = 9                    best 9
gain(15)   = 15                   best 15
gain(7)    = 7                    best 15
gain(20)   = 20 + max(15, 7) = 35 best max(15, 20 + 15 + 7) = 42
gain(-10)  = -10 + 35 = 25        best max(42, -10 + 9 + 35) = 42   answer 42

陷阱

  • best 從 0 開始:root = [-3] 會回傳 0,但路徑至少要包含一個節點,答案是 -3。
  • 回傳 node.val + left + right:父節點會延伸一條已經轉彎的路徑。
  • 把全域值夾到 0 而不是臂:max(0, …) 只放在每一條臂上。

Longest Univalue Path — LC 687

關鍵 — 一條臂只能穿過兩端值相同的邊繼續延伸。所以在記錄轉彎 left + right 之前,子節點的臂在 child.val == node.val 時加一,否則歸零。答案數的是邊。

歸約到:binary_tree § Template 5 — 臂穿過值不同的邊就歸零。

python
# python
# LC 687 - Longest Univalue Path
# IDEA: return the longest same-value arm going down from me (in edges); record left + right
# time = O(N), space = O(H)
def longestUnivaluePath(root):
    best = 0

    def arm(node):
        nonlocal best
        if not node:
            return 0
        left = arm(node.left)
        right = arm(node.right)
        left = left + 1 if node.left and node.left.val == node.val else 0
        right = right + 1 if node.right and node.right.val == node.val else 0
        best = max(best, left + right)
        return max(left, right)

    arm(root)
    return best

陷阱

  • 子節點值不同就不往下遞迴:那棵子樹裡仍可能有答案。[1,4,5,4,4,null,5] 的答案 2 來自根(值 1)底下的 4-4-4。
  • 數節點而不是數邊:單一節點是 0,不是 1。

Closest Leaf in a Binary Tree — LC 742

關鍵 — 最近的葉子可能在節點 k 的上方(經過父節點再往另一支下去),所以先把樹變成無向圖。接著從 k 做 BFS,節點依距離順序被走到,第一個彈出的葉子就是答案。

歸約到:Template 2 — 同樣的父節點邊與 BFS,只是停在第一個沒有子節點的節點,而不是走 k 輪。

python
# python
# LC 742 - Closest Leaf in a Binary Tree
# IDEA: one DFS adds every child <-> parent edge and finds node k; BFS from k; first leaf popped wins
# time = O(N), space = O(N)
from collections import defaultdict, deque

def findClosestLeaf(root, k):
    graph = defaultdict(list)
    start = None
    stack = [(root, None)]
    while stack:                                  # build the undirected graph
        node, parent = stack.pop()
        if node.val == k:
            start = node
        if parent:
            graph[node].append(parent)
            graph[parent].append(node)
        for child in (node.left, node.right):
            if child:
                stack.append((child, node))

    queue = deque([start])                        # BFS from k, not from the root
    seen = {start}
    while queue:
        node = queue.popleft()
        if not node.left and not node.right:      # a leaf in the ORIGINAL tree
            return node.val
        for neighbour in graph[node]:
            if neighbour not in seen:
                seen.add(neighbour)
                queue.append(neighbour)

陷阱

  • 從 root 開始 BFS:找到的是離根最近的葉子。[1,2,3,4,null,null,null,5,null,6]、k = 2 必須回傳 3(往上兩條邊再過去),而不是 2 那條鏈底下的葉子。
  • 用圖上的度數判斷葉子:只有一個子節點的樹,根的度數是 1,但它不是葉子。要用原本的 left / right。
  • DFS 填好 graph 之後又重新建立它,每個節點就都沒有鄰居了。

相似題 — LC 863(距離為 k 的所有節點)· LC 2385(感染整棵樹要幾分鐘:從起點做 BFS 輪數)

模式選擇策略

第 2 步 — 套用模式:

text
Root-to-leaf path problem?
  → Pre-order DFS + backtracking
  → Pattern: add node → check leaf → recurse → remove node (backtrack)

Path sum from ANY node to ANY node (downward)?
  → Pre-order DFS + prefix sum HashMap (2-sum trick)
  → Pattern: map.put(0,1) → curSum += val → check (curSum-target) in map
             → add to map → recurse → backtrack (decrement map)

Subtree computation (bottom-up)?
  → Post-order DFS
  → Pattern: recurse left, recurse right → combine at current node

Identify or compare subtrees by structure?
  → Post-order DFS + serialize "val,left,right" + HashMap
  → Pattern: serialize(left) + serialize(right) → build key "val,L,R"
             → map.getOrDefault(key,0) == 1 → duplicate! → add to result
             → map.put(key, count+1) → return key to parent

BST / sorted property?
  → In-order DFS
  → Pattern: recurse left → process node → recurse right

練習路徑

階段 題目(易 → 難) 加上了什麼
核心 ⭐ LC 543 ok ⭐ · LC 235 ok ⭐ · LC 236 ok · LC 124 again ⭐ 最長的轉彎路徑、BST 與一般樹上的 LCA、帶值總和的轉彎路徑
練習 LC 112 ok · LC 113 ok · LC 1650 ok · LC 863 ok · LC 1740 ok · LC 437 again Template 4、1(沒有根)、2、3、5,各一次
延伸 LC 1644 again · LC 865 again · LC 2096 again · LC 2385 未評定 目標可能不存在、隱含的目標、寫成步驟的路徑、父節點表上的 BFS 輪數

總結

題目要你求… 掏這個出來 章節
兩個節點的交會點 後序 LCA Template 1
含最深節點的子樹 後序回傳 (depth, node) LC 865 變形
任意方向距離 k 步的節點 父節點表+BFS Template 2
任意兩節點之間的邊數 LCA + 兩次 get_dist Template 3
所有符合某規則的根到葉路徑 前序 DFS+回溯 Template 4
任意到任意向下路徑的數量 前序 DFS+前綴和 HashMap Template 5
通過任一節點的最長路徑 後序高度+全域最大值 LC 124 卡片

在這裡代價最大的三個錯誤:

  • 「找不到」回傳 0 而不是 -1 — 0 是一個合法的距離
  • 一旦有了父節點這種邊,就忘了 visited 集合 — BFS 會永遠繞不出來
  • 從 root 而不是從 lca 呼叫 get_dist — 量到的是錯的路徑