1. 从一道经典题说起:为什么遍历序列能反推二叉树
二叉树的遍历,说到底是数据结构里最基础也最要命的一块内容。基础在于,递归定义下三种遍历的代码写起来不超过十行;要命在于,一旦考试或面试里把遍历和“还原二叉树”“求深度”“判断搜索树”这些组合起来,一大批人就开始犯迷糊。
而这里面最经典的一道题,就是“已知二叉树的先根遍历序列和中根遍历序列,还原整棵二叉树,并输出后根遍历序列”,或者它的变体“已知中根和后根遍历序列,求先根遍历”。GESP六级里经常考,很多学校的期末卷子上也有,面试题里也会以“从前序遍历和中序遍历构造二叉树”的形式出现。
这道题考察的本质不是“会不会背遍历顺序”,而是“能不能理解递归分治”。说白了,给你一棵树的先序和中序,你如果能手动还原出来,那说明你对“根节点在哪、左右子树怎么切分”有了真正的直觉;如果只是背了代码模板,换个问法就抓瞎,那说明基本功还欠着火候。
这篇文章我会把这三种遍历的关系、还原二叉树的完整手算流程、C++代码实现,以及我实际刷题和带人过程中踩过的坑,一次性讲清楚。适合正在准备GESP六级、蓝桥杯、考研复试笔试,或者单纯想把二叉树遍历吃透的人。看完你不仅能写出还原代码,还能解释清楚为什么用“先序+中序”能唯一确定一棵二叉树,而“先序+后序”不行。
先说结论:前序(先根)、中序、后序遍历,分别对应“根左右”“左根右”“左右根”的访问顺序。这不是三个孤立的知识点,而是同一棵二叉树在三种不同的“遍历策略”下的输出。理解这一点,是后面所有推导的地基。
2. 三种遍历的本质:递归序的三种“截取”方式
2.1 递归视角下的遍历顺序
很多初学者把三种遍历当成三个独立的函数来记,这是一个误区。我更倾向于用“递归序”来理解。
想象你有一个递归函数traverse(node),它的逻辑是:如果节点为空就返回,否则执行以下三件事:
- 第一次到达节点时,记录节点(先序遍历的时机);
- 递归访问左子树;
- 第二次回到节点时,记录节点(中序遍历的时机);
- 递归访问右子树;
- 第三次回到节点时,记录节点(后序遍历的时机)。
也就是说,每个节点在递归过程中会被“经过”三次。第一次经过时输出,就是先序遍历;第二次经过时输出,就是中序遍历;第三次经过时输出,就是后序遍历。
这个视角有什么用?它让你意识到,三种遍历共享同一个递归框架,差异只在于输出语句的位置。代码层面,它们几乎一模一样:
void preorder(TreeNode* root) { if (!root) return; cout << root->val << " "; // 先序:先访问根 preorder(root->left); preorder(root->right); } void inorder(TreeNode* root) { if (!root) return; inorder(root->left); cout << root->val << " "; // 中序:左、根、右 inorder(root->right); } void postorder(TreeNode* root) { if (!root) return; postorder(root->left); postorder(root->right); cout << root->val << " "; // 后序:左、右、根 }这三段代码,唯一的区别就是cout的位置。理解了递归序,你连代码都不用背,直接推都能推出来。
2.2 序列特征:从输出反推结构
除了递归代码,更重要的是掌握三种遍历序列本身的特征。这些特征直接决定了后面“还原二叉树”的算法设计。
拿下面这棵二叉树举例:
A / \ B C / \ \ D E F它的三种遍历序列分别是:
- 先根遍历(根左右):
A B D E C F - 中根遍历(左根右):
D B E A C F - 后根遍历(左右根):
D E B F C A
观察这三个序列,你会发现三条非常重要的规律:
第一,先序序列的第一个元素一定是整棵树的根节点。因为先序最先访问根,所以A必然是根。同理,后序序列的最后一个元素也一定是根节点。
第二,中序序列中,根节点的左边全是左子树的节点,右边全是右子树的节点。比如中序D B E A C F中,根A左边是D B E,对应左子树B的全部节点;右边是C F,对应右子树C的全部节点。这是还原二叉树最核心的依据。
第三,同一棵树的三种遍历序列,节点集合完全相同,只是顺序不同。这条看起来像废话,但实际做题时,它帮你快速验证自己是否写错。比如你用先序+中序还原出二叉树后,手动再跑一遍后序,发现和你求出的序列节点集合不一致,那肯定是哪里出了问题。
2.3 为什么“先序+中序”能唯一确定,而“先序+后序”不能
这是面试和考试里最爱追问的一个问题。先说答案:中序序列提供了左右子树的分界线,而先序(或后序)提供了根节点的位置,二者结合才能唯一确定一棵二叉树。如果只有先序+后序,虽然能确定根,但无法确定左右子树的边界,因此某些情况下会对应多棵不同的二叉树。
举个例子。先序A B,后序B A,这两棵树的先序和后序都一样:
A A / \ B B第一棵是A的左孩子为B,第二棵是A的右孩子为B。先序都是A B,后序都是B A,但它们是两棵不同的二叉树。所以“先序+后序”没有唯一解。
而“先序+中序”为什么就一定唯一?因为先序确定根,中序确定根的左区间和右区间,然后递归地对左区间和右区间重复这个过程,每一步都是确定的,没有二义性。
这个原理听着简单,但真正理解它,你才能在手算还原时做到“知其然也知其所以然”,而不是照着答案硬凑。
3. 手算还原:先序+中序如何一步步推出整棵树
3.1 核心心法:先找根,再切区间
在做这道题的时候,我习惯把中序序列看成一个“一维数组”,根节点是数组里的一个“分隔点”,分隔点左边是左子树区间,右边是右子树区间。然后对每个区间递归重复。
具体步骤如下:
- 从先序序列中取出第一个元素,它就是当前树的根节点。
- 在中序序列中找到这个根节点的位置
pos。 - 中序序列中
[左边界, pos-1]就是左子树的中序序列,[pos+1, 右边界]就是右子树的中序序列。 - 根据左子树的中序序列长度,从先序序列中切出左子树的先序序列和右子树的先序序列。
- 对左右子树分别递归执行上述过程。
关键点在第四步。很多人在这里卡住,因为“先序序列”不像中序序列那样能直接看出左右边界。但有个简单的计算办法:先序序列中,紧跟根节点后面的左子树节点个数个元素,就是左子树的先序序列;剩下的就是右子树的先序序列。
而左子树的节点个数,恰好等于中序序列中pos - 左边界。
3.2 完整手算演示
我们用上面的例子来走一遍:
先序:A B D E C F中序:D B E A C F
第一层:
- 先序第一个是
A,根节点。 - 中序中
A在第4个位置,左边D B E(3个节点),右边C F(2个节点)。 - 左子树的先序序列 = 先序中紧跟
A的3个元素 =B D E。 - 右子树的先序序列 = 剩下2个 =
C F。
第二层(左子树):
- 左子树的先序
B D E,中序D B E。 - 先序第一个
B,是左子树的根。 - 中序中
B的左边是D,右边是E。 - 左左子树:先序
D,中序D,只有一个节点。 - 左右子树:先序
E,中序E,只有一个节点。
第二层(右子树):
- 右子树的先序
C F,中序C F。 - 先序第一个
C,是右子树的根。 - 中序中
C的左边为空,右边是F。 - 右左子树为空,右右子树为
F。
整棵树就出来了:
A / \ B C / \ \ D E F然后再跑一遍后序:D E B F C A,完成。
整个过程不涉及任何“猜”,每一步都有确定的依据。你只要保证“中序切区间”“先序切长度”这两个动作不出错,结果必然正确。
3.3 变体:已知中序+后序怎么处理
如果题目给的是中序和后序,逻辑完全对称:
- 后序序列的最后一个元素是根节点。
- 在中序中找到根的位置,切分左右子树。
- 关键区别在于:需要先递归还原右子树,再递归还原左子树?不,代码顺序上无所谓,但切分后序序列的左右子树区间时,要从后往前切。
具体来说,后序中,根节点在最后,它前面紧挨着的若干个元素属于右子树,再往前属于左子树。但是要注意顺序:后序的左子树在前、右子树在后,所以切分时左子树的节点按原相对顺序在前,右子树在后。
树的结构没变,还是上面那棵树,先看中序D B E A C F和后序D E B F C A:
- 后序最后一个
A是根。 - 中序
A左边3个D B E,右边2个C F。 - 后序中去掉
A,前3个是D E B,对应左子树的后序;后2个F C,对应右子树的后序。 - 左子树递归:后序
D E B,中序D B E,后序最后是B为根,中序B左边D,右边E,还原。 - 右子树递归:后序
F C,中序C F,后序最后C为根,中序C右边F,还原。
两种题型套路一模一样,只要把“先序取头”换成“后序取尾”,其他照搬。
4. 代码实现:递归还原二叉树的完整方案
4.1 基于数组区间的递归写法(C++)
先给出最常用、面试和考试里最能体现功底的写法:用下标区间表示子树的范围,不在递归里额外创建数组,效率高,代码也干净。
#include <iostream> #include <vector> #include <unordered_map> using namespace std; struct TreeNode { char val; TreeNode *left, *right; TreeNode(char v) : val(v), left(nullptr), right(nullptr) {} }; // 先序序列 pre,中序序列 in // pl, pr 表示先序区间 [pl, pr] // il, ir 表示中序区间 [il, ir] TreeNode* buildTree(const vector<char>& pre, const vector<char>& in, int pl, int pr, int il, int ir, unordered_map<char, int>& pos) { if (pl > pr || il > ir) return nullptr; char rootVal = pre[pl]; TreeNode* root = new TreeNode(rootVal); int rootIdx = pos[rootVal]; // 根在中序中的位置 int leftSize = rootIdx - il; // 左子树节点数 root->left = buildTree(pre, in, pl + 1, pl + leftSize, il, rootIdx - 1, pos); root->right = buildTree(pre, in, pl + leftSize + 1, pr, rootIdx + 1, ir, pos); return root; } void postorder(TreeNode* root) { if (!root) return; postorder(root->left); postorder(root->right); cout << root->val << " "; } int main() { vector<char> pre = {'A', 'B', 'D', 'E', 'C', 'F'}; vector<char> in = {'D', 'B', 'E', 'A', 'C', 'F'}; unordered_map<char, int> pos; for (int i = 0; i < in.size(); i++) { pos[in[i]] = i; } TreeNode* root = buildTree(pre, in, 0, pre.size() - 1, 0, in.size() - 1, pos); postorder(root); return 0; }这段代码里,最重要的两个变量是rootIdx和leftSize:
rootIdx告诉你在中序里根在哪,从而确定左右子树的中序区间;leftSize告诉你在先序里,左子树占多少个节点,从而确定左右子树的先序区间。
尤其注意root->right的那一行递归参数:pl + leftSize + 1,这个+1是把根节点自己跳过去。很多人写错,就是在这里多一个少一个的问题。
4.2 为什么用哈希表存中序位置
有的教材或视频里,每次递归都在中序序列里线性扫描找根的位置,代码简单但效率低。在树的节点数不超过100时无所谓,但一旦节点数上千,线性查找会让整体复杂度变成O(n²),而用哈希表可以把查找降到O(1),整体O(n)。
这个优化在笔试和面试里都是加分项。虽然GESP六级的数据范围往往不大,但养成用哈希表的习惯,后面做更复杂的树题(比如构造最大二叉树、从前序和中序构造二叉树等LeetCode题)会受益。
提示:使用哈希表时要注意,如果二叉树节点值有重复,那么哈希表会失效。一般来说题目会保证节点值唯一。如果存在重复值,就不能直接用字符或数值定位,需要另想办法,比如带下标信息。这个问题在后面“常见问题”里再展开。
4.3 已知中序+后序的代码写法
如果题目给的是中序和后序,代码类似,只需把“先序取头”改成“后序取尾”,并调整递归区间:
TreeNode* buildTreeFromInPost(const vector<char>& in, const vector<char>& post, int il, int ir, int pl, int pr, unordered_map<char, int>& pos) { if (il > ir || pl > pr) return nullptr; char rootVal = post[pr]; TreeNode* root = new TreeNode(rootVal); int rootIdx = pos[rootVal]; int leftSize = rootIdx - il; root->left = buildTreeFromInPost(in, post, il, rootIdx - 1, pl, pl + leftSize - 1, pos); root->right = buildTreeFromInPost(in, post, rootIdx + 1, ir, pl + leftSize, pr - 1, pos); return root; }注意这里后序序列里,左子树的区间是[pl, pl + leftSize - 1],右子树区间是[pl + leftSize, pr - 1]。pr - 1是因为后序最后一个元素是当前根,要去掉。
整体思路和前序版本完全对称。如果你能独立把这两个版本都写出来,说明你真的理解了递归分治,而不是在背模板。
5. 非递归遍历:当面试官追问“你还能怎么写”时
5.1 用栈模拟先序和中序遍历
递归代码虽然好写,但递归深度等于树的高度。如果树退化成链表(每个节点只有一个孩子),深度可能达到几万甚至几十万,导致栈溢出。因此,掌握非递归版本也很重要。
先序遍历的非递归实现很容易,用栈辅助:
void preorderIterative(TreeNode* root) { if (!root) return; stack<TreeNode*> st; st.push(root); while (!st.empty()) { TreeNode* cur = st.top(); st.pop(); cout << cur->val << " "; if (cur->right) st.push(cur->right); if (cur->left) st.push(cur->left); } }注意:因为栈是后进先出,所以先压右孩子,再压左孩子,这样左孩子先出栈,才能保证“根左右”的顺序。
中序遍历的非递归稍微复杂一点,核心思想是“沿着左子树一路压栈,到底后弹栈访问,然后转向右子树”:
void inorderIterative(TreeNode* root) { if (!root) return; stack<TreeNode*> st; TreeNode* cur = root; while (cur || !st.empty()) { while (cur) { st.push(cur); cur = cur->left; } cur = st.top(); st.pop(); cout << cur->val << " "; cur = cur->right; } }这段代码的“节点访问发生在弹栈时”,正好对应中序“左根右”的顺序。如果看不懂,建议手动模拟一遍,压栈、弹栈的过程走几轮就会有感觉。
5.2 用两个栈实现后序遍历
后序的非递归方式最经典的是“两个栈”法。原理:先序遍历是“根左右”,后序遍历是“左右根”。如果把先序遍历改成“根右左”(即交换左右孩子入栈顺序),然后逆序输出,就得到了“左右根”。
void postorderIterative(TreeNode* root) { if (!root) return; stack<TreeNode*> st1, st2; st1.push(root); while (!st1.empty()) { TreeNode* cur = st1.top(); st1.pop(); st2.push(cur); if (cur->left) st1.push(cur->left); if (cur->right) st1.push(cur->right); } while (!st2.empty()) { cout << st2.top()->val << " "; st2.pop(); } }亲眼跑一遍:st1弹出顺序是“根右左”,压入st2,最后st2弹出顺序自然就是“左右根”。这个方法好记、好用,基本不会出错。
实操心得:如果是笔试答题,优先写递归版本,代码短、不容易错;如果面试聊到性能,再补充非递归版本。千万别在基础题里给自己增加复杂度,写错反而扣分。
6. 常见问题与排查技巧实录
6.1 递归边界写错了,程序直接崩了
最常见的错误就是递归边界条件。比如我在早期写这段代码时,经常顺手写成if (pl == pr) return nullptr;,导致单节点树直接被切断。
正确写法是if (pl > pr || il > ir) return nullptr;。判断条件是“区间为空”,而不是“区间里只有一个元素”。原因很简单:递归到叶子节点时,它的左右孩子对应的是空区间,这时候就应该返回nullptr,而不是继续递归。
排查方法也很简单:打印每一步递归的pl, pr, il, ir,看区间是否合理。比如某个节点的左子树为空时,il > rootIdx - 1,这时必须能正确地返回nullptr。
6.2 索引计算多了1或少了1
这是最隐蔽、最烦人的错误。我见过很多人写还原代码,逻辑完全正确,就是结果不对,最后发现是某个区间边界差了1。
这里给你一个“保险”的排查思路:
- 先写一个简单的测试用例:三个节点的完全二叉树。先序
A B C,中序B A C。手动还原成:
A / \ B C在递归函数里打印每次的
rootVal, rootIdx, leftSize,和手算结果对比。如果发现右子树的先序起点不对,重点检查
pl + leftSize + 1这个+1有没有漏掉。这个+1代表跳过根节点本身。对比左右子树区间长度是否等于对应中序区间长度。如果再某一个递归层里先序区间长度和中序区间长度对不上,说明上一层的切分已经错了。
6.3 二叉树节点值有重复怎么办
前面提到哈希表定位根的前提是节点值唯一。但有些题不会明确说值唯一,此时如果直接按值查找,会出错。
解决办法有两种:
- 如果题目允许,给每个节点附加一个唯一编号(比如结构体里带
id字段),用编号建哈希表; - 如果只能用值,那么在中序中查找根时,需要结合先序的顺序来判断取哪个位置。比如先序中的第一个值在中序中出现多次,你得根据左右子树区间是否合理来判断哪个位置是对的。这在算法上更复杂,但面试里偶尔会见到。
从实用角度,大部分考试题目会保证值唯一,看到这种题先松一口气,但心里要有数:如果没保证,别硬用哈希表。
6.4 递归深度过大导致栈溢出
树的高度等于节点数时,递归代码会在节点数达到几万时触发栈溢出。笔试时如果题目给的数据范围很大,你需要考虑用非递归的栈模拟来替代递归。
不过话说回来,对于GESP六级这种级别,树的高度一般不会太离谱。我在实际教学中更强调:先把递归版本写对,再考虑优化。递归都没写对就想着非递归,那是本末倒置。
6.5 后序序列和先序序列“搞反了”
有些题目会让你“根据后序和中序构造二叉树”,这时要注意:后序取的是末尾元素作为根,不是开头。我见过不少考生,拿到后序序列,下意识地取第一个元素当根,结果全错。
这里送你一个口诀:先序开头是根,后序末尾是根,中序根分左右。做题前先把这句话在草稿纸上写一遍,能有效避免开头就错。
7. 从遍历到进阶:搜索二叉树、满二叉树和深度计算
7.1 中序序列的“有序性”是搜索二叉树的关键
很多题目不会只考还原二叉树,而是会把“搜索二叉树(BST)”和“遍历”结合起来。搜索二叉树有一个非常著名的性质:中序遍历结果是递增有序的。
这条性质有什么用?举个例子,给你一棵树的后序遍历序列,让你判断它是不是搜索二叉树。做法就是:还原成中序序列(或者先还原树再中序遍历),然后检查是否严格递增。
再比如LeetCode 98题“验证二叉搜索树”,核心解法之一就是中序遍历并检查有序性。这里的“中序”不是随便选的,而是因为搜索二叉树的中序天生有序,这是它最本质的遍历特征。
遇到这类题时,先问自己一句:“这道题用哪种遍历能直接暴露数据结构的关键性质?”答案往往是中序。
7.2 满二叉树和完全二叉树的遍历规律
GESP六级热词里还有“满二叉树”。满二叉树的定义是:每一层的节点数都达到最大值,即深度为h的满二叉树共有2^h - 1个节点。
满二叉树在遍历上有个特点:所有叶子节点都在同一层,且没有节点只有一个孩子。这意味着,如果你知道它是满二叉树,给定先序和后序,也能唯一还原(因为每个节点要么有两个孩子,要么是叶子,不存在“只有一个孩子”的二义性)。
更进一步,满二叉树每一层的节点数量是固定的,所以如果给了先序序列,你可以直接推算出每一层有哪几个节点。这在“已知先序+后序+满二叉树条件”的题目里是一个巧妙的突破口。我之前遇到过一道题:先序 + 后序 + 满二叉树条件,求树的结构。解法思路是:既然每层节点数固定,那么可以根据序列长度反推层数,然后逐层构造。整个过程不需要中序,因为满二叉树的形态完全由节点数和层数决定。
7.3 求二叉树深度:遍历的天然副产品
求深度是二叉树题里的“标配”,代码同样基于递归:
int maxDepth(TreeNode* root) { if (!root) return 0; return max(maxDepth(root->left), maxDepth(root->right)) + 1; }思路一句话:当前节点的深度 = 左子树深度和右子树深度的较大值 + 1。
如果你已经掌握了先序、中序、后序的递归框架,这个代码几乎是顺理成章的。它与遍历的唯一区别是:遍历是“经过节点时做事”,求深度是“从子树返回时做事”。
如果把“求深度”和“还原二叉树”结合,就能出更综合的题。比如:“已知先序和中序,还原二叉树后,求它的深度。”本质上就是两步:先还原,再求深度。中间环节越多,越考验你对每一步的熟练度,所以基本功必须扎实。
实操心得:我建议每个人至少把“构造树→三种遍历→求深度→判断搜索树”这四个操作在本地完整跑一遍,并用同一棵树验证结果。这个组合练习做熟了,绝大多数二叉树基础题都难不倒你。
8. 为什么这道“送分题”年年有人丢分
最后说点题外话。二叉树的遍历序列题,在GESP和各类考试里其实是“送分题”,因为套路固定、逻辑清晰。但每年还是有一批人丢分,原因通常不在“不会写代码”,而在三个地方:
第一,读题不仔细。题目让输出后序,有人输出先序;题目给的是中序+后序,有人默认是先序+中序。这种错误最可惜,完全可以通过“读题后在草稿纸上标明输入输出顺序”来避免。
第二,手算不过关。有的同学写代码之前,先靠编辑器里的测试用例验证,一旦手算都算不明白,写代码的时候自然一头雾水。我个人强烈建议:拿到一道遍历题,先在纸上把样例手算一遍,再写代码。手算出来了,代码其实就是一个翻译过程。
第三,递归逻辑没有内化。很多人是“背代码”而不是“理解递归”。表面上看代码写对了,但只要题目一变形,比如“现在给你中序和后序,不给你先序”,就不知道从何下手。要治这个问题,唯一的办法是把递归分治的“找根→切区间→递归左右”这个循环彻底吃透,能做到用自己的话讲出来,才算真正学会。
这棵树的遍历序列题,看起来只是数据结构里最基础的一块,但它背后牵扯的“递归分治”思想,是整个算法学习的骨架。树、图、排序、动态规划,到处都在用“把大问题切成小问题”的思路。你说它只是“一道题”,其实它是你算法功底的试金石。
这篇文章最后,分享一个我个人的习惯:每次拿到一道树相关的题目,不论难易,我都会先在草稿纸上画出树结构,然后手动写出三种遍历序列,再用代码跑一遍对比。这个习惯看起来慢,实际上帮你省了大量debug的时间,而且越到后面越能发现——你对一棵树的“直觉”就是这么一点点练出来的。