深蓝黑背景封面:左侧大标题「两个遍历序列,还原一棵树」,副标题「前序定根、中序分左右」;右侧原创示意图——上方两条遍历序列(前序、中序)各一排小方块,箭头向下汇聚到一棵已还原的二叉树,根节点高亮;左上标注「面试经典 · 算法」,右上标注「Day 01 · 10-29」 图 1:本文封面,两条序列如何合成一棵树。

先说明属性:这是一篇历史笔试题复盘,题目出自本地历史笔试资料,与任何公司的当前招聘无关。题面很经典:给定一棵二叉树的前序遍历和中序遍历序列,把树还原出来。考点不在「会不会」,而在「分治点找得准不准」。

第一步,想清楚两个序列各自提供了什么信息。

前序遍历的顺序是根、左、右——它的第一个元素一定是整棵树的根。中序遍历的顺序是左、根、右——一旦知道根是谁,根左边的所有元素构成左子树,右边的构成右子树。一个给「谁是根」,一个给「谁归哪边」,两条信息正好互补,这就是能还原的根本原因。

这里藏着一个唯一性前提:序列里不能有重复值。如果两个节点值相同,「根在中序里的位置」就有歧义,同一对序列可能对应多棵树。所以题面默认节点值互不相同,答题时主动点出这个前提,是加分项。

第二步,把分治点写成下标运算。

递归的主轴只有三句话:取前序当前区间的首元素作为根;在中序当前区间里定位这个根,得到下标 k,k 左边是左子树、右边是右子树;左子树的大小是 k 减去中序左边界,按这个大小把前序区间也切成左右两段,分别递归。

关键细节在前序的切分:前序区间里紧跟根的一段,长度恰好等于左子树大小,这段是左子树的前序,剩下的是右子树的前序。分治点说到底就一个——根在中序中的下标,其余边界都由它推算出来。

第三步,写出可运行代码并实测。

朴素写法每次在中序里线性查找根,最坏 O(n²);建一个「值到下标」的哈希表,每次 O(1) 定位,整体降到 O(n)。下面这份实现用 Node.js 22.15.0 直接运行(node buildtree.js)即可,内置三组用例:题面小例、200 节点随机树往返校验、不自洽输入拦截:

'use strict';

// 由前序 + 中序遍历还原二叉树:分治点 = 根在中序中的下标
function buildTree(preorder, inorder) {
  if (preorder.length !== inorder.length) throw new RangeError('两个序列长度必须一致');
  const pos = new Map(inorder.map((v, i) => [v, i])); // 值 -> 中序下标
  const build = (pl, pr, il, ir) => {
    if (pl > pr) return null; // 空区间:没有节点
    const rootVal = preorder[pl]; // 前序首元素就是当前子树的根
    const k = pos.get(rootVal);
    if (k === undefined || k < il || k > ir) throw new Error('序列不自洽,无法还原');
    const leftSize = k - il; // 中序里根左边的元素数 = 左子树大小
    return {
      val: rootVal,
      left: build(pl + 1, pl + leftSize, il, k - 1), // 前序紧跟根的一段是左子树
      right: build(pl + leftSize + 1, pr, k + 1, ir), // 剩下的是右子树
    };
  };
  return build(0, preorder.length - 1, 0, inorder.length - 1);
}

function toPreorder(root, out = []) {
  if (root) { out.push(root.val); toPreorder(root.left, out); toPreorder(root.right, out); }
  return out;
}
function toInorder(root, out = []) {
  if (root) { toInorder(root.left, out); out.push(root.val); toInorder(root.right, out); }
  return out;
}

// 用例 1:题面小例
const pre1 = ['A', 'B', 'D', 'C'];
const in1 = ['D', 'B', 'A', 'C'];
const t1 = buildTree(pre1, in1);
console.log(`还原后前序 [${toPreorder(t1)}] 与原前序一致:${JSON.stringify(toPreorder(t1)) === JSON.stringify(pre1)}`);
console.log(`还原后中序 [${toInorder(t1)}] 与原中序一致:${JSON.stringify(toInorder(t1)) === JSON.stringify(in1)}`);

// 用例 2:随机 200 节点树,往返校验
function randomTree(size) {
  const vals = Array.from({ length: size }, (_, i) => i);
  for (let i = vals.length - 1; i > 0; i--) {
    const j = Math.floor(Math.random() * (i + 1));
    [vals[i], vals[j]] = [vals[j], vals[i]];
  }
  let root = null;
  const insert = (node, v) => {
    if (!node) return { val: v, left: null, right: null };
    if (v < node.val) node.left = insert(node.left, v);
    else node.right = insert(node.right, v);
    return node;
  };
  for (const v of vals) root = insert(root, v);
  return root;
}
const big = randomTree(200);
const pre2 = toPreorder(big);
const in2 = toInorder(big);
const rebuilt = buildTree(pre2, in2);
console.log(`200 节点随机树往返校验:${JSON.stringify(toPreorder(rebuilt)) === JSON.stringify(pre2) && JSON.stringify(toInorder(rebuilt)) === JSON.stringify(in2)}`);

// 用例 3:不自洽输入(中序里不存在前序的根)
try {
  buildTree(['A', 'B'], ['A', 'C']);
} catch (e) {
  console.log(`不自洽输入:${e.message}`);
}

实测输出:

还原后前序 [A,B,D,C] 与原前序一致:true
还原后中序 [D,B,A,C] 与原中序一致:true
200 节点随机树往返校验:true
不自洽输入:序列不自洽,无法还原

往返校验是最有说服力的自证:随机生成一棵树,序列化成前序和中序,再还原回来,两条序列与原始输出逐位一致——还原的树与原树同构。

分步演示图:第一步,取前序序列首元素作为根节点;第二步,在中序序列中定位该根,根左边的元素构成左子树、右边构成右子树;第三步,按左子树大小在前序中切出左右两段,对两段分别递归。下方实例:前序 A B D C、中序 D B A C,根为 A,中序中 A 左侧 D B 为左子树、右侧 C 为右子树;再对前序 B D 与中序 D B 递归得 B 的左孩子 D。右侧总结:分治点 = 根在中序中的下标;序列无重复值是唯一能还原的前提 图 2:分治三步走——前序定根、中序切边、按大小分前序。

可能的追问。

「前序加后序能不能还原?」不能唯一还原:后序只能告诉你根(最后一个元素),但分不清一棵单孩子子树是左孩子还是右孩子——前序后序的第二个元素都只能给出「子树的根」,给不出左右归属。除非题目额外限定是满二叉树。「复杂度?」哈希表定位根后,每个节点恰好被处理一次,时间 O(n),递归栈深度等于树高,最坏 O(n)。「为什么要建下标哈希表?」把每次线性查找的 O(n) 压成 O(1),这是本题从「能做对」到「做得好」的分水岭。

这套「从两个视角的序列重建结构」的思路远不止二叉树:解析器从 token 流重建语法树、从扁平配置重建树状状态、分布式日志从分片序列还原全局序,都是同一个建模范式——找到那个能把两个视角对齐的分治点。回到笔试现场,建议的答题节奏:先讲两条序列各自的信息量与无重复值前提,再给出分治下标推导,最后主动把复杂度从 O(n²) 优化到 O(n)。你刷题时遇到过「序列不自洽」的脏输入吗?是怎么处理的?欢迎聊聊。