递归与二叉树

由遍历序列反推二叉树

反过来的问题

前中后序那三个时刻讲的是「给一棵树,怎么走出序列」。 这一篇反过来:给你序列,把树还原出来。

它值得单独一篇,是因为解法依赖一个不显然的观察:

前序和后序负责「指认根」,中序负责「分开左右」。 两个职责缺一不可 —— 这也是下面那个「前序 + 后序反而不行」的原因。

前序 + 中序

前序是「根左右」,所以第一项一定是根。 拿这个根去中序里找位置,它左边的全是左子树、右边的全是右子树:

前序  [3 | 9 | 20 15 7]      3 是根
中序  [9 | 3 | 15 20 7]      3 左边 [9] 是左子树,右边 [15,20,7] 是右子树
function buildPreIn(preorder, inorder) {
  const pos = new Map();
  inorder.forEach((v, i) => pos.set(v, i));   // 值 → 中序下标,把「找根」降到 O(1)
  let p = 0;                                   // 前序游标,全局只走一遍

  function build(lo, hi) {                     // 负责中序区间 [lo, hi]
    if (lo > hi) return null;
    const val = preorder[p++];                 // 前序当前项就是这棵子树的根
    const node = { val, left: null, right: null };
    const i = pos.get(val);
    node.left = build(lo, i - 1);              // ⚠️ 必须先左后右
    node.right = build(i + 1, hi);
    return node;
  }
  return build(0, inorder.length - 1);
}

⭐ 这里有个容易被忽略的设计:前序游标 p 是全局的,只往前走,从不回退。 每层递归只管「我负责中序的哪一段」,根是谁由 p 自动给出。 不需要同时切两个数组的区间 —— 那种写法要维护四个下标,边界错的概率高得多。

🚨 build(lo, i-1) 必须写在 build(i+1, hi) 前面。 因为前序是「根左右」,p 接下来要吐出的是左子树的根。 两行调换位置,p 就把右子树的根发给了左子树。

中序 + 后序:顺序要反过来

后序是「左右根」,所以最后一项是根,倒着读。 但倒着读的序列是「根右左」—— 所以递归时必须先建右子树:

function buildInPost(inorder, postorder) {
  const pos = new Map();
  inorder.forEach((v, i) => pos.set(v, i));
  let p = postorder.length - 1;                // 从末尾往前

  function build(lo, hi) {
    if (lo > hi) return null;
    const val = postorder[p--];
    const node = { val, left: null, right: null };
    const i = pos.get(val);
    node.right = build(i + 1, hi);             // 🚨 先右
    node.left = build(lo, i - 1);              // 后左
    return node;
  }
  return build(0, inorder.length - 1);
}

🚨 写成「先左后右」的症状:不是答案错,是爆栈

这个错很值得说,因为它的表现和你预期的不一样。

顺序反了之后,p 吐出的「根」并不属于当前这段中序区间, 于是 pos.get(val) 给回一个落在 [lo, hi] 之外的下标。 区间不再收缩,递归下不去底:

输入 中序 [9,3,15,20,7] / 后序 [9,15,7,20,3]
  正确:[3,9,20,null,null,15,7]
  反了:💥 RangeError: Maximum call stack size exceeded

⭐ 这个错的行为可以精确刻画,不用报百分比。穷举 n=1~11 的全部 82499 种树形, 逐棵比对:

错版崩溃      ⟺  树中存在【某个节点有两个孩子】
错版侥幸正确  ⟺  树是一条链(每个节点至多一个孩子),恰好 2^(n-1) 棵
答案错        0 棵(所有 n 上都是 0)

⭐ 于是出错率有闭式解:1 − 2^(n-1) / Catalan(n)。

 n           树形数     其中是链    出错率
 1                1           1      0%
 2                2           2      0%       ← 两个节点以内,这个 bug 测不出来
 3                5           4     20.00%    ← 最小能暴露它的规模
 5               42          16     61.90%
