力扣第437题——路径总和 III,是我心里“二叉树上递归题”的一道分水岭。它挂在中等难度,看起来和112、113同属“路径总和”家族,但命题角度完全变了:前两问要么问“是否存在一条从根到叶子的路径”,要么问“把所有满足条件的路径列出来”,而这一题直接改成“整棵树里到底有多少条方向向下的路径,节点值之和恰好等于 targetSum”。路径的起点不一定是根节点,终点也不一定是叶子节点,这一下就把很多人从“背模板”的状态打回原形。这篇文章我打算从暴力解法讲起,再把真正高频的套路——前缀和加回溯——完整推导一遍,代码都给全,最后补上我在实际刷题和面试复盘里踩过的坑。理解这道题之后,你顺带也能把“和为 K 的子数组”那一类题目一起打通。
1. 读懂“路径总和 III”:问题定义和题目边界
1.1 题目到底在问什么
力扣官方的描述是:给定一棵二叉树的根节点 root 和一个整数 targetSum,求这棵二叉树里节点值之和等于 targetSum 的路径数目。路径不需要从根节点开始,也不需要在叶子节点结束,但是方向必须向下,也就是只能从父节点走向子节点。
这句话里有三个约束必须拆开看:
- 任意起点:你可以从任意节点开始计数,包括根节点,也包括叶子节点。
- 任意终点:走到任意节点就可以停下来,不要求必须是叶子节点。
- 只能向下:路径不能向上拐,不能跨兄弟节点去组合,也不能从左子树绕到右子树。
这意味着,我们在递归时只能沿着 parent -> child 的方向延伸。只要选择了某个起点,后面每一个节点都只能继续向左孩子或右孩子走,一旦回头,这条路径就失效了。这个“方向只能向下”的限制是整个题目的根基,也是后面所有解法的前提。
1.2 先看清题目的示例
题目给的示例树长这样:
10 / \ 5 -3 / \ \ 3 2 11 / \ \ 3 -2 1targetSum = 8 时,答案是 3。三条满足条件的路径分别是:
- 5 -> 3
- 5 -> 2 -> 1
- -3 -> 11
我建议拿到题之后先把这棵树画在纸上,不写代码,亲手把三条路径找出来。找完你会发现,满足条件的路径大多藏在树的中间位置,而不是从根出发。这和前面“路径总和 I / II”那种从根开始一路走到叶子的场景,体感差别非常大。
1.3 和前两题相比,难在哪里
112 路径总和:只要求返回一个布尔值,判断从根到叶子是否存在一条路径和为 targetSum。因为起点和终点都固定,DFS 里维护一个剩余值就能解决。
113 路径总和 II:仍然是从根到叶子,但要收集全部满足条件的路径,所以要带着一个路径列表在 DFS 里做回溯,把当前尝试过的节点加进去,递归完再弹出来。
437 路径总和 III:起点和终点全部放开,问题从“判断存在性”和“输出路径”变成了“统计数量”。如果没有前缀和意识,很容易写出一个看起来很对的双重 DFS,然后在复杂度分析那一步卡壳。
这道题在面试里经常被当作“递归加数据结构”的综合考察点。很多候选人一上来就写从每个节点开始向下枚举的版本,问复杂度时才发现,一旦树退化成一条链,时间会直接到 O(n^2)。所以正确思路不是硬往下递归,而是想办法把“任意连续段求和”的问题转化成可以复用的信息。
2. 暴力双 DFS:先保证正确性
2.1 思路拆解
最简单也最符合直觉的思路:每个节点都有资格成为路径的起点。于是外层遍历整棵树,把每个节点都当作起点;内层从起点出发,沿着向下的方向延伸,统计所有子路径的和等于 targetSum 的个数。这就是“双重 DFS”。
第一层 DFS 负责枚举起点,第二层 DFS 负责从某个起点向下延伸。每一层的动作都很朴素,所以正确性非常容易验证。
2.2 Python 参考实现
from typing import Optional class Solution: def pathSum(self, root: Optional[TreeNode], targetSum: int) -> int: if not root: return 0 # 第二层DFS:以 node 为起点,向下的路径中能凑出多少 remaining def dfs_start(node: Optional[TreeNode], remaining: int) -> int: if not node: return 0 count = 0 # 当前节点本身就能凑成一条路径,计数加1 if node.val == remaining: count += 1 # 继续向左右子节点延伸 count += dfs_start(node.left, remaining - node.val) count += dfs_start(node.right, remaining - node.val) return count # 第一层DFS:枚举每个节点作为起点 return ( dfs_start(root, targetSum) + self.pathSum(root.left, targetSum) + self.pathSum(root.right, targetSum) )代码里的self.pathSum(root.left, targetSum)不是多余的。它负责把左孩子也当成新的起点,再执行一次完整的起点枚举。右孩子同理。所以每个节点都既会被外层当成起点,也会被内层当成某个起点路径上的中间节点,这种重叠枚举正是题目要求的“任意起点”特性。
2.3 复杂度分析
时间上,在平衡二叉树中,每个起点向下延伸的平均深度大约是 O(log n),所以整体时间复杂度是 O(n log n)。但是当树退化成一条链表时,起点有 O(n) 个,每个起点向下最多延伸 O(n) 层,整体就变成 O(n^2)。力扣虽然给了 1000 个节点的规模,O(n^2) 在很多用例下也勉强能过,但那是因为数据没有刻意刁难。如果面试官让你分析复杂度,你必须主动说出退化情况。
空间上,递归栈最深是树的高度,最坏也是 O(n)。这个暴力版本不是最优解,但它有一个巨大优点:逻辑简单,适合作为和正确答案对拍的参考实现。我自己在写前缀和解法之前,就先用这个版本跑了一遍题目的示例,确认了整体流程。
2.4 这里有一个容易懵的点
很多初学者看到self.pathSum里又调用了self.pathSum,会怀疑是不是重复计算。这里要明确:第一层递归枚举的是起点,第二层递归负责累计一段路径的和,两者职责不同。重复的只是每个节点既当起点又被访问,但这不是真实路径,只是枚举组合。暴力解法本质上是枚举“起点”和“终点”的全部配对组合,所以正确。
不过既然暴力能过,为什么还要学前缀和?因为树的深度一旦拉到接近上限,O(n^2) 就不稳定了。更关键的是,前缀和思路是这一类“连续段求和”问题的通用模板,学会它能直接迁移到其他题目。
3. 前缀和加回溯:把时间复杂度压缩到 O(n)
3.1 前缀和怎么和树扯上关系
先看数组版本:给定一个数组 nums 和整数 k,问有多少个连续子数组的和等于 k。如果依次求出前缀和 prefix[i],那子数组 [l, r] 的和就是 prefix[r] - prefix[l-1]。所以我们真正要找的是有多少对前缀和之差等于 k。用一个哈希表记录前缀和出现的次数,一次遍历就能完成。这就是 LeetCode 560 题“和为 K 的子数组”的标准解法。
树上的 DFS 天然形成一条“从根节点到当前节点”的链。链上的每个节点都对应一个前缀和,这个概念和数组里的前缀和完全一一对应。唯一的差别是:数组是线性的,从左往右扫一遍不会回头;树分叉之后,你会从左分支回到父节点,再进入右分支,所以必须把左分支里的前缀和从哈希表里撤销,否则右分支会误以为左分支的路径和自己在同一条链上。
3.2 核心公式
假设当前 DFS 访问到节点 node,current_sum表示从根节点到 node 这条链上所有节点值的累加和。以 node 作为路径终点、起点在这条链上某个位置的路径,如果路径和正好等于 targetSum,就等价于:
current_sum - old_target = targetSum移项得到:
old_target = current_sum - targetSum也就是说,只要哈希表里记录过 root 到当前节点这条路径上出现过的所有前缀和,那么current_sum - targetSum的出现次数,就等于以当前节点为终点的满足条件的路径条数。把所有节点作为终点时的满足路径数累加,就是整棵树的答案。
有两个边界必须严格注意:
- 哈希表初始要放
{0: 1},它表示“空路径”的前缀和是 0,用来统计从根节点出发的整条路径。 - 查询必须发生在把
current_sum写入哈希表之前,否则 targetSum 等于 0 时会把“当前节点到自己”这种长度为 0 的假路径也算进去。
3.3 为什么回溯必不可少
如果不做回溯,第一次 DFS 进入左分支时记录的前缀和,在返回父节点之后仍然留在哈希表里。当后续 DFS 走到右分支的某个节点时,它就会误以为左分支的前缀和和当前节点在同一个根到节点的链路上,从而组装出一条根本不存在、横跨左右分支的路径。这类错误最典型的表现就是最终答案偏大,而且树越复杂偏得越离谱。
所以,左右子树递归完成之后,必须把当前节点写入的那个前缀和计数减掉。如果减到 0,可以直接删除键,也可以保留值为 0 的键,查询时getOrDefault返回 0,不影响结果。我建议真正编码时删除键,这样调试打印哈希表时不会产生误导。
3.4 完整 Python 实现
from typing import Optional class Solution: def pathSum(self, root: Optional[TreeNode], targetSum: int) -> int: ans = 0 # 前缀和 -> 出现次数 # 初始 0 -> 1 代表空路径,用于统计从根出发的整条链 prefix_sums = {0: 1} def dfs(node: Optional[TreeNode], current_sum: int) -> None: nonlocal ans if not node: return # 每走到一个节点,就把节点值累加进当前前缀和 current_sum += node.val # 关键点:先查询,后写入哈希表 ans += prefix_sums.get(current_sum - targetSum, 0) # 把当前前缀和记录进去,供后续子节点查询 prefix_sums[current_sum] = prefix_sums.get(current_sum, 0) + 1 dfs(node.left, current_sum) dfs(node.right, current_sum) # 回溯:当前节点处理完,必须把计数减掉 prefix_sums[current_sum] -= 1 if prefix_sums[current_sum] == 0: del prefix_sums[current_sum] dfs(root, 0) return ans递归入口传入的current_sum是 0,根节点的值在第一次递归里累加,这样的设计是为了让prefix_sums里的{0: 1}能服务于根节点本身。如果你改成在调用前就把 root.val 传进去,逻辑会乱,不建议。
3.5 手动走一遍示例树
我用上面的代码,把示例树的访问顺序拉出来,只看对最终答案有贡献的节点:
| 访问节点 | 当前累加和 | 查询的旧前缀 | 命中次数 | 对应路径 |
|---|---|---|---|---|
| 10 | 10 | 2 | 0 | 无 |
| 5 | 15 | 7 | 0 | 无 |
| 3(5的左子) | 18 | 10 | 1 | 5 -> 3 |
| 3(3的左子) | 21 | 13 | 0 | 无 |
| -2 | 16 | 8 | 0 | 无 |
| 2 | 17 | 9 | 0 | 无 |
| 1 | 18 | 10 | 1 | 5 -> 2 -> 1 |
| -3 | 7 | -1 | 0 | 无 |
| 11 | 18 | 10 | 1 | -3 -> 11 |
三次命中,正好对应题目示例里的三条路径。注意,表中的 21、16、17 这些前缀和,在对应节点递归结束返回时就被删除了,所以访问 2、1 以及 -3、11 时,哈希表里不会残留左分支的旧前缀和。这就是回溯的现场效果。
4. 工程细节:查询顺序、负数节点、溢出处理
4.1 “先查询再更新”是命门
这一点值得单独拎出来说。当前节点进入函数后,先把 node.val 累加进 current_sum,然后立刻查询current_sum - targetSum。为什么必须这样?
如果先把 current_sum 写入哈希表再查询,当 targetSum 等于 0 时,查询current_sum - 0会把刚刚插入的 current_sum 也查出来,这就凭空多出一条“当前节点到自己”的长度为零的假路径。举个最极端的例子:单节点树 root = [0],targetSum = 0,正确答案应该是 1,因为节点 0 本身的值就是 0。顺序写反的话,你会得到 2。这个用例我在本地加进自测清单后,再也没犯过顺序错误。
4.2 哈希表计数必须用次数,不能用集合
有些同学觉得用 Set 记录“出现过的前缀和值”就够了。这个想法在节点值全为正数时碰巧能过,但只要路径里出现负数,同一个前缀和很可能在同一条路径上多次出现。
比如路径 1 -> -1 -> 0,targetSum = 0。手数一下满足条件的路径:[1, -1]、[1, -1, 0]、[0],一共 3 条。用 Set 记录前缀和时,因为前缀和 0 早就出现过,第二次、第三次需要重复计数的情况做不到,只能统计 2 条。原因就是 Set 只记录存在性,不记录次数。同一个前缀和出现多次,意味着有多个不同起点都能和当前终点组成满足条件的路径,所以必须用“前缀和 -> 次数”的哈希表。
4.3 数值溢出和语言差异
力扣的节点值范围是 -10^9 到 10^9,节点数最多 1000,因此 current_sum 完全可能达到 10^12 级别。Python 的 int 是任意精度,所以直接写没问题。Java 和 C++ 如果用 int 累积,就可能出现整数溢出,导致答案变成负数或者莫名其妙的大数。
面试用 Java 时,我建议 HashMap 的键和递归参数都用 long:
class Solution { private int target; private int ans; private Map<Long, Integer> prefixSums = new HashMap<>(); public int pathSum(TreeNode root, int targetSum) { this.target = targetSum; prefixSums.put(0L, 1); dfs(root, 0L); return ans; } private void dfs(TreeNode node, long currentSum) { if (node == null) return; currentSum += node.val; ans += prefixSums.getOrDefault(currentSum - target, 0); prefixSums.put(currentSum, prefixSums.getOrDefault(currentSum, 0) + 1); dfs(node.left, currentSum); dfs(node.right, currentSum); int count = prefixSums.get(currentSum) - 1; if (count == 0) { prefixSums.remove(currentSum); } else { prefixSums.put(currentSum, count); } } }Java 版本里我特意把回溯写成了“减到 0 就删除”的形态,避免哈希表里出现一堆值为 0 的键。
4.4 回溯计数减到 0 后的两种做法
有人在回溯时直接写成prefix_sums[current_sum] -= 1,但不删键。这样做不会出错,因为查询用的是getOrDefault,对于一个值为 0 的键,返回结果同样是 0。但问题在于调试时哈希表里残留很多值为 0 的键,你很难分辨哪些前缀和真的出现在当前路径上。我自己的习惯是减到 0 就del,这样每一个时刻哈希表里存活的键都精确对应当前这条链上出现过的有效前缀和,排查问题会清晰很多。
5. 变体题与面试答题模板
5.1 路径总和家族横向对比
| 题目 | 起点 | 终点 | 返回结果 | 核心技巧 |
|---|---|---|---|---|
| 112 路径总和 | 根节点 | 叶子 | 布尔值 | DFS 维护剩余值 |
| 113 路径总和 II | 根节点 | 叶子 | 所有路径列表 | DFS + 回溯保存路径 |
| 437 路径总和 III | 任意节点 | 任意节点(向下) | 路径数量 | 前缀和 + 回溯 + 哈希表 |
这个表把问题差异摆得很清楚。437 的难点并不在于递归本身,而在于“任意起点”把之前固定的根节点前缀信息变成了需要动态维护的集合。
5.2 从树到数组,一套模板通用
前缀和加哈希表模板并不只适用于树。数组场景下,LeetCode 560 题“和为 K 的子数组”就是一模一样的思路:
class Solution: def subarraySum(self, nums: List[int], k: int) -> int: prefix_counts = {0: 1} cur = 0 ans = 0 for num in nums: cur += num ans += prefix_counts.get(cur - k, 0) prefix_counts[cur] = prefix_counts.get(cur, 0) + 1 return ans对比 437 的代码,核心结构惊人一致:先累加,再查旧前缀,再写新前缀。数组版本不需要回溯,因为数组是线性顺序,不会出现分支串扰;树版本必须回溯,因为树有分支。理解和记住这个差异,你就同时掌握了两道题。
5.3 如果面试官要你输出具体路径怎么办
前缀和方案只统计数量,哈希表里并不保存某个前缀和对应的具体起点。如果要连路径一起输出,我建议回到 DFS 加回溯的思路:维护一个从当前根向下的节点列表,每次进入新节点时,从列表尾部倒着遍历,把每一段连续路径的和都算一遍,等于 targetSum 就记录这条路径。树深度最多 1000,倒序遍历的代价可以接受,而且代码逻辑非常直接。面对扩展题,这是一个靠谱的兜底方案。
5.4 面试里的推荐讲法
我建议按照“审题 -> 暴力 -> 前缀和优化 -> 编码 -> 复杂度”五步来讲:
- 先说明题目限制:任意起点、任意终点,但方向必须向下。把问题理解成统计若干条垂直向下的连续链段。
- 给出暴力思路:枚举起点,向下延伸,复杂度 O(n^2)。
- 指出链上的累计和可以用前缀和相减,树的 DFS 天然形成一条链。
- 说明哈希表存前缀和出现次数,配合回溯。
- 写代码时重点展示“先查询再更新”和“回溯删除”,最后报出时间 O(n)、空间 O(n)。
这种讲法既有层次,又能让面试官看到你从暴力到优化的完整思考过程。
6. 踩坑实录与自测清单
6.1 我实际踩过的三个坑
坑一:刚写前缀和解法时,忘了在递归结束后把 prefix_sums[current_sum] 减回去,结果答案偏大。后来我在代码里打印哈希表,发现左分支的数据一直泄漏到右分支,才明白回溯是树版本前缀和的核心,不是可有可无的修饰。
坑二:第一次用 Set 做哈希表,遇到带负数的用例,怎么跑都对不上暴力解。后来用1 -> -1 -> 0,targetSum = 0 这个用例手工推了一遍,才发现问题出在“存在性”和“次数”的区别上。从那以后我默认用字典记录次数,彻底不用 Set。
坑三:用 Java 实现时没注意溢出,构造一条长链测试用例时答案直接变成负数。把 HashMap 的键和递归参数都改成 long 之后才恢复正常。这个问题在本地测试不容易暴露,但面试官只要构造一组大数据,就会立刻发现。
6.2 本地自测清单
每次写完 437 的解法,我都会跑这几组用例:
- 空树:root = None,targetSum 任意,输出 0。
- 单节点等于 target:root = [1],targetSum = 1,输出 1。
- 单节点为 0:root = [0],targetSum = 0,输出 1。这组用例专门验证“先查询再更新”。
- 负数路径:构造 1 -> -1 -> 0 这样有正有负的链,验证哈希表计数的次数逻辑。
- 退化链表:1 -> 2 -> 3 -> 4,确认不会在时间复杂度上卡死。
- 题目示例树:targetSum = 8,验证输出 3。
如果你本地有暴力版本,可以写一个简单的对拍函数,随机生成二叉树,比较暴力解和前缀和解的结果。这是最稳的验证方式。
6.3 如果限制条件继续放宽
如果题目改成“任意起点、任意终点,且不再限制方向”,那这道题就从简单的 DFS 升级成了树上的路径统计问题,通常要用到奇偶深度或者更复杂的树上差分技巧。实际面试很少直接出到那一层,但理解“向下限制”如何让前缀和方案成立,能帮助你在遇到新题时更快判断能不能套用这个模板。
我把这道题吃透后,最大的感受是:不要急着背结论,而是先把暴力和前缀和两版都写一遍,再用示例树手动走一遍。只有当你亲眼看到从根出发的前缀和,和中间某一段路径建立起减法关系,后续遇到类似题目才真正有把握。网上很多模板只给结论,偏偏忽略了回溯这个树上前缀和和数组前缀和最大的区别。把这一点想明白,437 这道题就算真正过关了。