樹的 LCA、距離與路徑問題
範圍 — 最近共同祖先、節點間距離、父節點表(雙向)走訪,以及根到葉的路徑模板——所有答案是一條路徑或一個交會點、而不是某種形狀的樹問題。 另見: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
# 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
// 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]
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,
只是穿了一件樹的外衣。
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
# 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
# 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
// 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,用上面那棵樹):
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
# 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
# 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
# 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,目標是隱含的——所有位於最大深度的節點。
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。所以每次遞迴呼叫都回傳一個配對:
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)
這是它的一般化形狀——由下往上聚合,同時帶著一個候選答案。 只要題目說*「滿足…的最小子樹」或「兩側極端交會的那個節點」*,就掏這招出來。
# 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
// 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
# 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)
視覺化:
[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 | 改成前序 — 資訊往下流,不是往上 |
判斷提示:
"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
# 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
// 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
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
# 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
// 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(逐次呼叫的追蹤在下面的視覺化裡)
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 條邊,要從 LCA5量。 - 在帶 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):
- 碰到
None→ 回傳-1(這條路是死路)。 - 碰到目標 → 回傳累積的
dist(一個 ≥ 0 的邊數)。 - 否則帶著
dist + 1遞迴left/right。left != -1→ 目標在左子樹,立刻把那個距離往上傳(短路,跳過右邊)。- 否則回傳
right(不是右子樹的合法距離,就是兩邊都失敗的-1)。
為什麼 dist_p + dist_q 永遠正確 — 從 LCA 出發只有兩種形狀:
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。
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)
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) — 這步比較有意思,因為左半邊是死路:
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 條邊)
這段追蹤裡要注意的兩件事:
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 的值往上傳 |
哨兵傳遞速查:
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
# 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) 為例:
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
# 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):
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
# 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
// 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
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
# 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
// 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
# 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
// 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
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
# 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
// 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
# 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
# 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
// 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]
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
# 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
# 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 步 — 套用模式:
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— 量到的是錯的路徑