目录
1. 一句话定义
前序遍历 = 根 → 左 → 右
对每个节点都按"先记自己、再走左子树、最后走右子树"的顺序处理。
口诀: "先干自己的事"
2. 样例树
全文统一这一棵:
1
/ \
2 3
/ \ \
4 5 6
class TreeNode {
constructor(val, left = null, right = null) {
this.val = val; this.left = left; this.right = right;
}
}
const root = new TreeNode(1,
new TreeNode(2, new TreeNode(4), new TreeNode(5)),
new TreeNode(3, null, new TreeNode(6))
);
前序结果 (先记下来, 后面一步步推): 1 2 4 5 3 6
3. 核心: 迭代模板
function preOrder(root) {
if (!root) return [];
const out = [], stack = [root];
while (stack.length) {
const cur = stack.pop();
out.push(cur.val);
if (cur.right) stack.push(cur.right);
if (cur.left) stack.push(cur.left);
}
return out;
}
核心三行 (弹 → 记 → 压右压左) 就是前序迭代的全部。
逐步推演
栈: [1] → 弹 1, 记 1, 压 3, 压 2 结果: [1]
栈: [3, 2] → 弹 2, 记 2, 压 5, 压 4 结果: [1, 2]
栈: [3, 5, 4] → 弹 4, 记 4 结果: [1, 2, 4]
栈: [3, 5] → 弹 5, 记 5 结果: [1, 2, 4, 5]
栈: [3] → 弹 3, 记 3, 压 6 结果: [1, 2, 4, 5, 3]
栈: [6] → 弹 6, 记 6 结果: [1, 2, 4, 5, 3, 6]
栈: [] → 结束
为什么前序迭代最简单?
对比三种 DFS 的迭代版:
| 顺序 | 根出现时机 | 迭代难度 | 关键技巧 |
|---|---|---|---|
| 前序 | 第 1 个 | ⭐ 最短 | 弹栈即访问 |
| 中序 | 中间 | ⭐⭐ 中等 | cur 指针一路压左 |
| 后序 | 最后 | ⭐⭐⭐ 最绕 | last 标记 / 双栈反转 |
根本原因: 前序的"根"和"递归顺序的第一件事"是同一时刻 —— 弹栈就能访问, 不需要任何额外指针。
中序要"先压左路、回头时再访问", 所以得用 cur 指针; 后序要"等左右都处理完", 必须标记。
只要问题的"根先出现", 就优先选前序: 最短、最容易调、最不容易出 bug。
4. 关键细节: 压栈顺序
栈是 LIFO (后进先出)。要让 node.left 先于 node.right 出栈, 必须 后压左。
正反例对比:
节点有 children = [左, 右]
先压左: stack = [左, 右] → 弹右 → 错
后压左: stack = [右, 左] → 弹左 → 对 ✓
这是前序迭代版 唯一需要记的顺序。
5. 与递归的一句话关系
递归版就是同一件事的另一种写法:
function preOrderRec(node) {
if (!node) return;
console.log(node.val);
preOrderRec(node.left);
preOrderRec(node.right);
}
对比:
- 递归: 引擎帮你维护栈, 写法短, 但有栈深限制 (JS 默认 ~10000 帧, 链表式二叉树直接爆栈)
- 迭代: 自己维护栈, 长几行, 但能处理任意深度, 还能在栈里塞别的状态 (累积和、当前路径、已访问标志位)
实战首选迭代。递归只在两种情况下保留: (a) 教学/极小树; (b) 必须依赖系统栈的特殊场景 (极少数)。
6. 五大经典应用 (全部迭代)
栈里能塞"额外状态"是迭代的杀手锏, 看下面 5 题怎么用。
① LC 144. 二叉树的前序遍历
就是 §3 的模板, 不再重复。
② LC 114. 把二叉树展平成"前序链表" (就地改造)
原题 (LC 114. Flatten Binary Tree to Linked List): 把一棵二叉树就地改造成一条"链表"——所有节点的
left必须置为null, 所有right串成单链表, 顺序必须等于前序遍历的结果。
栈里跟着一个 prev 指针 —— 把前一个处理过的节点连到当前:
function flatten(root) {
if (!root) return;
const stack = [root];
let prev = null;
while (stack.length) {
const cur = stack.pop();
if (cur.right) stack.push(cur.right);
if (cur.left) stack.push(cur.left);
if (prev) {
prev.right = cur;
prev.left = null;
}
prev = cur;
}
}
要点: 前序迭代一遍拿到节点顺序, 边走边用 prev 把链表连好。
③ LC 113. 所有和为 target 的根到叶路径
栈里塞 三元组 [节点, 累积和, 当前路径] —— 这就是"额外状态":
function pathSum(root, target) {
const res = [];
if (!root) return res;
const stack = [[root, root.val, [root.val]]];
while (stack.length) {
const [node, sum, path] = stack.pop();
if (!node.left && !node.right) {
if (sum === target) res.push([...path]);
continue;
}
if (node.right) stack.push([node.right, sum + node.right.val, [...path, node.right.val]]);
if (node.left) stack.push([node.left, sum + node.left.val, [...path, node.left.val]]);
}
return res;
}
要点: 累积和 sum 和路径 path 一起跟着节点走, 走到叶子一结算。
④ LC 297. 序列化与反序列化
原题 (LC 297. Serialize and Deserialize Binary Tree): 设计两个互逆函数:
serialize(root)把树变成字符串deserialize(data)把字符串还原回树还原出来的树结构必须和原树完全一致。序列化格式由你自定, 你说了算。 唯一限制: round-trip 必须成立——
deserialize(serialize(root))要等于root。
序列化 就是前序 + # 占位, 迭代版比递归版简单:
function serialize(root) {
const out = [], stack = [root];
while (stack.length) {
const node = stack.pop();
out.push(node ? node.val : '#');
if (node) { // null 也得压, 反解时才知道结构
stack.push(node.right);
stack.push(node.left);
}
}
return out.join(',');
}
反序列化 要绕一点 —— 父节点的两个孩子不是挨着的两个 token, 中间隔着左子树的全部 token。所以栈里要记"现在轮到填左孩子还是右孩子": 先看直觉版 (递归, 5 行就能写对):
function deserialize(tokens, i) {
if (tokens[i] === '#') return [null, i + 1];
const node = new TreeNode(parseInt(tokens[i], 10));
[node.left, i] = deserialize(tokens, i + 1); // 先建左子树
[node.right, i] = deserialize(tokens, i + 1); // 再建右子树
return [node, i];
}
跟着输入 tokens = [1, 2, #, #, 3, #, #] 走一遍 (deserialize(tokens, 0)):
deserialize(0): token[0]=1, 建 node=1
deserialize(1): token[1]=2, 建 node=2
deserialize(2): token[2]='#', 返回 [null, 3] ← 2.left = null
deserialize(3): token[3]='#', 返回 [null, 4] ← 2.right = null
返回 [node=2, i=4]
deserialize(4): token[4]=3, 建 node=3
deserialize(5): token[5]='#', 返回 [null, 6] ← 3.left = null
deserialize(6): token[6]='#', 返回 [null, 7] ← 3.right = null
返回 [node=3, i=7]
返回 [node=1, i=7]
为什么递归版"自然"就对? 因为递归调用栈自动记住了"现在该填谁的哪个孩子"——你从左子树的递归返回, 自然就知道下一步该填右孩子。
为什么还要学迭代版? 如果树特别深 (比如链表形态的 10⁵ 节点), 递归调用栈会爆掉。JavaScript 默认栈深度约 1 万层, 超过就 Maximum call stack size exceeded。所以我们要自己模拟一个栈——栈帧就是"暂停的递归调用"。
| 递归调用 | 对应的栈帧 |
|---|---|
deserialize(0) 还没返回 | { 节点=1, 现在填 left } |
deserialize(1) 还没返回 | { 节点=2, 现在填 left } |
deserialize(2) 已经返回 | (已弹出栈) |
deserialize(3) 已经返回 | (已弹出栈) |
deserialize(4) 还没返回 | { 节点=3, 现在填 left } |
栈顶 = "最深层的、还没返回的"那个调用。每个调用做的事就是"先填 left, 再填 right"。
跟着迭代版走一遍 —— 同样的输入 tokens = [1, 2, #, #, 3, #, #], 起始 i=1, 每帧 { node, slot }, slot ∈ { left, right, done }:
第 1 步: 读 token[1] = 2
- 栈顶
{ 1, left }→ 当前要填1.left - token 不是
#→ 建节点 2 → 挂到1.left - 新节点 2 入栈, 等它填 left
- 推进栈顶: 1 从
left→right(左已填完) - 栈顶
{2,left}不是 done, 不弹 - 结果:
1.left = 2, 栈 =[ {1,right}, {2,left} ]
第 2 步: 读 token[2] = #
- 栈顶
{ 2, left }→ 当前要填2.left - token 是
#→ 不建节点,2.left保持 null - 推进栈顶: 2 从
left→right - 结果: 栈 =
[ {1,right}, {2,right} ]
第 3 步: 读 token[3] = #
- 栈顶
{ 2, right }→ 当前要填2.right - token 是
#→ 不建节点,2.right保持 null - 推进栈顶: 2 从
right→done - 栈顶是 done → 弹出 2 (相当于递归里"调用返回了")
- 新栈顶
{1,right}不是 done, 停 - 结果: 栈 =
[ {1,right} ]
第 4 步: 读 token[4] = 3
- 栈顶
{ 1, right }→ 当前要填1.right - token 不是
#→ 建节点 3 → 挂到1.right - 新节点 3 入栈
- 推进栈顶: 1 从
right→done - 结果:
1.right = 3, 栈 =[ {1,done}, {3,left} ]
第 5 步: 读 token[5] = #
- 栈顶
{ 3, left }→ 当前要填3.left - token 是
#→3.left保持 null - 推进: 3 从
left→right - 结果: 栈 =
[ {1,done}, {3,right} ]
第 6 步: 读 token[6] = #
- 栈顶
{ 3, right }→ 当前要填3.right - token 是
#→3.right保持 null - 推进: 3 从
right→done - 栈顶 done → 弹! 弹完
{1,done}也弹! - 结果: 栈 =
[], 主循环退出
最终构造出的树:
1
/ \
2 3
自查 3 个关键点:
- 为什么栈顶是
slot=left, 读到的 token 就填 left? 因为栈顶就是"当前在等什么"。每次读完一个 token (无论#还是数字), slot 都要前进一格。 - 为什么
slot变成done时才能弹? 因为 done = "我自己的 left 和 right 都填完了", 相当于递归里"我这个调用返回了", 外层才能继续。 - 为什么弹完要看新栈顶是不是 done? 因为父节点的 done 是由"子节点已经处理完"触发的——孙子弹掉时, 爹也得跟着弹; 爹弹掉时, 爷爷也得弹, 会一直连锁弹上去。
下面的迭代版代码就是把上面的心智模型翻译成代码。
function deserialize(data) {
const tokens = data.split(',');
if (!tokens[0] || tokens[0] === '#') return null;
const root = new TreeNode(parseInt(tokens[0], 10));
// { node, slot } —— slot: 'left' | 'right' | 'done'
const stack = [{ node: root, slot: 'left' }];
let i = 1;
while (stack.length && i < tokens.length) {
const top = stack[stack.length - 1]; // peek, 不弹
const tok = tokens[i++];
if (tok !== '#') {
const child = new TreeNode(parseInt(tok, 10));
top.node[top.slot] = child; // 写到真实节点, 不是栈帧
stack.push({ node: child, slot: 'left' });
}
top.slot = top.slot === 'left' ? 'right' : 'done';
while (stack.length && stack[stack.length - 1].slot === 'done') {
stack.pop(); // 左右都填完, 出栈
}
}
return root;
}
反序列化的关键: peek 而不是 pop。父节点留在栈里, 等左孩子处理完回来再填右孩子。当 slot === 'done' 时才真正弹出。
怎么验证它是对的: 把下面这段贴到 Node.js 跑, 全绿就是对的:
class TreeNode { constructor(v,l=null,r=null){this.val=v;this.left=l;this.right=r;} }
function serialize(root) {
const out=[], stack=[root];
while(stack.length){
const n=stack.pop();
out.push(n?n.val:'#');
if(n){ stack.push(n.right); stack.push(n.left); }
}
return out.join(',');
}
function deserialize(data) {
const tokens=data.split(',');
if(!tokens[0]||tokens[0]==='#') return null;
const root=new TreeNode(parseInt(tokens[0],10));
const stack=[{node:root,slot:'left'}];
let i=1;
while(stack.length&&i<tokens.length){
const top=stack[stack.length-1];
const tok=tokens[i++];
if(tok!=='#'){
const child=new TreeNode(parseInt(tok,10));
top.node[top.slot]=child; // 关键: top.node, 不是 top
stack.push({node:child,slot:'left'});
}
top.slot=top.slot==='left'?'right':'done';
while(stack.length&&stack[stack.length-1].slot==='done') stack.pop();
}
return root;
}
// 验证: 构造一棵树 → 序列化 → 反序列化 → 再序列化 → 三者一致
function dump(r){ return r?`${r.val}(${dump(r.left)},${dump(r.right)})`:'null'; }
const a=new TreeNode(1,new TreeNode(2,new TreeNode(4)),new TreeNode(3,new TreeNode(5),new TreeNode(6)));
const s1=serialize(a), b=deserialize(s1), s2=serialize(b);
console.log('原树:', dump(a));
console.log('还原:', dump(b));
console.log('s1:', s1);
console.log('s2:', s2);
console.log('往返一致:', s1===s2 && dump(a)===dump(b));
// 期望: 往返一致 = true
⑤ LC 257. 二叉树的所有路径
栈里塞 [节点, 当前路径], 同 §③ 套路:
function binaryTreePaths(root) {
const res = [];
if (!root) return res;
const stack = [[root, [String(root.val)]]];
while (stack.length) {
const [node, path] = stack.pop();
if (!node.left && !node.right) {
res.push(path.join('->'));
continue;
}
if (node.right) stack.push([node.right, [...path, String(node.right.val)]]);
if (node.left) stack.push([node.left, [...path, String(node.left.val)]]);
}
return res;
}
要点: path 一律用 [...path, ...] 创建新数组, 不要 push/pop, 天然避免兄弟节点共享引用导致答案串台。
7. 通用解题模板
几乎所有"路径类"或"自顶向下"问题都套这个:
function solve(root) {
const result = [];
if (!root) return result;
const stack = [[root, /* 初始状态: [], 0, {} 都可以 */]];
while (stack.length) {
const [node, state] = stack.pop();
state.push(/* 进入时的选择 */); // 1) 进入: 做选择
if (isAnswer(node)) result.push(answer(state)); // 可能是答案
if (node.right) stack.push([node.right, [...state]]); // 2) 右
if (node.left) stack.push([node.left, [...state]]); // 3) 左
}
return result;
}
两件套 (替代递归版的 push/pop 回溯):
- 进入时做选择 (
[...state, newVal]复制) - 复制状态再压栈 (
[...state], 避免兄弟节点共享)
对照 §6 应用:
- LC 257:
state= 路径数组,isAnswer= 叶子 - LC 113:
state=[累积和, 路径],isAnswer= 叶子且 sum 命中 - LC 114:
state=prev指针, 走到每个节点都把 prev 串起来
8. 经典题目索引
| # | 题目 | 用前序做什么 | 难度 |
|---|---|---|---|
| LC 144 | 前序遍历 | 本题 | 简 |
| LC 226 | 翻转二叉树 | 交换左右 | 简 |
| LC 257 | 所有路径 | 路径累加 | 简 |
| LC 112 | 路径总和 | 累加和判断 | 简 |
| LC 113 | 路径总和 II | 路径枚举 | 中 |
| LC 114 | 展平为链表 | 就地改造 | 中 |
| LC 129 | 根到叶数字之和 | 数字累加 | 中 |
| LC 297 | 序列化与反序列化 | 前序 + # | 难 |
| LC 437 | 路径和 III | 前序 + 前缀和 | 中 |
9. 常见 Bug 清单
| # | Bug | 错在哪 | 正确做法 |
|---|---|---|---|
| 1 | 漏写 if (!root) return ...; | 空树返回 undefined, 调用方崩 | 永远先判空 |
| 2 | 压栈顺序写反 (先压左后压右) | 输出顺序错 | 先压右、后压左 |
| 3 | stack.push(node.left) 没判空 | null 进数组, 下次 cur.left.val 崩 | 先判再 push |
| 4 | 路径题共享 path 引用 | 兄弟节点答案串台 | [...path, ...] 复制 |
| 5 | result.push(path) 没 spread | 后续操作影响已存路径 | result.push([...path]) |
| 6 | if (node.left || node.right) | 0 这种合法值被判 false | if (node.left !== null) |
| 7 | 反序列化写 top[top.slot] = child | top 是栈帧 {node, slot}, 不是节点本身——结果 child 被挂到栈帧上, 真实树全是 null, 只有空树/单节点这种没孩子的用例碰巧能过 | 必须写 top.node[top.slot] = child |
最容易错的 3 个: 压栈顺序 (2)、路径共享引用 (4)、空指针 (1/3)。
10. 复杂度分析
| 写法 | 时间 | 空间 |
|---|---|---|
| 递归 | O(n) | O(h) |
| 迭代 (本文) | O(n) | O(h) |
h = 树高, 平衡树 O(log n), 退化成链表 O(n)。
迭代跟递归的复杂度完全一样, 但迭代有 3 个实际优势:
- 没有栈深限制 (链表式 10⁵ 深度的树递归直接爆)
- 能在栈里塞别的状态 (§6 的 sum / path / slot)
prev/ 累加器这类外部变量天然能用, 不用绕参数列表
11. 一句话总结
前序 = 根 → 左 → 右; 迭代三行:
弹 → 记 → 压右压左; 凡是"父→子传值"、路径类、序列化、自顶向下统计, 都用前序 + 迭代栈。