樹 — LeetCode 實戰範例
範圍 — binary_tree.md 各模板的二元樹題目集:每題歸在它化約到的模板底下,只寫它多出來的那一步,模板本身不在這裡重講。 另見:binary_tree.md — 這些題目化約到的模板;tree.md — 樹的觀念、走訪策略與 Morris threading;tree_lca_distance.md — LCA、距離與路徑題;bst_examples.md — 用到 BST 順序的題目;tree_codec.md 與 tree_construction.md — 樹的序列化與建構。
LeetCode 題目清單
概覽
經典的二元樹題目,依各題化約到的 binary_tree.md 模板分組,順序與該檔的模板順序一致。模板在 binary_tree.md,這一份只放題目,每張卡只寫這題比模板多了什麼。凡是某個模板的錨點題,或已在別份檔案完整解過的題目,Index 只放一列指過去,不在這裡再抄一份。
Index
高度 vs 深度,以及深度搜尋用 -1 當「沒找到」的哨兵值,統一在 binary_tree.md § 0-5 講一次。
遞迴走訪範例
Boundary of Binary Tree — LC 545
關鍵 — 邊界是三段走訪接起來的:左邊界用前序(往下走時記錄),所有葉子由左到右,再接右邊界用後序(回程時記錄,所以是由下往上)。兩段邊界走訪都不記葉子,角落的節點才不會被列兩次。
歸約到:binary_tree § Template 1 — 一次走訪變成三次,每段都依它在邊界上被讀出的順序記錄。
# python
# LC 545 - Boundary of Binary Tree
# IDEA: root, then the left boundary top-down, the leaves left to right, the right boundary bottom-up;
# neither boundary walk records a leaf, so no node appears twice
# time = O(n), space = O(h)
def boundaryOfBinaryTree(root):
if not root:
return []
result = [root.val]
def is_leaf(node):
return not node.left and not node.right
def left_boundary(node): # pre-order: record on the way down
while node and not is_leaf(node):
result.append(node.val)
node = node.left if node.left else node.right
def leaves(node): # every leaf, left to right
if not node:
return
if is_leaf(node):
result.append(node.val)
return
leaves(node.left)
leaves(node.right)
def right_boundary(node): # post-order: record on the way back up
stack = []
while node and not is_leaf(node):
stack.append(node.val)
node = node.right if node.right else node.left
while stack:
result.append(stack.pop())
if not is_leaf(root): # a lone root is not also a leaf
left_boundary(root.left)
leaves(root)
right_boundary(root.right)
return result
陷阱
- 邊界上的節點只有右子節點:
[1,2,null,null,3,…]— 沒有左子節點時,左邊界要接著往右子節點走。只跟著.left走會提早停下。 root = [1]:root 本身是葉子,再收一次葉子會印出[1, 1]。要先擋掉 root。root = [1,null,2,3,4]:root 沒有左子樹,所以左邊界只有[1];葉子是3, 4,右邊界是2,答案[1,3,4,2]。
層序範例
Minimum Depth of Binary Tree — LC 111
關鍵 — BFS 依深度順序抵達節點,所以第一個出佇列的葉子就是最淺的,搜尋可以就此停止;遞迴版的
min則必須特別處理只有一個子節點的節點,因為空的那一側不是葉子。
歸約到:binary_tree § Template 2 — 層序佇列,加上遇到第一個葉子就提早結束。
# python
# LC 111 - Minimum Depth of Binary Tree
# IDEA: BFS carrying the depth; the first leaf dequeued is the shallowest
# time = O(n), space = O(w) w = maximum width of a level; stops at the first leaf
from collections import deque
def minDepth(root):
if not root:
return 0
queue = deque([(root, 1)])
while queue:
node, depth = queue.popleft()
if not node.left and not node.right: # the first leaf BFS reaches is the shallowest
return depth
if node.left:
queue.append((node.left, depth + 1))
if node.right:
queue.append((node.right, depth + 1))
陷阱
- 遞迴寫成
1 + min(minDepth(left), minDepth(right)),在[2,null,3,null,4,null,5,null,6]上會回傳 1:空的左子節點得 0,但它不是葉子。正確答案是 5。缺一個子節點時,只往另一邊遞迴。 - LC 104(最大深度)沒有這個陷阱:
max永遠不會挑空的那一側。
Binary Tree Right Side View — LC 199
關鍵 — 從右邊看到的節點,就是每一層由左到右的最後一個節點;先記下該層的大小,才知道哪一次出佇列是最後一個。
歸約到:binary_tree § Template 2 — 每層的歸納只保留最後一個節點。
# python
# LC 199 - Binary Tree Right Side View
# IDEA: level-order BFS; record the last node of each level
# time = O(n), space = O(w)
from collections import deque
def rightSideView(root):
result = []
if not root:
return result
queue = deque([root])
while queue:
level_size = len(queue)
for i in range(level_size):
node = queue.popleft()
if i == level_size - 1: # the last node of the level is the one you see
result.append(node.val)
if node.left:
queue.append(node.left)
if node.right:
queue.append(node.right)
return result
陷阱
- 只沿著右子節點走:
[1,2,3,4]第 2 層看得到4,雖然它掛在左子樹下。答案是[1,3,4],不是[1,3]。 - 先走右再走左、在每個新深度記下第一個節點的 DFS 也行,空間 O(h);
len(result) == depth就是「第一次到這個深度」的判斷。
由走訪序建樹範例
Construct Binary Tree from Inorder and Postorder Traversal — LC 106
關鍵 — postorder 的最後一個值是 root,所以從尾端往回讀;倒著讀是 root、右、左,因此必須先建右子樹再建左子樹。
歸約到:binary_tree § Template 3 — 同樣用 inorder 切分,只是 root 從另一端取,兩個遞迴呼叫對調。
# python
# LC 106 - Construct Binary Tree from Inorder and Postorder Traversal
# IDEA: postorder[-1] is the root; its inorder index splits [lo, hi]; build RIGHT first
# time = O(n), space = O(n) the index map, plus O(h) recursion
def buildTree(inorder, postorder):
index_of = {val: i for i, val in enumerate(inorder)}
post_idx = [len(postorder) - 1] # next root, read from the END of postorder
def build_range(lo, hi): # the subtree whose inorder slice is [lo, hi]
if lo > hi:
return None
root = TreeNode(postorder[post_idx[0]])
post_idx[0] -= 1
mid = index_of[root.val]
root.right = build_range(mid + 1, hi) # RIGHT first: postorder read backwards is root, right, left
root.left = build_range(lo, mid - 1)
return root
return build_range(0, len(inorder) - 1)
陷阱
- 先建左子樹:從 postorder 尾端讀到的下一個值屬於右子樹,所以
inorder = [9,3,15,20,7]、postorder = [9,15,7,20,3]建出來 20 會跑到左邊。 - 每次呼叫都切片(
inorder[:idx]、postorder[:idx])並呼叫inorder.index(...),各是 O(n),在歪斜的樹上就是 O(n²);用索引 map 加一個移動的指標才是 O(n)。
根到節點狀態範例
Count Good Nodes in Binary Tree — LC 1448
關鍵 — 一個節點是不是「好節點」只取決於它上方的最大值,所以這個最大值當參數往下傳;它是
int,每次呼叫各有一份,回程時沒有東西要復原。
歸約到:tree_lca_distance § Template 4 — 根到節點的狀態是一個數字而不是 list,所以回溯那一步就不見了。
# python
# LC 1448 - Count Good Nodes in Binary Tree
# IDEA: pre-order DFS carrying the maximum on the path from the root
# time = O(n), space = O(h)
def goodNodes(root):
def dfs(node, max_so_far):
if not node:
return 0
good = 1 if node.val >= max_so_far else 0
max_so_far = max(max_so_far, node.val) # an int: each call has its own copy
return good + dfs(node.left, max_so_far) + dfs(node.right, max_so_far)
return dfs(root, root.val)
陷阱
- 用
>取代>=會漏掉等於目前最大值的節點:[3,3,null,4,2]中第二個 3 是好節點,答案是 3,用>會回傳 2。 - 起始值設成 0 而不是
root.val,遇到負數就錯;root 一定是好節點,所以用它自己的值(或-inf)當起點。
Maximum Difference Between Node and Ancestor — LC 1026
關鍵 — 一條路徑上最大的
|祖先 − 子孫|就是這條路徑的最大值減最小值,所以把這兩個數往下帶, 路徑結束時讀出high − low即可。不用比較任何一對,也用不到 BST 的順序。
歸約到:tree_lca_distance § Template 4 —— 沿根到葉路徑往下帶狀態; 和 bst § Template 4 的上下界是同樣兩個值,只是拿來擴張而不是檢查。
# python
# LC 1026 - Maximum Difference Between Node and Ancestor
# IDEA: carry the path's min and max down; at the end of a path the answer is max - min
# time = O(n), space = O(h)
def maxAncestorDiff(root):
def dfs(node, low, high):
if not node:
return high - low # the path ended: its widest spread
low = min(low, node.val)
high = max(high, node.val)
return max(dfs(node.left, low, high), dfs(node.right, low, high))
return dfs(root, root.val, root.val)
陷阱
- 每個節點只跟父節點比:LC 1026 的範例 1,
[8,3,10,1,6,null,14,null,null,4,7,13],會回傳 5(8 − 3)而不是 7(8 − 1,相隔兩條邊)。
| 驗證(LC 98) | 祖先最大差(LC 1026) | |
|---|---|---|
| 往下帶的 | 合法區間 | 路徑上的最小值與最大值 |
| 節點落在區間外時 | 回傳 false | 擴張區間 |
| 子節點結果用什麼合併 | and |
max |
高度往上傳範例
Maximum Depth of Binary Tree — LC 104
關鍵 — 一棵樹的深度是兩棵子樹中較深者再加一;這就是模板回傳的高度,而且 root 自己的回傳值就是答案,不需要全域變數。
歸約到:binary_tree § Template 5 — 只用回傳值,不用全域變數。
# python
# LC 104 - Maximum Depth of Binary Tree
# IDEA: post-order; my depth = 1 + the deeper child's depth
# time = O(n), space = O(h)
def maxDepth(root):
if not root:
return 0
return 1 + max(maxDepth(root.left), maxDepth(root.right))
陷阱
- 數邊而不是數節點:LC 104 數的是節點,所以
maxDepth(None) = 0,單一節點是 1。None回傳-1(數邊的慣例)會讓[3,9,20,null,null,15,7]得到 2。 - 10⁴ 個節點的鏈,遞迴就有 10⁴ 層;Python 需要
sys.setrecursionlimit,或改用逐層計數的層序 BFS。
Balanced Binary Tree — LC 110
關鍵 — 高度和判定結果在同一次後序走訪裡一起回傳:
-1代表「下面某處已經不平衡」,每個祖先直接往上傳,所以每個節點只走一次。
歸約到:binary_tree § Template 5 — 高度同時兼任失敗訊號,而不是拿去更新全域變數。
# python
# LC 110 - Balanced Binary Tree
# IDEA: post-order height; -1 is the "unbalanced" sentinel that short-circuits upward
# time = O(n), space = O(h)
def isBalanced(root):
def height(node): # -1 means "already unbalanced below here"
if not node:
return 0
left = height(node.left)
if left == -1:
return -1
right = height(node.right)
if right == -1:
return -1
if abs(left - right) > 1:
return -1
return 1 + max(left, right)
return height(root) != -1
第二種解法 — 由上而下,以及為什麼比較貴。 多數人第一次寫的版本是先在 root 比較兩邊高度,再遞迴進兩個子節點,所以 height() 會對每個祖先把整棵子樹重走一次。它是對的;放在這裡只是為了面試官接下來會問的複雜度問題。
# python
# LC 110 - Balanced Binary Tree (top-down)
# IDEA: at every node compare the two subtree heights, then recurse into both children
# time = O(n log n) on a balanced tree, O(n^2) upper bound; space = O(h)
def isBalanced(root):
def height(node):
if not node:
return 0
return 1 + max(height(node.left), height(node.right))
if not root:
return True
if abs(height(root.left) - height(root.right)) > 1:
return False
return isBalanced(root.left) and isBalanced(root.right)
用「遞迴每一層的工作量 × 層數」來算。每種形狀各自給出一個每層成本和一個層數,絕不能兩種形狀各取一個:
| 平衡的樹 | 歪斜的樹(一條鏈) | |
|---|---|---|
| 層數 | log n | n |
第 d 層的工作量 |
2^d 次呼叫 × 子樹大小 n/2^d ≈ n | 1 次呼叫 × 子樹大小 n − d |
| 總和 | n · log n = O(n log n) — T(n) = 2T(n/2) + O(n) | n + (n−1) + … + 1 = O(n²) — T(n) = T(n−1) + O(n) |
換成以節點來數:height() 會因為每個祖先各走訪一個節點一次,所以總和就是所有深度的和,即 n ×(平均深度)。只有平衡的形狀能把那個深度壓在 log n。
陷阱
- O(n² log n) 是把歪斜情況的級數(本身已經是所有層的總和)乘上平衡情況的高度得到的。它把層數算了兩次。
- 在鏈上,這段由上而下的程式其實在 root 就結束了(高度 n−1 和 0 差超過 1),所以 O(n²) 是這種遞迴模式的成本,由沒有提早結束的程式全額支付,例如在每個節點都呼叫
height的天真直徑寫法。 - 由下而上版本只在兩次呼叫之後才檢查一次
-1:仍是 O(n),但會白白走完一個已經失敗節點的右子樹。在遞迴右邊之前先測left == -1。
樹形 DP 範例
Binary Tree Cameras — LC 968
關鍵 — 每次呼叫回傳一個狀態 — 未覆蓋、有攝影機、已覆蓋 — 由父節點做決定。空子節點算已覆蓋,所以葉子會回報「未覆蓋」,逼父節點裝攝影機,一台可以蓋三層而不是一層。未覆蓋的節點自己什麼都不放:交給父節點決定。
歸約到:binary_tree § Template 9 — 三個狀態取代 (take, skip),每個節點用貪心規則取代取 max。
# python
# LC 968 - Binary Tree Cameras
# IDEA: post-order DFS returning one of 3 states; the parent makes the decision
# time = O(n), space = O(h)
def minCameraCover(root):
UNCOVERED, CAMERA, COVERED = 0, 1, 2
cameras = 0
def dfs(node):
nonlocal cameras
if not node:
return COVERED # so a leaf reports UNCOVERED and its parent acts
left = dfs(node.left)
right = dfs(node.right)
if left == UNCOVERED or right == UNCOVERED: # test this FIRST
cameras += 1
return CAMERA
if left == CAMERA or right == CAMERA:
return COVERED
return UNCOVERED # decide nothing: the parent places the camera
if dfs(root) == UNCOVERED: # the root has no parent to defer to
cameras += 1
return cameras
// java
// LC 968 - Binary Tree Cameras
// IDEA: post-order, 3 states; an uncovered child forces a camera here
// time = O(n), space = O(h)
private static final int UNCOVERED = 0, CAMERA = 1, COVERED = 2;
private int cameras = 0;
public int minCameraCover(TreeNode root) {
cameras = 0;
if (dfs(root) == UNCOVERED) {
cameras++; // the root has no parent to defer to
}
return cameras;
}
private int dfs(TreeNode node) {
if (node == null) {
return COVERED;
}
int left = dfs(node.left);
int right = dfs(node.right);
if (left == UNCOVERED || right == UNCOVERED) {
cameras++;
return CAMERA;
}
if (left == CAMERA || right == CAMERA) {
return COVERED;
}
return UNCOVERED;
}
試跑 — root = [1,2,3,4,5,null,null,6,7]
1
/ \
2 3
/ \
4 5
/ \
6 7
post-order: 6, 7, 4, 5, 2, 3, 1
6, 7 : both children null (COVERED) -> UNCOVERED
4 : left UNCOVERED -> camera #1, CAMERA
5 : leaf -> UNCOVERED
2 : left CAMERA, right UNCOVERED -> the uncovered test wins: camera #2, CAMERA
3 : leaf -> UNCOVERED
1 : right UNCOVERED -> camera #3, CAMERA
answer 3
陷阱
- 先測「有子節點裝了攝影機」再測「有子節點未覆蓋」:上例的節點 2 會回傳 COVERED,讓 5 附近完全沒有攝影機。
- 忘了 root:
[0]是一個沒有父節點、未覆蓋的葉子,答案是 1,不是 0。 - 空節點回傳 UNCOVERED 會讓每個葉子都裝攝影機;空節點必須是「已覆蓋但沒有攝影機」的狀態。
結構修改範例
Invert Binary Tree — LC 226
關鍵 — 翻轉一棵樹就是在每個節點交換兩個子節點指標;遞迴回傳翻好的子樹、父節點再接回去,所以兩邊的結果都要在任一指標被覆寫之前算好。
歸約到:binary_tree § Template 10 — 回傳重建好的子樹再重新指派;沒有刪除任何東西。
# python
# LC 226 - Invert Binary Tree
# IDEA: post-order; invert both subtrees, then swap them
# time = O(n), space = O(h)
def invertTree(root):
if not root:
return None
left = invertTree(root.left) # compute BOTH before assigning either
right = invertTree(root.right)
root.left = right
root.right = left
return root
// java
// LC 226 - Invert Binary Tree
// IDEA: post-order; cache both inverted subtrees, then swap
// time = O(n), space = O(h)
public TreeNode invertTree(TreeNode root) {
if (root == null) {
return null;
}
TreeNode left = invertTree(root.left); // cache both results first
TreeNode right = invertTree(root.right);
root.left = right;
root.right = left;
return root;
}
陷阱
root.left = invertTree(root.right); root.right = invertTree(root.left);— 第二行讀到的是第一行剛覆寫的左指標,所以[2,1,3]會變成[2,3,3]這種亂掉的形狀。Python 的root.left, root.right = f(root.right), f(root.left)之所以安全,只是因為右邊會先整個算完。
Flatten Binary Tree to Linked List — LC 114
關鍵 — 每次呼叫把自己的子樹攤平,並回傳子樹的尾巴(前序中的最後一個節點);父節點就能用 O(1) 接成
node → 左鏈 → 右鏈:把原本的右子樹掛在左鏈的尾巴上,不必一路走下去找。
歸約到:binary_tree § Template 10 — 後序重新接線,回傳的是尾巴而不是新的 root。
# python
# LC 114 - Flatten Binary Tree to Linked List
# IDEA: post-order; flatten both sides, splice node -> left chain -> right chain, return the tail
# time = O(n), space = O(h)
def flatten(root):
def flatten_tail(node): # flatten node's subtree, return its LAST node
if not node:
return None
left_tail = flatten_tail(node.left)
right_tail = flatten_tail(node.right)
if left_tail: # splice: node -> left chain -> right chain
left_tail.right = node.right
node.right = node.left
node.left = None
if right_tail: # the tail is the last non-empty box
return right_tail
if left_tail:
return left_tail
return node
flatten_tail(root)
試跑 — root = [1,2,5,3,4,null,6]
flatten_tail(3) -> 3 flatten_tail(4) -> 4
flatten_tail(2): left_tail 3, right_tail 4
3.right = 4, 2.right = 3, 2.left = None => 2 -> 3 -> 4, return 4
flatten_tail(6) -> 6
flatten_tail(5): no left; right_tail 6 => 5 -> 6, return 6
flatten_tail(1): left_tail 4, right_tail 6
4.right = 5, 1.right = 2, 1.left = None => 1 -> 2 -> 3 -> 4 -> 5 -> 6
陷阱
- 在
left_tail.right = node.right之前寫node.right = node.left,原本的右子樹就丟了:先讀出來,或先暫存。 - 找尾巴的順序是右 → 左 → 自己,和走訪順序相反:有右半段時,鏈就結束在右半段。有右子節點但沒有左子節點的節點,仍要回傳
right_tail。 - 不回傳尾巴、改在每個節點用
while cur.right走下去找,是 O(n·h),在歪斜的樹上是 O(n²)。
不適用時 — 追問要求 O(1) 額外空間:那是 tree.md § Template 1 的 Morris 重接,不用遞迴,把每棵左子樹最右邊的節點接到右子節點。
兩棵樹同步走範例
Symmetric Tree — LC 101
關鍵 — 一棵樹對稱,就是它的左右子樹互為鏡像,也就是把 LC 100 用在兩棵子樹上、配對交叉:外側配外側(
a.left、b.right),內側配內側(a.right、b.left)。
歸約到:binary_tree § Template 12 — 一棵樹和自己比較,子節點交叉配對。它只在一個地方解,遞迴與迭代兩種寫法、試跑和直接配對的 bug 都在:dfs.md § LC 101。
陷阱
- 逐層比較數值、檢查每層是不是回文,會漏掉形狀:
[1,2,2,null,3,null,3]去掉 null 後每層數值都是回文,但它不對稱。 a.left配b.left檢查的是相等,不是對稱。
Merge Two Binary Trees — LC 617
關鍵 — 兩棵樹都有節點的地方,合併後的節點是兩者之和;只有一棵有的地方,整棵子樹原封不動拿來用,遞迴就停在那裡,不必複製。
歸約到:binary_tree § Template 12 — 同步走訪回傳新節點,而不是布林值。
# python
# LC 617 - Merge Two Binary Trees
# IDEA: walk both trees in lockstep; sum where both exist, reuse the lone subtree otherwise
# time = O(min(n1, n2)), space = O(min(h1, h2))
def mergeTrees(root1, root2):
if not root1:
return root2 # reuse the other subtree as it is
if not root2:
return root1
merged = TreeNode(root1.val + root2.val)
merged.left = mergeTrees(root1.left, root2.left)
merged.right = mergeTrees(root1.right, root2.right)
return merged
陷阱
- 遞迴進單獨的那棵子樹去複製它,成本是 O(n1 + n2),而不是只算重疊部分;原樣回傳才是重點。
- 把和寫進
root1再回傳(很多解法這樣做)也會被接受,但它改動了輸入;要說清楚你用的是哪一種。
Flip Equivalent Binary Trees — LC 951
關鍵 — 每個節點的子節點都可能被交換過,所以兩種配對都接受:同步檢查
left-left / right-right,或left-right / right-left。
歸約到:binary_tree § Template 12 — 每個節點試兩種配對,用 or 接起來。
# python
# LC 951 - Flip Equivalent Binary Trees
# IDEA: Same Tree, but the children pair may also be matched crosswise
# time = O(n), space = O(h) values are unique, so at most one pairing can match at each node
def flipEquiv(root1, root2):
if not root1 or not root2:
return root1 is root2
if root1.val != root2.val:
return False
same = flipEquiv(root1.left, root2.left) and flipEquiv(root1.right, root2.right)
flipped = flipEquiv(root1.left, root2.right) and flipEquiv(root1.right, root2.left)
return same or flipped
陷阱
- O(n) 的界依賴數值唯一:有重複值時,兩種配對都可能遞迴很深,最差情況比線性還差。
- 先把兩棵樹正規化(每個節點的子節點依值排序)在這題也行,但只因為值是唯一的;兩路檢查才是通用寫法。
Reverse Odd Levels of Binary Tree — LC 2415
關鍵 — 在完美二元樹裡,一層中第
i個節點和它鏡像位置的節點,可以用同步走兩半的方式找到:外側配外側、內側配內側,和 LC 101 的配對完全一樣;反轉一層就是交換每一對的值。
歸約到:binary_tree § Template 12 — LC 101 的鏡像配對,在奇數層交換值而不是比較值。
# python
# LC 2415 - Reverse Odd Levels of Binary Tree
# IDEA: walk mirror pairs in lockstep; swap their values on odd levels
# time = O(n), space = O(log n) the tree is perfect, so its height is log n
def reverseOddLevels(root):
def pair(a, b, level): # a and b are mirror positions on the same level
if not a: # perfect tree: a and b are empty together
return
if level % 2 == 1:
a.val, b.val = b.val, a.val
pair(a.left, b.right, level + 1) # outer pair
pair(a.right, b.left, level + 1) # inner pair
pair(root.left, root.right, 1)
return root
陷阱
- 交換節點而不是交換值,就得在兩邊重接父節點;這題只需要交換
val。 a.left配b.left會把位置i對到i + half,不是它的鏡像:一層是1 … 8時會變成5 6 7 8 1 2 3 4,而不是8 7 6 5 4 3 2 1。
不適用時 — 樹不是完美二元樹,或你偏好直觀的做法:用層序 BFS 收集每個奇數層再反轉它的值(binary_tree § Template 2),空間 O(w)。