先根遍历、中根遍历、后根遍历,这三个词凑在一起,任何一个学过数据结构的人都眼熟。可真正让大批人卡住的,不是三种遍历本身怎么写,而是它的逆过程:已知一棵二叉树的后根遍历序列和中根遍历序列,怎么推出先根序列?反过来,已知先根序列和中根序列,怎么推出后根序列?这道题在期末考、考研、笔试面试里出现的频率高得离谱,看起来就是递归分治,但真动手写代码的时候,不是索引算错,就是递归边界写崩,运行时错误一串一串往外冒。
我打算把这条思路完整讲透:先说为什么能还原、怎么手工推演,再给出可以直接抄走的代码,最后把写这类程序最常见的运行时错误挨个排查一遍。不管你是在校学生,还是在准备面试的开发者,看完之后应该能自己直接敲出可运行的代码,而不是停留在“道理我都懂,一写就报错”的状态。
1. 这道经典题到底在考什么
1.1 三种遍历序列的关系:时机不同,顺序不同
先根、中根、后根三种遍历,在中文教材里经常也叫先序、中序、后序。它们对应的英文是 preorder、inorder、postorder,只要看到这几个词,指的就是同一套东西。三者的区别,本质上是深度优先搜索(DFS)访问节点的“时机”不同。
用递归的视角看,任何一棵二叉树的遍历都可以压缩成三行代码:
// 伪代码,三种遍历只差一行输出的位置 void dfs(TreeNode* root) { if (root == nullptr) return; // 在这里输出 root,就是先根遍历 dfs(root->left); // 在这里输出 root,就是中根遍历 dfs(root->right); // 在这里输出 root,就是后根遍历 }先根是“根左右”,进入节点立刻输出;中根是“左根右”,左子树访问完再输出;后根是“左右根”,右子树访问完才输出。这个顺序不是拍脑袋定的,它决定了一个关键事实:中根遍历天然地把左右子树分成两段,而先根或后根遍历能把根节点的位置指出来。两者一配合,整棵树就能唯一还原。
1.2 还原一棵树,本质是把遍历过程倒过来
考试和面试里出现这类题目,不是为了让你背模板,而是在考察一件事:你理不理解递归序本身。
树是递归结构:一棵树由根、左子树、右子树组成,而每个子树又是一棵树。遍历是把树“压扁”成一个线性序列的过程,还原就是反过来的线性展开过程。你手里有两组序列,一组给出根的位置线索,另一组给出左右子树的分界线。把这两份线索叠在一起,就能递归地把树重新构造出来。
很多人背下了“后序最后一个节点是根”这句话,却还是写不对代码,原因是他不知道这句话之后,左右子树的序列区间到底怎么切。理解了递归序,这些问题自然就通了。
2. 还原二叉树的原理:根节点是突破口
2.1 后序和中序还原先序:后序的最后一个节点就是根
先看最常见的一种情况:已知后根遍历和中根遍历,求先根遍历。
后根遍历的顺序是“左子树、右子树、根”。所以在一段后序序列里,最后一个元素必然是当前子树的根节点。这是整道题的突破口。
找到根之后,再去中根序列里找这个根的位置。中根遍历的顺序是“左子树、根、右子树”,因此根在中序序列中的位置,就像一把刀,把整个中序序列切成了左右两段:左边全是左子树的节点,右边全是右子树的节点。
到这里,树的左右子树包含哪些节点,已经清清楚楚。剩下的问题只有一个:如何在原序列中把左右子树对应的子序列单独切出来,继续递归。
2.2 中序的真正作用:把树切成两半
中序序列的价值不在于“输出顺序”,而在于它精确记录了每个节点的左右边界。
假设当前处理的后序序列区间是[postL, postR],中序序列区间是[inL, inR]。根是后序的post[postR]。在中序里找到根的位置k之后,可以立刻确定:
- 左子树的节点数:
leftSize = k - inL - 左子树的中序区间:
[inL, k-1] - 右子树的中序区间:
[k+1, inR]
再回头看后序序列。后序序列的结构是“左子树全部节点 + 右子树全部节点 + 根”,左子树有leftSize个节点,那么:
- 左子树的后序区间:
[postL, postL + leftSize - 1] - 右子树的后序区间:
[postL + leftSize, postR - 1]
注意,右子树的后序区间终点是postR - 1,因为postR位置已经被根占用了。这个区间切分是整个算法的灵魂,后面写代码时所有边界错误,几乎都出在这里。
用一句话总结这个套路:中序负责告诉你“左右各有多少个”,后序负责告诉你“从哪个位置开始是右子树”。两个信息一叠加,区间就完全确定了。
2.3 为什么先序和后序不能还原一棵二叉树
既然后序+中序能还原,先序+中序也能还原,那“先序+后序”行不行?答案是:不行。
原因很简单。后序序列的最后一个节点是根,先序序列的第一个节点也是根,但两个序列都不能回答同一个问题:左子树到底包含哪些节点?
举个反例。第一棵树:A 的左孩子是 B。第二棵树:A 的右孩子是 B。这两棵树的先序遍历都是“AB”,后序遍历都是“BA”,但它们是两棵完全不同的树。也就是说,仅凭先序和后序,你区分不了左孩子和右孩子,还原结果不唯一。
中序在中间夹着根,天然提供了左右分界。这就是为什么所有还原二叉树的题目里,中序序列永远是标配。
3. 纸上推演:不写代码也能还原
3.1 完整案例:中序+后序推先序
原理听起来简单,但纸上推演才能真正检验理解程度。我们用一个具体例子把过程走一遍。
已知:
- 中根序列:
D G B A E C H F - 后根序列:
G D B E H F C A
第一步,看后序最后一个元素是 A,整棵树的根就是 A。到中序里找 A,位置在第四个,左边是D G B,右边是E C H F。所以左子树有 3 个节点,右子树有 4 个节点。
第二步,后序序列去掉最后的根 A,剩下G D B E H F C。前 3 个是左子树的后序序列,也就是G D B;后面 4 个是右子树的后序序列,也就是E H F C。
现在处理左子树:中序D G B,后序G D B。后序最后一个 B 是左子树的根。到中序D G B里找 B,B 在最后,因此 B 的左子树有 2 个节点D G,右子树为空。再看后序G D,前 2 个正好对应左子树的G D。
继续处理左子树的左子树:中序D G,后序G D。后序最后一个 D 是根,到中序D G里找 D,D 在开头,说明 D 的左子树为空,右子树是 G。后序G单独处理,G 既无左子树也无右子树。
到这里,左边的结构已经清楚了:B 是 A 的左孩子,B 的左孩子是 D,D 的右孩子是 G。
再看右子树:中序E C H F,后序E H F C。后序最后一个 C 是右子树的根,到中序里找 C,C 在第二个位置,左边是 E,右边是 H F。所以 C 的左子树是 E,右子树有 2 个节点H F。后序E H F去掉最后的根 C 后剩下E H F,前 1 个 E 是左子树,后 2 个H F是右子树的后序。
右子树的左子树只有一个 E,单节点。右子树的右子树:中序H F,后序H F。后序最后一个 F 是根,中序里 F 在最后,左边是 H,右边为空。最终 H 是 F 的左孩子。
整棵树还原出来,先根遍历的结果是:A B D G C E F H。
3.2 切换方向:先序+中序推后序
再换个方向验证一遍。已知:
- 先根序列:
A B D E C F - 中根序列:
D B E A F C
这一次,先序第一个元素 A 是根。到中序里找 A,位置在第四个,左边D B E,右边F C。左子树有 3 个节点,右子树有 2 个节点。
先序序列去掉根 A,剩下B D E C F。前 3 个是左子树的先序B D E,后 2 个是右子树的先序C F。
处理左子树:先序B D E,中序D B E。先序第一个 B 是根,中序里 B 在中间,左边 D,右边 E。因此 B 的左孩子是 D,右孩子是 E。
处理右子树:先序C F,中序F C。先序第一个 C 是根,中序里 C 在最后,左边 F。因此 C 的左孩子是 F,右子树为空。
整棵树的形状是:A 的左孩子是 B,右孩子是 C;B 左 D 右 E;C 左 F。后根遍历的结果是:D E B F C A。
3.3 推演中要盯住的几个关键点
纸上推演能成功,靠的是严格保持“左右子树区间连续”的原则。这里有几个容易出错的细节。
第一,中序里定位根之后,左子树节点数要用“根的位置减去区间左端点”,不是“根的位置”,也不是“根的位置加一”。因为中序区间的左端点不一定是 0,进入深层递归后,左端点可能是任意下标。
第二,后序序列里切分左右子树时,一定要拿leftSize作为长度依据,而不是靠肉眼看序列元素。元素看起来像不代表区间对,只有长度才是唯一判据。
第三,推演时建议把每一层递归的四个区间写出来。写的过程中你会自然发现,进入下一层递归时,左子树区间和右子树区间是刚好首尾相接的。一旦发现区间不连续或者重叠,说明切分已经出错了。
4. 代码实现:一套逻辑写两个方向
4.1 用哈希表定位根节点,别用线性查找
纸上推演时,找根在中序里的位置是靠眼睛扫的。写代码时如果每次都用循环去扫,时间复杂度会变成 O(n²)。数据量小无所谓,但二叉树节点一多,性能立刻看得出来。
正确做法是提前遍历一遍中序序列,把每个节点值到下标的位置关系存进哈希表:
#include <bits/stdc++.h> using namespace std; vector<char> pre, in, post; unordered_map<char, int> inPos; int n;哈希表的作用很简单:在递归过程中,给定根节点,O(1) 能拿到它在中序里的下标。代价是一次 O(n) 的预处理,换来整体 O(n) 的算法复杂度,非常划算。
4.2 后序+中序求先序:输出放在递归前
先序序列的生成顺序是“根、左子树、右子树”,所以在递归函数里,应该先把当前根节点放入结果数组,再递归处理左子树和右子树。
// 后序 + 中序 -> 先序 void getPre(int postL, int postR, int inL, int inR) { if (postL > postR) return; char root = post[postR]; // 后序序列最后一个位置是根 pre.push_back(root); // 先序:先输出根 int k = inPos[root]; // 根在中序序列中的位置 int leftSize = k - inL; // 左子树节点个数 // 左子树:后序 [postL, postL + leftSize - 1],中序 [inL, k-1] getPre(postL, postL + leftSize - 1, inL, k - 1); // 右子树:后序 [postL + leftSize, postR - 1],中序 [k+1, inR] getPre(postL + leftSize, postR - 1, k + 1, inR); }递归出口是postL > postR,表示当前的序列区间为空。这个条件同时覆盖了空树、单节点、没有左子树或右子树的各种情况。
4.3 先序+中序求后序:输出放在递归后
后序序列的生成顺序是“左子树、右子树、根”,因此根节点必须在左右子树递归完成之后才放入结果数组。代码结构和上面几乎对称:
// 先序 + 中序 -> 后序 void getPost(int preL, int preR, int inL, int inR) { if (preL > preR) return; char root = pre[preL]; // 先序序列第一个位置是根 int k = inPos[root]; // 根在中序序列中的位置 int leftSize = k - inL; // 左子树节点个数 // 左子树:先序 [preL + 1, preL + leftSize],中序 [inL, k-1] getPost(preL + 1, preL + leftSize, inL, k - 1); // 右子树:先序 [preL + leftSize + 1, preR],中序 [k+1, inR] getPost(preL + leftSize + 1, preR, k + 1, inR); post.push_back(root); // 后序:最后输出根 }注意后序求先序时,左子树的先序区间是从preL + 1开始,长度为leftSize,所以终点是preL + leftSize。右子树从preL + leftSize + 1开始,到preR结束。这里的preL + 1是先序序列里跳过了根节点的位置,很多人的越界错误就发生在这个加一减一上。
主函数调用也很简单:
int main() { // 输入 n 和三个序列,这里省略具体读入 // 需要先读入中序序列,建立哈希表 inPos.clear(); for (int i = 0; i < n; i++) { inPos[in[i]] = i; } // 后序 + 中序 -> 先序 getPre(0, n - 1, 0, n - 1); // 先序 + 中序 -> 后序 getPost(0, n - 1, 0, n - 1); for (char c : pre) cout << c; cout << "\n"; for (char c : post) cout << c; cout << "\n"; return 0; }如果你用的是 Python,同样的逻辑可以直接用列表和字典实现,代码更短:
def build_pre_from_post_in(post, in_seq): in_pos = {v: i for i, v in enumerate(in_seq)} pre = [] def dfs(pl, pr, il, ir): if pl > pr: return root = post[pr] k = in_pos[root] left_size = k - il pre.append(root) dfs(pl, pl + left_size - 1, il, k - 1) dfs(pl + left_size, pr - 1, k + 1, ir) dfs(0, len(post) - 1, 0, len(in_seq) - 1) return pre核心逻辑一致,只是没有了数组下标越界的风险。
4.4 区间边界怎么记才不容易错
写这类递归,最痛苦的就是记不住四个区间到底怎么切。我自己的记忆办法是:先算 leftSize,再写左子树,再写右子树。
左子树区间无论在中序还是后序,都是从当前区间左端点开始,长度是 leftSize。右子树区间就复杂一些:中序的右子树从k+1开始到inR结束,后序的右子树从“左子树终点 + 1”开始,到“当前区间右端点减 1”结束,因为当前区间右端点是根。
如果你怕记错,可以把“当前递归层次里每个区间对应的含义”用注释写在代码旁边。我在实战中见过太多人把postR - 1写成postR,结果那个根节点被反复递归处理,最终栈溢出或者重复输出。
5. 写二叉树程序总是报运行时错误?问题多半出在这
5.1 栈溢出:递归出口和递归深度
“一运行程序就崩溃,弹窗提示栈溢出”,这是这门课里最经典的报错场景。原因通常有两种。
第一种是递归出口写错了。有人把if (postL > postR) return;写成if (postL == postR) return;,这样当区间为空时,递归不会停止,会一直用错误的区间调用下去,直到栈爆掉。空区间的本质是“左端点大于右端点”,不是“左端点等于右端点”。
第二种是递归深度本身过大。二叉树如果退化成一条链——比如所有节点都只有左孩子——那么递归深度就等于节点数。节点数上万时,系统栈很容易被压爆。解决办法是把递归改成显式栈的迭代写法,或者直接做成循环压栈模拟。对于课程作业和一般面试题,递归写法通常已经够用,但你要明白这个隐患存在。
5.2 数组越界:区间切分算错了
数组越界报错信息通常长这样:“vector subscript out of range”或者“segmentation fault”。它不一定是访问了一个巨大下标,更多时候是某个区间算成了负数或者超出了 n 的范围。
我见过最典型的错误,是把leftSize = k - inL写成了leftSize = k。当inL不是 0 的时候,这个值会比真实的左子树大小大出一截,导致左子树的区间终点超出右边界,直接越界。
还有一种错误出现在后序求先序的右子树区间上。应该是postL + leftSize到postR - 1,有人会写成postL + leftSize + 1到postR。这样会跳过根节点位置的左边一个节点,产生奇怪的重复输出和越界。
要避免这类问题,没有捷径。我的习惯是把每个区间的 start 和 end 都打印出来,跟纸上推演的结果比对。一旦发现某个区间的长度和当前子树节点的实际数量对不上,立刻就能定位到是哪一步切分逻辑出了问题。
5.3 找不到根节点:输入问题与重复值
有时候程序不崩溃,但结果明显不对,比如遍历序列里少了一个节点,或者多了一个重复节点。这往往是哈希表定位时出了问题。
中序序列里的节点值必须互不相同。如果存在两个节点值相同的节点,哈希表只会存一个下标,另一个节点在定位时就会被错误映射。这种情况在题目正常输入里不会出现,但如果你自己构造测试数据时不小心,就会踩中。
另外,如果读入的三个序列长度不一致,或者中序和后序本身不对应同一棵树,那么在后序中取出来的根节点,有可能在中序里根本不存在。哈希表查找会返回一个默认值,导致后续 leftSize 算出一个离谱的数字。处理方式是提前检查,如果发现根节点不在中序哈希表里,直接报错终止。
5.4 调试利器:打印区间
排查这类递归程序,最高效的方式就是在递归入口打印日志,把每一层的四个区间边界和当前根节点输出到屏幕上。
void getPre(int postL, int postR, int inL, int inR) { if (postL > postR) return; cout << "post [" << postL << "," << postR << "] " << "in [" << inL << "," << inR << "] root=" << post[postR] << "\n"; // ... 后续逻辑 }对照输出,你可以看到每一次递归的区间是否正确覆盖了当前子树的节点。如果左子树和右子树的区间加起来不等于当前区间,说明切分逻辑一定在某处出了问题。这个技巧比看报错信息管用得多,我调试这类题目时几乎每次都靠它。
6. 再往深走一点:复杂度、乱序与延伸
6.1 时间复杂度与极端情况分析
这套算法的整体时间复杂度是 O(n)。每个节点被访问一次,哈希表定位是 O(1)。递归栈的深度等于树的高度 h,最坏情况是链状树,h 等于 n,此时空间复杂度是 O(n);平衡二叉树情况下,空间复杂度是 O(log n)。加上哈希表本身占用的 O(n) 空间,算法总体最坏空间复杂度 O(n)。
有些追求极致的题目会要求你“只输出序列,不建树”。上面的代码本来就是直接生成序列,没有显式构建树节点,已经满足这个要求了。如果你需要额外构建出 TreeNode,只要在递归过程中用根节点值 new 一个节点,再递归设置左右孩子指针即可,思路完全一致。
6.2 不建树直接输出,这个思路还能怎么用
我遇到过不少同学,看到“还原二叉树”就条件反射地先建树,再遍历输出。在不需要树结构本身、只要序列的题目里,这是绕了远路。
直接输出有什么好处?第一,省掉了 TreeNode 的内存分配,代码更简洁。第二,避免了因为没有初始化左右孩子指针而导致的运行时错误——这是另一个高频报错点。第三,递归输出和递归建树的逻辑完全相同,少一层处理就少一个出错的位置。
当然,如果题目后续要求你查询某个节点的父节点、深度、路径,那就老老实实建树。输出序列和建树是两种目标,用哪一种取决于后续操作,不必为了炫技而强行不建树。
6.3 先序+后序不唯一的完整说明
前面已经用两节点反例说明了先序+后序无法唯一确定二叉树。这个问题还有一个更普遍的说法:当某个节点只有一个孩子时,先序+后序无法判断这个孩子是左孩子还是右孩子。
只有孩子节点时,先序里它是“根的孩子”,位置在后序里也是“根的孩子”,但方向信息完全丢失。中序之所以能打破这种模糊,是因为中序会把这个孩子明确放在根的左边或右边,方向信息被完整保留了下来。这就是中序在各种还原题目中“不可替代”的深层原因。
6.4 与线索二叉树、二叉搜索树的联系
学过线索二叉树的话,你会更容易理解中序的特殊地位。线索二叉树利用节点的空指针域记录中序前驱和后继,本质上是把中序序列的线性关系直接织进树结构里。这种设计之所以成立,正是因为中序序列天然地记录了每个节点在“左根右”顺序中的前后位置。
二叉搜索树则是另一个极端:它的中序序列一定是递增有序的。如果你得知一棵树是二叉搜索树,那么即使不给你中序序列,光凭先序或者后序也能还原整棵树,因为中序序列可以自己推出来——直接排序即可。但普通二叉树没有这个性质,所以必须显式给出中序序列。
题目还有可能把已知条件换成“层次遍历 + 中序”,原理本质上一样:层次遍历的第一个元素是根,中序仍然负责切分左右子树。你只需要额外维护层次序列中哪些节点属于左子树、哪些属于右子树即可。理解了“中序切分 + 其他序列找根”这个核心套路,遇到任何变体都不慌。
最后分享一个我自己的习惯。我写这类递归代码之前,一定会先在草稿纸上把四个区间画出来,标清楚根、左子树、右子树分别对应哪一段,才动笔写代码。踩过几次坑之后我深刻体会到,大部分运行时错误不是逻辑不懂,而是区间图画错了。你如果现在正被这道题折磨,不妨先放下键盘,拿笔画一画,代码很快就能顺下来。