二元樹
範圍 — 二元樹特有的思路:DFS 的狀態往哪個方向流(往下還是往上),以及建立在這個基礎上的 11 個結構化模板。 另見:tree.md — 一般性的樹概念與走訪策略;tree2.md — 各模式現成的模板;bst.md — 樹有序的情況。
LeetCode 題目清單
時間複雜度
| 資料結構 | 搜尋 | 插入 | 刪除 | 最小/最大 |
|---|---|---|---|---|
| Binary Tree | O(n) | O(n) | O(n) | O(n) |
一般(未排序)的二元樹 — 沒有順序性,所以每個操作都可能走遍所有節點。平衡樹會把搜尋/插入/刪除降到 O(log n)。空間是儲存的 O(n) 加上遞迴堆疊的 O(h)。有序的版本見 bst.md。
總覽
二元樹是一種階層式資料結構,每個節點最多有兩個子節點(左與右)。它是 BST、堆積等許多進階資料結構的基礎,也是理解樹相關演算法的關鍵。
關鍵性質
- 複雜度:見上面的時間複雜度表
- 核心想法:具有遞迴性質的階層結構
- 什麼時候用:階層式資料、搜尋、排序、決策、運算式解析
參考資料
辨識
限制條件的線索出自 complexity_cheatsheet.md 裡的那把尺。
| 題目敘述裡的訊號 | 限制條件線索 | 該用 | 不是它,如果 |
|---|---|---|---|
| 「回傳中序/前序/後序走訪」 | n ≤ 100 | 模板 1 遞迴,append 的位置決定順序 |
「不用遞迴」、樹很深 → 模板 2 的堆疊 |
| 「一層一層」、「右視圖」、「每個深度」 | n ≤ 2000 | 模板 2 佇列長度快照 | 節點之間的空隙也要算 → 模板 11 |
| 「由前序與中序建出這棵樹」 | n ≤ 3000,值不重複 | 模板 3 索引 map | 給的是一條帶 null 標記的字串 → tree_codec.md |
| 「所有總和 = target 的根到葉路徑」 | n ≤ 5000 | 模板 4 append / pop | 路徑可以從任何地方開始 → 樹上的前綴和,tree_lca_distance.md |
| 「直徑」、「任兩節點間的最長路徑」、「是否平衡」 | n ≤ 10⁴ | 模板 5 高度往上回傳,轉彎的路徑記在全域變數 | 需要的值來自上方(深度、目前為止的最大值)→ 往下傳,§0-5 |
| 「最低共同祖先」,有 root、兩個節點都存在 | n ≤ 10⁵ | 模板 6 用回傳值表示找到了沒 | 是 BST → 依值往下走(bst.md);沒有 root、只有 parent 指標 → tree_lca_distance.md |
| 「用少於 O(n) 的時間數出完全二元樹的節點數」 | n ≤ 5 · 10⁴ | 模板 7 比較左側脊 | 不是完全樹 → O(n) 走訪 |
「填好每個 next 指標」、「常數額外空間」 |
n ≤ 6000 | 模板 8 虛擬頭 + 尾指標 | 允許 O(n) 空間 → 模板 2 |
| 「不能同時選父節點和它的子節點」、「覆蓋每個節點」 | n ≤ 10⁴ | 模板 9 回傳一組狀態 | 每棵子樹一個數字就夠 → 模板 5 |
| 「刪除節點並回傳森林」、「剪枝」 | n ≤ 1000 | 模板 10 回傳新的子樹 | 有序的樹依範圍修剪 → bst.md |
| 「某層的寬度要算進中間的 null」、「是否為完全樹」 | n ≤ 3000 | 模板 11 堆積式索引 | 依欄輸出、同格還要排序 → tree.md |
| 「相同的樹」、「對稱」、「合併兩棵樹」、函式簽名裡有兩個根 | n ≤ 2000 | 模板 12 對節點對遞迴 | 要在一棵樹裡找另一棵 → 把子樹雜湊起來(tree_codec.md) |
0) 概念:DFS 的狀態往哪個方向流? 優先度 5/5 — 必備 — 幾乎每一輪面試都會出現
選模板之前先回答一個問題:一個節點需要的資訊,是從它上面來的,還是從它下面來的? 光是這一個答案,就能把幾乎所有樹 DFS 題目分成三種形狀。
0-1) 三種 DFS 形狀
| 形狀 | 狀態流向 | 函式簽名 | 答案從哪裡讀 | 經典 LC |
|---|---|---|---|---|
| A — 由上而下、回看 | 往下,透過 parent 參數 | dfs(node, parent, state) |
從全域變數 | 112、129、1448、298 |
| B — 由上而下、前看 | 往下,由父節點決定 | dfs(node, state) |
從全域變數 | 298、687 |
| C — 由下而上(後序) | 往上,透過回傳值 | dfs(node) -> state |
從回傳值(+ 全域變數) | 104、543、124、337 |
A 和 B 只是同一種由上而下走訪的兩種寫法。 C 才是真正不同的演算法。在 A/B 之間選是風格問題;在由上而下與由下而上之間選是正確性問題。
0-2) A vs B — 回看 vs 前看
兩者都能解 LC 298,時間都是 O(n)、空間都是 O(h)。差別在於父子比較這件事由誰負責。
# python
# LC 298 - Binary Tree Longest Consecutive Sequence
# STYLE A: LOOK-BACK — recurse first, compare against the parent I was handed
# time = O(n), space = O(h)
class Solution(object):
def longestConsecutive(self, root):
if not root:
return 0
self.max_len = 0
self.dfs(root, None, 0) # seed: no parent -> streak resets to 1
return self.max_len
def dfs(self, node, parent, curr_len):
if not node: # <-- null handled HERE (base case)
return
# I decide MY OWN state from my parent's
if parent and node.val == parent.val + 1:
curr_len += 1
else:
curr_len = 1 # streak broke -> restart AT me
self.max_len = max(self.max_len, curr_len)
# ALWAYS recurse both sides - a broken streak can restart anywhere below
self.dfs(node.left, node, curr_len)
self.dfs(node.right, node, curr_len)
# python
# LC 298 - Binary Tree Longest Consecutive Sequence
# STYLE B: LOOK-FORWARD — I compute each CHILD's state before calling it
# time = O(n), space = O(h)
class Solution(object):
def longestConsecutive(self, root):
if not root:
return 0
self.max_len = 0
self.dfs(root, 1) # seed: root is a streak of length 1
return self.max_len
def dfs(self, node, curr_len):
self.max_len = max(self.max_len, curr_len)
# I decide MY CHILDREN's state, and guard the null BEFORE recursing
if node.left: # <-- null handled HERE (call-site guard)
self.dfs(node.left, curr_len + 1 if node.left.val == node.val + 1 else 1)
if node.right:
self.dfs(node.right, curr_len + 1 if node.right.val == node.val + 1 else 1)
並排比較
| A — 回看 | B — 前看 | |
|---|---|---|
| 誰做比較 | 子節點,比自己 | 父節點,對每個子節點比 |
| null 怎麼處理 | 基底情況 if not node: return |
呼叫端擋掉 if node.left: |
n 個節點的呼叫次數 |
2n + 1(null 也會被呼叫) | n(null 永遠不會被呼叫) |
| 額外參數 | 有 — parent(或 parent_val) |
沒有,直接讀 node.val |
| 起始呼叫 | dfs(root, None, 0) |
dfs(root, 1) |
| 比較邏輯要寫幾次 | 一次 | 兩次(左 + 右) |
| N 元樹(LC 589/1522) | for c in node.children: dfs(c, node, s) — 不用改 |
必須把比較邏輯再包進迴圈裡 |
| 圖/被改建成圖(LC 863) | 很自然 — parent = 「我從哪來」 |
很彆扭 — 沒有固定的子節點集合 |
這是實測出來的,不是嘴上說說:在一棵 15 個節點的完美樹上,寫法 A 執行了 31 次呼叫,寫法 B 執行了 15 次。多出來的那些是
None子節點。都是 O(n) — B 只是常數比較小。
該選哪一個
- 預設選 A。 轉移邏輯只有一份,擴展到 N 元樹和圖都不用改,而且
if not node這個基底情況是其他每個樹模板早就在訓練的習慣。 - 這些情況選 B:比較需要邊的兩端,而你想避開一個 null 分支 — 或者你根本不能走進某個子節點(剪枝),因為 B 是在遞迴之前就先做決定。
- A 洩漏的狀態比較少。 在 B 裡,
self.max_len必須在節點本身更新(不是在子節點),否則根節點自己的長度永遠不會被算進去。 - 不要把子樹序列化來找連續串。 像
"{val}-{left}-{right}"這樣的字串是整棵樹的前序傾印:兄弟節點緊挨在一起,真正的父子邊反而被#標記隔開,所以掃描+1步會把3-9-#-#-4-#-5-#-#讀成 9 和 4 相連。而且建出來要 O(n²)。序列化回答的是「兩棵子樹是否相同?」(LC 652),不是「這條鏈有多長?」。
哨兵值捷徑(以及它什麼時候會爆)
如果傳的不是節點而是一個假的父節點值,寫法 A 的 if parent and ... 就消失了:
# python
# LC 298 - the `parent_val` sentinel variant of Style A
# IDEA: seed with (root.val - 1) so the root automatically satisfies "val == parent_val + 1" -> length 1
dfs(root, root.val - 1, 0) # no `if parent` branch needed inside dfs
⚠️ 這只在狀態取決於父節點的值時才成立。 如果你需要的是父節點的身分 — 例如 LC 993(Cousins in Binary Tree),兩個節點必須同深度但父節點不同 — 就一定要傳實際的節點。在那裡傳 parent_val,只要兩個父節點的值剛好相同,答案就會默默地錯掉。
0-3) A/B 的選擇適用於每一道樹 DFS 題嗎? — 不
A vs B 這個問題只對由上而下的題目有意義:也就是一個節點的答案,完全由從根走到它的那條路徑決定。問自己:
Can I answer for this node using ONLY what I learned on the way down?
├── YES -> Top-Down. Pick Style A or B freely (they are interchangeable).
│ Root-to-leaf sums, depth, path constraints, "ancestor so far".
└── NO, I need a fact about my SUBTREE (its height / best path / sum)
-> Bottom-Up (Style C). A and B CANNOT express this.
Depth, diameter, max path sum, balance, subtree aggregates.
C 的辨認特徵:某個節點的答案要把兩個子節點的結果合起來(left + right + node.val),或者節點回傳的東西跟全域變數追蹤的東西不一樣。
| LC | 題目 | 形狀 | 為什麼 |
|---|---|---|---|
| 112 / 113 | Path Sum I / II | A 或 B | 累計和從上面來 |
| 129 | Sum Root to Leaf Numbers | A 或 B | 往下累積 num*10 + val |
| 1448 | Count Good Nodes | A 或 B | 把 maxSoFar 帶下去 |
| 1026 | Max Diff Node vs Ancestor | A 或 B | 把 (min, max) 帶下去 |
| 298 | Longest Consecutive Sequence | A 或 B | 連續長度從上面來 |
| 993 | Cousins in Binary Tree | 只能 A | 需要父節點,不是它的值 |
| 863 | All Nodes Distance K | 只能 A | 樹被當成圖來走;parent = 從哪來 |
| 104 / 111 | Max / Min Depth | C | 需要子節點的高度 |
| 543 | Diameter | C | 在節點上算 left + right |
| 110 | Balanced Binary Tree | C | 比較子樹高度 |
| 124 | Max Path Sum | C | 回傳單邊,全域記錄兩邊相加 |
| 687 | Longest Univalue Path | B 和 C | 用 B 算往下的單邊,用 C 把兩邊接起來 → 見模板 9 |
| 337 | House Robber III | C | 回傳 (take, skip) 二元組 → 見模板 9 |
| 236 | LCA | C | 需要知道「p/q 有沒有在我下面被找到」 |
LC 298 是少見的三種寫法都能解的題目 — 它的路徑嚴格往下(所以由上而下可行),而且一棵子樹往下最長的連續段也有明確定義(所以由下而上也可行)。跟下面**模板 9(樹 DP — 由下而上回傳多個狀態)**的 C 版本對照一下 — 它是把
cur_len往上回傳,而不是往下串。大部分題目只吃一種形狀。
卡住時把 A 改成 C
如果由上而下的嘗試需要子樹的資訊,機械式的修法是:別再把累計器往下傳,改成把它往上回傳,答案仍然放全域變數。
# python
# LC 298 - Style C (bottom-up): return "longest run STARTING at me, going down"
# IDEA: post-order; the global captures the best, the return value feeds my parent
# time = O(n), space = O(h)
def helper(node):
if not node:
return 0
l, r = helper(node.left), helper(node.right) # children FIRST
cur = 1
if node.left and node.left.val == node.val + 1:
cur = max(cur, l + 1)
if node.right and node.right.val == node.val + 1:
cur = max(cur, r + 1)
self.max_len = max(self.max_len, cur) # global != return value
return cur
0-4) 三種形狀共通的坑 — 是重置,不是停止
在 LC 298(以及每一道「最長的 X 連續段」樹題)裡,連續段斷掉時必須從 1 重新開始,絕不是終止遞迴:
# python
# IDEA: carry the current streak down; a break restarts it at 1 and keeps exploring
# time = O(n), space = O(h)
# LC 298 - Binary Tree Longest Consecutive Sequence
def longestConsecutive(root):
best = 0
def dfs(node, parent, curr_len):
nonlocal best
if node is None:
return
if parent and node.val == parent.val + 1:
curr_len += 1
# ✅ correct - streak breaks: restart at 1, but keep exploring
else:
curr_len = 1
# 🚫 wrong here: `return` - a longer streak may start deeper in this subtree
best = max(best, curr_len)
# recurse on BOTH children whether the streak continued or reset
dfs(node.left, node, curr_len)
dfs(node.right, node, curr_len)
dfs(root, None, 0)
return best
在 4000 棵隨機樹上驗證過:寫法 A、B、C 每一個案例都跟暴力解一致,包括下面這棵之字形的樹 — 路徑 1→2→3→4 左右交錯,仍然是合法的,因為唯一的規則就是父 → 子。
1
\
2 longest = 4 (1 -> 2 -> 3 -> 4)
/
3
\
4
0-5) 深度 vs 高度 — 樹的 DFS 算的兩個數字 優先度 4/5 — 高價值 — 這裡有缺口就會掉關
這是 §0-1 的同一條規則,套用在幾乎每道樹題都會問的兩個量上。要問的是:父節點在呼叫子節點之前,就已經知道這個值了嗎?
| 量 | 量的是 | 誰先知道 | 流向 | 形狀 | 經典 LC |
|---|---|---|---|---|---|
| 深度 | 從根往下到這個節點的邊數 | 父節點 | 往下,當參數:dfs(child, depth + 1) |
A / B | 102, 111, 199, 1302 |
| 高度 | 從這個節點往下到葉節點、最長路徑上的節點數(葉節點 = 1,空樹 = 0) | 子節點 | 往上,當回傳值:return 1 + max(l, r) |
C | 104, 110, 543, 366 |
| 距離 | 兩個節點之間的邊數 | 單靠任一方都不知道 | 兩者都要:從 LCA 量深度,或建 parent map | — | 1740, 863 → tree_lca_distance.md |
注意單位:這裡的深度算邊數(根 = 0),高度算節點數(葉節點 = 1)。 下面的程式碼回傳的就是這兩種值,而且這樣 LC 543 的
left_height + right_height直接就是邊數。LC 104 是兩種讀法都成立的那一題。 它的答案算的是節點數,所以等於根的高度,也等於最深葉節點的深度 + 1。因此既可以由上往下(把
depth帶到每個葉節點),也可以由下往上(回傳高度)。這就是 §0-3 把深度同時列在兩個分支下的原因。
深度 — 往下傳
# python
# LC 102 - Binary Tree Level Order Traversal (the DFS version)
# IDEA: carry depth as an argument; the first visit to a depth opens its list
# time = O(n), space = O(h)
def nodes_by_depth(root):
levels = []
def dfs(node, depth):
if node is None:
return
if depth == len(levels): # first node seen at this depth
levels.append([])
levels[depth].append(node.val)
dfs(node.left, depth + 1) # the child's depth is decided HERE, by the parent
dfs(node.right, depth + 1)
dfs(root, 0)
return levels
# python
# Depth of one target node (edges from the root), -1 if it is not in the tree
# IDEA: pre-order DFS carrying depth; the -1 sentinel means "not below me"
# time = O(n), space = O(h)
def depth_of(root, target):
def dfs(node, depth):
if node is None:
return -1
if node is target:
return depth
left = dfs(node.left, depth + 1)
if left != -1: # found on the left: stop, skip the right
return left
return dfs(node.right, depth + 1)
return dfs(root, 0)
三種走訪唯一的差別,就是 append 放在前序 / 中序 / 後序的哪個位置。走訪本身見 模板 1 和 模板 2。
高度 — 往上回傳
高度是後序算的:先算兩個子節點,再 1 + max(left, right)。它最經典的兩個用法各只講一次,不在這裡重複 — LC 543 用全域變數記錄兩條臂、回傳值只帶一條,是 模板 5 的錨點題;LC 110 回傳 -1 當作「已經不平衡」的訊號一路往上短路,是 tree_examples.md 裡的一張問題卡。
全域變數不等於回傳值。 LC 543 回傳一條臂(1 + max),記錄的卻是兩條臂(left + right)。回傳 left + right 是最常見的 bug:父節點會去延伸一條已經轉過彎的路徑。LC 124 和 LC 687 也遵守同一條規則,只是臂換成總和、或加上同值的檢查(它們的卡片:LC 124、LC 687)。
距離 — 兩者同時用上
距離同時需要來自上方和下方的資訊,所以放在 tree_lca_distance.md:
- p 和 q 兩個節點之間(LC 1740):
d(p, q) = depth(p) + depth(q) − 2·depth(lca)。先找 LCA,再從 LCA 跑兩次那一節的get_dist。LC 1740 給的是值,所以get_dist比的是root.val == target;上面的depth_of比的是節點本身,會回傳-1。見 Template 3:節點間距離。 - 離 target 距離為 k 的所有節點(LC 863):用一次 DFS 記下每個節點的父節點,樹就變成一張無向圖,再帶著
visited集合往外走。見 Template 2:parent map + BFS。
題型分類
模式 1:樹的走訪
- 說明:以特定順序走訪所有節點(前序、中序、後序、層序)
- 辨認關鍵字:「走訪所有節點」、「印出這棵樹」、「序列化這棵樹」
- 例題:LC 94、LC 144、LC 145、LC 102
- 模板:用走訪模板
模式 2:樹的建構
- 說明:從走訪序列或其他表示法建出樹
- 辨認關鍵字:「從……建構」、「build tree」、「deserialize」
- 例題:LC 105、LC 106、LC 108、LC 297
- 模板:用建構模板
模式 3:路徑問題
- 說明:找出具有特定性質的路徑(和、長度、樣態)
- 辨認關鍵字:「路徑和」、「從根到葉」、「最長路徑」
- 例題:LC 112、LC 113、LC 257、LC 124
- 模板:用路徑模板搭配回溯
模式 4:樹的性質
- 說明:檢查或計算樹的性質(高度、平衡、對稱)
- 辨認關鍵字:「高度」、「平衡」、「對稱」、「直徑」
- 例題:LC 104、LC 110、LC 101、LC 543
- 模板:用性質檢查模板
模式 5:LCA 與距離
- 說明:找共同祖先,或計算節點之間的距離
- 辨認關鍵字:「最近共同祖先」、「節點間的距離」
- 例題:LC 236、LC 235、LC 863
- 模板:用 LCA 模板
模式 6:在樹上做二分搜尋
- 說明:把二分搜尋技巧套用在樹的性質上(高度、節點數、結構)
- 辨認關鍵字:「O(log n) 時間」、「完全二元樹」、「數節點」、「找第 k 個元素」
- 例題:LC 222(Count Complete Tree Nodes)、LC 230(Kth Smallest in BST)
- 模板:用二分搜尋 + 樹性質模板
- 關鍵洞見:
- 對完全二元樹,可以在樹的結構上做二分搜尋
- 檢查左/右子樹的性質來決定往哪邊搜
- 時間複雜度可以從 O(n) 降到 O(log²n)
完全樹的陣列表示法
- 注意,如果我們用一個
array來表示complete binary tree,並且store the root node at index 1- 那麼,任何節點的
父節點索引就是[index of the node / 2] - 那麼,
左子節點的索引就是[index of the node * 2] - 那麼,
右子節點的索引就是[index of the node * 2 + 1] - 參考實作:https://github.com/yennanliu/CS_basics/blob/master/data_structure/python/MinHeap.py#L36-L40
- video:講得非常好!!
- 性質
- 怎麼存?
- 用陣列加索引
- 怎麼找父節點?
- n / 2
- 注意:
n 是陣列裡的「索引」
- 怎麼找左右子節點?
- 左子節點:n * 2
- 右子節點:n * 2 + 1
- 怎麼判斷一個節點是不是葉節點?
- 檢查 i > (節點總數) / 2
-

