新闻详情

新闻详情

首页 / 资讯中心 / 详情

前序中序构造二叉树:递归分治与哈希表优化

发布时间:2026/10/1 14:09:53来源:尧图网络
前序中序构造二叉树:递归分治与哈希表优化
1. 题目到底在问什么先搞懂前序和中序的关系很多朋友第一次看到“从前序与中序遍历序列构造二叉树”这个题目第一反应是两个序列摆在这儿怎么就能把一棵树拼回来其实这个题的核心不是“怎么拼”而是“凭什么能拼”。你得先理解前序遍历和中序遍历各自的特点才能明白为什么这题能解以及怎么解才不容易出错。前序遍历的顺序是“根 - 左 - 右”也就是说前序序列的第一个元素必然是整棵树的根节点。中序遍历的顺序是“左 - 根 - 右”根节点在中序序列里的位置恰好把左子树的所有节点和右子树的所有节点分在左右两边。这两个特性一结合就构成了构造二叉树的全部依据先用前序确定根再用中序切分左右子树然后递归处理左右两半。可以说这是一道非常典型的“分治”思想应用题也是面试里高频出现的二叉树基础题。这个题适合谁看如果你正在刷LeetCode尤其是准备面试那这道题几乎是必刷的如果你刚学完二叉树遍历想找一个能串联起“遍历结果”和“树结构”的练习这题也非常合适。它不要求你有高深的算法功底但要求你对递归、数组切片、哈希表这些基本功足够熟悉。别小看它很多人在这个题上栽跟头不是因为思路不对而是被细节坑了比如递归边界搞错、索引算错、数组拷贝过多导致超时等等。这篇文章我会把整个推导过程、代码实现、常见报错和优化技巧全部拆开讲清楚你照着走一遍基本就能把这道题吃透。2. 解题思路拆解为什么递归能行核心依据是什么想要真正掌握这道题不能只背代码得先弄明白递归的每一步在做什么。我们可以把问题拆成四个层次来看。2.1 前序和中序的组合信息量一棵二叉树如果只给前序序列你只知道根在开头但不知道哪些节点属于左子树、哪些属于右子树。如果只给中序序列你也不知道谁是根。但把两个序列放在一起信息就闭环了。前序序列的第一个元素是根拿着这个根去中序序列里找它的位置中序里这个位置左边的全部节点就是左子树的中序序列右边的全部节点就是右子树的中序序列。既然左右子树的节点数量确定了那在前序序列里紧跟根节点之后的连续几个节点就分别对应左子树的前序和右子树的前序。这样原问题就被拆成了两个更小的子问题用左子树的前序和中序构造左子树用右子树的前序和中序构造右子树。当你把子树也当作一棵独立的树来看它的前序序列的第一个元素依然是子树的根于是同样的逻辑可以一直往下套直到序列为空。这个过程用一句大白话概括就是前序负责“找根”中序负责“分家”。递归就是在反复执行“找根、分家”这两个动作。理解了这个你就不会在写代码时迷失方向。2.2 递归参数设计的两个方案实现这个递归最常见的有两种参数设计方式。第一种是直接传数组切片例如在Python里写preorder[pre_left1 : pre_left1left_size]这种形式代码看起来简洁直观但每层递归都会产生新的数组空间开销大在数据量大的时候容易拖慢速度。第二种是传原始数组加索引范围比如用四个整数pre_left, pre_right, in_left, in_right来标记当前处理范围在原数组中的起止位置这样递归全程都只操作原始数组没有额外拷贝效率更高。我这里更推荐第二种方式原因很简单LeetCode上的测试用例有时候会给出节点数很多的树虽然大多数时候切片也能过但一旦遇到极端数据性能差距就很明显。而且面试时你写出“不拷贝数组”的版本面试官通常会认为你对递归和索引控制的理解更深一层。当然如果你只是刚入门先用切片版本把思路跑通再改成索引版本也是一种循序渐进的学习路径。2.3 用哈希表加速查找根节点位置在中序序列里找根节点的位置最常见的方法是循环遍历每次递归都从头到尾扫描一遍。但这样做的代价是每层递归都要花费O(n)的时间去找根总时间复杂度会退化到O(n^2)。优化方式很简单先遍历一次中序序列把每个节点值对应的下标存进一个哈希表Python里的字典、Java里的HashMap之后每次查找根节点的位置只需要O(1)的时间。这个优化几乎是必须的因为中序序列中的节点值假设不重复恰好满足哈希表的使用条件。这个哈希表在整个递归过程中只需要构建一次放在递归函数外面或者作为不可变参数传入都可以。需要注意的是如果题目给出的节点值可能有重复那这种“值定位”的方法就不成立了需要结合其他信息来处理。不过LeetCode 105题明确说明节点值不重复所以我们可以放心用。2.4 递归终止条件与空树处理递归必须要有出口否则就会无限调用直到栈溢出。这个题的出口有两种情况当左边界大于右边界时说明当前范围内没有节点返回None当左边界等于右边界时说明当前范围内只有一个节点这时候其实可以提前构造叶子节点返回但也可以不特判让它走完整个流程因为下一步递归左右子树时子范围会变成空范围自然也能终止。我建议代码里只写if pre_left pre_right: return None这一个终止条件就够了不用画蛇添足。很多刚刷题的朋友喜欢把“等于”的情况单独处理其实没有必要反而容易引入索引错误。3. 手写实现Python版本逐行拆解思路讲完了下面进入实操环节。我用Python写一版推荐实现然后逐行解释每一步在干什么以及为什么这么写。class TreeNode: def __init__(self, val0, leftNone, rightNone): self.val val self.left left self.right right class Solution: def buildTree(self, preorder: List[int], inorder: List[int]) - Optional[TreeNode]: # 建立中序值到下标的映射方便O(1)查找 in_index {val: idx for idx, val in enumerate(inorder)} def helper(pre_left, pre_right, in_left, in_right): # 递归终止没有节点时返回空 if pre_left pre_right: return None # 前序范围的第一个节点一定是当前子树的根 root_val preorder[pre_left] root TreeNode(root_val) # 找到根在中序中的位置 in_root in_index[root_val] # 左子树的节点个数 left_size in_root - in_left # 递归构造左子树 root.left helper(pre_left 1, pre_left left_size, in_left, in_root - 1) # 递归构造右子树 root.right helper(pre_left left_size 1, pre_right, in_root 1, in_right) return root return helper(0, len(preorder) - 1, 0, len(inorder) - 1)这段代码的核心就只有五行在干活其余都是边界控制。我们来逐一拆解第一步构建in_index字典。这里用字典推导式把每个值在中序序列中的下标存下来。注意这个字典是针对整棵树的不会变化所以放在helper外部避免每层递归都重新构建。第二步定义helper函数接收四个参数。pre_left和pre_right是当前子树在前序数组中的左边界和右边界in_left和in_right是当前子树在中序数组中的左边界和右边界。这里的区间我统一采用闭区间也就是左右边界都包含在内。如果你习惯左闭右开也可以但一定要保持一致否则索引计算会出错。第三步检查终止条件。当pre_left pre_right时说明当前子树没有任何节点返回None。这里不需要检查in_left in_right因为前序子树的长度和中序子树的长度永远相等只是各自位置的表示方式不同前序范围为空中序范围也一定为空。第四步取根节点。preorder[pre_left]就是当前子树根节点的值然后创建树节点。第五步通过字典拿到根在中序中的位置in_root接着计算左子树节点数量left_size in_root - in_left。这一步是整个递归计算的关键。因为中序序列中根节点左边的所有元素都属于左子树所以用根的位置减去子树中序起点就能算出左子树有多少个节点。第六步计算左右子树在前序和中序中的范围。这里我建议你画一张图来理解前序: [root, 左子树节点们..., 右子树节点们...] pre_left pre_left1 pre_leftleft_size pre_leftleft_size1 pre_right 中序: [左子树节点们..., root, 右子树节点们...] in_left in_root-1 in_root in_root1 in_right有了这张示意图索引推导就一目了然了。左子树的前序范围从pre_left 1开始长度是left_size所以结束位置是pre_left left_size右子树的前序范围紧跟着左子树从pre_left left_size 1开始到pre_right结束。左子树的中序范围是in_left到in_root - 1右子树的中序范围是in_root 1到in_right。第七步递归构造左右子树最后返回root。整个递归过程就像剥洋葱每次剥掉一个根节点剩下的左右子问题结构完全相同。你只要保证每一层都正确切分了范围最终就能还原整棵树。4. 从Python拓展到Java和C索引控制才是通用的难点很多同学在Python里写通了换到Java或者C就又卡住了原因通常是语言语法不熟悉或者对对象引用的理解不到位。这里我给出Java版本的核心代码并重点说明几个容易出错的地方。class Solution { private MapInteger, Integer indexMap; public TreeNode buildTree(int[] preorder, int[] inorder) { int n preorder.length; indexMap new HashMap(); for (int i 0; i n; i) { indexMap.put(inorder[i], i); } return helper(preorder, inorder, 0, n - 1, 0, n - 1); } private TreeNode helper(int[] preorder, int[] inorder, int preLeft, int preRight, int inLeft, int inRight) { if (preLeft preRight) { return null; } int rootVal preorder[preLeft]; TreeNode root new TreeNode(rootVal); int inRoot indexMap.get(rootVal); int leftSize inRoot - inLeft; root.left helper(preorder, inorder, preLeft 1, preLeft leftSize, inLeft, inRoot - 1); root.right helper(preorder, inorder, preLeft leftSize 1, preRight, inRoot 1, inRight); return root; } }这里面有一个细节值得提醒Java的HashMap在使用get之前一定要确保键存在。由于题目保证值不重复且所有值都在中序序列中所以这里不会出现空指针问题。但如果你擅自修改了输入比如构建了一个不包含根节点的中序数组那get就会返回null自动拆箱赋值给int时会抛出空指针异常。这是Java新手比较容易踩的坑。再看看C版本逻辑完全一样只是在哈希表的定义上略有差别用unordered_mapint, int即可。C里需要注意的点是递归深度当二叉树退化成链表形状时递归深度可能达到n如果编译器栈空间有限可能栈溢出。LeetCode的测试用例通常不会那么极端但你自己在本地测试时要注意。说到底不管用什么语言索引计算都是同一个公式区别只在于语法。只要你理解了一张图就能轻松迁移到任何语言。反过来如果你只是背住了Python代码换一种语言就懵说明你还没真正掌握这一类题的通用解法需要回头把“范围计算”的逻辑再捋一捋。5. 避坑指南这些运行时错误90%的人都遇到过题目本身思路不复杂但实际提交时很多人会栽在各种边界错误上。下面梳理几个最常见的报错场景和排查方法。5.1IndexError: list index out of range这个错误几乎人人都会遇到根源在于索引计算越界。最常见的情况是左子树为空时left_size为0那么pre_left 1和pre_left 1 0是同一个值导致左子树递归时pre_left pre_right正常返回None但如果我写成了if pre_left pre_right: return None就会漏掉空范围的判断越界错误就出现了。所以务必使用而不是作为终止条件或者两者都写上但不要再额外处理“等于”的情况。还有一种越界场景是忘了更新中序右边界。比如某些朋友在递归右子树时写成了helper(pre_left left_size 1, pre_right, in_root 1, in_right)看起来没问题但如果pre_right没有跟着子树范围缩小而是一直沿用整棵树的右边界那么跨度一大就可能访问到不属于当前子树的元素虽然不一定立刻报错但构造出来的树一定是错误的甚至可能因为索引超过数组长度而报错。排查这类问题的最好办法是打印每一层递归的四个参数肉眼检查范围和实际子树的对应关系。5.2RecursionError: maximum recursion depth exceeded递归深度超限通常是因为终止条件没生效递归永远无法结束。终止条件写错往往是两个原因一是条件判断用了导致空范围时返回不了二是索引更新时某个参数没有向“缩小范围”的方向移动比如递归左子树时右边界还是pre_right这就可能导致左子树的递归范围越来越大。排查时建议先在递归函数第一行加一条打印语句输出当前的pre_left、pre_right、in_left、in_right以及根节点值。一旦看到某个分支的范围没有收窄或者反复出现相同的参数组合就能快速定位到错误行。5.3 切片版本超时如果你第一版用了Python的列表切片写法提交发现超时不用惊讶。列表切片的时间复杂度和空间复杂度都是O(n)每层递归都要复制两个列表总体开销会变得非常大。优化方法就是改成索引传参。这里顺便说一句LeetCode上很多二叉树相关题目都可以用类似“索引传参代替数组切片”的技巧来提速这是一个值得养成的好习惯。5.4 节点值不唯一时怎么办原题明确“inorder 和 preorder 都由 无重复 的值组成”所以哈希表方案成立。但如果你在别的地方遇到类似题目节点值可能重复那你不能仅凭值去中序里定位根。一个通用的替代方案是用(值, 中序下标)的元组作为哈希表的键或者干脆在多个相同值出现时借助额外的约束条件来确定唯一匹配。不过这个超出本题范围你只要记住这个提示面试时如果被追问“如果有重复值怎么做”你能说出思路就够了。6. 相似题目与扩展一道题刷出一串题LeetCode 105不是孤立存在的。你把它搞透之后会发现有一系列二叉树构造题都用了同一个套路或者说同一种“遍历序列定位根”的思路。我建议你把以下题目连在一起刷效果会更好。第一道是LeetCode 106从中序与后序遍历序列构造二叉树。思路几乎相同区别在于后序序列的最后一个元素是根节点。你用后序确定根用中序分左右然后递归代码和105题高度相似真正需要改动的只有几个索引位置。刷完105再刷106整体难度至少降一半。第二道是LeetCode 889根据前序和后序遍历构造二叉树。这道题稍微复杂一点因为前序和后序不能唯一确定一棵二叉树题目要求返回任意一棵符合条件的树即可。你需要利用前序序列中第二个元素来确定左子树的根再用它在后序中的位置来计算左子树规模。它考察的是对遍历序列更深入的理解能帮你把“前序、中序、后序”三者的关系彻底打通。第三道是二叉树的序列化与反序列化对应LeetCode 297。这道题不再是给定两个序列而是要你自己设计一种方式把一棵树编码成字符串再从这个字符串还原树。通常做法是采用前序或层序遍历加上空节点标记。做完这道题你会更深刻地体会到“遍历序列 空标记”如何唯一确定一棵树。除了LeetCode的题目你还可以自己写一个验证程序给定一棵随机生成的二叉树分别做前序和中序遍历然后用105题的代码去还原最后再用层序遍历比较两棵树是否一致。这个自测流程能帮你发现哪些地方理解偏了比单纯刷题更有效。7. 现场调试实录一次真实的排查过程这里分享一个我前两天帮别人看代码时遇到的真实案例。朋友写的Python代码如下def buildTree(self, preorder, inorder): dict_ {v:i for i,v in enumerate(inorder)} def helper(pre_l, pre_r, in_l, in_r): if pre_l pre_r: return None root_val preorder[pre_l] root TreeNode(root_val) idx dict_[root_val] left_size idx - in_l root.left helper(pre_l1, pre_lleft_size1, in_l, idx) root.right helper(pre_lleft_size1, pre_r, idx1, in_r) return root return helper(0, len(preorder), 0, len(inorder))乍看好像没什么问题但提交后一直报错。我让他打印了每一层递归的参数很快就发现问题所在他把区间定义成了左闭右开终止条件写成了pre_l pre_r这个本身没错但递归右子树时传的pre_r是整棵树的右边界而不是当前子树范围内的右边界。在左闭右开区间里pre_r应该是当前子树在前序中的结束位置的下一个下标也就是pre_l left_size 1 (右子树的节点数)不能直接用最外层的pre_r。越说越抽象直接看具体例子假设前序是[3, 9, 20, 15, 7]中序是[9, 3, 15, 20, 7]。根是3左子树只有一个节点9。在递归左子树时传入的前序范围是pre_l1到pre_lleft_size1也就是下标1到2这个区间只有9没问题。但递归右子树时如果直接传pre_r而pre_r是5那么范围和左子树的前序范围重叠加起来就会在右子树递归中出现错误。正确的做法是右子树的pre_r应该等于pre_l left_size 1 (in_r - idx - 1)其中in_r - idx - 1是右子树的节点数。说白了pre_r要跟着子树实际规模走不能偷懒沿用外层参数。这个例子说明一旦你选择了左闭右开的区间表示那么每一个边界的含义都要重新推导不能把闭区间版本的公式直接套用。很多资料默认使用闭区间因为更直观如果你坚持用开区间请一定自己画图推导一遍否则很容易踩坑。8. 实战经验总结几个实用的刷题与写码习惯在反复刷这道题的过程中我总结出几个可以迁移到所有二叉树递归题的实用习惯这里分享给你。第一个习惯是“先画图再写码”。别急着敲代码先在草稿纸上画一棵小树然后写出它的前序和中序序列模拟一遍递归过程。你不需要画复杂的树只要画三五个节点的例子就能把索引关系验证清楚。很多人写递归出错都是因为脑子里的图和实际代码不一致。第二个习惯是“用最小例子验证边界”。我经常用只有三个节点的树来测试代码根、左孩子、右孩子。这个例子能覆盖所有分支左子树非空、右子树非空、递归终止。如果这棵树能通过调试再测试只有根节点的情况。你会发现大部分索引错误在最小例子里就会暴露。第三个习惯是“写一点调一点”。不要在全部代码写完后才去调试。先写好递归函数和终止条件用一个非常小的输入跑通再逐步完善索引计算。LeetCode支持在本地调试也可以把函数复制到自己的IDE里加上打印辅助。这样排查问题的速度会快很多。第四个习惯是“把递归函数控制在5行以内”。如果递归体超过5行大概率可以拆分或者重构。这道题的递归体只有“创建根节点、计算left_size、递归左右子树”这三件事一旦代码看起来臃肿往往意味着你绕了弯。还有一个关于面试的小建议如果你在面试中遇到这题不要上来就写代码。你可以先和面试官说“前序序列第一个元素是根然后用哈希表记录中序位置递归构建左右子树。”一句话表达思路比闷头写代码加分得多。面试官看重的不是你背得多熟而是你是否真的理解了这个过程以及能否清楚讲出为什么哈希表能让复杂度降为O(n)。9. 关于复杂度与扩展空间的一点补充最后聊一下复杂度这也是面试必问的点。不考虑哈希表构建的话每个节点都会被访问一次递归过程中每次调用只做常数次操作所以时间复杂度是O(n)n是节点数。空间复杂度方面哈希表需要O(n)的空间递归调用栈在最坏情况下树退化成链表深度为n因此总体空间复杂度是O(n)。如果你用递归构建树而不额外存储任何信息理论上空间复杂度就是O(n)因为节点本身也要占用空间但通常讨论算法复杂度时我们只关注额外辅助空间所以回答“哈希表O(n) 栈O(n)总O(n)”是标准答案。这道题的递归过程本质上是在“重建”一棵树而不是“转换”一棵树。理解这一点对你理解动态规划里的记忆化搜索也有帮助因为两者的递归框架很相似先处理当前层然后递归处理子问题再合并结果。只不过在构造二叉树的情境下子问题的合并就是直接挂接左右孩子指针看上去不像是“合并”而是“组装”。当你把105题刷透后可以顺手试试同时给定“前序中序”、“中序后序”、“前序后序”三种场景下的构造思路对比它们的异同。你会发现这一类题的核心永远只有一个利用一种遍历确定根再用另一种遍历切分子树。把这个思想内化了你以后遇到任何关于树重构的问题都会有一种“不过如此”的感觉。在实际操作中我用这套思路大概可以做到三分钟内无bug通过你也值得花这个功夫去练。每次写完拿到AC不要急着做下一题回来再想想“如果输入是空数组怎么办”“如果左子树为空怎么办”把这些边界情况都用注释写下来这样你的代码才真正算吃透了。
网站建设高端定制企业官网
RELATED

