想当初刚开始刷二叉树的时候,我一度以为只要把前中后序遍历和层序遍历吃透就够了。结果刷到这个系列的第十五篇,迎面撞上的几道题——路径总和、从中序与后序构造二叉树、最大二叉树、合并二叉树——直接把我之前那点"遍历自信"打碎了。这些题表面上看还是二叉树,本质上却从"按顺序访问节点"变成了"在递归过程中做决策、记录路径、反推结构"。
这一篇就把这几道题放在一起复盘:路径总和这类问题考的是递归返回值怎么设计,构造类问题考的是区间怎么切分不重不漏,合并类问题考的是空节点怎么处理。如果你也正好卡在这一批题上,建议先别急着往下刷,把这几个核心套路理清楚,后面的二叉搜索树题目会顺很多。
1. 路径总和到底在考什么:递归返回值设计的三种形态
1.1 为什么路径总和比"所有路径"简单:先看要不要返回值
路径总和这道题的描述很直接:给一棵二叉树和一个目标值,判断是否存在一条从根节点到叶子节点的路径,路径上所有节点值加起来刚好等于目标值。
这个"判断是否存在"的需求,决定了递归函数可以设计成返回布尔值。因为只要找到一条合法路径,整道题就可以结束了,不需要继续跑完剩下所有分支。
def hasPathSum(root: TreeNode, targetSum: int) -> bool: if root is None: return False if root.left is None and root.right is None: return root.val == targetSum return hasPathSum(root.left, targetSum - root.val) or hasPathSum(root.right, targetSum - root.val)这里有一个很关键的设计:我没有维护一个累加变量,而是把目标值不断减去当前节点值。每往下走一层,剩余要凑的数就减少当前节点的值;走到叶子节点时,只需要判断叶子节点的值是不是等于剩余的目标值。
这个转换在路径类题目里非常常见。与其带一个 sum 变量到处传,不如直接把目标值当参数递归传递,省掉一层状态维护,代码也简洁不少。
另外要注意终止条件的选择。我判断的是叶子节点而不是空节点,因为题目要求路径必须从根到叶子。如果写成空节点返回 False,功能上也能跑,但会多走一层无效递归,并且遇到空树的情况还需要额外处理。判断叶子节点更贴合题意,效率也更好。
1.2 路径总和II:从"找到一条"变成"找齐所有",返回值就不够了
路径总和II升级了:不仅要判断存在,还要把所有满足条件的路径全部找出来。这时候问题性质变了,递归函数的返回值设计也得跟着变。
如果你还沿用第一题的写法,让递归函数返回布尔值,麻烦就来了。一旦在某个分支找到了合法路径,函数就 return 了,剩下那些同样可能满足条件的分支根本来不及访问。所以这题的正确写法是:递归函数不返回值,而是通过参数传递两个东西——一个收集结果的数组,一个记录当前路径的数组。
def pathSum(root: TreeNode, targetSum: int) -> List[List[int]]: res = [] def dfs(node: TreeNode, remain: int, path: List[int]): if not node: return if not node.left and not node.right and remain == node.val: res.append(path + [node.val]) return dfs(node.left, remain - node.val, path + [node.val]) dfs(node.right, remain - node.val, path + [node.val]) dfs(root, targetSum, []) return res这里我用的是path + [node.val],也就是每次递归生成一条新路径,上层函数拿到的 path 完全不受影响。这样写最省心,不需要手动恢复现场。
但很多人会问:那是不是不用学回溯了?不是的。另一种更经典的写法是显式回溯,用path.append(node.val)和path.pop()配合。两种写法都能过,但显式回溯对理解"恢复现场"这个概念更有帮助,尤其是后面刷那些更复杂的回溯题时,脑子里如果有了"递归回来要撤销刚才的选择"这个意识,会好写很多。
还有一个小坑必须提醒:如果你选择显式回溯,在把 path 加入结果的时候,一定要写res.append(path[:])或者res.append(list(path)),不能直接res.append(path)。因为 path 是引用传递,后面递归返回时还会被继续修改,直接 append 的话,最后 res 里存的其实是同一个列表的引用,回溯完就全变成空列表了。这个错误很隐蔽,调试的时候中间态看着都对,最后结果却离奇消失。
1.3 三种返回值形态对照:路径类题目的选择标准
把路径类题目做多了,你会发现递归函数的返回值其实就三种形态,选择标准很清晰:
- 返回布尔值:只需要判断存在性,找到一条合法路径就提前终止。路径总和第一题就是这种。
- 不返回值:需要遍历整棵树,把所有满足条件的结果收集起来。路径总和II、二叉树的所有路径都是这种。
- 返回计算值:子树要把某种信息汇总给父节点,比如最大深度、节点数量、平衡判断等。
我在这一篇里反复强调返回值设计,是因为这直接决定代码能不能正确表达搜索意图。返回值选错了,代码怎么改都是别扭的。判断是否提前终止、是否收集全部结果,这两个问题一确定,返回值设计基本就出来了。
这个选择标准不只是路径类题目适用,整个二叉树递归题都是同一个逻辑。先想清楚"这一层递归要往上返回什么",再动手写,比盲目套模板要可靠得多。
2. 从中序+后序构造二叉树:先搞清楚两个序列是怎么切出来的
2.1 后序的最后一个元素是根,中序把序列切成两半
从中序遍历和后序遍历构造二叉树,是构造类题目里最经典的一道。很多人拿到题目第一反应是懵:两个数组,怎么还原一棵树?
别急着背代码,先理解切分原理。中序遍历的顺序是"左-根-右",所以只要在中序序列里找到根节点的位置,根左边那一截就是整棵左子树的中序,右边那一截就是整棵右子树的中序。后序遍历的顺序是"左-右-根",所以后序序列的最后一个元素一定是根节点。
问题来了:后序序列剩下那一大段里,左子树和右子树的分界线在哪?答案是要借助中序切出来的左子树长度来判断。左子树有多少个节点,后序序列前面就有多少个节点属于左子树。
拿一个具体例子走一遍就清楚多了。假设中序是[9, 3, 15, 20, 7],后序是[9, 15, 7, 20, 3]:
- 后序最后一个元素是 3,所以根节点值是 3。
- 在中序里找到 3,下标是 1,所以左子树中序是
[9],右子树中序是[15, 20, 7]。 - 左子树长度是 1,所以后序里前 1 个元素
[9]是左子树后序,剩下的[15, 7, 20]是右子树后序。
注意这里有一个很多人转不过弯的点:为什么后序的右子树段是[15, 7, 20],看起来和中序右子树[15, 20, 7]顺序不一样?因为后序是"左-右-根"的顺序,右子树的根 20 不一定在最后。但左右子树分界线完全由左子树长度决定,这个长度从中序里能精确拿到,所以不会切错。
2.2 切片写法最容易理解,但面试建议写下标区间
理解了切分逻辑之后,第一版代码用切片写是最直观的:
def buildTree(inorder: List[int], postorder: List[int]) -> TreeNode: if not postorder: return None root_val = postorder[-1] root = TreeNode(root_val) if len(postorder) == 1: return root index = inorder.index(root_val) left_inorder = inorder[:index] right_inorder = inorder[index + 1:] left_postorder = postorder[:len(left_inorder)] right_postorder = postorder[len(left_inorder):-1] root.left = buildTree(left_inorder, left_postorder) root.right = buildTree(right_inorder, right_postorder) return root切片写法最大的好处是思路直接,每一层递归处理的是"新的小数组",边界问题少。但缺点也明显:每次递归都要复制数组,空间开销大;而且每次都要用inorder.index(root_val)线性查找根节点位置,时间开销也不小。
面试的时候,这份代码大概率能过,但如果你想让代码更上一层楼,建议改成下标区间 + 哈希表的写法。
def buildTree(inorder: List[int], postorder: List[int]) -> TreeNode: index_map = {value: idx for idx, value in enumerate(inorder)} def dfs(in_left, in_right, post_left, post_right): if post_left >= post_right: return None root_val = postorder[post_right - 1] root = TreeNode(root_val) root_idx = index_map[root_val] left_len = root_idx - in_left root.left = dfs(in_left, root_idx, post_left, post_left + left_len) root.right = dfs(root_idx + 1, in_right, post_left + left_len, post_right - 1) return root return dfs(0, len(inorder), 0, len(postorder))这份代码里所有区间统一采用左闭右开。这是我自己写构造类题目最在意的一个约定:开闭必须统一。今天写左闭右开,明天写左闭右闭,一旦忘了调整终止条件和边界,代码非常难调。
拆解一下边界:后序的根在post_right - 1,因为右端点不包含。左子树的后序区间从post_left开始,连续left_len个;右子树的后序区间从post_left + left_len开始,到post_right - 1结束。中序的左子树区间是[in_left, root_idx),右子树区间是[root_idx + 1, in_right)。只要把这里想明白,前序+中序构造其实就是把根的位置从"末尾"挪到"开头",其余完全一样。
2.3 前序+后序为什么不能唯一确定二叉树
顺着这个题往下想,有人会问:那用前序和后序能构造二叉树吗?
答案是:不一定能唯一确定。前序确定根在最前面,后序确定根在最后面,但左右子树的分界线无法确定。举个最简单的例子:一棵只有两个节点的树,根是 1,孩子是 2。前序是[1, 2],后序是[2, 1],但 2 可以是 1 的左孩子,也可以是 1 的右孩子,前序和后序根本无法区分这两种情况。
所以当你在刷题群里看到"只用前序+后序重建二叉树"的题目,要么题目额外给了条件,比如二叉搜索树或者满二叉树,要么题目问的就是"为什么无法唯一确定"。这个知识盲区提前补上,面试被问到不至于懵。
3. 最大二叉树的构造思路:递归函数的入参该设计成什么
3.1 最大二叉树:递归构造的第二个典型模板
最大二叉树这道题的规则是:给定一个不含重复元素的整数数组,数组中的最大值作为根节点;最大值左边的子数组递归构造左子树,右边的子数组递归构造右子树。
乍一看跟题目本身没关系,但做进去就会发现,它的套路和中序+后序构造几乎一模一样:找根、切区间、递归左右。唯一的区别是,这里找根的方式不是从后序数组里取最后一个元素,而是要在当前区间里扫描一遍找最大值。
def constructMaximumBinaryTree(nums: List[int]) -> TreeNode: def build(left, right): if left >= right: return None max_idx = left for i in range(left + 1, right): if nums[i] > nums[max_idx]: max_idx = i node = TreeNode(nums[max_idx]) node.left = build(left, max_idx) node.right = build(max_idx + 1, right) return node return build(0, len(nums))每次找最大值都要线性扫描当前区间,所以整体时间复杂度是 O(n²),最坏情况下数组有序时表现最差。但通过这道题是绰绰有余的。
3.2 参数设计的决定性作用:不要把切片当后路
这题最常见的入门写法是:每次递归把数组切成nums[:max_idx]和nums[max_idx+1:],然后传给下一层。
这种切片写法在本地能跑,评测也能过,但有一个很现实的问题:每次切片都会复制一个数组,空间开销叠加起来很可观。更重要的是,切片的写法把"当前子树在原始数组上的范围"这个信息藏了起来,代码可读性反而不如传边界参数。
我自己的习惯是:构造类题目的递归函数,一律设计成"原始数组引用 + 左右边界"的形式。这样递归的每一层都能清楚地知道自己在处理哪一段,后面遇到有序数组转二叉搜索树、线段树这类题目,写起来风格也是统一的。
传边界参数还有一个附带好处:方便调试。你可以随时在递归入口打印 left 和 right,清楚地看到区间是怎么一层层缩小的,哪一步边界算错了一眼就能找出来。切片版本想打印"我在处理哪一段"就不那么直观了。
3.3 彩蛋:最大二叉树可以用单调栈一趟构建
区间法是最直观的解法,但如果你已经写熟了,可以了解一个进阶版本:单调栈一趟构建,时间复杂度降到 O(n)。
核心思路是:从左到右扫描数组,维护一个单调递减栈。当前元素大于栈顶元素时,不断出栈,最后一个出栈的节点会成为当前元素的左孩子;如果当前元素小于栈顶,则栈顶的右孩子指向当前元素。扫描结束后,栈底就是整棵树的根。
def constructMaximumBinaryTree(nums: List[int]) -> TreeNode: stack = [] for num in nums: node = TreeNode(num) while stack and stack[-1].val < num: node.left = stack.pop() if stack: stack[-1].right = node stack.append(node) return stack[0]第一次看这段代码可能会觉得"这个写法怎么能对"。我当时也反复验证了很久才想明白:当前元素是它所在区间的最大值,所有比它小且已经出栈的节点,在最终树里一定都在它的左侧,并且会挂在它的左孩子路径上。这个逻辑是最大二叉树定义的自然推论。
不过实话实说,面试时能用区间法清晰讲出来已经足够,单调栈属于锦上添花的扩展,不用强求第一次就吃透。
4. 合并二叉树:两个树同时递归的正确打开方式
4.1 空节点提前返回:合并二叉树的第一准则
合并二叉树的规则很简单:两棵树相同位置的节点值相加;如果某一侧只有一棵树有节点,直接接上那棵树的节点。
递归出口是这道题的灵魂。
def mergeTrees(root1: TreeNode, root2: TreeNode) -> TreeNode: if not root1: return root2 if not root2: return root1 node = TreeNode(root1.val + root2.val) node.left = mergeTrees(root1.left, root2.left) node.right = mergeTrees(root1.right, root2.right) return node注意这里的出口设计:root1 为空,直接返回 root2;root2 为空,直接返回 root1。这样后续递归就不需要再判断空的情况了,左右子树递归的时候哪怕传进去一个空节点,也能被这个出口正常兜住。
很多初学者会写出一堆分支判断:如果两个都不为空怎么办,如果一个为空一个不为空怎么办。这样写逻辑上没错,但代码会变得又臭又长。用"空节点提前返回"的思想,所有分支都被压缩成了两个出口条件,递归体一下子干净很多。
这里也体现了一个通用的递归设计思想:终止条件要尽量把"不需要当前层处理的情况"全部挡在递归入口。处理得越干净,单层逻辑就越简单。
4.2 复用原树还是新建树:先问清楚需求
上面这版代码默认是可以修改原树的,直接把 root1 和 root2 的值加在一起建新节点,子树也直接复用原树的节点。这在大多数在线评测平台是允许的。
但如果题目明确要求不能修改原树,那情况就不一样了。尤其是当一边子树为空时,需要把另一边的子树完整复制出来,而不是直接返回原节点。这种"深拷贝式"的写法在面试中不常见,但一旦被问到,最好能答出来。
我的建议是:做题之前先确认题意——是否允许修改输入数据。这不仅是代码洁癖问题,而是很多题目解法的选择分岔口。允许修改输入,代码可以非常简洁;不允许修改,就要在一开始设计递归函数时就考虑复制逻辑,而不是后面补救。
这类"两棵树同时递归"的问题还有一个常见错误:递归调用时把左右子树传反了。比如mergeTrees(root1.left, root2.right),这种错误在节点不多的时候可能根本暴露不出来,因为树结构不对称时结果也是错的,但很难肉眼察觉。我写这种题目时,每次递归调用的成对参数都会下意识检查一遍左右方向。
4.3 合并类问题的变形方向
合并二叉树这个基础模板能延伸出不少场景:两棵树做交集、在合并过程中统计节点值差异、合并后判断是否平衡等。核心都是"同时递归两棵树的同位置节点"。
把这个模板理解到位之后,你会发现它在二叉搜索树相关题目里也有应用。比如后续会遇到"两棵二叉搜索树的所有元素"这类题目,虽然解法是先中序遍历再合并有序数组,但"同时处理两棵树"这个思维是共通的。先把这个两路递归的手感练出来,后面刷题会轻松一些。
5. 这一篇做完,我总结的二叉树递归套路
5.1 用这一篇的题复盘递归三部曲
老生常谈的递归三部曲,只有在具体题目里对照着用,才能真正变成自己的东西。这一篇的几道题正好是绝佳样本。
先看参数和返回值。路径总和返回布尔值,因为找到一条就提前结束;路径总和II不返回值,因为要收集所有路径;中序+后序构造返回 TreeNode,因为要把子树层层组装给上一层;合并二叉树也返回 TreeNode,因为要把合并结果往上传递。
再看终止条件。路径总和在叶子节点停下;构造类题目在区间为空时停下;合并二叉树在某一方为空时停下。
最后看单层逻辑。路径总和做targetSum - node.val的递减和 or 合并;构造类题目先找根再切区间;合并二叉树先把两个根节点值相加再递归左右。
我把这些整理成一个表格,方便日后复习时快速回忆:
| 题目 | 递归返回值 | 终止条件 | 单层核心操作 |
|---|---|---|---|
| 路径总和 | bool | 叶子节点 | targetSum - node.val |
| 路径总和II | 无 | 叶子节点 | 收集路径 |
| 中序+后序构造 | TreeNode | 区间为空 | 找根、切区间 |
| 最大二叉树 | TreeNode | 区间为空 | 找最大值下标 |
| 合并二叉树 | TreeNode | 一方为空 | 根节点值相加 |
5.2 路径类题目最容易漏掉的一步:恢复现场
如果你选择写显式回溯而不是路径拷贝,那么"恢复现场"是路径类题目最容易翻车的地方。
我见过不少类似的错误写法:
def dfs(node, target, path): if not node: return path.append(node.val) if not node.left and not node.right and sum(path) == target: res.append(path[:]) # 这里必须拷贝 dfs(node.left, target, path) dfs(node.right, target, path) path.pop()如果res.append(path)没加拷贝,结果是灾难性的:path 在递归返回的过程中不断 pop,最终 res 里的所有路径都会变成空列表。这个错误很难用眼睛发现,因为调试时你看到 res 里确实有数据,但那只是"还没被回溯清空"的假象。
一个实用的习惯是:只要在递归里用到了共享的可变列表,每次加入结果时都要问自己一句——这个列表之后还会不会被修改?会,就拷贝。这个习惯能帮你省掉大量查 bug 的时间。
5.3 构造类题目的自查方法
构造类题目写完怎么验证?我有一个很笨但很管用的方法:写两个辅助函数,把构造出来的树分别做一遍前序遍历和中序遍历,看看能不能还原出原始数组。
def preorder(root): if not root: return [] return [root.val] + preorder(root.left) + preorder(root.right) def inorder(root): if not root: return [] return inorder(root.left) + [root.val] + inorder(root.right)对中序+后序构造的题,构造完之后做一次中序遍历,如果结果和原始中序数组完全一致,说明根的定位和区间切分大概率没问题。对最大二叉树,做一次前序遍历,检查根是不是每次都出现在正确的位置。
这个方法虽然朴素,但在本地调试时比肉眼盯变量高效得多。构造类题目最容易出的是边界错误,比如区间端点差了 1,这种问题靠读代码很难发现,一跑辅助函数立刻现行。
最后说一点实际体会。二叉树刷到十五篇这个阶段,很多人会觉得"怎么又是递归",但递归真正的难点从来不是会不会写,而是怎么控制递归的走向和边界。这一篇反复强调的三件事——返回值设计、区间开闭约定、共享变量的拷贝与恢复——是递归三件套里最实用的三个抓手。我自己在写构造类题目前,一定会先在纸上把中序和后序的切分过程画一遍,至少画到第二层递归。这个习惯帮我少调了无数个小时的 bug。如果你也在这一篇卡住,不妨停下来,选一个例子手动展开到第二层递归,很多边界问题都会瞬间变得清楚。