10           16,796         512     96.95%
20    6,564,120,420     524,288     99.99%

🚨 所以「崩溃很吵、一跑就知道」有个前提:你的用例不能是链。 n ≤ 2 时全部树形都是链,必然测不出;n = 3 的 5 种形状里也只有 1 种能暴露。 👉 判据:测这类「左右搞反」的 bug,用例必须含一个有两个孩子的节点。

⭐ 顺带:前序版写成「先右后左」的刻画完全相同(同样穷举验过), 正文这一节讲的是后序版,但两边是一回事。

📌 真正需要警惕的是那些「不崩、只是悄悄算错」的 bug —— 比如摩尔投票在没有众数时照样返回一个像样的值。 同样是写错,症状的响亮程度差别很大,而响亮的那种其实更容易对付。

⭐ 为什么前序 + 后序反而不够

这是这道题最值得想清楚的地方。前序和中序行、中序和后序行, 前序和后序却不行 —— 直觉上信息量明明一样多。

原因在开头那句:前序和后序都只会「指认根」,谁都不能分开左右。

最小反例只要两个节点:

树A:  1          树B:  1
     /                  \
    2                    2

前序   [1, 2]           [1, 2]      ← 一样
后序   [2, 1]           [2, 1]      ← 一样
中序   [2, 1]           [1, 2]      ← 只有它能分开

📌 判据很干净:当某个节点只有一个孩子时,前序 + 后序分不出它是左孩子还是右孩子。

⭐ 这条判据穷举证明过,不是观察。把「形状 × 值的全排列」全部列出来 (n=7 时 2162160 棵带标号树),按 (前序, 后序) 分组:

 n   带标号树数   分组数    落在多树组里的   有单孩子节点的
 3           30       12               24               24    ✅ 逐棵一致
 5        5,040    1,080            4,800            4,800    ✅
 7    2,162,160  257,040        2,136,960        2,136,960    ✅

「有歧义」与「有单孩子节点」在 n=1~7 上是同一批树,一棵不差。

🚨 而歧义比例这件事,一半可以直接算出来,不用测。 满二叉树(每节点 0 或 2 个孩子)的节点数必为奇数 —— 所以偶数个节点的树必然有单孩子节点,歧义率恒为 100%,这是定理不是实测:

n           全部树形数        其中满二叉树
2                    2                  0     ← 偶数一律 0
4                   14                  0
10              16,796                  0
11              58,786                 42
21   24,466,267,020             16,796

⚠️ 所以真正需要采样的是奇数 n。实测 20 万棵随机树:

 n      1     3       5       9      11      19      21      49/51
有歧义  0%  66.66%  86.56%  97.87%  99.07%  99.98%  99.99%  100.00%

⭐ 十个节点往上,奇数规模也基本必然有歧义(n=11 已经 99.07%)—— 结论成立,但支撑它的必须是奇数那几档。

📌 反过来也验证了:限定成满二叉树之后歧义就消失了 —— 采样 78001 棵满二叉树,(前序,后序) 相同却形状不同的有 0 棵。

⭐ 所以力扣 #889 从前序与后序遍历序列构造二叉树(中等) 的题面原话是「如果存在多个答案,您可以返回其中任何一个」, 而不是「返回那棵树」—— 题面里这种措辞的松紧,往往正对应着一个可以证明的结论。

那张哈希表值不值:看数据形状

pos 那张「值 → 中序下标」的表,是为了把「在中序里找根」从 O(n) 降到 O(1)。 去掉它改用线性扫描,理论上是 O(n) → O(n²)。

🚨 但「线性扫描」有两种自然写法,「链状」也有两个方向, 而这两个选择各自都能让结论翻转。 先看扫描步数(与机器无关,可精确计算):