- 怎麼存?
- 那麼,任何節點的
範例:
假設你有一棵像這樣的完全二元樹:
10
/ \
15 20
/ \ /
30 40 50
這棵樹用**陣列(1 起始)**表示會是:
# `n is an "index"` in array
Index: 1 2 3 4 5 6
Value: [10, 15, 20, 30, 40, 50]
關係:
-
索引 2 的節點(15)
- 父節點:2 / 2 = 1 → 10
- 左子節點:2 * 2 = 4 → 30
- 右子節點:2 * 2 + 1 = 5 → 40
-
陣列轉完全樹
- dev
-
完全二元樹(Complete binary tree)- 完全二元樹是一種二元樹:
除了最後一層之外每一層都被填滿,而且最後一層的節點全部盡量靠左。 - wiki
- 例子:
- 是完全二元樹
- 不是完全二元樹

- 完全二元樹是一種二元樹:
模板與演算法
模板比較表
| 模板類型 | 適用情境 | 做法 | 時間 | 空間 | 什麼時候用 |
|---|---|---|---|---|---|
| 遞迴走訪 | 單純走訪 | 遞迴 | O(n) | O(h) | 預設選擇,程式碼乾淨 |
| 迭代走訪 | 記憶體吃緊 | 堆疊/佇列 | O(n) | O(h) | 避開遞迴的額外開銷 |
| Morris 走訪 | 空間吃緊 | 執行緒化(threading) | O(n) | O(1) | 要求常數空間時 |
| 層序 | BFS 題目 | 佇列 | O(n) | O(w) | 一層一層處理 |
| 在樹上二分搜尋 | 完全/平衡樹 | 二分搜尋 | O(log²n) | O(log n) | 利用樹結構做最佳化 |
模板 1:遞迴的前序 / 中序 / 後序走訪 — LC 94
辨識 — 「回傳前序/中序/後序走訪」、「拜訪每個節點」,或任何答案是「依某種順序排出的節點值清單」的題目。這題 n ≤ 100,但同一段程式碼就是之後每道大題的 O(n) 骨架。
關鍵 — 遞迴剛好拜訪每個節點一次;那唯一一行 append 放在哪裡(兩次遞迴呼叫之前、之間或之後),是決定順序的唯一因素。
# python
# LC 94 - Binary Tree Inorder Traversal
# IDEA: recurse left, record the node, recurse right; move the append to get pre- or post-order
# time = O(n), space = O(h) h = height of the tree (recursion stack)
def inorderTraversal(root):
result = []
def dfs(node):
if not node:
return
# result.append(node.val) <- here: PRE-order (root, left, right)
dfs(node.left)
result.append(node.val) # here: IN-order (left, root, right)
dfs(node.right)
# result.append(node.val) <- here: POST-order (left, right, root)
dfs(root)
return result
// java
// LC 94 - Binary Tree Inorder Traversal
// IDEA: recurse left, record the node, recurse right; move the add() for pre- or post-order
// time = O(n), space = O(h)
public List<Integer> inorderTraversal(TreeNode root) {
List<Integer> result = new ArrayList<>();
dfs(root, result);
return result;
}
private void dfs(TreeNode node, List<Integer> result) {
if (node == null) {
return;
}
dfs(node.left, result);
result.add(node.val); // in-order position
dfs(node.right, result);
}
複雜度
- 時間 O(n):每個節點呼叫一次
dfs,每個 null 子節點再多一次,而每次呼叫除了兩次遞迴之外只做 O(1) 的事。 - 空間 O(h):遞迴堆疊在目前這條根到節點的路徑上每層放一個 frame;歪斜樹
h = n,平衡樹是log n。輸出清單不算在內。
陷阱
return [root.val] + preorder(root.left) + preorder(root.right)看起來漂亮,但每一層都在複製清單:n 個節點的歪斜樹(LC 94[1,null,2,null,3,…])要花 O(n²)。請 append 到同一個共用清單。- 10⁵ 個節點的歪斜樹會超過 Python 預設的遞迴上限(1000)— 改用 模板 2 的顯式堆疊。
不適用時 — 樹是 BST,而你要的是「第 k 小」或「是否合法」:走訪一樣,但提早停止和排序不變式放在 bst.md。
練習 — LC 94 · LC 144 · LC 145 · LC 589 (n 元樹:改成迴圈走 children)
模板 2:迭代走訪 — 用堆疊做中序、用佇列做層序 — LC 102
辨識 — 「一層一層」、「每個深度」、「右視圖」、「之字形」、「每層的平均」→ 佇列。「不用遞迴」、「樹可能深達 10⁵」,或是一次吐出一個節點的迭代器(LC 173)→ 堆疊。
關鍵 — 讀 level_size = len(queue) 的那一刻,佇列裡剛好就是一整層,所以內層的 for 迴圈只會把這一層清空,不多不少。(堆疊這邊:它存的是還欠一次拜訪的左側脊,所以頂端永遠是排序順序裡的下一個節點。)
# python
# LC 102 - Binary Tree Level Order Traversal
# IDEA: snapshot the queue length = the size of the current level, drain exactly that many
# time = O(n), space = O(w) w = maximum width of a level
from collections import deque
def levelOrder(root):
if not root:
return []
result = []
queue = deque([root])
while queue:
level_size = len(queue) # every node now in the queue is on this level
level = []
for _ in range(level_size):
node = queue.popleft()
level.append(node.val)
if node.left:
queue.append(node.left)
if node.right:
queue.append(node.right)
result.append(level)
return result
# LC 94 - Binary Tree Inorder Traversal (iterative)
# IDEA: push the whole left spine, pop = visit, then move to the right child
# time = O(n), space = O(h)
def inorder_iterative(root):
result = []
stack = []
current = root
while current or stack:
while current: # go as far left as possible
stack.append(current)
current = current.left
current = stack.pop() # leftmost unvisited node
result.append(current.val)
current = current.right # its right subtree comes next
return result
// java
// LC 102 - Binary Tree Level Order Traversal
// IDEA: snapshot the queue size = the current level, drain exactly that many
// time = O(n), space = O(w)
public List<List<Integer>> levelOrder(TreeNode root) {
List<List<Integer>> result = new ArrayList<>();
if (root == null) {
return result;
}
Deque<TreeNode> queue = new ArrayDeque<>();
queue.offer(root);
while (!queue.isEmpty()) {
int levelSize = queue.size(); // read ONCE, before the loop adds children
List<Integer> level = new ArrayList<>();
for (int i = 0; i < levelSize; i++) {
TreeNode node = queue.poll();
level.add(node.val);
if (node.left != null) {
queue.offer(node.left);
}
if (node.right != null) {
queue.offer(node.right);
}
}
result.add(level);
}
return result;
}
複雜度
- 時間 O(n):每個節點進佇列一次、出佇列一次;內層
for迴圈的次數加總起來就是 n。 - 空間 O(w):佇列裡最多只有相鄰的兩層;完全樹最寬的一層大約 n / 2。中序的堆疊只放一條根到節點的脊,O(h)。
陷阱
- Java 的
for (int i = 0; i < queue.size(); i++)會在迴圈 poll 和 offer 的同時重新讀取大小,於是某些層被截短或被合併:LC 102[3,9,20,null,null,15,7]就不再回傳[[3],[9,20],[15,7]]。請在迴圈開始前只讀一次大小。 - 先寫
if not root: return []:空樹否則會把None放進佇列,然後在.val上當掉。 - 用
list.pop(0)取代deque.popleft()會讓每次出佇列都是 O(n) — 寬的樹上就變 O(n²)。
不適用時 — 答案需要一層之內的水平空隙(LC 662):讓節點帶著堆積式索引,見 模板 11。欄(LC 314、LC 987)是另一種座標:帶著 col,左子 col - 1、右子 col + 1。
練習 — LC 102 ⭐ · LC 199 (每層的最後一個節點) · LC 103 (每隔一層反轉) · LC 173 (把中序堆疊做成迭代器) · LC 314
模板 3:由前序 + 中序建樹 — LC 105
辨識 — 「由前序和中序(或中序和後序)走訪建出這棵二元樹」,值不重複。n ≤ 3000 → O(n²) 的 index() 版也會過,但面試期待的是 O(n)。
關鍵 — preorder[pre_index] 永遠是正在建的這棵子樹的根,而它在 inorder 裡的位置,會把剩下的範圍剛好切成左子樹的值和右子樹的值。先建左再建右,pre_index 就會依序走完 preorder。
# python
# LC 105 - Construct Binary Tree from Preorder and Inorder Traversal
# IDEA: next preorder value is the root; its inorder index splits [lo, hi] into left / right
# time = O(n), space = O(n) the index map, plus O(h) recursion
def buildTree(preorder, inorder):
index_of = {}
for i, val in enumerate(inorder):
index_of[val] = i # O(1) lookup instead of inorder.index()
pre_index = 0 # next root to take from preorder
def build(lo, hi): # build the subtree whose values are inorder[lo..hi]
nonlocal pre_index
if lo > hi:
return None
root_val = preorder[pre_index]
pre_index += 1
root = TreeNode(root_val)
mid = index_of[root_val]
root.left = build(lo, mid - 1) # left FIRST: preorder lists the left subtree next
root.right = build(mid + 1, hi)
return root
return build(0, len(inorder) - 1)
// java
// LC 105 - Construct Binary Tree from Preorder and Inorder Traversal
// IDEA: next preorder value is the root; its inorder index splits the range
// time = O(n), space = O(n)
private Map<Integer, Integer> indexOf = new HashMap<>();
private int preIndex = 0;
public TreeNode buildTree(int[] preorder, int[] inorder) {
for (int i = 0; i < inorder.length; i++) {
indexOf.put(inorder[i], i);
}
preIndex = 0;
return build(preorder, 0, inorder.length - 1);
}
private TreeNode build(int[] preorder, int lo, int hi) {
if (lo > hi) {
return null;
}
int rootVal = preorder[preIndex];
preIndex++;
TreeNode root = new TreeNode(rootVal);
int mid = indexOf.get(rootVal);
root.left = build(preorder, lo, mid - 1); // left before right
root.right = build(preorder, mid + 1, hi);
return root;
}
複雜度
- 時間 O(n):每次呼叫消耗一個 preorder 的值、建立一個節點;
index_of[root_val]是 O(1) 的 map 查詢,用inorder.index()的話整個建樹會變成 O(n²)。 - 空間 O(n):
index_ofmap 有 n 筆;遞迴再加 O(h)。
試跑
preorder = [3,9,20,15,7] inorder = [9,3,15,20,7]
build(0,4) root 3 mid 1 -> left = build(0,0), right = build(2,4)
build(0,0) root 9 mid 0 -> both children build(lo > hi) = None
build(2,4) root 20 mid 3 -> left = build(2,2) root 15, right = build(4,4) root 7
result [3,9,20,null,null,15,7]
陷阱
- 先建
root.right再建root.left,preorder 就會以錯的順序被消耗:LC 105 範例 1 會回傳一棵[3,20,9,…]形狀的亂樹。(後序 + 中序,LC 106,要從尾端取根,並且先建右。) - 每次呼叫都切片
preorder[1:mid+1],每次就是 O(n) → 歪斜樹上變 O(n²)。 - 值重複會讓
index_of壞掉;題目保證不重複,所以要把這個假設講出來。
不適用時 — 你拿到的是一條帶 null 標記的序列("1,2,#,#,3"):那是序列化的編解碼,一個前序解析器,在 tree_codec.md。
練習 — LC 105 ⭐ · LC 106 (後序:從尾端取根、先建右) · LC 889 (前序 + 後序,左子樹的根是 preorder[1]) · LC 1008 (只靠前序建 BST:用上下界取代 map)
Path Sum II,根到葉路徑 — LC 113
關鍵 — 前序 DFS:進入節點時 append、離開時 pop,所以
path永遠是根到目前節點的路徑, 兄弟節點不會看到彼此的節點。
歸約到:tree_lca_distance.md § Template 4,那份表單負責整個根到葉路徑家族 (LC 112 / 113 / 129 / 257 / 437),Python 與 Java、試跑和陷阱都在那裡。這份表單不留副本,下面的模板編號維持不變。
模板 5:高度往上回傳、答案記在全域變數 — LC 543
辨識 — 「直徑」、「任兩節點間的最長路徑」、「最大路徑和」、「是否平衡」、「坡度」— 某個節點的答案需要兩個子節點的結果,而且最佳答案可能在一個不是根的節點轉彎。
關鍵 — 函式回傳一條臂(高度,父節點可以接著延伸),並把轉彎的路徑 left + right 記錄到全域變數;全域變數絕不是回傳值,因為父節點沒辦法延伸一條已經轉過彎的路徑。
# python
# LC 543 - Diameter of Binary Tree
# IDEA: return my height (in nodes) to my parent; record the path that bends at me
# time = O(n), space = O(h)
def diameterOfBinaryTree(root):
best = 0 # longest path seen so far, in edges
def height(node):
nonlocal best
if not node:
return 0
left = height(node.left)
right = height(node.right)
best = max(best, left + right) # the path that bends at node
return 1 + max(left, right) # only ONE arm continues upward
height(root)
return best
// java
// LC 543 - Diameter of Binary Tree
// IDEA: return the height, record left + right in a field
// time = O(n), space = O(h)
private int best = 0;
public int diameterOfBinaryTree(TreeNode root) {
best = 0;
height(root);
return best;
}
private int height(TreeNode node) {
if (node == null) {
return 0;
}
int left = height(node.left);
int right = height(node.right);
best = Math.max(best, left + right);
return 1 + Math.max(left, right);
}
複雜度
- 時間 O(n):
height以後序對每個節點跑一次,兩次呼叫之後只做 O(1) 的事。 - 空間 O(h):遞迴堆疊;全域變數是 O(1)。
陷阱
- 回傳
left + right而不是1 + max(left, right):每個葉子都回傳 0,往上每個節點也都是 0,所以[1,2,3,4,5]的答案是 0 而不是 3。 - 單位:
height(None) = 0數的是節點,所以left + right已經是轉彎路徑的邊數。再加 1 會讓 LC 543 範例 2([1,2])的答案變成 2 而不是 1。 - LC 124(最大路徑和)有負值:每條臂要用
max(0, arm)截掉負數,全域變數從-inf開始,否則[-3]會回傳 0。 - 在迴圈裡再呼叫一次
height(由上而下寫的 LC 110)在歪斜樹上是 O(n²);高度只算一次,用-1當「已經不平衡」的旗標回傳。
不適用時 — 節點需要的值來自它的上方(深度、路徑上目前為止的最大值,LC 1448):改成當參數往下傳 — 見 §0-5。
練習 — LC 543 ⭐ · LC 104 ⭐ · LC 110 ⭐ · LC 687 · LC 124 ⭐
Lowest Common Ancestor of a Binary Tree — LC 236
關鍵 — 後序:每次呼叫回傳它找到的目標(或 LCA,或什麼都沒有);第一個左右子都回傳東西的節點就是分岔點,也就是 LCA。
歸約到:tree_lca_distance.md § Template 1,那份表單負責整個 LCA 家族:Python 與 Java 程式碼、一次試跑,以及各改一個前提的變形(BST 上的 LC 235、目標可能不存在的 LC 1644、有 parent 指標的 LC 1650、一組目標的 LC 1676)。
模板 7:數完全樹的節點 — LC 222
辨識 — 「完全二元樹」、「設計一個少於 O(n) 的演算法」。n 最多 5 · 10⁴ — O(n) 也會過,所以題目真正問的是追問。
關鍵 — 在完全樹裡,如果左子樹最左邊的深度等於右子樹的,那左子樹就是完美樹(2^h − 1 個節點);否則右子樹是完美樹,少一層。不管哪種,一半用公式算,只需要遞迴進另一半。
# python
# LC 222 - Count Complete Tree Nodes
# IDEA: compare the left spines of the two subtrees; the perfect half is counted by 2^h,
# recurse only into the other half
# time = O(log^2 n), space = O(log n)
def countNodes(root):
if not root:
return 0
def left_depth(node): # nodes on the leftmost path
depth = 0
while node:
depth += 1
node = node.left
return depth
left_h = left_depth(root.left)
right_h = left_depth(root.right)
if left_h == right_h:
# left subtree is perfect: 2^left_h - 1 nodes, plus the root = 2^left_h
return (1 << left_h) + countNodes(root.right)
# right subtree is perfect, one level shorter: 2^right_h - 1 nodes, plus the root
return (1 << right_h) + countNodes(root.left)
// java
// LC 222 - Count Complete Tree Nodes
// IDEA: equal left spines -> left half perfect, else right half perfect; recurse into the other
// time = O(log^2 n), space = O(log n)
public int countNodes(TreeNode root) {
if (root == null) {
return 0;
}
int leftH = leftDepth(root.left);
int rightH = leftDepth(root.right);
if (leftH == rightH) {
return (1 << leftH) + countNodes(root.right);
}
return (1 << rightH) + countNodes(root.left);
}
private int leftDepth(TreeNode node) {
int depth = 0;
while (node != null) {
depth++;
node = node.left;
}
return depth;
}
第二種解法 — 比較同一個節點的兩條脊(同樣的界;值得會它的理由:最左與最右深度相等就證明這棵子樹是完美樹,這正是面試官想聽到的那句話,代價是兩邊不等時兩個子節點都要遞迴):
# python
# LC 222 - Count Complete Tree Nodes (same-node spines)
# IDEA: leftmost depth == rightmost depth -> perfect subtree -> 2^h - 1; else 1 + both children
# time = O(log^2 n), space = O(log n) one child is always perfect and returns at once
def countNodes(root):
if not root:
return 0
left_h, node = 0, root
while node: # leftmost spine
left_h += 1
node = node.left
right_h, node = 0, root
while node: # rightmost spine
right_h += 1
node = node.right
if left_h == right_h:
return (1 << left_h) - 1
return 1 + countNodes(root.left) + countNodes(root.right)
複雜度
- 時間 O(log² n):每次呼叫往下一層(共 h = log n 次呼叫),而每次呼叫在
left_depth裡走兩條長度 ≤ log n 的左側脊。 - 空間 O(log n):每層一個堆疊 frame。
陷阱
1 << h算的是子樹加上目前的根;寫成(1 << h) - 1再替根加 1 也一樣,但忘了根,LC 222 範例 1([1,2,3,4,5,6])就會回傳 5。- 量右子樹的右側脊而不是左側脊會讓判斷失效:最後一層是從左邊填起,只有左側脊才告訴你它真正的深度。
不適用時 — 不保證是完全樹:只有 O(n) 的走訪是對的。
練習 — LC 222 · LC 958 (是否完全?BFS,模板 11) · LC 1261 · LC 1104 (在之字形標號的樹上做堆積索引運算)
模板 8:O(1) 空間串接同層節點 — LC 117 優先度 5/5 — 必備 — 幾乎每一輪面試都會出現
辨識 — 「把每個 next 指標指向它右邊的下一個節點」、「只能用常數額外空間」,而且樹不是完美樹(LC 117;LC 116 是完美樹的情況)。
關鍵 — 你站在第 k 層時,它的 next 鏈已經串好了,所以它就是第 k 層的佇列;一邊走它,一邊用虛擬頭節點 + 移動的尾指標把第 k + 1 層縫起來。
# python
# LC 117 - Populating Next Right Pointers in Each Node II
# IDEA: walk level k through its own `next` chain; build level k+1's chain with dummy + tail
# time = O(n), space = O(1) output pointers not counted
def connect(root):
cur = root # head of the level being walked
while cur:
dummy = Node(0) # dummy.next will be the head of the NEXT level
tail = dummy
while cur: # walk the current level through `next`
if cur.left:
tail.next = cur.left
tail = tail.next
if cur.right:
tail.next = cur.right
tail = tail.next
cur = cur.next
cur = dummy.next # drop down one level
return root
// java
// LC 117 - Populating Next Right Pointers in Each Node II
// IDEA: traverse level k via its own `next` chain; build level k+1's chain with dummy + tail
// time = O(n), space = O(1)
public Node connect(Node root) {
Node cur = root;
while (cur != null) {
Node dummy = new Node(0);
Node tail = dummy;
while (cur != null) {
if (cur.left != null) {
tail.next = cur.left;
tail = tail.next;
}
if (cur.right != null) {
tail.next = cur.right;
tail = tail.next;
}
cur = cur.next;
}
cur = dummy.next;
}
return root;
}
複雜度
- 時間 O(n):每個節點經由
cur = cur.next抵達一次,經由tail.next被串一次。 - 空間 O(1):只有
cur、dummy和tail;模板 2 的 BFS 佇列不見了。
陷阱
- 完美樹的捷徑
cur.left.next = cur.right; cur.right.next = cur.next.left在 LC 117 範例 1([1,2,3,4,5,null,7])會出錯:節點 3 沒有左子節點,所以cur.next.left是None,5 永遠接不到 7。 - 每層都要一個新的
dummy;重複使用同一個,下一層是空的時候dummy.next會指著舊的那層,外層迴圈永遠不會結束。
不適用時 — 題目允許 O(n) 空間,或要每層一個清單:就用一般的 模板 2。
練習 — LC 117 · LC 116 (完美樹:同一段程式碼原封不動) · LC 199
模板 9:樹 DP — 回傳多個狀態 — LC 337 優先度 5/5 — 必備 — 幾乎每一輪面試都會出現
辨識 — 「不能選兩個相鄰(父子)的節點」、「覆蓋每一個節點」、「每個節點有 k 種模式」— 任何把父節點的選擇和子節點選了什麼綁在一起的限制。
關鍵 — 每次呼叫對自己這個根的每個狀態各回傳一個最佳值(這裡是 (take, skip)),父節點就能挑它自己的選擇所允許的子節點狀態。遞迴式(LC 337):take(n) = n.val + skip(L) + skip(R),skip(n) = max(take(L), skip(L)) + max(take(R), skip(R))。
# python
# LC 337 - House Robber III
# IDEA: post-order DP returning (take, skip) per subtree
# time = O(n), space = O(h)
def rob(root):
def helper(node):
if not node:
return (0, 0) # (take, skip)
left = helper(node.left)
right = helper(node.right)
take = node.val + left[1] + right[1] # robbing me forces both children to be skipped
skip = max(left) + max(right) # skipping me leaves each child free
return (take, skip)
return max(helper(root))
// java
// LC 337 - House Robber III
// IDEA: post-order DP; each call returns {take, skip}
// time = O(n), space = O(h)
public int rob(TreeNode root) {
int[] res = robHelper(root);
return Math.max(res[0], res[1]);
}
private int[] robHelper(TreeNode node) {
if (node == null) {
return new int[]{0, 0};
}
int[] l = robHelper(node.left);
int[] r = robHelper(node.right);
int take = node.val + l[1] + r[1];
int skip = Math.max(l[0], l[1]) + Math.max(r[0], r[1]);
return new int[]{take, skip};
}
複雜度
- 時間 O(n):每個節點一次後序呼叫,合併兩組值是 O(1)。
- 空間 O(h):遞迴堆疊;每個 frame 只放兩個小 tuple。
試跑
LC 337 example 1: [3,2,3,null,3,null,1]
leaf 3 -> (3, 0) leaf 1 -> (1, 0)
node 2 (right child 3) -> take = 2 + 0 + 0 = 2, skip = 0 + max(3,0) = 3 -> (2, 3)
node 3 (right child 1) -> take = 3 + 0 + 0 = 3, skip = 0 + max(1,0) = 1 -> (3, 1)
root 3 -> take = 3 + 3 + 1 = 7, skip = max(2,3) + max(3,1) = 6 -> answer 7
陷阱
- 只回傳一個「這棵子樹的最佳值」會丟掉父節點需要的資訊:貪婪的「每隔一層搶一次」在
[2,1,3,null,4]上得到 6,答案是 7(搶 3 和 4)。 - 跳過一個節點不代表一定要搶它的子節點:
skip = take(L) + take(R)在鏈[4,1,null,2,null,3]上回傳 6,答案是 7(搶 4 和 3)。每個子節點要各自取最大值。 - 孫節點遞迴
max(val + rob(grandchildren), rob(children))沒有 memo 的話,每棵子樹會被兩個祖先各呼叫一次 — 深的樹上是指數時間。回傳 tuple 本身就是 memo。
變形 — 一樣是後序的「回傳我這棵子樹的資訊」骨架,只是承載的東西不同:
| LC | 題目 | 每次呼叫回傳什麼 |
|---|---|---|
| 337 | House Robber III | (take, skip) — 上面那個模板 |
| 968 | Binary Tree Cameras | 節點狀態:needsCover / hasCamera / covered(在三個狀態上做貪婪) |
| 508 | Most Frequent Subtree Sum | 子樹和,往上的路上記進一個 HashMap |
| 652 | Find Duplicate Subtrees | 一個正規化字串 val,left,right,記進一個 HashMap;計數剛好到 2 時把節點加入答案 |
| 563 | Binary Tree Tilt | 子樹和,同時把 abs(leftSum - rightSum) 累加到全域變數 |
| 687 | Longest Univalue Path | 往下同值的最長單邊;全域最大值 = 左邊 + 右邊 |
| 549 | Binary Tree Longest Consecutive Sequence II | (inc, dec) — 從我這裡往下的最長遞增/遞減連續串;全域最大值 = inc + dec - 1(下面有完整寫法) |
# python
# LC 652 - Find Duplicate Subtrees
# IDEA: canonical subtree id string + Counter; two subtrees are equal iff their ids are equal
# time = O(n^2) worst case (string building), space = O(n^2)
from collections import Counter
def findDuplicateSubtrees(root):
count = Counter()
result = []
def subtree_id(node):
if not node:
return "#" # null marker keeps ids unambiguous
key = "%s,%s,%s" % (node.val, subtree_id(node.left), subtree_id(node.right))
count[key] += 1
if count[key] == 2: # report each duplicate exactly once
result.append(node)
return key
subtree_id(root)
return result
// java
// LC 652 - Find Duplicate Subtrees
// IDEA: serialize every subtree into a canonical id string, count ids in a map.
// Use a null marker ("#") - without it "1,2" is ambiguous.
// time = O(n^2) worst case (string building), space = O(n^2)
public List<TreeNode> findDuplicateSubtrees(TreeNode root) {
Map<String, Integer> cnt = new HashMap<>();
List<TreeNode> res = new ArrayList<>();
subtreeId(root, cnt, res);
return res;
}
private String subtreeId(TreeNode node, Map<String, Integer> cnt, List<TreeNode> res) {
if (node == null) {
return "#";
}
String key = node.val + ","
+ subtreeId(node.left, cnt, res) + ","
+ subtreeId(node.right, cnt, res);
int c = cnt.merge(key, 1, Integer::sum);
if (c == 2) { // == 2 (not >= 2) so each duplicate is reported once
res.add(node);
}
return key;
}
練習 — LC 337 · LC 563 · LC 652 · LC 549 (下面有完整寫法) · LC 968
實作變形:一個節點兩條連續串 — LC 549 Binary Tree Longest Consecutive Sequence II 優先度 4/5 — 高價值 — 這裡有缺口就會掉關
LC 298(§0-2)的續集,改了兩件事:連續串可以遞增也可以遞減,而且可以在節點處轉彎 (
子 → 父 → 子),不再只能一路往下。 出處:binary-tree-longest-consecutive-sequence-ii.py
為什麼要回傳一組值,而不是一個數字 — 從值為 v 的節點看出去,值是 v + 1 的子節點只能延長遞增串,
值是 v - 1 的子節點只能延長遞減串。父節點兩件事都要問,所以每次呼叫回傳 (inc, dec):
從這個節點出發、一路往下的最長遞增/遞減連續串。
// java
// LC 549 - Binary Tree Longest Consecutive Sequence II
// IDEA: post-order DP. Each call returns {inc, dec} = longest ascending / descending
// consecutive run STARTING at this node and going straight down.
// The bent (child -> node -> child) path is scored into a global, not returned.
/**
* time = O(N), space = O(H) // H = tree height (recursion stack)
*/
private int res = 0;
public int longestConsecutive(TreeNode root) {
helper(root);
return res;
}
// returns int[]{ inc, dec }
private int[] helper(TreeNode node) {
if (node == null) {
return new int[]{0, 0};
}
int[] l = helper(node.left);
int[] r = helper(node.right);
int inc = 1, dec = 1; // the node alone is already a run of length 1
/** null-check the child BEFORE reading child.val */
if (node.left != null) {
if (node.left.val == node.val + 1) {
inc = Math.max(inc, l[0] + 1);
} else if (node.left.val == node.val - 1) {
dec = Math.max(dec, l[1] + 1);
}
}
if (node.right != null) {
if (node.right.val == node.val + 1) {
inc = Math.max(inc, r[0] + 1);
} else if (node.right.val == node.val - 1) {
dec = Math.max(dec, r[1] + 1);
}
}
// the path is allowed to BEND here: descending arm + this node + ascending arm
res = Math.max(res, inc + dec - 1);
// only the STRAIGHT runs are usable by the parent
return new int[]{inc, dec};
}
# python
# LC 549 - Binary Tree Longest Consecutive Sequence II
# IDEA: post-order DP returning (inc, dec) per subtree; bent path scored into a global
class Solution:
def longestConsecutive(self, root):
# time = O(N), space = O(H)
self.res = 0
def helper(node):
if not node:
return (0, 0) # (inc, dec)
l_inc, l_dec = helper(node.left)
r_inc, r_dec = helper(node.right)
inc = dec = 1 # the node alone is a run of length 1
if node.left:
if node.left.val == node.val + 1:
inc = max(inc, l_inc + 1)
elif node.left.val == node.val - 1:
dec = max(dec, l_dec + 1)
if node.right:
if node.right.val == node.val + 1:
inc = max(inc, r_inc + 1)
elif node.right.val == node.val - 1:
dec = max(dec, r_dec + 1)
# bend at THIS node: descending arm + node + ascending arm
self.res = max(self.res, inc + dec - 1)
return (inc, dec) # straight runs only go up
helper(root)
return self.res
轉彎的過程
2 helper(1) -> (inc=1, dec=1) leaf
/ \ helper(3) -> (inc=1, dec=1) leaf
1 3 at node 2: left.val 1 == 2 - 1 -> dec = l_dec + 1 = 2
right.val 3 == 2 + 1 -> inc = r_inc + 1 = 2
answer here = inc + dec - 1 = 2 + 2 - 1 = 3 (1 -> 2 -> 3)
returns (2, 2) upward — NOT 3, a bent path cannot be extended
三個讓它正確的關鍵
- 1是因為節點被算了兩次 — 它既是遞減那一臂的最後一格,也是遞增那一臂的第一格。 葉節點一樣成立:1 + 1 - 1 = 1。- 兩臂永遠不可能是同一個子節點 —
inc只會從值為val + 1的子節點長出來,dec只會從val - 1長出來,一個子節點不可能兩者皆是。所以inc + dec - 1一定是一條真實存在的路徑,不會把同一條分支 重複算兩次。(LC 124 / 543 是靠分別寫left、right白拿這個保證;這裡則來自值的判斷。) - 全域記答案,往上只回傳單邊 — 轉彎後的長度寫進
res;往上只給(inc, dec)。 和 LC 124(最大路徑和)、LC 543(直徑)、LC 687(同值路徑)是同一種拆法。
LC 298 vs LC 549
| LC 298 | LC 549 | |
|---|---|---|
| 方向 | 嚴格遞增,父 → 子 | 遞增或遞減 |
| 形狀 | 只能一路往下 | 可以轉彎:子 → 節點 → 子 |
| 狀態 | 一個純量 cur_len 往下帶(§0-2 的 A/B 型) |
一組 (inc, dec) 往上回傳(C 型/本模板) |
| 答案 | cur_len 的全域最大值 |
inc + dec - 1 的全域最大值 |
| 中斷規則 | 連續中斷 → cur_len = 1 |
連續中斷 → 那一臂就停在 1 |
陷阱:把
res初始化成0是安全的(空樹必須回傳0,而任何真實節點至少算得到1 + 1 - 1 = 1), 但把兩臂的初始值設成0就不安全 — 葉節點會回報-1。
模板 10:後序的結構性修改 — LC 1110 優先度 4/5 — 高價值 — 這裡有缺口就會掉關
辨識 — 「刪除節點……並回傳森林」、「剪掉所有……的子樹」、「移除值為 x 的葉子」— 樹的形狀會改變,結果是一個節點(或節點清單),不是一個數字。
關鍵 — 遞迴回傳新的子樹(可能是 None),父節點再把它重新指派回去:node.left = walk(node.left, …)。就靠這一行,不需要父指標就能把被刪的子節點拆下來。父節點被刪掉、自己卻留下來的節點是新的森林根,所以這件事以 is_root 往下傳。
# python
# LC 1110 - Delete Nodes And Return Forest
# IDEA: return None to detach a deleted node; pass `is_root` (my parent was deleted) down
# time = O(n), space = O(n) the set of values to delete, plus O(h) recursion
def delNodes(root, to_delete):
to_del = set(to_delete)
forest = []
def walk(node, is_root):
if not node:
return None
deleted = node.val in to_del
if is_root and not deleted:
forest.append(node) # I survive and nobody points at me
node.left = walk(node.left, deleted) # my children are roots iff I am deleted
node.right = walk(node.right, deleted)
return None if deleted else node # None detaches me from my parent
walk(root, True)
return forest
// java
// LC 1110 - Delete Nodes And Return Forest
// IDEA: DFS carrying `isRoot`; a deleted node returns null, which detaches it from its parent
// time = O(n), space = O(n)
public List<TreeNode> delNodes(TreeNode root, int[] to_delete) {
Set<Integer> toDel = new HashSet<>();
for (int v : to_delete) {
toDel.add(v);
}
List<TreeNode> forest = new ArrayList<>();
walk(root, true, toDel, forest);
return forest;
}
private TreeNode walk(TreeNode node, boolean isRoot, Set<Integer> toDel, List<TreeNode> forest) {
if (node == null) {
return null;
}
boolean deleted = toDel.contains(node.val);
if (isRoot && !deleted) {
forest.add(node);
}
node.left = walk(node.left, deleted, toDel, forest);
node.right = walk(node.right, deleted, toDel, forest);
return deleted ? null : node;
}
第二種解法 — BFS,由父節點那一側拆開。 不用遞迴,所以深的樹也沒有 O(h) 的堆疊:每個節點看自己的子節點,把被刪的子節點拆掉;如果自己被刪了,就把留下來的子節點收進森林。值得會它的理由:面試官要求迭代版本,或 n 大到遞迴深度會出問題。
# python
# LC 1110 - Delete Nodes And Return Forest (BFS variant)
# IDEA: the parent decides: detach a deleted child; a deleted parent hands its survivors to the forest
# time = O(n), space = O(n)
from collections import deque
def delNodes(root, to_delete):
to_del = set(to_delete)
forest = [] if root.val in to_del else [root]
queue = deque([root])
while queue:
node = queue.popleft()
for side in ("left", "right"):
child = getattr(node, side)
if not child:
continue
queue.append(child)
if child.val in to_del:
setattr(node, side, None) # detach the deleted child
elif node.val in to_del:
forest.append(child) # survivor under a deleted parent
return forest
複雜度
- 時間 O(n):
walk每個節點拜訪一次;node.val in to_del是 O(1) 的 set 查詢。 - 空間 O(n):
to_del最多放 n 個值;遞迴堆疊再加 O(h)。
陷阱
- 呼叫
walk(node.left, …)卻沒把結果指派回去,被刪的節點就還掛在樹上:LC 1110 範例 1([1,2,3,4,5,6,7],刪[3,5])回傳的樹裡還留著 3 和 5。 - 被刪節點的子節點就算自己也被刪了,仍然是「根」— 靠
is_root and not deleted這個判斷,才不會把被刪的根放進森林([1,2],刪[1,2]→[])。 - 用清單
to_delete而不是 set 做成員判斷,每次就是 O(k):整體 O(n · k)。
變形
- LC 814(Binary Tree Pruning) — 沒有森林,只有一棵樹,而且刪除的判斷取決於已經剪過的子節點,所以檢查必須放在兩次遞迴呼叫之後。
# python
# LC 814 - Binary Tree Pruning
# IDEA: prune both children first, THEN test whether I became a zero-valued leaf
# time = O(n), space = O(h)
def pruneTree(root):
if not root:
return None
root.left = pruneTree(root.left)
root.right = pruneTree(root.right)
if root.val == 0 and not root.left and not root.right:
return None
return root
// java
// LC 814 - Binary Tree Pruning
// IDEA: prune children first, THEN test if I became a valueless leaf
// time = O(n), space = O(h)
public TreeNode pruneTree(TreeNode root) {
if (root == null) {
return null;
}
root.left = pruneTree(root.left);
root.right = pruneTree(root.right);
if (root.val == 0 && root.left == null && root.right == null) {
return null;
}
return root;
}
不適用時 — 樹是 BST,要依值的範圍(LC 669)或單一鍵值(LC 450)移除節點:有序性讓你能一次丟掉整邊,見 bst.md。
練習 — LC 1110 · LC 226 ⭐ (回傳交換後的子樹) · LC 814 · LC 1325 · LC 114
模板 11:帶位置索引的 BFS — LC 662 優先度 4/5 — 高價值 — 這裡有缺口就會掉關
辨識 — 「某一層的寬度要算進兩端節點之間的 null 位置」、「這棵樹是否完全」— 一種單純數每層節點數表達不出來的水平位置。
關鍵 — 每個節點帶著它在完全樹陣列佈局裡的索引(left = 2i、right = 2i + 1),所以索引編碼的是含空隙在內的位置;某層的寬度 = last − first + 1。
# python
# LC 662 - Maximum Width of Binary Tree
# IDEA: BFS with (node, heap_index); re-base each level to 0; width = last - first + 1
# time = O(n), space = O(w) w = maximum width of a level
from collections import deque
def widthOfBinaryTree(root):
if not root:
return 0
best = 0
queue = deque([(root, 0)])
while queue:
level_size = len(queue)
first = queue[0][1] # index of the leftmost node on this level
last = 0
for _ in range(level_size):
node, index = queue.popleft()
index -= first # re-base to 0 so indices stay small
last = index
if node.left:
queue.append((node.left, 2 * index))
if node.right:
queue.append((node.right, 2 * index + 1))
best = max(best, last + 1)
return best
// java
// LC 662 - Maximum Width of Binary Tree
// IDEA: BFS carrying the heap-style index; re-base each level so the int never overflows
// time = O(n), space = O(w)
public int widthOfBinaryTree(TreeNode root) {
if (root == null) {
return 0;
}
int best = 0;
Deque<TreeNode> nodes = new ArrayDeque<>();
Deque<Integer> idx = new ArrayDeque<>();
nodes.offer(root);
idx.offer(0);
while (!nodes.isEmpty()) {
int size = nodes.size();
int first = idx.peek();
int last = 0;
for (int i = 0; i < size; i++) {
TreeNode n = nodes.poll();
int j = idx.poll() - first; // re-based index
last = j;
if (n.left != null) {
nodes.offer(n.left);
idx.offer(2 * j);
}
if (n.right != null) {
nodes.offer(n.right);
idx.offer(2 * j + 1);
}
}
best = Math.max(best, last + 1);
}
return best;
}
第二種解法 — DFS 加上「每層第一個索引」的 map。 一樣的堆積索引,不用佇列:先走左邊的前序走訪,會最先碰到每個深度最左邊的節點,所以那個索引只記一次,之後同一深度的節點都拿來跟它比。當解法其餘部分本來就是 DFS 時值得知道;預設仍是上面的 BFS,因為它每層重新定基準,索引保持很小。
# python
# LC 662 - Maximum Width of Binary Tree (DFS variant)
# IDEA: pre-order, left first; the first index seen at each depth is that level's leftmost
# time = O(n), space = O(h) plus one entry per level in first_index
def widthOfBinaryTree(root):
first_index = {} # depth -> index of the leftmost node seen there
best = 0
def dfs(node, depth, index):
nonlocal best
if not node:
return
if depth not in first_index: # pre-order, left first: the first visit is leftmost
first_index[depth] = index
best = max(best, index - first_index[depth] + 1)
dfs(node.left, depth + 1, 2 * index)
dfs(node.right, depth + 1, 2 * index + 1)
dfs(root, 0, 0)
return best
複雜度
- 時間 O(n):每個節點進出佇列各一次,和模板 2 一樣。
- 空間 O(w):佇列最多放一層(加上正在加入的子節點)。
試跑
LC 662 example 1: [1,3,2,5,3,null,9]
level 0: (1,0) first 0 last 0 width 1
level 1: (3,0) (2,1) first 0 last 1 width 2
level 2: (5,0) (3,1) (9,3) first 0 last 3 width 4 <- the null at index 2 counts
陷阱
- 不重新定基準的話,索引每層翻倍:3000 層深的歪斜樹需要
2^3000,Java 的int會溢位(Python 的大整數也會變慢)。每層減掉該層第一個索引。 first必須在迴圈 pop 任何東西之前讀;在迴圈裡i == 0時讀也行,但在第一次 pop 之後讀queue[0]拿到的是第二個節點。
變形
- LC 958(Check Completeness of a Binary Tree) — 同樣是「空隙很重要」的想法,但更簡單的做法是把
null子節點也推進佇列,並要求一旦 pop 出一個null,後面就不能再出現真正的節點。
# python
# LC 958 - Check Completeness of a Binary Tree
# IDEA: BFS that enqueues null children too; in a complete tree every real node comes first
# time = O(n), space = O(w)
from collections import deque
def isCompleteTree(root):
queue = deque([root])
seen_null = False
while queue:
node = queue.popleft()
if not node:
seen_null = True
else:
if seen_null:
return False # a real node after a hole -> not complete
queue.append(node.left) # push nulls on purpose
queue.append(node.right)
return True
// java
// LC 958 - Check Completeness of a Binary Tree
// IDEA: BFS enqueuing nulls too. In a complete tree all real nodes come first.
// time = O(n), space = O(w)
public boolean isCompleteTree(TreeNode root) {
Queue<TreeNode> q = new LinkedList<>(); // LinkedList, because ArrayDeque rejects null
q.add(root);
boolean seenNull = false;
while (!q.isEmpty()) {
TreeNode n = q.poll();
if (n == null) {
seenNull = true;
} else {
if (seenNull) {
return false;
}
q.add(n.left);
q.add(n.right);
}
}
return true;
}
不適用時 — 題目問的是欄(LC 314、LC 987)。欄不是堆積式索引:在 [1,2,3] 上,根和它的左子都在自己那一層的位移 0,卻分屬欄 0 與欄 −1。帶著 col(左 col - 1、右 col + 1);LC 987 還要把每一格依 (row, value) 排序,見 tree.md。
練習 — LC 662 · LC 958 · LC 919 (完全二元樹插入器)
模板 12:兩棵樹同步走 — LC 100
辨識 — 「兩棵樹是否相同」、「這棵樹是不是自己的鏡像」、「合併兩棵樹」、「一棵樹是否為另一棵的子樹」:輸入是兩個根(或一個根和它自己比),而一對節點的答案取決於它們子節點配成的那幾對的同一個答案。n ≤ 100 到 2000 → 同時走兩棵樹一次,O(n)。
關鍵 — 一次呼叫拿著每棵樹各一個節點,對配好的子節點對遞迴。兩個 None 檢查放最前面,而且順序固定:兩個都是 None 算相同,只有一個是 None 算不同,所以到了比值那一步兩個節點一定都存在。
# python
# LC 100 - Same Tree
# IDEA: recurse on (p, q) pairs; both None = equal, one None = different, then compare values
# time = O(min(n, m)), space = O(min(h1, h2)) stops at the first difference
def isSameTree(p, q):
if not p and not q: # both ran out together
return True
if not p or not q: # only one ran out: shapes differ
return False
if p.val != q.val:
return False
return isSameTree(p.left, q.left) and isSameTree(p.right, q.right)
// java
// LC 100 - Same Tree
// IDEA: recurse on (p, q) pairs; both null = equal, one null = different, then compare values
// time = O(min(n, m)), space = O(min(h1, h2))
public boolean isSameTree(TreeNode p, TreeNode q) {
if (p == null && q == null) {
return true;
}
if (p == null || q == null) {
return false;
}
if (p.val != q.val) {
return false;
}
return isSameTree(p.left, q.left) && isSameTree(p.right, q.right);
}
複雜度
- 時間 O(min(n, m)):每次呼叫比一對,而且遇到第一對不同就停,所以不會走超過較小的那棵樹。
- 空間 O(min(h1, h2)):遞迴堆疊,兩棵樹還共有的每一層一個 frame。
陷阱
- 在
None檢查之前先比p.val != q.val:p = [1,2]、q = [1,null,2]在其中一邊走完的那一刻就會出錯。 - 只有一邊是
None時回傳True:[1,2]和[1]會被判成相同。 - 怎麼配對就是題目本身:LC 101 配的是
(left.left, right.right)和(left.right, right.left);LC 951 交換後的配對也接受;LC 617 是合併而不是比較。這些題的卡片在 tree_examples.md。
不適用時 — 要在大樹裡找另一棵樹(LC 572):這個模板會在大樹的每個節點各跑一次,O(n · m)。用 (value, left ID, right ID) 指派固定大小的 ID(tree_codec.md)期望是 O(n + m);替每棵子樹建一整個字串則可能是 O(n² + m²)。
練習 — LC 100 · LC 101 (鏡像配對) · LC 617 (合併,每一對建一個新節點) · LC 951 (兩種配對都行) · LC 572 (在大樹的每個節點上跑)
依模式分類的題目
依模式的題目分類
模式 1:樹走訪題目
| 題目 | LC # | 難度 | 關鍵技巧 | 模板 |
|---|---|---|---|---|
| Binary Tree Inorder Traversal | 94 | Easy | 堆疊/遞迴 | 模板 1/2 |
| Binary Tree Preorder Traversal | 144 | Easy | 堆疊/遞迴 | 模板 1/2 |
| Binary Tree Postorder Traversal | 145 | Easy | 堆疊/遞迴 | 模板 1/2 |
| Binary Tree Level Order Traversal | 102 | Medium | 佇列 BFS | 模板 2 |
| Binary Tree Zigzag Level Order | 103 | Medium | BFS + 方向 | 模板 2 |
| Binary Tree Right Side View | 199 | Medium | 層序/DFS | 模板 2 |
| Binary Tree Vertical Order | 314 | Medium | BFS + 雜湊表 | 模板 2 |
| Find Bottom Left Tree Value | 513 | Medium | 層序 | 模板 2 |
模式 1b:層序的各種變形(BFS 骨架完全相同,只有每層的彙整方式不同)
這些全都是模板 2 那個
while queue: for _ in range(level_size)迴圈,只改一行。骨架學一次就好。
| 題目 | LC # | 難度 | 改動的那一行 |
|---|---|---|---|
| Level Order Traversal II | 107 | Medium | 最後把結果串列反轉(或用 insert(0, level)) |
| Average of Levels | 637 | Easy | res.append(sum(level) / len(level)) |
| Find Largest Value in Each Row | 515 | Medium | res.append(max(level))(取該層最大值) |
| Maximum Level Sum | 1161 | Medium | 追蹤 sum(level),並回傳最大者從 1 起算的層號 |
| Cousins in Binary Tree | 993 | Easy | 同深度(同一層)但父節點不同 → 入佇列時順便記住父節點 |
| Maximum Width of Binary Tree | 662 | Medium | 每個節點帶著堆積索引 → 模板 11 |
| Check Completeness | 958 | Medium | null 子節點也入佇列 → 模板 11 的變形 |
| Vertical Order Traversal | 987 | Hard | 像 LC 314 那樣依欄做 BFS,但同位置時要用 (row, value) 決勝負 → 每欄必須排序 |
模式 2:樹建構題目
| 題目 | LC # | 難度 | 關鍵技巧 | 模板 |
|---|---|---|---|---|
| Construct from Preorder & Inorder | 105 | Medium | 索引對照表 | 模板 3 |
| Construct from Inorder & Postorder | 106 | Medium | 索引對照表 | 模板 3 |
| Construct from Preorder & Postorder | 889 | Medium | 遞迴 | 模板 3 |
| Convert Sorted Array to BST | 108 | Easy | 二分搜尋 | 模板 3 |
| Serialize and Deserialize Tree | 297 | Hard | BFS/DFS | 模板 3 |
| Construct from String | 536 | Medium | 堆疊/遞迴 | 模板 3 |
模式 3:路徑題目
| 題目 | LC # | 難度 | 關鍵技巧 | 模板 |
|---|---|---|---|---|
| Path Sum | 112 | Easy | DFS | 模板 4 |
| Path Sum II | 113 | Medium | DFS + 回溯 | 模板 4 |
| Binary Tree Paths | 257 | Easy | DFS + 記錄路徑 | 模板 4 |
| Sum Root to Leaf Numbers | 129 | Medium | DFS | 模板 4 |
| Binary Tree Maximum Path Sum | 124 | Hard | DFS + 全域最大值 | 模板 4 |
| Longest Consecutive Sequence | 298 | Medium | DFS + 計數器 | 模板 4(見 §0-2:由上而下和由下而上都能解) |
| Longest Consecutive Sequence II | 549 | Medium | DFS + 狀態組 | 模板 9(路徑可轉彎,inc + dec - 1) |
| Path Sum III | 437 | Medium | 前綴和 | 模板 4 + 樹上前綴和模板 |
模式 4:樹性質題目
| 題目 | LC # | 難度 | 關鍵技巧 | 模板 |
|---|---|---|---|---|
| Maximum Depth | 104 | Easy | DFS/BFS | 模板 5 |
| Minimum Depth | 111 | Easy | DFS/BFS | 模板 5 |
| Balanced Binary Tree | 110 | Easy | 檢查高度 | 模板 5 |
| Diameter of Binary Tree | 543 | Easy | DFS + 取最大 | 模板 5 |
| Symmetric Tree | 101 | Easy | 鏡像檢查 | 模板 5 |
| Same Tree | 100 | Easy | 兩棵樹同步 DFS | 模板 5 |
模式 4b:性質/雙樹 DFS 骨架的各種轉折
| 題目 | LC # | 難度 | 轉折在哪 |
|---|---|---|---|
| Flip Equivalent Binary Trees | 951 | Medium | LC 100 的雙樹 DFS,但兩種配對都接受:(L,L)&(R,R) 或 (L,R)&(R,L) |
| Merge Two Binary Trees | 617 | Easy | 雙樹 DFS,但缺一個節點不算不符 — 直接回傳另一邊 |
| Max Difference Between Node and Ancestor | 1026 | Medium | 改成由上而下而非由下而上:把 (minSoFar, maxSoFar) 往下推;每片葉子上的答案是 max - min |
| Most Frequent Subtree Sum | 508 | Medium | 由下而上的子樹和 + 頻率表 → 模板 9 |
| Binary Tree Tilt / Longest Univalue Path | 563 / 687 | Easy / Medium | 往上回傳一個值,同時把另一個值累加進全域變數 → 模板 9 |
模式 5:LCA 與距離題目
| 題目 | LC # | 難度 | 關鍵技巧 | 模板 |
|---|---|---|---|---|
| Lowest Common Ancestor | 236 | Medium | DFS | 模板 6 |
| LCA of BST | 235 | Easy | BST 性質 | 模板 6 |
| LCA II(目標可能不存在) | 1644 | Medium | DFS + 數找到幾個 | 模板 6 的變形 |
| LCA III(有父指標、沒有 root) | 1650 | Medium | 往上走兩條父鏈(LC 160) | 模板 6 的變形 |
| LCA IV(N 個目標) | 1676 | Medium | 對著目標 set 做 DFS |
模板 6 的變形 |
| Distance K from Target | 863 | Medium | 轉成圖 | 模板 6 |
| LCA of Deepest Leaves | 1123 | Medium | DFS + 深度 | 模板 6 |
模式 6:在樹上二分搜尋的題目
| 題目 | LC # | 難度 | 關鍵技巧 | 模板 |
|---|---|---|---|---|
| Count Complete Tree Nodes | 222 | Medium | 對高度做二分搜尋 | 模板 7 |
| Kth Smallest in BST | 230 | Medium | 中序 + 二分搜尋 | 模板 7 |
| Closest BST Value | 270 | Easy | 在 BST 上二分搜尋 | 模板 7 |
| Closest BST Value II | 272 | Hard | 中序 + 雙指標 | 模板 7 |
依難度分類的完整題目清單
Easy 題(基礎)
- LC 94: Binary Tree Inorder Traversal - 基本走訪
- LC 100: Same Tree - 樹的比對
- LC 101: Symmetric Tree - 鏡像性質檢查
- LC 104: Maximum Depth - 基本遞迴
- LC 108: Convert Sorted Array to BST - 陣列轉樹
- LC 110: Balanced Binary Tree - 高度計算
- LC 111: Minimum Depth - 用 BFS 找最短路徑
- LC 112: Path Sum - 簡單的路徑追蹤
- LC 144: Binary Tree Preorder Traversal - 堆疊的使用
- LC 145: Binary Tree Postorder Traversal - 堆疊操作
- LC 226: Invert Binary Tree - 樹的修改
- LC 235: LCA of BST - BST 性質
- LC 257: Binary Tree Paths - 蒐集路徑
- LC 543: Diameter of Binary Tree - 全域最大值模式
- LC 572: Subtree of Another Tree - 樹的比對
Medium 題(核心)
- LC 102: Binary Tree Level Order Traversal - BFS 基礎
- LC 103: Binary Tree Zigzag Level Order - 帶方向的層序
- LC 105: Construct from Preorder & Inorder - 索引對照表
- LC 106: Construct from Inorder & Postorder - 陣列切片
- LC 113: Path Sum II - 回溯路徑
- LC 114: Flatten Binary Tree - 原地修改
- LC 116: Populating Next Right Pointers - 串接同層節點
- LC 129: Sum Root to Leaf Numbers - 組出數字
- LC 173: Binary Search Tree Iterator - iterator 設計
- LC 199: Binary Tree Right Side View - 每層最後一個元素
- LC 222: Count Complete Tree Nodes - 在樹上二分搜尋
- LC 230: Kth Smallest in BST - 中序性質
- LC 236: Lowest Common Ancestor - 經典 LCA
- LC 298: Binary Tree Longest Consecutive - 路徑追蹤
- LC 314: Binary Tree Vertical Order - 欄位索引
- LC 437: Path Sum III - 樹上的前綴和(模板)
- LC 513: Find Bottom Left Tree Value - 層序的變形
- LC 536: Construct from String - 解析成樹
- LC 549: Binary Tree Longest Consecutive II - 可轉彎的路徑,(inc, dec) 狀態
- LC 654: Maximum Binary Tree - 單調堆疊
- LC 863: All Nodes Distance K - 轉成圖
Hard 題(進階)
- LC 124: Binary Tree Maximum Path Sum - 全域最佳化
- LC 297: Serialize and Deserialize - 字串轉樹
- LC 834: Sum of Distances in Tree - 換根技巧
- LC 968: Binary Tree Cameras - 樹上的貪婪
模式選擇策略
Problem Analysis Flowchart:
1. Does the problem require visiting nodes in specific order?
├── YES → Use Traversal Templates (1 or 2)
│ ├── Need all nodes level by level? → Level Order (Template 2)
│ ├── Need specific order (pre/in/post)? → Template 1
│ └── Need iterative approach? → Template 2
└── NO → Continue to 2
2. Does the problem involve building/modifying tree structure?
├── YES → Use Construction Template (3)
│ ├── From traversal sequences? → Template 3
│ ├── From array/string? → Template 3 variant
│ └── Serialize/Deserialize? → Custom Template 3
└── NO → Continue to 3
3. Does the problem involve paths from root to leaves?
├── YES → Use Path Template (4)
│ ├── Need all paths? → Template 4 with result collection
│ ├── Need path sum? → Template 4 with sum tracking
│ └── Need max/min path? → Template 4 with optimization
└── NO → Continue to 4
4. Does the problem ask for tree properties?
├── YES → Use Property Template (5)
│ ├── Height/Depth? → Template 5 basic
│ ├── Balance/Symmetry? → Template 5 with comparison
│ └── Diameter/Width? → Template 5 with global max
└── NO → Continue to 5
5. Does the problem involve finding ancestors or distances?
├── YES → Use LCA Template (6)
│ ├── Common ancestor? → Template 6
│ └── Distance between nodes? → Template 6 + path tracking
└── NO → Continue to 6
6. Does the problem require O(log n) time or involve complete/balanced tree optimization?
├── YES → Use Binary Search on Trees Template (7)
│ ├── Complete binary tree? → Template 7 with height optimization
│ ├── BST with kth element? → Template 7 with inorder traversal
│ └── Need log time complexity? → Template 7 with binary search
└── NO → Use Template 1 (plain recursion) or reconsider problem type
決策框架
- 辨認模式:找關鍵字(走訪、路徑、建構、性質、祖先)
- 選模板:把題目需求對上模板的能力
- 調整解法:依特定限制改寫模板
- 最佳化:考慮迭代 vs 遞迴、空間 vs 時間的取捨
練習路徑
| 階段 | 題目(易 → 難) | 增加了什麼 |
|---|---|---|
| 核心 ⭐ | LC 226 ok ⭐ · LC 104 ok ⭐ · LC 100 ok ⭐ · LC 572 again ⭐ · LC 543 ok ⭐ · LC 110 ok ⭐ · LC 102 ok ⭐ · LC 1448 ok ⭐ · LC 105 ok ⭐ | 交換後回傳、高度往上、兩棵樹一起遞迴、轉彎的路徑、BFS 分層、狀態往下傳、由走訪建樹 |
| 練習 | LC 94 ok · LC 113 ok · LC 199 ok · LC 222 again · LC 117 未評定 · LC 337 ok · LC 1110 未評定 | 堆疊、回溯、數完全樹、O(1) 串接同層、tuple DP、結構性修改 |
| 延伸 | LC 662 ok · LC 652 again · LC 549 again · LC 968 again | 位置索引、子樹 id、每個節點兩條連續串、三狀態貪婪 |
總結與速查
複雜度速查
| 操作 | 時間 | 空間 | 備註 |
|---|---|---|---|
| 走訪(任何順序) | O(n) | O(h) | h = 高度,平衡時為 O(log n) |
| 層序 | O(n) | O(w) | w = 最大寬度 |
| 建構 | O(n) | O(n) | 建出整棵樹 |
| 找路徑 | O(n) | O(h) | 要列出所有路徑時可能需要 O(n) |
| 檢查性質 | O(n) | O(h) | 通常掃一趟就夠 |
| LCA | O(n) | O(h) | BST 可最佳化到 O(log n) |
| 在樹上二分搜尋 | O(log²n) | O(log n) | 用於完全/平衡樹 |
| 序列化/反序列化 | O(n) | O(n) | 字串表示法 |
模板速查
| 模板 | 最適合 | 什麼時候別用 | 關鍵程式碼樣態 |
|---|---|---|---|
| 遞迴走訪 | 程式碼乾淨 | 有堆疊爆掉的風險時 | 順序決定處理位置 |
| 迭代走訪 | 大樹 | 單純遞迴就夠時 | 操作堆疊/佇列 |
| 建構 | 建樹 | 修改既有的樹時 | 索引對照表是關鍵 |
| 路徑 | 根到葉 | 樹中任意路徑 | 回溯模式 |
| 性質 | 樹的度量 | 路徑題 | 由下而上計算 |
| LCA | 共同祖先 | 單純走訪 | 提早回傳模式 |
| 在樹上二分搜尋 | 完全/平衡樹 | 一般的樹 | 高度比較 + 遞迴 |
常見模式與技巧
模式:用全域變數做最佳化
class Solution:
def maxPathSum(self, root):
self.max_sum = float('-inf')
def helper(node):
if not node:
return 0
left = max(0, helper(node.left))
right = max(0, helper(node.right))
self.max_sum = max(self.max_sum, left + right + node.val)
return max(left, right) + node.val
helper(root)
return self.max_sum
模式:用分隔符處理層
def rightSideView(root):
if not root:
return []
result, queue = [], [root, None]
while queue:
node = queue.pop(0)
if node:
if queue[0] is None: # Last node in level
result.append(node.val)
if node.left:
queue.append(node.left)
if node.right:
queue.append(node.right)
elif queue: # Level delimiter
queue.append(None)
return result
解題步驟
- 分析:釐清樹的結構與要求的輸出
- 選擇:依模式挑合適的模板
- 實作:把模板調整成符合需求
- 最佳化:考慮改用迭代、剪枝
- 測試:檢查空根、單一節點、歪斜樹
常見錯誤與提示
🚫 常見錯誤:
- 忘了基底情況:一定要檢查
if not root - 走訪途中修改樹:可能把樹的結構搞壞
- 沒處理 null 子節點:存取
.left/.right前先檢查 - 走訪順序搞錯:前序 ≠ 中序 ≠ 後序
- 參照 vs 值:Python 傳的是物件參照
✅ 最佳實務:
- 變數名要有意義:寫
left_height而不是l - 先處理邊界情況:空樹、單一節點
- 遞迴與迭代都想一遍:知道它們的取捨
- 小心追蹤狀態:用輔助函式讓邏輯更清楚
- 用歪斜樹測:那是遞迴深度的最壞情況
面試提示
- 釐清:問清楚樹的性質(平衡嗎?是 BST 嗎?完全嗎?)
- 畫圖:把小例子(3-5 個節點)畫出來
- 切入方式:先講遞迴解,再提一下迭代的替代做法
- 複雜度:一定要講出時間與空間複雜度
- 邊界情況:null、單一節點、全左/全右歪斜
相關主題
- 二元搜尋樹(BST):節點滿足 left < root < right 時
- 堆積:滿足堆積性質的完全二元樹
- 圖:樹是圖的特例(無環、連通)
- 字典樹(Trie):做前綴比對的樹
- B-Tree:資料庫用的自平衡樹
Java 實作注意事項
// Java TreeNode definition
class TreeNode {
int val;
TreeNode left, right;
TreeNode(int x) { val = x; }
}
// Use Queue interface with LinkedList
Queue<TreeNode> queue = new LinkedList<>();
// Stack for iterative traversal
Stack<TreeNode> stack = new Stack<>();
Python 實作注意事項
# TreeNode definition
class TreeNode:
def __init__(self, val=0, left=None, right=None):
self.val = val
self.left = left
self.right = right
# Use collections.deque for O(1) operations
from collections import deque
queue = deque([root])
# List as stack (append/pop)
stack = []
面試必會題:LC 94、102、104、105、110、124、222、226、236、297、543 進階題:LC 124、222(最佳化版)、297、437、863、968 關鍵字:二元樹、走訪、DFS、BFS、遞迴、路徑、LCA、建構、在樹上二分搜尋、完全樹