相关资讯

更多精彩内容,欢迎继续阅读

较早相关资讯

最新相关资讯

2026.3.10:最新claude code完美解决方案,跳过登录界面,使用国内deepseek-v4-pro进行计费 2026/10/1 15:05:34

2026.3.10:最新claude code完美解决方案,跳过登录界面,使用国内deepseek-v4-pro进行计费

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

阅读更多 →
DD-WRT无线模式全解析:从AP到Client Repeater的配置与验证 2026/10/1 15:05:34

DD-WRT无线模式全解析:从AP到Client Repeater的配置与验证

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

阅读更多 →
OpenClaw 主要发布版本  核心区别:TaoToken 统一 Key 接入配置与验证 2026/10/1 15:05:34

OpenClaw 主要发布版本 核心区别:TaoToken 统一 Key 接入配置与验证

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

阅读更多 →
从复位向量到FreeRTOS第一个任务:STM32启动流程全解析 2026/10/1 15:05:34

从复位向量到FreeRTOS第一个任务:STM32启动流程全解析

做过几年STM32的朋友大概都有这种体验:点灯、串口打印、FreeRTOS多任务调度都能跑,但被问一句“从按下复位键到第一个任务跑起来,这中间芯片到底干了些什么”,多半一时语塞。不是你不会,而是这条链路藏在启动文件、C库…

阅读更多 →
别把 FlashQLA 当成所有 Qwen 推理的通用加速包:RTX 3090 上先卡住的是这 3 个边界与 TaoToken 配置骨架 2026/10/1 15:05:33

别把 FlashQLA 当成所有 Qwen 推理的通用加速包:RTX 3090 上先卡住的是这 3 个边界与 TaoToken 配置骨架

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

阅读更多 →
STM32上电启动全流程:从复位向量到RTOS第一个任务 2026/10/1 15:05:27

STM32上电启动全流程:从复位向量到RTOS第一个任务

从做嵌入式开发的第一天起,我就会发现一个很有意思的现象:很多人写了一两年STM32代码,能用标准库甚至HAL库把外设调得飞起,但你要问他“复位引脚释放之后,CPU到底干了什么,又是怎么一步步跑到我们的main函数…

阅读更多 →

今日资讯

本周资讯

本月资讯

看完文章仍有疑问?

联系尧图顾问,获取一对一建站咨询

立即免费咨询 📞 400-888-8888
📞 ✉