二元樹 — 四種遍歷方式與遞迴/迭代實作完整教學 | 資料結構與演算法
二元樹(Binary Tree) 是每個節點最多有兩個子節點的樹狀結構,也是學習遞迴思維的最佳切入點。掌握四種遍歷方式與遞迴/迭代的雙重實作,你將能解決絕大多數樹相關的面試題,同時深刻理解 DFS 與 BFS 兩大搜尋框架的本質。
前言
想像你是一個家族族譜的管理員。族譜是一棵樹,每個人最多有兩個子女(左子節點和右子節點)。現在要「走訪」所有成員,你有幾種選法?
- 前序:先記錄自己,再去左邊,最後去右邊(從頂層長輩開始,按輩份往下記)
- 中序:先走完左邊所有人,再記錄自己,最後走右邊(若依名字排序,中序走法恰好得到有序結果)
- 後序:先走完兩邊所有子孫,最後才記錄自己(適合計算每個人的「後代人數」)
- 層序:一代一代地走,先記錄所有第一代,再記錄所有第二代(像公司的組織圖展示)
這就是二元樹遍歷的本質——何時處理當前節點的選擇。
本文學習要點:
- 四種遍歷的概念、圖解與使用場景
- 遞迴與迭代兩種實作方式(含 Morris Traversal 的 O(1) 空間版本)
- 樹的高度、深度、節點計數
- 完全二元樹與滿二元樹的定義與性質
- C++ 對照實作與複雜度分析
- 五道 LeetCode 練習題解析
核心概念
樹的基本術語
在正式寫程式碼之前,先把術語對齊:
| 術語 | 說明 |
|---|---|
| 根節點(Root) | 樹的頂端,沒有父節點 |
| 葉節點(Leaf) | 沒有子節點的節點 |
| 高度(Height) | 從節點到最遠葉節點的邊數(根的高度 = 整棵樹的高度) |
| 深度(Depth) | 從根節點到該節點的邊數(根的深度 = 0) |
| 層(Level) | 深度相同的節點集合(根在第 0 層或第 1 層,依定義而定) |
二元樹的類型
完全二元樹(Complete Binary Tree):除了最後一層外,每一層都填滿節點,且最後一層的節點靠左對齊。堆積(Heap) 就是完全二元樹,可以用陣列高效儲存,index i 的左右子節點分別在 2i+1 與 2i+2。
滿二元樹(Full Binary Tree):每個節點要麼是葉節點(0 個子節點),要麼有兩個子節點。節點數與葉節點數滿足:葉節點數 = 內部節點數 + 1。
平衡二元樹(Balanced Binary Tree):任意節點的左右子樹高度差不超過 1。AVL Tree 和 Red-Black Tree 都是平衡二元搜尋樹(下一篇主題)。
四種遍歷的本質與圖解
遍歷的本質只有一件事:決定何時處理當前節點。
1
/ \
2 3
/ \ / \
4 5 6 7
| 遍歷方式 | 英文 | 處理時機 | 結果 |
|---|---|---|---|
| 前序 | Preorder | 進入節點時(根→左→右) | 1, 2, 4, 5, 3, 6, 7 |
| 中序 | Inorder | 左子樹處理完後(左→根→右) | 4, 2, 5, 1, 6, 3, 7 |
| 後序 | Postorder | 離開節點時(左→右→根) | 4, 5, 2, 6, 7, 3, 1 |
| 層序 | Level Order | 逐層從上到下(BFS) | [1], [2, 3], [4, 5, 6, 7] |
迭代前序使用 Stack(後進先出)的執行過程:
push 1 → pop 1 (處理), push 3, push 2
pop 2 (處理), push 5, push 4
pop 4 (處理)
pop 5 (處理)
pop 3 (處理), push 7, push 6
pop 6 (處理)
pop 7 (處理)
→ 結果:1, 2, 4, 5, 3, 6, 7
注意:右子節點先入 Stack,因為 Stack 是後進先出,左子節點要先處理。
四種遍歷的實際應用場景
- 前序:複製樹(先建根再建子節點)、序列化、DOM 渲染
- 中序:在 BST 中取得有序序列、資料庫索引
- 後序:刪除樹(先刪子節點才能刪父節點)、計算子樹大小、編譯器的 AST(抽象語法樹)求值
- 層序:組織圖渲染、最短路徑(無權重樹)、鋸齒形遍歷
JavaScript / TypeScript 實作
TreeNode 節點定義
// ─── 節點定義 ────────────────────────────────────────────────────────────────
class TreeNode {
val: number;
left: TreeNode | null;
right: TreeNode | null;
constructor(
val: number = 0,
left: TreeNode | null = null,
right: TreeNode | null = null
) {
this.val = val;
this.left = left;
this.right = right;
}
}
前序遍歷(Preorder Traversal)
// 遞迴版本:O(n) time, O(h) space(h = 樹高)
function preorderRecursive(root: TreeNode | null): number[] {
const result: number[] = [];
function dfs(node: TreeNode | null): void {
if (!node) return;
result.push(node.val); // 根:先處理自己
dfs(node.left); // 左:再走左子樹
dfs(node.right); // 右:最後走右子樹
}
dfs(root);
return result;
}
// 迭代版本:使用顯式 Stack,右先入(後進先出,所以左先處理)
function preorderIterative(root: TreeNode | null): number[] {
if (!root) return [];
const result: number[] = [];
const stack: TreeNode[] = [root];
while (stack.length > 0) {
const node = stack.pop()!;
result.push(node.val);
if (node.right) stack.push(node.right); // 右先入
if (node.left) stack.push(node.left); // 左後入(先出)
}
return result;
}
中序遍歷(Inorder Traversal)
// 遞迴版本:左→根→右
function inorderRecursive(root: TreeNode | null): number[] {
const result: number[] = [];
function dfs(node: TreeNode | null): void {
if (!node) return;
dfs(node.left); // 左
result.push(node.val); // 根:中間才處理
dfs(node.right); // 右
}
dfs(root);
return result;
}
// 迭代版本:一路向左壓 Stack,彈出後轉向右子樹
function inorderIterative(root: TreeNode | null): number[] {
const result: number[] = [];
const stack: TreeNode[] = [];
let curr: TreeNode | null = root;
while (curr !== null || stack.length > 0) {
// 一路向左,把沿途節點全壓入 Stack
while (curr !== null) {
stack.push(curr);
curr = curr.left;
}
// 左子樹走完,彈出並處理
curr = stack.pop()!;
result.push(curr.val);
// 轉向右子樹
curr = curr.right;
}
return result;
}
後序遍歷(Postorder Traversal)
// 遞迴版本:左→右→根
function postorderRecursive(root: TreeNode | null): number[] {
const result: number[] = [];
function dfs(node: TreeNode | null): void {
if (!node) return;
dfs(node.left);
dfs(node.right);
result.push(node.val); // 根:最後才處理
}
dfs(root);
return result;
}
// 迭代版本技巧:前序是「根→左→右」,後序是「左→右→根」
// 只要把前序改成「根→右→左」(右先入 Stack),再 reverse 即得後序
function postorderIterative(root: TreeNode | null): number[] {
if (!root) return [];
const result: number[] = [];
const stack: TreeNode[] = [root];
while (stack.length > 0) {
const node = stack.pop()!;
result.push(node.val);
if (node.left) stack.push(node.left); // 左先入(後處理)
if (node.right) stack.push(node.right); // 右後入(先處理)
// 此時順序是 根→右→左
}
return result.reverse(); // 反轉後得到 左→右→根
}
層序遍歷(Level Order Traversal)
// 使用 Queue(先進先出)逐層展開
// 關鍵技巧:用 levelSize 固定「每一層的節點數」,避免 queue 動態增長影響判斷
function levelOrder(root: TreeNode | null): number[][] {
if (!root) return [];
const result: number[][] = [];
const queue: TreeNode[] = [root];
while (queue.length > 0) {
const levelSize = queue.length; // 固定本層節點數
const level: number[] = [];
for (let i = 0; i < levelSize; i++) {
const node = queue.shift()!;
level.push(node.val);
if (node.left) queue.push(node.left);
if (node.right) queue.push(node.right);
}
result.push(level);
}
return result;
}
// 測試
const root = new TreeNode(1,
new TreeNode(2, new TreeNode(4), new TreeNode(5)),
new TreeNode(3, new TreeNode(6), new TreeNode(7))
);
console.log(levelOrder(root)); // [[1], [2, 3], [4, 5, 6, 7]]
樹的高度、深度與節點計數
// 最大深度(即樹的高度):後序 DFS,當前高度 = 1 + max(左高度, 右高度)
// LeetCode 104
function maxDepth(root: TreeNode | null): number {
if (!root) return 0;
return 1 + Math.max(maxDepth(root.left), maxDepth(root.right));
}
// 最小深度:注意葉節點的定義(左右子節點都為 null 才算葉節點)
function minDepth(root: TreeNode | null): number {
if (!root) return 0;
// 只有右子樹時,不能直接取 min(左邊的 0 不代表葉節點)
if (!root.left) return 1 + minDepth(root.right);
if (!root.right) return 1 + minDepth(root.left);
return 1 + Math.min(minDepth(root.left), minDepth(root.right));
}
// 節點計數:遍歷所有節點
function countNodes(root: TreeNode | null): number {
if (!root) return 0;
return 1 + countNodes(root.left) + countNodes(root.right);
}
Morris 中序遍歷(O(1) 空間)
Morris Traversal 利用葉節點的空指標,暫時串連回祖先,省去 Stack 空間,讓空間複雜度降至 O(1):
// 核心思想:找當前節點的中序前驅(左子樹最右節點),
// 將其 right 指標暫時指向當前節點,形成「線索(Thread)」
function morrisInorder(root: TreeNode | null): number[] {
const result: number[] = [];
let curr: TreeNode | null = root;
while (curr !== null) {
if (curr.left === null) {
// 左子樹為空,直接處理當前節點,向右移動
result.push(curr.val);
curr = curr.right;
} else {
// 找左子樹的最右節點(中序前驅)
let predecessor: TreeNode = curr.left;
while (predecessor.right !== null && predecessor.right !== curr) {
predecessor = predecessor.right;
}
if (predecessor.right === null) {
// 第一次訪問:建立線索,深入左子樹
predecessor.right = curr;
curr = curr.left;
} else {
// 第二次訪問:移除線索,處理當前節點
predecessor.right = null; // 務必恢復結構!
result.push(curr.val);
curr = curr.right;
}
}
}
return result;
}
Morris Traversal 的注意事項:第二次訪問前驅節點時,必須將線索(predecessor.right = null)恢復,否則會破壞原樹結構,導致後續遍歷出錯——這是最容易犯的錯誤。
翻轉二元樹
// LeetCode 226:後序 DFS,先翻轉子節點再交換
function invertTree(root: TreeNode | null): TreeNode | null {
if (!root) return null;
const left = invertTree(root.left);
const right = invertTree(root.right);
// 子節點翻轉完成後,交換自己的左右子節點
root.left = right;
root.right = left;
return root;
}
從前序 + 中序建構二元樹(LeetCode 105)
// 關鍵洞察:
// 1. preorder[0] 必然是整棵樹的根
// 2. 在 inorder 找到根的位置 mid
// 3. inorder[0..mid-1] 是左子樹,inorder[mid+1..n-1] 是右子樹
// 4. 用 Map 預存中序索引,將查詢從 O(n) 降至 O(1)
function buildTree(preorder: number[], inorder: number[]): TreeNode | null {
const map = new Map<number, number>();
inorder.forEach((val, i) => map.set(val, i));
let preIdx = 0;
function helper(left: number, right: number): TreeNode | null {
if (left > right) return null;
const rootVal = preorder[preIdx++];
const node = new TreeNode(rootVal);
const mid = map.get(rootVal)!;
// 注意:必須先建左子樹(對應 preIdx 的下一個元素)
node.left = helper(left, mid - 1);
node.right = helper(mid + 1, right);
return node;
}
return helper(0, inorder.length - 1);
}
二元樹最大路徑和(LeetCode 124)
// 難點:區分兩個量
// gain(node) = node.val + max(gain(left), gain(right)) ← 只能選一邊,用於回傳父節點
// path(node) = node.val + gain(left) + gain(right) ← 左右都選,用於更新全局答案
function maxPathSum(root: TreeNode | null): number {
let ans = -Infinity;
function gain(node: TreeNode | null): number {
if (!node) return 0;
// 若子樹貢獻為負,不如不選(取 0)
const L = Math.max(0, gain(node.left));
const R = Math.max(0, gain(node.right));
// 以當前節點為「拐點」的最大路徑和(可左右都選)
ans = Math.max(ans, node.val + L + R);
// 向父節點只能提供單邊最大值(路徑不能分叉)
return node.val + Math.max(L, R);
}
gain(root);
return ans;
}
C++ 對照
#include <algorithm>
#include <climits>
#include <queue>
#include <stack>
#include <unordered_map>
#include <vector>
// ─── 節點定義(LeetCode 常用裸指標版)────────────────────────────────────────
struct TreeNode {
int val;
TreeNode* left;
TreeNode* right;
TreeNode(int v = 0, TreeNode* l = nullptr, TreeNode* r = nullptr)
: val(v), left(l), right(r) {}
};
// ─── 前序迭代遍歷 ─────────────────────────────────────────────────────────────
std::vector<int> preorderIterative(TreeNode* root) {
std::vector<int> result;
if (!root) return result;
std::stack<TreeNode*> stk;
stk.push(root);
while (!stk.empty()) {
auto* node = stk.top(); stk.pop();
result.push_back(node->val);
if (node->right) stk.push(node->right); // 右先入
if (node->left) stk.push(node->left); // 左後入
}
return result;
}
// ─── 中序迭代遍歷 ─────────────────────────────────────────────────────────────
std::vector<int> inorderIterative(TreeNode* root) {
std::vector<int> result;
std::stack<TreeNode*> stk;
auto* curr = root;
while (curr || !stk.empty()) {
while (curr) {
stk.push(curr);
curr = curr->left;
}
curr = stk.top(); stk.pop();
result.push_back(curr->val);
curr = curr->right;
}
return result;
}
// ─── 後序迭代遍歷(前序修改版 + reverse)─────────────────────────────────────
std::vector<int> postorderIterative(TreeNode* root) {
std::vector<int> result;
if (!root) return result;
std::stack<TreeNode*> stk;
stk.push(root);
while (!stk.empty()) {
auto* node = stk.top(); stk.pop();
result.push_back(node->val);
if (node->left) stk.push(node->left); // 左先入
if (node->right) stk.push(node->right); // 右後入
}
std::reverse(result.begin(), result.end()); // 根→右→左 反轉 = 左→右→根
return result;
}
// ─── 層序遍歷 ─────────────────────────────────────────────────────────────────
std::vector<std::vector<int>> levelOrder(TreeNode* root) {
std::vector<std::vector<int>> result;
if (!root) return result;
std::queue<TreeNode*> q;
q.push(root);
while (!q.empty()) {
int levelSize = static_cast<int>(q.size());
std::vector<int> level;
level.reserve(levelSize);
for (int i = 0; i < levelSize; ++i) {
auto* node = q.front(); q.pop();
level.push_back(node->val);
if (node->left) q.push(node->left);
if (node->right) q.push(node->right);
}
result.push_back(std::move(level));
}
return result;
}
// ─── Morris 中序遍歷(O(1) 空間)─────────────────────────────────────────────
std::vector<int> morrisInorder(TreeNode* root) {
std::vector<int> result;
auto* curr = root;
while (curr) {
if (!curr->left) {
result.push_back(curr->val);
curr = curr->right;
} else {
auto* pred = curr->left;
while (pred->right && pred->right != curr)
pred = pred->right;
if (!pred->right) {
pred->right = curr; // 建立線索
curr = curr->left;
} else {
pred->right = nullptr; // 移除線索
result.push_back(curr->val);
curr = curr->right;
}
}
}
return result;
}
// ─── 最大深度 ─────────────────────────────────────────────────────────────────
int maxDepth(TreeNode* root) {
if (!root) return 0;
return 1 + std::max(maxDepth(root->left), maxDepth(root->right));
}
// ─── 翻轉二元樹(LeetCode 226)───────────────────────────────────────────────
TreeNode* invertTree(TreeNode* root) {
if (!root) return nullptr;
TreeNode* left = invertTree(root->left);
TreeNode* right = invertTree(root->right);
root->left = right;
root->right = left;
return root;
}
// ─── 從前序 + 中序建構(LeetCode 105)───────────────────────────────────────
class BuildTreeSolution {
std::unordered_map<int, int> inorderMap;
int preIdx = 0;
TreeNode* build(const std::vector<int>& preorder, int inLeft, int inRight) {
if (inLeft > inRight) return nullptr;
int rootVal = preorder[preIdx++];
auto* root = new TreeNode(rootVal);
int inMid = inorderMap[rootVal];
root->left = build(preorder, inLeft, inMid - 1);
root->right = build(preorder, inMid + 1, inRight);
return root;
}
public:
TreeNode* buildTree(std::vector<int>& preorder, std::vector<int>& inorder) {
for (int i = 0; i < (int)inorder.size(); ++i)
inorderMap[inorder[i]] = i;
return build(preorder, 0, (int)inorder.size() - 1);
}
};
複雜度分析
| 操作 | 時間複雜度 | 空間複雜度(遞迴/迭代) | 空間複雜度(Morris) |
|---|---|---|---|
| 四種遍歷 | O(n) | O(h) | O(1)(Morris 限中序) |
| 最大/最小深度 | O(n) | O(h) | — |
| 翻轉二元樹 | O(n) | O(h) | — |
| 建構(前序+中序) | O(n) | O(n) | — |
| 最大路徑和 | O(n) | O(h) | — |
h = 樹高。平衡樹為 O(log n),退化成鏈結串列(所有節點都偏一側)時 h = O(n)。
變體與延伸
線索二元樹(Threaded Binary Tree)
Morris Traversal 是「臨時線索化」,而線索二元樹是把空指標永久利用起來,指向中序前驅/後繼。這讓遍歷可以 O(1) 空間進行,且不需要在遍歷過程中動態建立/移除線索。
N-ary Tree(多叉樹)
二元樹的每個節點最多兩個子節點,而 N-ary Tree 可有任意數量的子節點。DOM Tree 和檔案系統目錄樹都是多叉樹。
前序遍歷的邏輯完全相同,只是從 dfs(left), dfs(right) 改成 for (const child of node.children) dfs(child)。
真實應用場景
前端 DOM 樹遍歷(前序):瀏覽器的 DOM 就是一棵樹,React 的 Virtual DOM diff 算法使用前序 DFS 遍歷節點進行渲染更新。
組織架構圖渲染(層序 BFS):企業組織圖、文件目錄等按層展示的視覺化,天然對應層序遍歷。
編譯器表達式解析樹(後序):(a + b) * c 被解析成抽象語法樹(AST),後序遍歷即可生成逆波蘭表示法,對應機器指令執行順序。
*
/ \
+ c
/ \
a b
後序遍歷:a b + c * ← 逆波蘭表示法(RPN)
面試考點
常見陷阱一:忘記處理 null 節點
幾乎所有樹的遞迴函數第一行都需要 if (!root) return ...,忘記會導致 null 解參考(null dereference)錯誤。
常見陷阱二:層序遍歷不固定 levelSize
// 錯誤寫法:queue 在迴圈中動態增長,導致本層和下層混在一起
for (let i = 0; i < queue.length; i++) { /* 每次 push 都會增大 queue.length */ }
// 正確寫法:先固定本層的節點數
const levelSize = queue.length;
for (let i = 0; i < levelSize; i++) { /* levelSize 是固定的 */ }
常見陷阱三:建構樹時左右子樹順序(LeetCode 105)
必須先建左子樹再建右子樹,因為 preIdx 是共用的閉包變數,順序錯誤會導致錯誤的前序索引對應。
常見陷阱四:最大路徑和混淆兩個量(LeetCode 124)
向父節點回傳的值只能帶一邊(路徑不能分叉),但更新全局答案時可以左右兩邊相加。這是最常混淆的地方:
// 向父節點回傳:只能選左或右其中一邊
return node.val + Math.max(L, R);
// 更新全局答案:可以左右兩邊都選(以當前節點為「拐點」)
ans = Math.max(ans, node.val + L + R);
常見陷阱五:Morris Traversal 忘記恢復結構
第二次訪問前驅節點時,必須執行 predecessor.right = null,否則原本的樹結構被破壞,後續遍歷結果錯誤。
LeetCode 練習
LeetCode 144 — Binary Tree Preorder Traversal(Easy)
直接套用迭代前序模板:Stack + 右先入左後入。
function preorderTraversal(root: TreeNode | null): number[] {
if (!root) return [];
const result: number[] = [];
const stack: TreeNode[] = [root];
while (stack.length > 0) {
const node = stack.pop()!;
result.push(node.val);
if (node.right) stack.push(node.right);
if (node.left) stack.push(node.left);
}
return result;
}
// 時間 O(n),空間 O(h)
LeetCode 94 — Binary Tree Inorder Traversal(Easy)
套用中序迭代模板:一路向左壓 Stack,彈出後轉向右子樹。
function inorderTraversal(root: TreeNode | null): number[] {
const result: number[] = [];
const stack: TreeNode[] = [];
let curr: TreeNode | null = root;
while (curr !== null || stack.length > 0) {
while (curr !== null) {
stack.push(curr);
curr = curr.left;
}
curr = stack.pop()!;
result.push(curr.val);
curr = curr.right;
}
return result;
}
// 時間 O(n),空間 O(h)
LeetCode 102 — Binary Tree Level Order Traversal(Medium)
層序遍歷的標準實作,用 levelSize 技巧按層分組。
function levelOrderTraversal(root: TreeNode | null): number[][] {
if (!root) return [];
const result: number[][] = [];
const queue: TreeNode[] = [root];
while (queue.length > 0) {
const levelSize = queue.length;
const level: number[] = [];
for (let i = 0; i < levelSize; i++) {
const node = queue.shift()!;
level.push(node.val);
if (node.left) queue.push(node.left);
if (node.right) queue.push(node.right);
}
result.push(level);
}
return result;
}
// 時間 O(n),空間 O(n)(最底層可能有 n/2 個節點)
LeetCode 104 — Maximum Depth of Binary Tree(Easy)
後序 DFS,當前深度 = 1 + max(左深度, 右深度)。
function maximumDepth(root: TreeNode | null): number {
if (!root) return 0;
return 1 + Math.max(maximumDepth(root.left), maximumDepth(root.right));
}
// 時間 O(n),空間 O(h)
LeetCode 226 — Invert Binary Tree(Easy)
後序 DFS:先翻轉左右子樹,再交換自己的左右子節點。
function invertBinaryTree(root: TreeNode | null): TreeNode | null {
if (!root) return null;
const left = invertBinaryTree(root.left);
const right = invertBinaryTree(root.right);
root.left = right;
root.right = left;
return root;
}
// 時間 O(n),空間 O(h)
總結
二元樹是樹狀資料結構的入口,也是遞迴思維訓練的最佳場域。回顧本文的核心知識點:
四種遍歷的本質是「何時處理當前節點」的選擇:前序(進入時)、中序(左子樹後)、後序(離開時)、層序(BFS 逐層)。
遞迴 vs. 迭代:遞迴寫法簡潔,但有堆疊溢出風險;迭代用顯式 Stack/Queue 模擬,不會溢出但程式碼較長。面試中兩種都要能寫出來。
Morris Traversal 用臨時線索省去 Stack 空間,達到 O(1) 空間的中序遍歷,是進階技巧。
後序 DFS 是萬能框架:最大深度、翻轉二元樹、路徑和、直徑,幾乎都是後序 DFS 的變形——先遞迴處理子樹,再根據子樹結果計算當前節點的答案。
五大陷阱:null 檢查、層序的 levelSize、建構樹的 preIdx 順序、最大路徑和的雙量區分、Morris 的線索恢復。
熟練樹的遍歷框架,是攻克圖論、動態規劃、分治法等進階主題的基石。希望這篇文章能幫助你建立清晰的樹思維,在面試中自信地處理各種樹題。如果有任何問題或疑惑,歡迎至 聯絡頁面 留言交流。
下一篇預告:我們將深入 二元搜尋樹(Binary Search Tree) 的世界,探討 BST 的插入、刪除、查詢操作,以及如何利用 BST 的有序性解決更多進階問題。