资讯动态

二叉树遍历与还原:从四种遍历到唯一重建二叉树

发布时间:2026/10/15 6:09:14 来源:尧图企业网站定制
1. 从一道面试题说起为什么“遍历”和“还原”总是一起考如果你刷过二叉树相关的题目大概率见过这种组合拳先让你写前序、中序、后序、层序四种遍历紧接着追问“给你中序前序能不能还原二叉树只给前序后序行不行”很多人栽就栽在第二部分——遍历背得滚瓜烂熟一遇到还原就不知道根节点从哪来、左右子树怎么切分。其实这两个知识点是强绑定的。遍历的本质是“按某种规则走一遍二叉树”而还原的本质是“从走出来的序列反推二叉树原本的形状”。理解了前者后者就只是把规则倒过来用而已。这篇就把四种种遍历的底层逻辑拆开讲透再把还原的递推写清楚最后附上我实际调试中踩过的坑希望能帮你把这块彻底打通。适合谁看正在学数据结构的学生、准备算法面试的开发者或者工作中需要处理树形结构但一直没时间系统梳理的朋友。不需要你有很深的基础只要知道二叉树节点有左孩子、右孩子、值这三样东西就足够读下去。2. 四种遍历的核心区别本质是“访问时机”问题很多人背遍历口诀背得很快前序“根左右”、中序“左根右”、后序“左右根”、层序按层从左到右。但口诀只是结果真正的区别在于你“什么时候处理根节点”。2.1 递归序里藏着所有遍历的秘密任何一个二叉树节点在递归遍历过程中都会经过它三次第一次是从父节点下来时第二次是左子树递归结束回来时第三次是右子树递归结束回来时。这三次经过的时机对应了三种深度优先遍历第一次经过时处理根 → 前序遍历第二次经过时处理根 → 中序遍历第三次经过时处理根 → 后序遍历这个概念我用一个生活类比帮学生理解过想象你逛一个商场的两层店铺每逛完一层都会回到中间的扶梯口。前序是你每次刚下到扶梯口就先拍照中序是你逛完左边店铺回到扶梯口时拍一张后序是你把两层都逛完、准备离开时才拍。照片拍摄顺序就对应了三种不同的访问序列。层序则完全是另一套玩法。它不用递归而是借助“队列”这个先进先出的结构从上往下、从左往右逐层访问。每访问一个节点就把它的左孩子、右孩子依次入队这样能保证同层的节点按顺序被处理完再往下一层走。2.2 四种遍历的访问顺序与数据规律假设有这么一棵二叉树A / \ B C / \ / \ D E F G它的四种遍历结果分别是前序遍历A B D E C F G中序遍历D B E A F C G后序遍历D E B F G C A层序遍历A B C D E F G把这四组序列放在一起看有几个规律非常值得记住。第一个规律是“根节点的位置”。前序遍历的第一个元素一定是整棵树的根后序遍历的最后一个元素也一定是整棵树的根。层序遍历的第一个元素同样是根。只有中序遍历比较特殊——它的根藏在序列中间左右分别是左子树和右子树的所有节点。第二个规律是关于“子树范围的连续性”。在中序遍历中一旦确定了根的位置根的左边就是左子树的中序结果右边就是右子树的中序结果中间不会夹杂别的树的节点。这个连续区间性质是后面还原二叉树最关键的依据。前序和后序遍历也类似子树内部的节点在序列里是连在一起的不会被外部节点打断。第三个规律是“叶子节点的相对顺序”。前序、中序、后序遍历中叶子的相对左右顺序是一致的。比如上面这棵树里有D、E、F、G四个叶子三序遍历里它们的相对顺序都是D、E、F、G。原因很简单不管什么顺序深度优先遍历都是先走完左子树再走右子树所以左侧叶子一定比右侧叶子先出现。2.3 复杂度与适用场景对比实际操作中每种遍历的时空代价都差不多但应用场景差别很大。遍历方式时间复杂度额外空间典型用途前序O(n)递归栈O(h)复制二叉树、序列化、表达式树前缀表示中序O(n)递归栈O(h)二叉搜索树排序输出、还原二叉树左中右划分后序O(n)递归栈O(h)删除二叉树、表达式树后缀计算、自底向上统计层序O(n)队列O(w)树宽度计算、最短路径层次搜索、按层打印这里的h是树高度w是树的最大宽度。递归实现的额外空间取决于递归深度也就是树高层序的空间取决于当前层的节点数量。极端情况下一棵倾斜成链状的树递归深度会退化到n所以递归写法在处理极度不平衡的树时需要留意栈溢出风险。3. 还原二叉树的核心逻辑为什么中序是关键拼图还原的本质是“从序列反推树的结构”。这里有一个非常重要的结论只有前序中序或者后序中序才能唯一确定一棵二叉树。只给前序后序无法唯一还原。原因稍后细说先来看最常见的前序中序还原法。3.1 为什么前序后序不能唯一确定二叉树很多资料直接说“前序后序不能唯一还原”但没说为什么。从头想一下前序的第一个节点是根后序的最后一个节点是根这两条信息其实是重复的——它们都只告诉你根是谁。知道了根之后你需要区分左右子树但前序和后序都只提供了“左子树在前右子树在后”这种相对顺序没有给出左右子树之间的明确界限。举个例子。一棵只有两个节点的树根是A左孩子是B。前序是“A B”后序是“B A”。如果根是A右孩子是B前序还是“A B”后序还是“B A”。同样的两串序列却可能对应两种完全不同的树。为什么中序能解决这个问题因为中序是“左根右”的结构一旦你知道了根是谁根的左边天然就是左子树集合右边天然就是右子树集合。它自带“左右分界线”的信息而前序和后序都没有这条线。所以“中序 任一前序或后序”就能唯一确定树“前序 后序”却做不到。3.2 手动推演一次前序中序的还原过程为了把思路理清拿前面那棵树来手动还原一遍。前序序列A B D E C F G 中序序列D B E A F C G第一步看前序的第一个元素A它就是根。去中序里找到A的位置A左边的“D B E”就是左子树的中序A右边的“F C G”就是右子树的中序。第二步看前序中剩下的部分“B D E C F G”。左子树的中序是“D B E”包含B、D、E三个节点所以前序里接下来的三个元素“B D E”属于左子树的前序剩下“C F G”属于右子树的前序。第三步对左子树重复这个过程。左子树前序“B D E”中第一个元素是B它就是左子树的根。在左子树中序“D B E”里找到B左边是D右边是E所以B的左孩子是D、右孩子是E。第四步回到右子树。右子树前序“C F G”中第一个元素是C它是右子树的根。在右子树中序“F C G”里找到C左边是F右边是G所以C的左孩子是F、右孩子是G。四步走完整棵树的结构就完整还原出来了。整个过程的核心动作只有两句话从前序里拿第一个元素当根去中序里找根的位置并切开左右子树然后递归处理左右两个子区间。后序中序的做法完全一样只是第一步改成了“从后序里拿最后一个元素当根”。3.3 还原过程中必须注意的边界条件实际写代码时最容易出问题的不是主逻辑而是各种边界情况。我总结了三个特别值得留意的点。第一个是空区间问题。当你已经确定根的位置之后左右两个子区间可能有一个是空的。比如根在中序的最左边说明它没有左子树此时左区间长度为零递归函数必须能正确处理“头指针大于尾指针”这个情况直接返回空指针。第二个是节点不匹配问题。递归过程中某个子树的前序区间和中序区间包含的节点集合如果不一致说明输入序列有问题应该立即返回错误而不是继续递归下去。我见过不少实现忽略了这个校验结果在空指针上崩掉。第三个是长度守恒问题。前序区间和中序区间描述的是同一个子树它们的长度必须相等。一旦发现长度不一致那也是输入数据有误的信号。这个校验在递归入口处做一次能帮你省掉很多排查时间。4. 递归还原的完整代码实现与验证理论学习完了接下来上实战。我用C写一份前序中序还原的完整实现带注释和测试然后带你一步步验证结果是否正确。4.1 基础数据结构与核心递归函数先定义二叉树节点结构然后实现还原逻辑。核心函数依赖一个“在中序区间内查找根节点位置”的操作简单做法是线性扫描更好一点的做法是提前用哈希表记录每个节点在中序序列里的下标。#include iostream #include vector #include unordered_map #include queue using namespace std; struct TreeNode { char val; TreeNode* left; TreeNode* right; TreeNode(char v) : val(v), left(nullptr), right(nullptr) {} }; class Solution { private: unordered_mapchar, int inIndex; // 记录中序序列中每个字符的下标 vectorchar pre; vectorchar in; public: TreeNode* buildTree(vectorchar preorder, vectorchar inorder) { pre preorder; in inorder; inIndex.clear(); for (int i 0; i (int)inorder.size(); i) { inIndex[inorder[i]] i; } return rebuild(0, (int)preorder.size() - 1, 0, (int)inorder.size() - 1); } TreeNode* rebuild(int preL, int preR, int inL, int inR) { if (preL preR || inL inR) return nullptr; char rootVal pre[preL]; TreeNode* root new TreeNode(rootVal); int pos inIndex[rootVal]; // 根在中序中的下标 int leftLen pos - inL; // 左子树的节点数 root-left rebuild(preL 1, preL leftLen, inL, pos - 1); root-right rebuild(preL leftLen 1, preR, pos 1, inR); return root; } };这段代码里最关键的计算是leftLen pos - inL。leftLen表示左子树有多少个节点这个数字决定了左子树在前序中的区间是[preL 1, preL leftLen]右子树在前序中的区间从preL leftLen 1开始。理解了这两个区间的来源整段代码就通了。4.2 验证还原结果正确性的测试方法还原出来的树对不对不能光靠肉眼看。我习惯用两种方式双重验证第一种是对还原出的树再做一次前序和中序遍历看结果是否和输入序列完全一致第二种是打印树的层次结构直观检查节点位置是否正确。下面这段测试代码把两种验证都做了。void printPre(TreeNode* root) { if (!root) return; cout root-val ; printPre(root-left); printPre(root-right); } void printIn(TreeNode* root) { if (!root) return; printIn(root-left); cout root-val ; printIn(root-right); } void printLevel(TreeNode* root) { if (!root) return; queueTreeNode* q; q.push(root); while (!q.empty()) { TreeNode* node q.front(); q.pop(); cout node-val ; if (node-left) q.push(node-left); if (node-right) q.push(node-right); } cout endl; } int main() { vectorchar preorder {A, B, D, E, C, F, G}; vectorchar inorder {D, B, E, A, F, C, G}; Solution sol; TreeNode* root sol.buildTree(preorder, inorder); cout 验证前序遍历: ; printPre(root); cout endl; cout 验证中序遍历: ; printIn(root); cout endl; cout 层序遍历结果: ; printLevel(root); cout endl; return 0; }运行这段代码输出结果应该和预期一致。层序输出“A B C D E F G”正好对应原来那棵树的层级形状证明还原成功。4.3 哈希表加速与冗余校验的取舍前面实现里用unordered_map记录了中序每个节点的位置这让查找根节点位置从O(n)降到了O(1)。在节点数量大的场景下这个优化非常明显。比如一棵有十万个节点的树如果不加哈希表每次递归都要扫描一个中序区间最坏情况会变成O(n^2)加了哈希表之后整体复杂度稳定在O(n)。不过哈希表也有它的代价它要求中序序列里的节点值互不重复。如果题目明确说明节点值可能有重复哈希表方案会失效必须退回线性扫描方案。挑选哪种实现取决于你对题目的判断。我个人的习惯是先把无重复版本的写好再在注释里标注“如果出现重复值改为在中序区间内扫描”宁可代码多一些注释也不要面试时当场想不出来变体方案。额外做一层入参校验也很值得。比如在rebuild函数开头检查preL和preR之间的长度是否等于inL和inR之间的长度不一致直接报错。这个检查在序列由程序外部传入时特别有用能帮你尽早发现数据错位的问题。5. 从递归到迭代非递归遍历的写法与边界处理递归写法简洁但有两个现实问题一是递归深度受系统栈限制树高超过一定阈值就可能栈溢出二是某些场景要求不能用递归比如面试官明确要求“不用递归实现”。所以四种遍历的非递归写法也需要掌握。5.1 前序与中序遍历的统一迭代框架前序和中序的迭代写法可以用同一个框架——显式维护一个栈模拟递归压栈的过程。区别只在访问节点的时机不同。// 非递归前序遍历 void preorderIterative(TreeNode* root) { if (!root) return; stackTreeNode* stk; stk.push(root); while (!stk.empty()) { TreeNode* node stk.top(); stk.pop(); cout node-val ; if (node-right) stk.push(node-right); if (node-left) stk.push(node-left); } } // 非递归中序遍历 void inorderIterative(TreeNode* root) { if (!root) return; stackTreeNode* stk; TreeNode* cur root; while (!stk.empty() || cur ! nullptr) { while (cur ! nullptr) { stk.push(cur); cur cur-left; } cur stk.top(); stk.pop(); cout cur-val ; cur cur-right; } }前序迭代写法里有一个细节经常有人写错压栈时要先压右孩子再压左孩子。因为栈是后进先出要让左孩子先被弹出处理就得最后压左孩子。中序迭代写法则更贴近“左根右”的本质先把当前节点的整条左链全部压入栈然后弹出一个处理再把指针移向它的右子树。5.2 后序遍历的两个经典技巧后序的非递归实现是四种里面最麻烦的因为根节点要到最后才处理你不知道当前是从左子树回来的还是从右子树回来的。我常用两个技巧绕开这个判断。第一个技巧是“双栈法”。用一个栈做常规遍历但按“根右左”的顺序走把结果压入另一个栈最后输出时反转为“左右根”。这个方法思路最直观也不容易出错。第二个技巧是“前序倒推法”。观察可知前序是“根左右”后序是“左右根”它们之间存在对称关系。如果先把前序的访问顺序改成“根右左”得到的结果再逆序就是标准的后序“左右根”。这个方法的代码量最小适合快速实现。void postorderIterative(TreeNode* root) { if (!root) return; stackTreeNode* stk; stackchar out; stk.push(root); while (!stk.empty()) { TreeNode* node stk.top(); stk.pop(); out.push(node-val); if (node-left) stk.push(node-left); if (node-right) stk.push(node-right); } while (!out.empty()) { cout out.top() ; out.pop(); } }5.3 层序遍历与按层分组的实用变体层序遍历的迭代写法用队列这个几乎不会写错。但面试和实际开发里更常遇到的是“按层分组输出”——要求不仅能逐层扫还能知道每一层到底有哪些节点。在二叉树按层打印、统计每层节点数、计算树的最大宽度等场景都需要在基础层序上做一层“分层”处理。分层的方法是在每一层开始前先记录当前队列的长度size然后只处理size个节点。这样每次循环结束队列里剩下的恰好是下一层的全部节点。vectorvectorchar levelOrderGroup(TreeNode* root) { vectorvectorchar result; if (!root) return result; queueTreeNode* q; q.push(root); while (!q.empty()) { int size q.size(); vectorchar level; for (int i 0; i size; i) { TreeNode* node q.front(); q.pop(); level.push_back(node-val); if (node-left) q.push(node-left); if (node-right) q.push(node-right); } result.push_back(level); } return result; }这个“先记长度再消费”的模式很通用处理图的最短路径层次扩展时也能复用同一套思路。6. 实战中的典型问题与排查经验代码写出来是一回事跑得对是另一回事。我把自己实际调试二叉树代码时遇到的高频问题整理成一张速查表每个问题都附上原因和解决的切入点。6.1 常见报错现象与根因对照现象可能原因排查方向递归还原后节点数量变少区间长度计算错误子树区间重叠打印每次递归的preL、preR、inL、inR四个值访问空指针崩溃递归出口条件写错空子树没有正确返回检查preL preR的判断时机避免越界访问还原结果左右颠倒中序里根的位置定位错误确认pos是相对整个中序数组的下标而不是相对inL的下标前序遍历输出后段出现重复节点压栈顺序把左右孩子反了检查前序迭代是先压right还是先压left层序分组数据错乱队里混入了上一层未处理节点确认每层开始时先用size锁定节点数哈希表miss键值导致异常序列中存在重复字符或非预期字符检查输入数据是否干净必要时回退为线性扫描6.2 一个真实的调试片段区间偏移问题有一次我在用后序中序还原时怎么调都发现右子树少了节点。打日志一看问题出在区间计算上。我当时的后序区间用的是类似“从后往前找根”的方式结果写成了这样root-left rebuild(preL, preL leftLen - 1, inL, pos - 1); root-right rebuild(preL leftLen, preR - 1, pos 1, inR);第一眼看上去好像没什么问题但仔细推演发现当递归进入右子树时传入的后序区间其实包含了不属于右子树的节点。根因是我没有明确区分“后序序列的根在最后”和“前序序列的根在最前”这两种不同情况套用了同一套区间切割逻辑。从那以后我都先把代码里关于区间的推导写在纸上验证一遍再落到代码里。平时养成的习惯是先写“左子树节点数为len所以左子树前序区间是……右子树前序区间是……”把这个推导过程作为注释写在函数正上方再开始写实现。这样即使写错也能根据注释快速定位是哪一步算错了。6.3 关于递归转迭代的三点心得把递归转迭代时我踩过几次坑之后总结出三点经验分享给你。第一点迭代版本不是递归的简单翻译而是换一种思路。比如递归的中序是“先处理左再根再右”迭代版本用while (cur)把左链全部入栈本质是先模拟递归压栈时一路向左的过程。强行套递归结构反而容易越写越乱。第二点写迭代版本前先在纸上画出栈的变化。我习惯用一个四列的表格分别记录“当前访问节点”“栈内容”“已输出序列”“下一步动作”。画两三个节点就能看出规律直接写代码时思路会清晰得多。第三点如果面试允许优先写递归但准备好一句话说明“如果需要处理超深树我可以改成迭代版本”。把两种写法都练熟比只熟练一种更能体现功力。7. 几个进阶的使用场景与扩展思路遍历与还原的知识点学完后可以看看它在实际项目中能怎么用。我挑三个我实际遇到过的场景展开说说。第一个是二叉树的序列化与反序列化。把一棵树保存到文件或者传输给另一个进程时需要把它变成一个字符串对端收到字符串后要恢复出原树。常见做法是前序遍历配合“空节点标记”比如用“#”表示空指针。这样序列化后的字符串包含完整信息反序列化时按照前序逐个读入遇到“#”就返回空指针。这类题目除了序列化本身还考验对递归顺序的理解。反序列化时也可以用中序其他序组合本质和这篇讲的还原一模一样。第二个是表达式树的构造与求值。编译器把表达式解析成语法树时经常用二叉树存储。中缀表达式“abc”对应的树按前序输出就是前缀表达式“abc”按后序输出就是后缀表达式“abc*”。给你一个后缀表达式序列整个过程就是从右往左建树遇到操作数建立叶子节点遇到运算符取出两个节点作为左右子树。理解和掌握从遍历序列还原的套路后表达式树的构造会顺很多。第三个是二叉搜索树的“重建”。给定一个二叉搜索树的前序序列其实不需要中序也能唯一恢复出这棵树因为二叉搜索树自带大小关系——左子树所有节点都小于根右子树所有节点都大于根。据此可以设计一个递归函数用值的大小划分左右子树区间。这是“前序中序还原”思路的一个变体去掉了中序的输入但引入了额外的性质约束。这几个场景都指向同一个核心能力对树的形态和遍历序列之间的映射关系足够敏感。理解透四序遍历的规律再去面对序列化、表达式树、BST重建这些问题时会明显感觉到底子扎实的好处。8. 我的一点实操体会二叉树遍历与还原这部分我在面试和实际项目中反复用到过也和不少学到这里卡住的开发者交流过。他们大多不是记不住口诀而是没有真正理解“中序提供左右分界线”这个关键点。如果你只记住“前序第一个是根”就去写还原代码遇到复杂一点的树很容易在区间边界上出错。我自己调试这类代码的经验是不要一上来就堆代码先在纸上画一棵三层左右的树手动写出前序和中序序列再按还原逻辑一步步写出节点位置。多画几棵不同的树尤其是包含单边子树比如只有左子树或只有右子树的情况边界条件自然就记住了。最后分享一个小技巧还原函数里除了返回树的根节点指针还可以顺带返回一些统计信息比如这棵子树的节点数。调试时打印这些信息能帮你快速定位区间切分是否出了问题。我有一段时间排查bug全靠这个办法比肉眼跟踪递归快得多。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