ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

二叉树遍历还原详解:后序+中序推先序,先序+中序推后序

二叉树遍历还原详解:后序+中序推先序,先序+中序推后序 先根遍历、中根遍历、后根遍历这三个词凑在一起任何一个学过数据结构的人都眼熟。可真正让大批人卡住的不是三种遍历本身怎么写而是它的逆过程已知一棵二叉树的后根遍历序列和中根遍历序列怎么推出先根序列反过来已知先根序列和中根序列怎么推出后根序列这道题在期末考、考研、笔试面试里出现的频率高得离谱看起来就是递归分治但真动手写代码的时候不是索引算错就是递归边界写崩运行时错误一串一串往外冒。我打算把这条思路完整讲透先说为什么能还原、怎么手工推演再给出可以直接抄走的代码最后把写这类程序最常见的运行时错误挨个排查一遍。不管你是在校学生还是在准备面试的开发者看完之后应该能自己直接敲出可运行的代码而不是停留在“道理我都懂一写就报错”的状态。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]右子树的中序区间[k1, 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里找 BB 在最后因此 B 的左子树有 2 个节点D G右子树为空。再看后序G D前 2 个正好对应左子树的G D。继续处理左子树的左子树中序D G后序G D。后序最后一个 D 是根到中序D G里找 DD 在开头说明 D 的左子树为空右子树是 G。后序G单独处理G 既无左子树也无右子树。到这里左边的结构已经清楚了B 是 A 的左孩子B 的左孩子是 DD 的右孩子是 G。再看右子树中序E C H F后序E H F C。后序最后一个 C 是右子树的根到中序里找 CC 在第二个位置左边是 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右孩子是 CB 左 D 右 EC 左 F。后根遍历的结果是D E B F C A。3.3 推演中要盯住的几个关键点纸上推演能成功靠的是严格保持“左右子树区间连续”的原则。这里有几个容易出错的细节。第一中序里定位根之后左子树节点数要用“根的位置减去区间左端点”不是“根的位置”也不是“根的位置加一”。因为中序区间的左端点不一定是 0进入深层递归后左端点可能是任意下标。第二后序序列里切分左右子树时一定要拿leftSize作为长度依据而不是靠肉眼看序列元素。元素看起来像不代表区间对只有长度才是唯一判据。第三推演时建议把每一层递归的四个区间写出来。写的过程中你会自然发现进入下一层递归时左子树区间和右子树区间是刚好首尾相接的。一旦发现区间不连续或者重叠说明切分已经出错了。4. 代码实现一套逻辑写两个方向4.1 用哈希表定位根节点别用线性查找纸上推演时找根在中序里的位置是靠眼睛扫的。写代码时如果每次都用循环去扫时间复杂度会变成 O(n²)。数据量小无所谓但二叉树节点一多性能立刻看得出来。正确做法是提前遍历一遍中序序列把每个节点值到下标的位置关系存进哈希表#include bits/stdc.h using namespace std; vectorchar pre, in, post; unordered_mapchar, 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]中序 [k1, 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]中序 [k1, 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。右子树区间就复杂一些中序的右子树从k1开始到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 与线索二叉树、二叉搜索树的联系学过线索二叉树的话你会更容易理解中序的特殊地位。线索二叉树利用节点的空指针域记录中序前驱和后继本质上是把中序序列的线性关系直接织进树结构里。这种设计之所以成立正是因为中序序列天然地记录了每个节点在“左根右”顺序中的前后位置。二叉搜索树则是另一个极端它的中序序列一定是递增有序的。如果你得知一棵树是二叉搜索树那么即使不给你中序序列光凭先序或者后序也能还原整棵树因为中序序列可以自己推出来——直接排序即可。但普通二叉树没有这个性质所以必须显式给出中序序列。题目还有可能把已知条件换成“层次遍历 中序”原理本质上一样层次遍历的第一个元素是根中序仍然负责切分左右子树。你只需要额外维护层次序列中哪些节点属于左子树、哪些属于右子树即可。理解了“中序切分 其他序列找根”这个核心套路遇到任何变体都不慌。最后分享一个我自己的习惯。我写这类递归代码之前一定会先在草稿纸上把四个区间画出来标清楚根、左子树、右子树分别对应哪一段才动笔写代码。踩过几次坑之后我深刻体会到大部分运行时错误不是逻辑不懂而是区间图画错了。你如果现在正被这道题折磨不妨先放下键盘拿笔画一画代码很快就能顺下来。
返回列表