已知先序与中序遍历序列求解后序遍历——C++实现详解
简介:树的遍历是计算机科学中的核心概念之一,前序、中序和后序遍历各有特点。本文重点讲解如何根据已知的先序和中序遍历序列重建二叉树并推导出后序遍历序列。通过递归与分治策略,利用先序确定根节点、中序划分左右子树的特性,可唯一构造后序序列。文章提供完整的C++实现方案,包含递归函数设计与数组索引操作,帮助读者深入理解树结构的遍历机制与算法逻辑。
二叉树重构的艺术:从先序与中序推导后序
你有没有遇到过这样的面试题——给你一段先序遍历和一段中序遍历,让你还原整棵二叉树?甚至进一步要求输出它的后序序列?这看似简单的题目背后,其实藏着数据结构中最精妙的递归逻辑之一。别小看它,哪怕只是多错一个索引,整棵树就会“长歪”了 😅。
今天咱们就来彻底拆解这个问题: 如何通过先序 + 中序,精准重建二叉树并生成正确的后序遍历结果 。这不是照搬模板,而是带你一步步理解背后的“道”——为什么是这个顺序?边界怎么算?哈希表为何能提速百倍?准备好了吗?我们从一棵最朴素的树开始讲起 🌳。
树的三种基本遍历方式:不只是顺序问题
在深入算法之前,得先搞清楚我们手里的“工具”到底是什么。对于任何一棵二叉树,有三种经典的遍历方式:
- 先序(Preorder) :根 → 左 → 右
- 中序(Inorder) :左 → 根 → 右
- 后序(Postorder) :左 → 右 → 根
它们不仅仅是访问节点的顺序不同,更代表了不同的“视角”。
比如下面这棵树:
A
/ \
B C
/ \ \
D E F
对应的三种遍历分别是:
- 先序: [A, B, D, E, C, F]
- 中序: [D, B, E, A, F, C]
- 后序: [D, E, B, F, C, A]
你会发现:
- 先序的第一个元素永远是当前子树的根 ;
- 中序把根夹在中间,左边全是左子树,右边全是右子树 ;
- 后序最后一个才是根,适合释放内存或表达式求值这类“先处理完孩子再动手”的场景 。
所以,当我们拿到先序和中序时,其实等于拿到了两个关键线索:
1. “谁是根?”——来自先序;
2. “根在哪一边?”——来自中序。
这两个信息一结合,就能像拼图一样把整棵树慢慢还原出来 ✨。
如何用先序找根?每次都是“头号人物”
想象一下你在读一本书的目录。如果这本书是按“章节→节→小节”的结构组织的,那每当你进入一个新的章节,第一个看到的一定是标题——也就是这一部分的“根”。
先序遍历就是这么个道理。无论你现在处理的是整棵树还是某个子树,只要你看它的先序序列, 第一个出现的节点就是当前子树的根 !
举个例子,还是上面那棵树:
先序: [A, B, D, E, C, F]
一开始, A 是根;接着往下走, B 是左子树的根;再往左, D 是 B 的左孩子的根……每一层都遵循同样的规则。
代码上体现为:
char rootVal = preorder[preStart]; // 每次取当前区间的首元素
TreeNode* root = new TreeNode(rootVal);
就这么简单?对!但光知道根还不够,因为你还不知道哪些节点属于左子树、哪些属于右子树。这时候就得请出中序来帮忙了 👏。
中序的作用:给根“划地盘”,左右立见分晓
如果说先序告诉你“谁当家”,那中序就在告诉你“家产怎么分”。
继续拿前面的例子来说:
中序: [D, B, E, A, F, C]
现在我们知道 A 是根,那我们在中序里找到 A 的位置(索引为3),然后看看它的左右两边:
- 左边 [D, B, E] → 属于左子树;
- 右边 [F, C] → 属于右子树。
这就相当于一次“分割操作”。一旦你知道了左子树有3个节点,右子树有2个节点,就可以反过来去切分先序序列了!
因为在先序中,结构是这样的:
[A] | [B, D, E] | [C, F]
↑ ↑ ↑
根 左子树 右子树
所以我们可以确定:
- 左子树的先序区间是 [1, 3] (跳过根后的连续3个)
- 右子树的先序区间是 [4, 5] (剩下的)
这样一来,原问题就被拆成了两个更小的问题:
1. 用 [B,D,E] 和 [D,B,E] 重建左子树;
2. 用 [C,F] 和 [F,C] 重建右子树。
是不是有点分治的意思了?🎉
哈希优化:别让查找拖慢整个递归
不过这里有个性能陷阱:每次都要从中序数组里找根的位置,怎么办?
最朴素的方法是线性扫描:
int findIndex(vector<char>& inorder, int inStart, int inEnd, char val) {
for (int i = inStart; i <= inEnd; ++i)
if (inorder[i] == val) return i;
return -1;
}
看起来没问题,但如果树很深,比如退化成一条链,每一层都要扫一遍,时间复杂度直接飙到 $O(n^2)$ ❌。
解决办法也很直接: 预处理一个哈希表,把每个值映射到它在中序中的位置 。
unordered_map<char, int> inorderMap;
for (int i = 0; i < inorder.size(); ++i)
inorderMap[inorder[i]] = i;
之后每次查根的位置只需要 $O(1)$ 时间:
int idx = inorderMap[rootVal]; // 快如闪电 ⚡
虽然多了 $O(n)$ 空间开销,但换来的是整体 $O(n)$ 时间复杂度的巨大提升 ✅。
| 方法 | 单次查找 | 总体复杂度 | 是否推荐 |
|---|---|---|---|
| 线性扫描 | O(n) | O(n²) | ❌ |
| 哈希映射 | O(1) | O(n) | ✅ 推荐! |
特别是在大规模数据或高频调用场景下,这种优化几乎是必须的。
子树边界的计算:差一个索引就全乱套
很多人写这个算法出错,不是逻辑不对,而是 索引算错了 。尤其是在递归传参的时候,稍不留神就会越界或者漏掉节点。
我们再来仔细推一遍边界公式。
假设当前处理的子树范围如下:
- 先序区间: [preStart, preEnd]
- 中序区间: [inStart, inEnd]
- 根节点在中序中的索引为 idx
那么:
- 左子树节点数 = leftSize = idx - inStart
- 所以左子树在先序中的范围是:
- 起始: preStart + 1 (跳过当前根)
- 结束: preStart + leftSize
- 右子树在先序中的范围是:
- 起始: preStart + leftSize + 1
- 结束: preEnd
对应地中序也要切分:
- 左子树中序: [inStart, idx - 1]
- 右子树中序: [idx + 1, inEnd]
这些参数都要原封不动地传进下一层递归。记住一句话: 左子树的长度决定了先序区间的切割点,而中序负责提供这个长度 。
为了验证这一点,来看一个具体例子:
先序: [1, 2, 4, 5, 3, 6, 7]
中序: [4, 2, 5, 1, 6, 3, 7]
初始调用: preStart=0 , preEnd=6 , inStart=0 , inEnd=6
根是 1 ,在中序中索引为3 → leftSize = 3 - 0 = 3
→ 左子树先序应为 [1+1, 1+3] = [1,3] → 对应 [2,4,5] ✔️
→ 右子树先序应为 [1+3+1,6] = [5,6] → 对应 [3,6,7] ✔️
完全匹配!👏
分治思想落地:递归三步走
整个算法的核心其实是典型的 分治策略 :
- 分解(Divide) :从先序取根,在中序划分左右子树;
- 解决(Conquer) :递归构建左右子树;
- 合并(Combine) :将左右子树挂到根节点上。
这个过程天然适合用递归来表达。每一层只关心自己的根和子树划分,具体的构造交给递归完成。
流程图如下:
graph TD
A[开始: 调用 helper] --> B{pre_start > pre_end?}
B -- 是 --> C[返回 null 或退出]
B -- 否 --> D[取 preorder[pre_start] 为根]
D --> E[查 inMap 得 rootIndex]
E --> F[计算左子树长度: len = rootIndex - in_start]
F --> G[递归处理左子树]
G --> H[递归处理右子树]
H --> I[将 root 加入 postorder]
I --> J[返回]
注意最后一步——只有当左右子树都建好了,才把根加进去,这才符合“后序”的定义!
后序构造的关键时机:延迟添加根节点
说到后序遍历,最关键的不是你怎么访问,而是 什么时候记录根节点 。
先序:一进门就记下来;
中序:走到中间才记;
后序:必须等左右都干完了,最后才记。
所以在递归函数里,你要把 push_back(rootVal) 放在两个递归调用之后:
helper(...左子树...); // 先搞定左
helper(...右子树...); // 再搞定右
postorder.push_back(rootVal); // 最后记根
这样栈会自动帮你保存上下文,直到所有子孙都被处理完毕,才会回到这一层执行最后一句。
举个简单例子:
1
/ \
2 3
/ \
4 5
递归展开:
1. 处理 1 → 查中序得索引3 → leftSize=3
2. 进入左子树 [2,4,5] 和 [4,2,5]
- 处理 2 → 索引1 → leftSize=1
- 进入左子树 [4] 和 [4]
- 叶子节点,直接 push 4
- 进入右子树 [5] 和 [5]
- 叶子节点,直接 push 5
- push 2
3. 进入右子树 [3] 和 [3]
- 叶子节点,push 3
4. push 1
最终得到 [4,5,2,3,1] —— 完美 ✅!
C++ 实现完整版:简洁高效可复用
说了这么多,来个完整的 C++ 实现吧。这套代码可以直接跑,支持任意整数节点值,并且做了充分的边界保护。
#include <vector>
#include <unordered_map>
using namespace std;
class Solution {
public:
vector<int> buildPostorder(vector<int>& preorder, vector<int>& inorder) {
unordered_map<int, int> inorderMap;
// 预处理:建立值到索引的映射
for (int i = 0; i < inorder.size(); ++i) {
inorderMap[inorder[i]] = i;
}
vector<int> postorder;
buildHelper(preorder, 0, preorder.size() - 1,
inorder, 0, inorder.size() - 1,
inorderMap, postorder);
return postorder;
}
private:
void buildHelper(const vector<int>& preorder, int preStart, int preEnd,
const vector<int>& inorder, int inStart, int inEnd,
unordered_map<int, int>& inorderMap,
vector<int>& postorder) {
// 终止条件:空子树
if (preStart > preEnd || inStart > inEnd) return;
// 当前根节点
int rootVal = preorder[preStart];
int rootIdx = inorderMap[rootVal];
int leftSize = rootIdx - inStart;
// 递归左子树
buildHelper(preorder, preStart + 1, preStart + leftSize,
inorder, inStart, rootIdx - 1,
inorderMap, postorder);
// 递归右子树
buildHelper(preorder, preStart + leftSize + 1, preEnd,
inorder, rootIdx + 1, inEnd,
inorderMap, postorder);
// 后序:最后加入根
postorder.push_back(rootVal);
}
};
使用示例:
#include <iostream>
int main() {
vector<int> pre = {1, 2, 4, 5, 3, 6, 7};
vector<int> in = {4, 2, 5, 1, 6, 3, 7};
Solution sol;
vector<int> result = sol.buildPostorder(pre, in);
cout << "Postorder: ";
for (int val : result) {
cout << val << " ";
}
cout << endl; // 输出: 4 5 2 6 7 3 1
return 0;
}
测试用例大全:覆盖各种极端情况
别忘了测试!尤其是边界条件最容易翻车。下面是精心设计的10组测试用例,涵盖常见模式:
| 编号 | 先序 | 中序 | 预期后序 |
|---|---|---|---|
| 1 | [1,2,4,5,3,6,7] | [4,2,5,1,6,3,7] | [4,5,2,6,7,3,1] |
| 2 | [1,2,3,4,5] | [5,4,3,2,1] | [5,4,3,2,1] |
| 3 | [1] | [1] | [1] |
| 4 | [1,2,3,4] | [1,2,3,4] | [4,3,2,1] |
| 5 | [2,1,3] | [1,2,3] | [1,3,2] |
| 6 | [3,1,2,4] | [1,3,4,2] | [1,4,2,3] |
| 7 | [5,3,2,4,7,6,8] | [2,3,4,5,6,7,8] | [2,4,3,6,8,7,5] |
| 8 | [1,2,4,8,9,5,3,6] | [8,4,9,2,5,1,6,3] | [8,9,4,5,2,6,3,1] |
| 9 | [10,5,1,8,15,12,20] | [1,5,8,10,12,15,20] | [1,8,5,12,20,15,10] |
| 10 | [] | [] | [] |
特别是第2、4、10种情况:
- 第2个是 完全左斜树 (链状),考验递归深度;
- 第4个是 完全右斜树 ,边界容易出错;
- 第10个是 空树 ,千万别忘了判空!
算法复杂度分析:时间和空间都说得清
| 指标 | 复杂度 | 说明 |
|---|---|---|
| 时间复杂度 | $O(n)$ | 每个节点访问一次,哈希查找 $O(1)$ |
| 空间复杂度 | $O(n)$ | 哈希表 $O(n)$,递归栈最坏 $O(n)$(链状树) |
| 是否可优化 | 否 | 已达理论最优 |
💡 小贴士:如果是满二叉树,递归深度为 $O(\log n)$;如果是链状树,则为 $O(n)$,要注意栈溢出风险。生产环境可用迭代+显式栈替代。
常见误区与调试技巧
新手常犯的错误我都替你们踩过了 😂,总结几个高发雷区:
❌ 错误1:索引计算偏移量不对
// 错误写法:没加1或少减1
root->left = build(..., preStart, preStart + leftSize, ...);
正确应该是 preStart + 1 开始!
❌ 错误2:忘记判断空区间
if (preStart > preEnd) return nullptr; // 必须加!
否则无限递归爆栈 💥。
❌ 错误3:哈希表未初始化或重复使用
如果多次调用函数,记得清空 postorder 和 inorderMap ,或者做成局部变量。
✅ 调试建议:
- 打印每一层的
preStart,preEnd,rootVal,观察是否合理; - 用手模拟两层递归,确认边界传递正确;
- 画个小树验证预期结果。
更进一步:能不能不用建树直接出后序?
当然可以!你会发现我们根本不需要真的构造 TreeNode 结构,因为我们只关心后序序列。
所以完全可以省掉建树的开销,直接收集节点值。上面的代码正是这么做的—— 轻量级、无对象分配、纯数组操作 ,效率更高!
如果你确实需要树结构(比如要做后续遍历或修改),那再返回 TreeNode* 也不迟。
总结:掌握本质,举一反三
这道题的价值远不止于“还原二叉树”。它教会我们几个重要的编程思维:
🧠 递归的本质是状态转移 :每一层只需处理当前状态,其余交给递归;
🧩 分治的核心是切割问题 :大问题 → 小问题 → 基本情况;
⚡ 预处理换时间 :哈希映射虽占空间,却换来质的飞跃;
📐 边界控制决定成败 :差之毫厘,谬以千里。
掌握了这套方法论,你不仅能解“先序+中序→后序”,还能轻松应对:
- “中序+后序→先序”
- “层序+中序→重建”
- 甚至“表达式树构造”、“AST解析”等高级应用。
写在最后:这才是工程师该有的思维方式
下次再有人问你:“你能根据两种遍历恢复一棵树吗?”
你可以微微一笑说:“不光能,我还知道为啥能,以及怎么让它跑得最快。” 😎
因为真正的技术,从来不只是“会不会写”,而是“懂不懂为什么这么写”。
而这,才是我们不断钻研的意义所在 ❤️。
🎯 一句话口诀收尾 :
先序定根,中序分家,哈希加速,递归开花,后序收尾,稳准快拿!
简介:树的遍历是计算机科学中的核心概念之一,前序、中序和后序遍历各有特点。本文重点讲解如何根据已知的先序和中序遍历序列重建二叉树并推导出后序遍历序列。通过递归与分治策略,利用先序确定根节点、中序划分左右子树的特性,可唯一构造后序序列。文章提供完整的C++实现方案,包含递归函数设计与数组索引操作,帮助读者深入理解树结构的遍历机制与算法逻辑。
更多推荐




所有评论(0)