☰
前序中序构造二叉树:递归分治与哈希表优化
2026/10/1 14:09:47 网站建设 项目流程

1. 题目到底在问什么:先搞懂前序和中序的关系

很多朋友第一次看到“从前序与中序遍历序列构造二叉树”这个题目,第一反应是:两个序列摆在这儿,怎么就能把一棵树拼回来?其实这个题的核心不是“怎么拼”,而是“凭什么能拼”。你得先理解前序遍历和中序遍历各自的特点,才能明白为什么这题能解,以及怎么解才不容易出错。

前序遍历的顺序是“根 -> 左 -> 右”,也就是说,前序序列的第一个元素必然是整棵树的根节点。中序遍历的顺序是“左 -> 根 -> 右”,根节点在中序序列里的位置,恰好把左子树的所有节点和右子树的所有节点分在左右两边。这两个特性一结合,就构成了构造二叉树的全部依据:先用前序确定根,再用中序切分左右子树,然后递归处理左右两半。可以说,这是一道非常典型的“分治”思想应用题,也是面试里高频出现的二叉树基础题。

这个题适合谁看?如果你正在刷LeetCode,尤其是准备面试,那这道题几乎是必刷的;如果你刚学完二叉树遍历,想找一个能串联起“遍历结果”和“树结构”的练习,这题也非常合适。它不要求你有高深的算法功底,但要求你对递归、数组切片、哈希表这些基本功足够熟悉。别小看它,很多人在这个题上栽跟头,不是因为思路不对,而是被细节坑了,比如递归边界搞错、索引算错、数组拷贝过多导致超时等等。这篇文章我会把整个推导过程、代码实现、常见报错和优化技巧全部拆开讲清楚,你照着走一遍,基本就能把这道题吃透。

2. 解题思路拆解:为什么递归能行,核心依据是什么

想要真正掌握这道题,不能只背代码,得先弄明白递归的每一步在做什么。我们可以把问题拆成四个层次来看。

2.1 前序和中序的组合信息量

一棵二叉树,如果只给前序序列,你只知道根在开头,但不知道哪些节点属于左子树、哪些属于右子树。如果只给中序序列,你也不知道谁是根。但把两个序列放在一起,信息就闭环了。前序序列的第一个元素是根,拿着这个根去中序序列里找它的位置,中序里这个位置左边的全部节点就是左子树的中序序列,右边的全部节点就是右子树的中序序列。既然左右子树的节点数量确定了,那在前序序列里,紧跟根节点之后的连续几个节点,就分别对应左子树的前序和右子树的前序。这样,原问题就被拆成了两个更小的子问题:用左子树的前序和中序构造左子树,用右子树的前序和中序构造右子树。当你把子树也当作一棵独立的树来看,它的前序序列的第一个元素依然是子树的根,于是同样的逻辑可以一直往下套,直到序列为空。

这个过程用一句大白话概括就是:前序负责“找根”,中序负责“分家”。递归就是在反复执行“找根、分家”这两个动作。理解了这个,你就不会在写代码时迷失方向。

2.2 递归参数设计的两个方案

实现这个递归,最常见的有两种参数设计方式。第一种是直接传数组切片,例如在Python里写preorder[pre_left+1 : pre_left+1+left_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, val=0, left=None, right=None): 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_left+1 pre_left+left_size pre_left+left_size+1 pre_right 中序: [左子树节点们..., root, 右子树节点们...] in_left in_root-1 in_root in_root+1 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 Map<Integer, 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_map<int, 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_l+1, pre_l+left_size+1, in_l, idx) root.right = helper(pre_l+left_size+1, pre_r, idx+1, 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_l+1到pre_l+left_size+1,也就是下标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,不要急着做下一题,回来再想想“如果输入是空数组怎么办”“如果左子树为空怎么办”,把这些边界情况都用注释写下来,这样你的代码才真正算吃透了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询