前序遍历 (Pre-Order Traversal): 迭代版(JS)

0 阅读11分钟

目录

  1. 一句话定义
  2. 样例树
  3. 核心: 迭代模板
  4. 关键细节: 压栈顺序
  5. 与递归的一句话关系
  6. 五大经典应用 (全部迭代)
  7. 通用解题模板
  8. 经典题目索引
  9. 常见 Bug 清单
  10. 复杂度分析
  11. 一句话总结

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 从 leftright (左已填完)
  • 栈顶 {2,left} 不是 done, 不弹
  • 结果: 1.left = 2, 栈 = [ {1,right}, {2,left} ]

第 2 步: 读 token[2] = #

  • 栈顶 { 2, left } → 当前要填 2.left
  • token 是 # → 不建节点, 2.left 保持 null
  • 推进栈顶: 2 从 leftright
  • 结果: 栈 = [ {1,right}, {2,right} ]

第 3 步: 读 token[3] = #

  • 栈顶 { 2, right } → 当前要填 2.right
  • token 是 # → 不建节点, 2.right 保持 null
  • 推进栈顶: 2 从 rightdone
  • 栈顶是 done → 弹出 2 (相当于递归里"调用返回了")
  • 新栈顶 {1,right} 不是 done, 停
  • 结果: 栈 = [ {1,right} ]

第 4 步: 读 token[4] = 3

  • 栈顶 { 1, right } → 当前要填 1.right
  • token 不是 # → 建节点 3 → 挂到 1.right
  • 新节点 3 入栈
  • 推进栈顶: 1 从 rightdone
  • 结果: 1.right = 3, 栈 = [ {1,done}, {3,left} ]

第 5 步: 读 token[5] = #

  • 栈顶 { 3, left } → 当前要填 3.left
  • token 是 #3.left 保持 null
  • 推进: 3 从 leftright
  • 结果: 栈 = [ {1,done}, {3,right} ]

第 6 步: 读 token[6] = #

  • 栈顶 { 3, right } → 当前要填 3.right
  • token 是 #3.right 保持 null
  • 推进: 3 从 rightdone
  • 栈顶 done → 弹! 弹完 {1,done} 也弹!
  • 结果: 栈 = [], 主循环退出

最终构造出的树:

       1
      / \
     2   3

自查 3 个关键点:

  1. 为什么栈顶是 slot=left, 读到的 token 就填 left? 因为栈顶就是"当前在等什么"。每次读完一个 token (无论 # 还是数字), slot 都要前进一格。
  2. 为什么 slot 变成 done 时才能弹? 因为 done = "我自己的 left 和 right 都填完了", 相当于递归里"我这个调用返回了", 外层才能继续。
  3. 为什么弹完要看新栈顶是不是 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 回溯):

  1. 进入时做选择 ([...state, newVal] 复制)
  2. 复制状态再压栈 ([...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压栈顺序写反 (先压左后压右)输出顺序错先压右、后压左
3stack.push(node.left) 没判空null 进数组, 下次 cur.left.val先判再 push
4路径题共享 path 引用兄弟节点答案串台[...path, ...] 复制
5result.push(path) 没 spread后续操作影响已存路径result.push([...path])
6if (node.left || node.right)0 这种合法值被判 falseif (node.left !== null)
7反序列化写 top[top.slot] = childtop 是栈帧 {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 个实际优势:

  1. 没有栈深限制 (链表式 10⁵ 深度的树递归直接爆)
  2. 能在栈里塞别的状态 (§6 的 sum / path / slot)
  3. prev / 累加器这类外部变量天然能用, 不用绕参数列表

11. 一句话总结

前序 = 根 → 左 → 右; 迭代三行: 弹 → 记 → 压右压左; 凡是"父→子传值"、路径类、序列化、自顶向下统计, 都用前序 + 迭代栈。