二叉树前序遍历 - 递归、迭代与 Morris 解法完全指南
通过交互式可视化掌握二叉树前序遍历。学习递归、迭代(栈)和 Morris O(1) 空间解法。用完整的 Java、Python、JavaScript 模板解决 LeetCode 144。
简介
前序遍历是三种基本的深度优先树遍历方法之一。遍历顺序遵循根 → 左 → 右,即:
- 访问当前节点
- 遍历左子树
- 遍历右子树
为什么面试官喜欢考它
前序遍历是常见的面试题,因为:
- 测试对递归与迭代权衡的理解
- 是许多树问题的基础(序列化、克隆、路径查找)
- Morris 遍历测试对树操作的高级理解
- 简单到可以快速编码,复杂到可以讨论权衡
何时使用前序遍历
- 树的序列化/反序列化(LC 297)
- 创建树的副本
- 从表达式树获取前缀表达式
- 检查子树(LC 572)
直观理解
1
/ \
2 3
/ \
4 5
前序遍历: 1 → 2 → 4 → 5 → 3
(根→左→右)
访问顺序:
步骤 1: 访问 1(根节点)
步骤 2: 向左,访问 2
步骤 3: 向左,访问 4
步骤 4: 回溯,向右,访问 5
步骤 5: 回溯到 1,向右,访问 3
交互式可视化
探索三种遍历方法。观察算法逐步遍历树的过程!
Ready to start
解法一:递归方法
递归解法直接实现定义:访问根节点,遍历左子树,遍历右子树。
代码模板
工作原理
- 基本情况:如果节点为空,返回
- 处理根节点:将当前节点的值添加到结果
- 递归左子树:对左子节点调用前序遍历
- 递归右子树:对右子节点调用前序遍历
调用栈隐式处理回溯。
解法二:迭代方法(栈)
迭代方法有两种实现风格,它们的核心思想相同,但代码组织方式不同。
写法一:直接出栈访问(简洁版)
这是最直观的写法。关键洞察:先压入右子节点,再压入左子节点,这样左子节点先被处理(后进先出)。
逐步演示:
树结构: 1
/ \
2 3
栈操作:
1. 压入 1 栈: [1]
2. 弹出 1, 访问 结果: [1], 压入 3, 压入 2 → 栈: [3, 2]
3. 弹出 2, 访问 结果: [1,2], 栈: [3]
4. 弹出 3, 访问 结果: [1,2,3], 栈: []
最终结果: [1, 2, 3]
为什么先压入右子节点?
栈是后进先出(LIFO)的。我们想先处理左子节点:
- 压入右子节点 → 它在栈底
- 压入左子节点 → 它在栈顶
- 弹出时先得到左子节点 ✓
写法二:使用 cur 指针(统一模板)
这种写法使用 cur 指针来控制遍历方向,可以与中序、后序遍历保持统一的代码结构。
核心思想:
- cur 指针:追踪当前遍历位置
- 一路向左:沿途立即访问每个节点(前序特点)
- 栈保存节点:用于稍后访问右子树
- 转向右边:左边走完后,转向右子树
为什么需要 cur 指针?
使用 cur 指针的好处是可以和中序、后序遍历形成统一模板:
- 前序:进入节点时访问(往左走时)
- 中序:离开节点时访问(从栈弹出时)
- 后序:左右都处理完时访问
这种写法更利于记忆和理解遍历算法的本质。
两种写法对比
| 特点 | 写法一(直接出栈) | 写法二(cur 指针) |
|---|---|---|
| 代码行数 | 更少 | 略多 |
| 理解难度 | 简单直观 | 需理解指针移动 |
| 统一性 | 前序独特 | 三种遍历统一 |
| 推荐场景 | 只写前序 | 需要记忆三种遍历 |
建议:如果只做前序遍历,用写法一更简洁;如果想掌握统一模板,用写法二更有利于理解和记忆。
解法三:Morris 遍历(O(1) 空间)
Morris 遍历通过使用线索二叉树临时修改树结构来实现 O(1) 空间。
核心思想
不使用栈,而是创建从节点回到其祖先节点的临时链接(线索)。这使我们能够在不使用额外空间的情况下返回父节点。
代码模板
Morris 前序遍历 vs 中序遍历
关键区别:何时访问节点。
- 前序 Morris:创建线索之前访问节点,然后向左移动
- 中序 Morris:通过线索返回之后访问节点
前序访问时机:
1. 无左子节点? → 访问,向右移动
2. 有左子节点,无线索? → 访问,创建线索,向左移动
3. 有左子节点,有线索? → 删除线索,向右移动
关键:向左之前先访问!
线索可视化
原始树: 添加线索后:
1 1
/ \ / \
2 3 2 3
/ \ / \
4 5 4 5
\
→ (线索指向 2)
统一模板:三种遍历的代码结构
使用 cur 指针,可以让前序、中序、后序遍历保持统一的代码结构,只需改变访问节点的时机。
核心模板
所有三种遍历都遵循相同的框架:
TreeNode cur = root;
Deque<TreeNode> stack = new ArrayDeque<>();
while (cur != null || !stack.isEmpty()) {
// 阶段1:向左探索
while (cur != null) {
// 前序:在这里访问节点 ⭐
stack.push(cur);
cur = cur.left;
}
// 阶段2:处理栈顶
cur = stack.pop();
// 中序:在这里访问节点 ⭐
// 阶段3:转向右子树
cur = cur.right;
}
三种遍历对比
前序遍历(根-左-右)
while (cur != null || !stack.isEmpty()) {
while (cur != null) {
result.add(cur.val); // ⭐ 进入时访问
stack.push(cur);
cur = cur.left;
}
cur = stack.pop();
cur = cur.right;
}
中序遍历(左-根-右)
while (cur != null || !stack.isEmpty()) {
while (cur != null) {
stack.push(cur);
cur = cur.left;
}
cur = stack.pop();
result.add(cur.val); // ⭐ 弹出时访问
cur = cur.right;
}
后序遍历(左-右-根)
TreeNode prev = null;
while (cur != null || !stack.isEmpty()) {
while (cur != null) {
stack.push(cur);
cur = cur.left;
}
cur = stack.peek();
if (cur.right == null || cur.right == prev) {
result.add(cur.val); // ⭐ 左右都处理完才访问
stack.pop();
prev = cur;
cur = null;
} else {
cur = cur.right;
}
}
统一模板对比表
| 特征 | 前序 | 中序 | 后序 |
|---|---|---|---|
| 访问时机 | 往左走时 | 从栈弹出时 | 左右都处理完 |
| 初始化 | cur = root | cur = root | cur = root; prev = null |
| 向左探索 | push + 访问 + left | push + left | push + left |
| 栈操作 | pop | pop | peek |
| 右子树处理 | 直接转向 | 直接转向 | 条件转向 |
| 额外变量 | 无 | 无 | prev(标记已访问) |
记忆技巧
前序 = 先访问:进入节点时就访问,然后继续向左
- 就像见到新朋友先打招呼,再深入交流
中序 = 中间访问:左边处理完,访问自己,再去右边
- 就像吃汉堡:左面包 → 肉(重点)→ 右面包
后序 = 后访问:等孩子们都处理完,最后才访问自己
- 就像家长:先照顾好孩子,最后才考虑自己
为什么前序不需要 cur?
前序遍历其实不必使用 cur 指针,因为访问顺序和 DFS 探索顺序一致:
// 前序简洁写法(写法一)
stack.push(root);
while (!stack.isEmpty()) {
TreeNode node = stack.pop();
result.add(node.val); // 出栈立即访问
if (node.right != null) stack.push(node.right);
if (node.left != null) stack.push(node.left);
}
但使用 cur 指针(写法二)的好处是:
- 统一思维模型:三种遍历用同一套框架
- 便于记忆:只需记住访问时机的区别
- 易于理解:清楚地看到”探索”和”访问”的分离
实战建议
面试场景:
- 只做前序:用简洁写法(写法一),代码更短
- 做多种遍历:用统一模板(写法二),展示深度理解
学习建议:
- 先掌握每种遍历的独立实现
- 再学习统一模板,理解本质
- 根据场景选择合适的写法
LeetCode 144:二叉树的前序遍历 (LC 144)
这是前序遍历的经典问题。上述三种解法都可以直接解决。
示例:
输入: root = [1,null,2,3]
1
\
2
/
3
输出: [1, 2, 3]
复杂度分析
| 方法 | 时间 | 空间 | 说明 |
|---|---|---|---|
| 递归 | O(n) | O(h) | h = 树高,斜树最坏 O(n) |
| 迭代 | O(n) | O(h) | 显式栈代替调用栈 |
| Morris | O(n) | O(1) | 每条边最多遍历两次 |
为什么 Morris 是 O(n) 时间?
虽然有嵌套循环,但每条边最多被遍历两次:
- 创建线索时一次
- 删除线索时一次
总计:2 × (n-1) 条边 = O(n)
常见错误
1. 压栈顺序错误(迭代)
// 错误:先压左子节点,最后才处理
if (node.left != null) stack.push(node.left);
if (node.right != null) stack.push(node.right);
// 正确:先压右子节点,再压左子节点
if (node.right != null) stack.push(node.right);
if (node.left != null) stack.push(node.left);
2. Morris:访问时机错误
# 前序遍历错误:在线索存在时访问
if predecessor.right:
result.append(current.val) # 太晚了!
# 正确:创建线索之前访问
if not predecessor.right:
result.append(current.val) # 现在访问!
predecessor.right = current
3. 忘记边界条件
// 别忘了空值检查!
function preorder(root) {
if (!root) return []; // 必须的!
// ...
}
边界情况
- 空树:返回
[] - 单节点:返回
[root.val] - 左斜树:1→2→3→4(测试栈深度)
- 右斜树:同左斜树
- 完全二叉树:标准情况
面试技巧
如何解释
- 从递归开始(最容易理解)
- 解释为什么栈可以模拟递归
- 提及 Morris 作为 O(1) 空间优化
- 讨论权衡(Morris 临时修改树)
后续问题
- “能用迭代实现吗?” → 栈解法
- “能用 O(1) 空间吗?” → Morris 遍历
- “如果不能修改树呢?” → 栈是最佳选择
- “如何处理非常深的树?” → Morris 防止栈溢出
何时不使用前序遍历
- 从 BST 获取有序序列:使用中序(得到升序)
- 自底向上删除树:使用后序
- 层序遍历:使用 BFS 和队列
总结
| 方面 | 递归 | 迭代 | Morris |
|---|---|---|---|
| 简单程度 | ⭐⭐⭐ | ⭐⭐ | ⭐ |
| 空间 | O(h) | O(h) | O(1) |
| 修改树 | 否 | 否 | 是(临时) |
| 面试使用 | 热身 | 标准 | 加分项 |
需要记住的关键模式:
- 前序 = 根 → 左 → 右
- 迭代:先压右,再压左(LIFO)
- Morris:向左之前先访问
- 所有方法时间复杂度都是 O(n)
掌握这三种方法,你就能自信地应对任何树遍历问题!