写法                形状    n=2000       n=4000      涨幅   闭式解
区间内从 lo 扫       右链        2,000        4,000    2.0×   n
区间内从 lo 扫       左链    2,001,000    8,002,000    4.0×   n(n+1)/2
区间内从 lo 扫       平衡       10,870       23,734    2.2×   ≈ n·log₂n / 2
整数组 indexOf       任何形状 2,001,000    8,002,000    4.0×   n(n+1)/2(与形状无关)

⚠️ indexOf 那一行是关键:它对任何形状都是 n(n+1)/2 —— 因为查一个值的代价就是它在数组里的下标,把 n 个根加起来恒等于 n(n+1)/2。 用这种写法,平衡树也是 O(n²)。

于是耗时(各预热 5 次、15 次取中位数)分成两种局面:

写法              形状    n=2000   n=4000    线性版/哈希版
区间内从 lo 扫     左链     12.6×    18.8×    ← O(n²),倍数随 n 涨
区间内从 lo 扫     右链      0.3×     0.3×    ← 线性版反而快 3 倍
区间内从 lo 扫     平衡      0.4×     0.3×    ← 线性版更快
整数组 indexOf     平衡     11.1×    20.2×    ← 换个写法,平衡树也变慢 20 倍

⭐ 所以两条结论都要带上口径:

  1. 「链状是最坏情况」只对左链成立。 右链下每段子树的根恰好就在区间左端, 一步命中,线性版反而比哈希版快 3 倍。「链状」这个词不够,得说清方向。
  2. 「平衡树上线性扫描反而快」只在「区间内从 lo 扫」这个写法下成立。 因为那时每层要扫的区间只有该子树那么长,总量 ≈ n·log₂n/2 且常数极小; 而哈希表要先花 O(n) 建表、每次查询还有哈希开销。

📌 和单调栈那篇得到的是同一个结论: 看到「优化成 O(n)」先问「在什么数据上」。 哈希表在这里买的不是平均速度, 是消掉最坏情况 —— 而判题机的数据正是照着最坏情况构造的。 ⭐ 再加一条:也要问「拿什么跟它比」。 同一个「线性扫描」的两种写法, 一个在平衡树上快、一个慢二十倍。

一类更大的题:序列化与反序列化

把树变成字符串、再变回来,是同一件事的一般形式。 关键在于空节点也要记下来 —— 否则就回到上面那个「分不出左右」的困境:

前序 + 空节点占位:  [1, 2, #, #, #]   ← 树A(2 是左孩子)
                   [1, #, 2, #, #]   ← 树B(2 是右孩子)

⭐ 补上 # 之后,单独一个前序就够了,不再需要中序 —— 因为「空」这个信息本身就把左右分开了。这正是 #297 的标准解法。

这一篇的判据

给了什么 能不能唯一还原 为什么
前序 + 中序 ✅ 前序指根,中序分左右
中序 + 后序 ✅ 后序指根(倒读),中序分左右
前序 + 后序 ❌ 两个都只能指根,没人分左右
前序 + 空节点占位 ✅ 「空」本身就分开了左右
前序(且是 BST) ✅ BST 的中序天然有序,等于白送一个中序

🚨 三个高频错误:

  1. 后序版忘了先右后左 —— 症状是爆栈,不是答案错
  2. 两边都切区间 —— 维护四个下标,边界几乎必错;用「全局游标 + 只切中序区间」
  3. 假设值不重复 —— pos 表用值当 key,有重复值时会互相覆盖。 ⚠️ 力扣这几道题明确保证节点值互不相同(#105 的提示原话是 「preorder 和 inorder 均 无重复 元素」),所以能这么写。 和最近公共祖先、 摩尔投票一样,这又是一处「解法在利用题面给的前提」—— 看到「题目保证……」就该想想自己有没有正在依赖它。

练习

勾选记录做过哪些,0 / 7 道。进度只存在这台设备的浏览器里;同一道题在别篇勾过,这里也会显示已做。