ARTICLE DETAIL

资讯详情

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

C++二叉树重构:先序+中序还原树的原理与代码实现

C++二叉树重构:先序+中序还原树的原理与代码实现 如果只给你一棵二叉树的先序遍历和中序遍历结果你能把原来的树完整还原出来吗我最早面对这个问题是在一次数据同步的项目讨论里对端只传过来两个数组要求这边把树形结构完整恢复。当时的第一反应是“这也能做到”后来翻了题解才发现这不只是 LeetCode 105 的经典解法更是遍历序列、递归分治、中序定位这套思想的集中体现。这篇文章我打算把“C 二叉树重构”这件事拆开讲清楚为什么先序中序能唯一确定一棵树先序后序为什么不行代码怎么写才不会在边界条件上翻车以及由重构延伸出去的一堆变体和工程建议。适合正在啃二叉树、准备算法面试或者实际项目中要做树形数据序列化与恢复的开发者看完可以直接抄代码也能理解背后的原理。1. 重构二叉树不是冷门操作工程里到处是它的影子1.1 树形数据的序列化与反序列化很多系统的数据结构在内存里是一棵树但在网络传输、磁盘存储、缓存落地时却只能按线性序列来传递。比如商品分类树后端给前端返回的 JSON 里有嵌套的 children前端拿到 JSON 后要把它还原成内存里的树对象比如分布式任务调度系统里一棵任务依赖树要压成数组发给另一台机器接收方再重建树形依赖再比如编译器的前端把 token 流解析成 AST本质上也是从线性序列恢复树结构。这两个操作在工程里分别叫序列化和反序列化。序列化是“树 - 线性序列”反序列化是“线性序列 - 树”。后者就是重构。所以千万别以为重构二叉树只是面试题它是很多基础软件里绕不开的基础能力。1.2 为什么面试和竞赛都爱考这个点搜索热词里与二叉树强相关的还有二叉树的遍历、二叉树的深度、搜索二叉树、完全二叉树和满二叉树。这些知识点通常排在学习路线的同一阶段而重构二叉树是把它们串起来的最佳枢纽。考重构实际考的并不是“背代码”而是你对三种遍历次序的理解深度。先序是“根左右”中序是“左根右”后序是“左右根”——这些话大家都背得出来但真正用“先序第一个节点一定是整棵树的根”这个性质去反推树的结构时很多人就卡住了。1.3 重构问题的标准定义先把问题形式化。给定一棵二叉树的不重复先序遍历序列 preorder 和中序遍历序列 inorder要求重建原二叉树并返回根节点。这里的“不重复”是一个常见前提原因后文会专门讲。LeetCode 105、剑指 Offer 7 都是这个模型的直出题换汤不换药。2. 三种遍历序列的“位置语义”以及中序为什么无法被取代2.1 访问时机的差异决定了序列结构二叉树三种遍历的差别表面上是输出顺序不同本质上是“根节点”被访问的时机不同。我用一张表把它列出来遍历方式输出顺序先序遍历根 - 左子树 - 右子树中序遍历左子树 - 根 - 右子树后序遍历左子树 - 右子树 - 根举个生活化类比假设你要给一个家族拍合影。先序是先拍家长再让左边家庭站好拍左边再让右边家庭拍右边中序是先拍左边整个家庭再请家长出来拍一张再拍右边家庭后序是最后才轮到家长前面全是孩子。这个类比能帮你在头脑中建立“位置语义”中序序列里任何一个节点它左半边的元素一定属于它的左子树右半边元素一定属于它的右子树。这条性质是整个重构算法的地基。2.2 先序后序为什么不能唯一确定一棵树这是最反直觉的一点。很多人觉得“两个序列都给了树还不唯一”我直接造一个反例。第一棵树1 / \ 2 3先序遍历是 1 2 3后序遍历是 3 2 1。第二棵树1 / 2 \ 3这棵树的先序遍历还是 1 2 3访问 1然后左子树 2再 2 的右子树 3后序遍历还是 3 2 1先 3然后 2最后 1。两棵完全不同的树却拥有完全相同的先序和后序序列。所以只靠“先序后序”无法还原出唯一形态。问题出在哪里出在先序和后序里某个节点究竟是父亲的左孩子还是右孩子这个信息丢了。第二棵树里 2 是 1 的左孩子3 是 2 的右孩子第一棵树里 2 和 3 分别是 1 的左、右孩子。两组序列完全无法区分这两种情况。2.3 中序在重构里扮演“分界线”的角色有了上面的对比中序的价值就凸显出来了中序遍历把每个根节点夹在中间左子树所有节点在左、右子树所有节点在右。当我们从先序中拿到一个根节点后只要在中序里找到它的位置就能瞬间把中序区间切成左右两段这两段恰好就是左子树和右子树的完整节点集合。这个思想贯彻整个算法先用先序确定“谁是根”再用中序确定“左右子树分别有哪些节点”接着递归处理左右两部分。中序就是那把裁纸刀把每一层的左右区域干净利落地切开。3. 手动推演从两个数组还原一棵真实二叉树3.1 选一个能看清递归过程的示例光学理论容易飘我直接用一个具体例子走一遍。preorder [3, 9, 20, 15, 7] inorder [9, 3, 15, 20, 7]第一步先序第一个元素是 3所以整棵树的根是 3。第二步在中序里找 3下标是 1。于是中序被切成三段左侧 [9] 是左子树中序右侧 [15, 20, 7] 是右子树中序。第三步回到先序根 3 后面的元素 [9, 20, 15, 7] 分别是谁的节点因为左子树只有一个节点 9所以先序中紧跟其后的 9 就是左子树根剩下的 [20, 15, 7] 属于右子树第一个 20 就是右子树根。第四步对右子树递归。中序 [15, 20, 7] 中20 在下标 1左侧 [15] 是它的左子树右侧 [7] 是它的右子树。还原结果如下3 / \ 9 20 / \ 15 7这个例子虽然简单却把重构的三板斧用全了先序取根、中序分割、区间递归。3.2 每一层递归到底在做什么把上面过程抽象成模板每一层递归都做四件事检查当前区间是否为空为空就返回空指针。从先序中取出当前子树对应的第一个元素作为根。在中序中找到根的位置计算出左子树的节点个数。用左子树节点个数把先序和中序都切成两份递归构建左、右子树。所有递归思路都逃不开这个模板区别只在于你用什么方式写入参、用什么方式找根。3.3 为什么这个思路是天生的递归结构二叉树本身就是递归定义的每个节点的左右子树依然是二叉树。你要处理的子问题“用一段先序和一段中序还原一棵子树”与原问题规模和结构完全一致只是范围缩小了。递归出口就是区间为空此时直接返回空指针。这种问题如果用迭代硬写非常难维护至少要维护一个显式的栈来模拟“区间划分”的过程复杂度也不占优势所以第一版实现都会选择递归。4. C 实现从最直白的递归到不踩坑的工程写法4.1 第一版每次在中序里线性查找根先给一个不借助哈希表的版本逻辑最直观但性能不是最优。这个版本的入参是四个下标我统一使用区间左闭右闭的写法即区间 [l, r] 包含两个端点。TreeNode* rebuild(const vectorint pre, const vectorint in, int preL, int preR, int inL, int inR) { if (preL preR || inL inR) return nullptr; int rootVal pre[preL]; int rootIn inL; while (rootIn inR in[rootIn] ! rootVal) rootIn; int leftSize rootIn - inL; TreeNode* root new TreeNode(rootVal); root-left rebuild(pre, in, preL 1, preL leftSize, inL, rootIn - 1); root-right rebuild(pre, in, preL leftSize 1, preR, rootIn 1, inR); return root; }这段代码很纯粹preL 是当前子树根在先序中的位置inL 和 inR 是当前子树在中序里的范围。while 循环每层要在线性扫描中序所以总复杂度最坏是 O(n^2)。当树退化成链时每层找根扫描的长度分别是 n、n-1、n-2……加起来就是平方级别。本地测试数据量小的时候感觉不出来数据量到几万就开始明显卡顿。4.2 第二版用哈希表把定位降到 O(1)工程上更常用的做法是预处理一个 unordered_map记录中序每个值对应的下标。递归时通过哈希表直接定位根在中序中的位置总复杂度降到 O(n)。class Solution { private: unordered_mapint, int index; vectorint pre; vectorint in; public: TreeNode* buildTree(vectorint preorder, vectorint inorder) { pre preorder; in inorder; int n inorder.size(); for (int i 0; i n; i) { index[in[i]] i; } return rebuild(0, 0, n - 1); } TreeNode* rebuild(int preRoot, int inLeft, int inRight) { if (inLeft inRight) return nullptr; int rootVal pre[preRoot]; int inRoot index[rootVal]; int leftSize inRoot - inLeft; TreeNode* root new TreeNode(rootVal); root-left rebuild(preRoot 1, inLeft, inRoot - 1); root-right rebuild(preRoot leftSize 1, inRoot 1, inRight); return root; } };很多初学者看不懂第二行递归参数preRoot leftSize 1是怎么来的。这里的关键是先序序列的结构是 [根, 整个左子树, 整个右子树]。根在 preRoot紧跟其后的连续 leftSize 个节点都属于左子树所以右子树根的位置就是preRoot leftSize 1。这个推导要刻在脑子里它是整个递归不变量的一部分。再注意一点这里递归函数不需要传 preRight也不需要专门判断先序区间越界。因为中序区间 [inLeft, inRight] 的长度已经代表了当前子树的全部节点数只要中序区间不为空当前子树节点的范围就确定根的位置总能从先序中取到。4.3 后序中序的对称写法如果题目给的是后序和中序比如 LeetCode 106思路完全对称只是根的定位方向变了。后序序列的结构是 [整个左子树, 整个右子树, 根]所以根是 post 序列的最后一个元素右子树的根在 post 中位于 postRoot 的前一位。TreeNode* rebuildPost(int postRoot, int inLeft, int inRight) { if (inLeft inRight) return nullptr; int rootVal post[postRoot]; int inRoot index[rootVal]; int rightSize inRight - inRoot; TreeNode* root new TreeNode(rootVal); root-right rebuildPost(postRoot - 1, inRoot 1, inRight); root-left rebuildPost(postRoot - rightSize - 1, inLeft, inRoot - 1); return root; }递归顺序先右后左或先左后右都行关键是 index 的计算。postRoot - rightSize - 1是什么postRoot 前面连续 rightSize 个节点属于右子树再往前一个就是左子树的根。这个参数写错整棵树就乱了。4.4 关于内存管理的提醒算法题里 new 出来的节点通常不用管释放平台会统一回收。但如果在本地测试、或者这些代码将来被放进生产项目new 出来的树需要用析构函数、递归 delete 或者智能指针来管理。最简单的做法是给 TreeNode 写一个递归析构先释放 left 再释放 right最后 delete 自己也可以用 vector 一次性分配所有节点等树不再使用后整体 clear减少频繁 new 的开销。5. 踩坑实录重构二叉树最容易翻车的四个细节5.1 边界出口写成空区间漏判断这是最典型的翻车点。递归出口的正确写法是if (inLeft inRight) return nullptr;有人写成if (inLeft inRight) return nullptr;区别在哪里当某子树恰好只有一个节点时inLeft inRight这个判断确实会返回空看起来对但当某子树为空时比如左子树不存在递归调用的参数会是inLeft 1, inRight 0此时 1 ! 0等于判断不触发代码继续往下执行访问越界程序直接崩。实际排错的过程通常是这样先崩溃然后单步调试发现递归参数里出现inLeft inRight的区间还在继续构建节点最后才意识到是出口判断的条件写错了。一个字符之差整棵树的递归逻辑全错。5.2 节点值重复时哈希表会失效前面提到的“不含重复值”并不是聊胜于无而是算法成立的条件。如果中序序列里同一个值出现多次比如 inorder [2, 1, 2]preorder [1, 2, 2]我们的 index 哈希表只能记录最后一次出现的位置当递归遇到根值为 2 时定位到的下标可能误导区间划分最终建出一棵与原树不一致的树。工程上要处理重复值时千万不能只靠值做索引。最稳妥的方案是在序列化阶段就给每个节点附加唯一编号或者保存节点地址反序列化时先按编号重建骨架再填值。只给两个原始数组在不含编号的情况下尝试恢复带有重复值的树本身就是一个没有唯一解的问题。5.3 递归深度与爆栈问题如果二叉树形态很差比如退化成了一条链那么递归深度等于节点数。当 n 是 10^5 甚至 10^6 时默认的函数调用栈大概率扛不住出现栈溢出。算法题里因为 n 通常不大这个问题常被忽略但处理真实业务数据时就要警惕。应对策略有两个方向。第一把递归改成显式栈的迭代版本栈里存放“待构建的节点上下文”包含父节点指针、标记当前处理到左还是右、以及对应的区间信息本质上是把系统栈搬到了堆上。第二设置线程栈空间更大的运行环境但这只是延后问题不解决根本。如果树本身就很深迭代版更可靠。5.4 验证重构结果的“笨办法”写完重构代码后怎么确认它真的还原对了我自己的习惯是写一个递归中序遍历把重构后的树重新输出一遍和输入的中序序列比对。如果一模一样再顺手输出先序和后序做二次确认如果三个遍历结果都能对上这棵树基本就是对的。这个方法看起来笨但排查效率出奇高。很多次我重构结果不对都是因为先序参数传错建立出来的树形态偏差但中序恰好碰巧保持一致这时再用层序或者画图工具一比对问题立刻暴露。6. 变体与延伸只给一种序列也能重建分割线在哪6.1 层序 中序也能重构但代价不一样如果给你层序遍历和中序遍历同样可以重建二叉树。层序的第一个元素是根这点和先序类似。不同点在于层序里左子树和右子树的节点是交错出现的无法直接像先序那样靠“连续 leftSize 个”来切分。一般做法是对于每个子树区间扫描一遍层序序列选第一个落在这个区间内的节点作为该子树根然后用中序定位划分左右区间继续递归。这个算法最坏情况是 O(n^2)因为每个递归层都可能扫描层序。面试中如果被问到能说出这个思路就够实际写代码的话比先序中序要绕得多。6.2 只用先序就能构建二叉搜索树搜索二叉树BST是一个特例它的中序遍历结果就是所有节点的升序排序。所以“先序 BST 性质”其实等于“先序 完整中序”天然具备重构条件。最简单的实现方式是按先序顺序向一棵空 BST 中依次插入节点。因为先序的第一个节点是根后续节点按 BST 插入规则落位最终得到的树就是唯一对应那组先序序列的 BST。更高效的分治做法是先序第一个是根然后找到第一个大于根的节点位置它左边属于左子树右边属于右子树递归构建即可。这个变体提醒我们重构的关键是“中序分界线”不一定非得显式给出中序数组只要某个性质等于隐含了一条中序就能继续套分治模型。6.3 带空标记的序列不需要中序也能唯一重建如果序列化时把空节点也写进序列里比如用 # 表示空那么只要一个序列就能唯一重建树。举个具体例子[3, 9, #, #, 20, 15, #, #, 7, #, #]重建过程就是先序遍历的逆过程读到一个非 # 值就建节点并递归建左右子树读到 # 就返回空指针。和“先序中序”重构相比它用显式标记替代了中序提供的位置信息代价是序列长度变长了一个常数倍。LeetCode 297 的序列化和反序列化就是这个思路。6.4 不建节点的“间接重构”只对区间递归有时候我们并不需要真正建立节点只是要根据两个序列计算一些树属性比如树的深度、是否完全二叉树、镜像后的后序序列等。这时候可以直接在 preorder 和 inorder 的区间上递归返回目标结果不 new 任何节点。举个例子求二叉树深度int treeDepth(const vectorint pre, const vectorint in, int preRoot, int inLeft, int inRight) { if (inLeft inRight) return 0; int inRoot index[pre[preRoot]]; int leftSize inRoot - inLeft; int leftDepth treeDepth(pre, in, preRoot 1, inLeft, inRoot - 1); int rightDepth treeDepth(pre, in, preRoot leftSize 1, inRoot 1, inRight); return max(leftDepth, rightDepth) 1; }这段代码和完整重构高度相似只是把 new 换成了返回值聚合。这种写法省内存、更纯粹也是理解分治思想很好的练习题。6.5 相关经典题地图总结一下与重构直接相关的常见题目题目序列形式核心思路LeetCode 105先序 中序先序取根中序切分LeetCode 106后序 中序后序取根中序切分LeetCode 297先序 空标记显式空节点模拟递归LeetCode 449先序BST利用 BST 的有序性剑指 Offer 7先序 中序与 105 相同这些题目背下来不难但理解了分治和中序分割遇到任何变体都能现场推。7. 复杂度、适用边界与我的工程建议7.1 各方案的复杂度对比实现方案时间复杂度空间复杂度适用场景线性查找 递归O(n^2)O(n)数据量小代码最简单哈希表 递归O(n)O(n)绝大多数情况的首选哈希表 显式栈O(n)O(n)树很深防爆栈递归栈的额外深度取决于树高 h。完全二叉树的高度是 O(log n)链式树是 O(n)。哈希表占 O(n) 空间这是不可避免的除非你能接受每次递归都线性扫描一次中序。7.2 入口数据合法性校验值得写真实工程里的数据经常不可信。重构之前至少要做三件事第一两个序列长度必须一致第二两个序列的元素集合必须相同第三中序序列中不能有重复值。这些校验可以在进入递归前一次性完成能避免递归过程中出现越界访问和死循环。算法题里输入通常已保证合法所以大家容易忽略这一点但对接外部系统时它往往是崩溃的第一道防线。7.3 关于面试追问的准备面试官围绕重构二叉树爱追问的问题我整理成三连节点值有重复怎么办只给先序和后序为什么不行能不能改成迭代写法前两个问题本文已经讲透了迭代版则需要你手动维护一个栈栈里存三元组“父节点指针、构建方向、中序区间”模拟递归的压栈和弹栈过程。我觉得比起死记答案更重要的是把“中序决定左右划分”这条主线抓住。无论面试官怎么变化输入格式你都从“哪一段序列能提供根、哪一段序列能提供分界”这个角度去拆解当场也能推出正确写法。7.4 我的个人编码习惯最后分享一个我写递归类题目时一直用的笨办法先把当前子树的 preRoot、inLeft、inRight 写到草稿纸上标出根、左子树区间、右子树区间再对照代码检查每个递归参数。刚开始写会慢遇到复杂的例子比如那种左右子树高度差很大的非平衡树多花两分钟人工跑一层能省下后面半个小时的调试时间。还有个小技巧本地调试时用 VS Code 配好 C 环境在rebuild函数入口打断点观察preRoot和inLeft、inRight的变化几轮单步下来你对递归的信心会明显提升。搭配一个“重建后再中序遍历校验”的小工具函数基本不会再被这类题卡住。
返回列表