刷到热题100的第437题时,我一开始的想法很简单:这不就是在二叉树上数路径吗?结果动手一写才发现,这题和之前做过的“路径总和”、“路径总和II”完全不是一回事。路径总和判断的是“根节点到叶子节点”是否存在一条满足条件的路,路径总和II还要你把路径打印出来,而437题的路径起点和终点都是任意的,只要沿着父节点到子节点的方向往下走,任何一段连续路径都算数。这种“任意起点、任意终点”的小改动,直接把难度拉高了一档。
这篇文章我打算从最直观的暴力解法开始讲,一步步过渡到面试官真正想听的前缀和+哈希表解法,再把实现细节、边界条件和容易踩的坑全部过一遍。无论你是刚开始刷二叉树的新手,还是已经能把递归写得行云流水的老手,这篇文章应该都能给你一点新的思考角度。
1. 别被“路径总和”这个名字骗了:三道题的演进决定了这题的难度
1.1 从“根到叶子”到“任意向下路径”,题意发生了质变
力扣里“路径总和”系列一共有三道题,我建议你按顺序刷,因为它们的难度是递进的:
| 题目 | 起点 | 终点 | 要求 |
|---|---|---|---|
| 路径总和 | 根节点 | 叶子节点 | 判断是否存在 |
| 路径总和II | 根节点 | 叶子节点 | 返回所有满足条件的路径 |
| 路径总和III | 任意节点 | 任意后代节点 | 统计满足条件的路径数量 |
前两道的路径被限定得很死:必须从根出发,必须到叶子为止。所以它们的解法就是标准的先序遍历,走到叶子节点时判断累加和是否等于目标值。这里有个关键细节:由于题目没限制节点值和目标值的正负(实际上很多用例里含有负数),你不能在累加和等于目标值时提前返回,必须走完整个分支才能确定答案。
而437题的“任意起点”意味着什么?意味着树上的每一个节点,都有资格成为某条合法路径的起点。这样一来,原先那种“从根一路走到叶子”的单次遍历就不够了——你需要枚举所有起点,对每个起点再向下枚举所有终点。这才是437题真正的难点所在。
1.2 “任意起点”让暴力解法的时间复杂度直接翻倍
我画个极端例子你就明白了。如果一棵树是链状结构,比如每个节点只有右孩子,那么这棵树退化成一条长度为n的链表。此时从第1个节点出发,有n-1个可选终点;从第2个节点出发,有n-2个可选终点……总路径数量是1+2+...+n,也就是O(n^2)量级。即便是一棵平衡二叉树,起点数有n个,每个起点向下延伸的平均深度也只有O(log n)左右,总工作量是O(n log n)。
这就是暴力和优化的分水岭:暴力解法我们当然要会写,它能帮助你验证思路、跑通测试用例,但面试时如果只给出暴力解法,面试官基本上会接着问一句“能不能优化到O(n)”。所以接下来的思路是:先写出暴力,再理解为什么暴力慢,最后搞清楚前缀和是怎么把复杂度降下来的。
2. 暴力解法:双重DFS,先把“能过”的方案写出来
2.1 核心思路:外层枚举起点,内层向下累加
暴力解法的思路非常直接,分成两层递归:
- 外层递归负责枚举路径的起点。以当前节点为起点时,调用一个内部函数,从这个起点出发向下累加。
- 内层递归负责在固定起点的情况下,向下扩展路径,每走到一个节点就判断“从起点到当前节点的路径和”是否等于目标值。如果等于,计数加1。
这里有一个容易和前面几题混淆的点:内层递归中,即使累加和已经等于目标值了,也不能停止向下遍历。比如目标值是5,一条路径是5 -> 0 -> 0,累加和第一次到达5时如果直接返回,就会漏掉后面两个同样是5的终点。又比如10 -> -5,在10这个节点累加和还没到5,但加上后面的-5后刚好等于5。所以内层遍历必须走到叶子节点为止。
写成Java代码就是下面这样:
class Solution { public int pathSum(TreeNode root, int targetSum) { if (root == null) { return 0; } // 以当前节点为起点搜一遍 + 在左子树里继续枚举起点 + 在右子树里继续枚举起点 return rootSum(root, targetSum) + pathSum(root.left, targetSum) + pathSum(root.right, targetSum); } private int rootSum(TreeNode node, long targetSum) { if (node == null) { return 0; } int count = 0; if (node.val == targetSum) { count++; } // 注意这里不能用 node.val == targetSum 就返回 count += rootSum(node.left, targetSum - node.val); count += rootSum(node.right, targetSum - node.val); return count; } }Python版本也顺手贴出来,逻辑完全一致:
class Solution: def pathSum(self, root: Optional[TreeNode], targetSum: int) -> int: if root is None: return 0 return self.root_sum(root, targetSum) \ + self.pathSum(root.left, targetSum) \ + self.pathSum(root.right, targetSum) def root_sum(self, node: Optional[TreeNode], target_sum: int) -> int: if node is None: return 0 count = 1 if node.val == target_sum else 0 count += self.root_sum(node.left, target_sum - node.val) count += self.root_sum(node.right, target_sum - node.val) return count2.2 为什么内层递归用 targetSum - node.val 这种写法
很多同学第一次写内层递归时,会习惯性地维护一个curSum,每走到一个节点就curSum += node.val,然后判断curSum == targetSum。这种写法没问题,但有一种更简洁的等价写法:不要累加当前和,而是把目标值不断减去节点值。如果某个节点值等于剩余目标值,说明从起点到这里的路径和正好等于原始目标值。
用targetSum - node.val的好处是省去了一个“当前累计和”变量,代码更干净,也更容易看出递归的数学含义:每往下走一步,需要凑的差值就减少相应节点值。不过如果为了和后面前缀和解法保持一致,你也可以在内部维护一个curSum,没有本质区别。
2.3 暴力的复杂度,以及为什么OJ能过但面试可能会被追问
暴力解法在最坏情况下时间复杂度是O(n^2),其中n是节点总数。空间复杂度是O(n),主要消耗在递归调用栈上——当树退化成链时,递归深度最大可以达到n。
在热题100的测试数据里,暴力解法其实是可以提交通过的,毕竟题目限定的数据规模不算特别夸张。但如果你在面试中写出了这个版本,最好主动把复杂度分析说清楚,然后告诉面试官:这个解法能过是因为数据范围允许,但理论上还可以优化到O(n),用前缀和+哈希表来做。展现出这种“我会暴力,但我知道怎么优化”的状态,往往比只会背最优解更能加分。
3. 前缀和登场:把树上任意向下路径变成两个前缀和的差
3.1 先回顾一维数组的最经典做法:和为K的子数组
要理解437的最优解,绕不开一道更简单的题:给一个整数数组和一个目标值K,统计有多少个连续子数组的和等于K。这题的暴力做法是枚举所有子数组,O(n^2)。但有一个经典优化:
定义前缀和pre[i]表示数组前i个元素的和。那么从第i+1个元素到第j个元素的连续子数组和等于pre[j] - pre[i]。要找和为K的子数组,就是找满足pre[j] - pre[i] = K的(i, j)对,也就是pre[i] = pre[j] - K。
具体实现时,用一个哈希表记录“当前已经出现过哪些前缀和、各出现多少次”。从头扫描数组,每次遇到pre[j],就去哈希表里查pre[j] - K出现了多少次,这个次数就是以j结尾、且和为K的连续子数组个数。
3.2 把树的路径对齐成前缀和之差
树结构的路径本质上也是一个“连续区间”——只不过区间是从某个祖先节点延伸到某个后代节点。如果我们维护一个变量curSum,表示从根节点到当前遍历到的节点的路径和,那么树上任意一条向下路径的和,都可以用两个这样的前缀和相减得到。
假设路径的起点是节点p,终点是当前节点node,那么路径和等于curSum(node) - curSum(parent(p))。其中parent(p)表示p的父节点。如果p本身就是根节点,那么parent(p)为空,相当于前缀和为0的空路径。也就是说:要判断从某个祖先p到当前节点node的路径和是否等于targetSum,只需要检查curSum(parent(p))是否等于curSum(node) - targetSum。
这听起来有点绕,但本质和一维数组完全一样。数组里是“前i个元素和”与“前j个元素和”之差;树里是“根到某个祖先的父节点”与“根到当前节点”的前缀和之差。两者的关键都落在:想办法快速查出所有满足条件的前缀和。
3.3 为什么树上需要“回溯”,而数组不需要
数组是线性结构,从左到右扫过去,每个前缀和全局共用,所有历史前缀和都可以保留。但树是分叉结构,在左子树里积累的前缀和记录,不能带进右子树。例如根节点有两个子节点,进入右子树时,如果哈希表里还留着左子树某个节点的前缀和,那么在查询右子树里的路径时,可能会错误匹配到左子树的节点,计算出根本不存在的路径。
所以,树上应用前缀和套路时,必须在递归返回的时候撤销状态。这就是“回溯”出现在这里的根本原因。你不需要背诵“这道题要回溯”,只要想清楚“左右子树的前缀和记录必须互相隔离”,自然就会在递归结束后删除自己添加的记录。一旦理解了这一点,代码基本不会写错。
4. 回溯+哈希表的完整实现:正确性、代码与三个必踩的坑
4.1 先查再更新,然后遍历子树,最后回溯
前缀和+哈希表的实现核心就一句话:每到一个节点,先查“以当前节点为终点的合法路径有多少条”,再把当前节点的前缀和记录进哈希表,遍历完左右子树后删除该记录。
完整Java代码如下:
class Solution { private Map<Long, Integer> prefixMap = new HashMap<>(); private long targetSum; public int pathSum(TreeNode root, int targetSum) { this.targetSum = targetSum; // 这个0非常关键,代表“空节点”的前缀和 prefixMap.put(0L, 1); return dfs(root, 0L); } private int dfs(TreeNode node, long curSum) { if (node == null) { return 0; } curSum += node.val; // 先查:以当前节点为终点,起点上方的前缀和需要等于 curSum - targetSum int count = prefixMap.getOrDefault(curSum - targetSum, 0); // 再更新:把当前前缀和记录下来 prefixMap.put(curSum, prefixMap.getOrDefault(curSum, 0) + 1); count += dfs(node.left, curSum); count += dfs(node.right, curSum); // 回溯:撤销当前节点对后续兄弟子树的影响 prefixMap.put(curSum, prefixMap.get(curSum) - 1); return count; } }Python版本:
from collections import defaultdict class Solution: def pathSum(self, root: Optional[TreeNode], targetSum: int) -> int: prefix = defaultdict(int) prefix[0] = 1 self.target_sum = targetSum self.ans = 0 def dfs(node: Optional[TreeNode], cur_sum: int) -> None: if node is None: return cur_sum += node.val self.ans += prefix[cur_sum - self.target_sum] prefix[cur_sum] += 1 dfs(node.left, cur_sum) dfs(node.right, cur_sum) prefix[cur_sum] -= 1 dfs(root, 0) return self.ans代码量很少,但正确性推理值得展开说说:假设当前节点是node,curSum是根到node的路径和。任何一条以node为终点的合法路径,都对应着一个起点p,使得curSum(node) - curSum(parent(p)) == targetSum,也就是curSum(parent(p)) == curSum(node) - targetSum。到达node时,哈希表里存储的是“根到node这条路径上所有节点(包括根节点的虚拟父节点)的前缀和出现次数”,所以prefixMap[curSum - targetSum]的值,就是所有以node为终点、且路径和等于targetSum的路径数量。因为每条路径只有一个终点,按终点分类统计,每个路径恰好被计入一次,不会重复也不会遗漏。
4.2 三个必踩的坑
第一个坑是最经典的:哈希表里漏了put(0L, 1)。这个初始键值代表“根节点之前的空路径前缀和”。如果没有它,所有从根节点出发的合法路径都会被漏掉。举个例子,一棵树只有一个根节点,值等于targetSum,没有0 -> 1这个初始记录,查询时prefixMap[curSum - targetSum]查到的是prefixMap[0],结果是0,正确答案1就没了。新手很容易在这里翻车。
第二个坑是更新顺序。必须先查再更新,不能先更新再查。如果targetSum恰好等于0,先更新的话,当前节点的前缀和curSum被记录下来,紧接着查询prefixMap[curSum - 0]就会把自己刚加入的记录也算进去,导致多计一条“从当前节点到当前节点且和为0”的路径。这看起来像是正确的(单个节点路径和确实可以是0,但前提是节点值本身为0,而不是用curSum去凑),实际上会造成系统性错误。
第三个坑是忘记回溯。很多同学写递归时记得进入子树前更新状态,却忘了递归返回后恢复状态。这导致左子树的前缀和记录污染右子树的查询结果。我自己的习惯是:写完递归调用之后立刻检查一遍,看有没有需要“撤销”的操作,宁可先写一个prefixMap.put(curSum, prefixMap.get(curSum) - 1)也不要在出问题时再回来补。
4.3 为什么用long而不是int
题目里单节点值范围看似安全,但路径和是沿途所有节点值的累加。当树高较大、节点值全是负数或全是正数时,累加和很容易超出int范围,在做减法时还可能带来溢出异常。所以无论是暴力版本还是前缀和版本,建议直接把前缀和类型声明为long。这在面试中也是一个能体现经验的细节。
5. 边界用例与复杂度:从“能过”到“所有情况都对”
5.1 这些测试用例一定要自己过一遍
写完之后先别急着提交,手推几个边界场景:
- 空树:root为null,路径数量为0。前缀和解法里
dfs(root, 0)直接返回0。 - 单节点树:节点值为targetSum,答案是1;否则是0。
- targetSum为0的树:这最容易出错。如果每个节点的值不为0,但路径上正负值相消,也能凑出很多路径。此时前缀和里key
curSum和curSum - 0相等,尤其要注意查询顺序。 - 全负数节点:同样能构成合法路径。暴力解法中,不能因为当前累加和已经小于目标值就剪枝,负数会继续拉低累加和。
我自己常用的验证方式是构造一个结构简单的树,用暴力法和前缀和法同时跑,比对结果。比如下面这棵树,targetSum=1:
1 / \ 2 -3 / \ 3 1手数答案:路径1(根节点本身)1条,路径2 -> 1(第二条路径,累加和3不对),路径1(右子树叶子节点本身)1条,路径-3 -> 1(根到右子树叶子,累加和-2不对),路径1 -> 2 -> 3(累加和6不对),路径2 -> 3(5不对),路径1(左子树右叶子)1条。正确答案是2。这个用例能帮你同时检查暴力递归和前缀和逻辑。
5.2 复杂度细节对比
前缀和解法每个节点只会被访问一次,每次访问只做常数次哈希表操作,时间复杂度是O(n)。空间复杂度主要由递归栈深度和哈希表大小决定,最坏情况下(树退化成链)递归深度O(n),哈希表存了n个不同前缀和,总共O(n)。平衡树情况下递归深度O(log n),哈希表长度还是O(n),总体依然是O(n)空间。对比一下暴力解法:
| 解法 | 时间复杂度 | 空间复杂度 | 适用性 |
|---|---|---|---|
| 双重DFS | 最坏O(n^2),平衡树O(n log n) | O(n) | 数据规模小,思路直白 |
| 前缀和+哈希表 | O(n) | O(n) | 标准最优解,面试推荐 |
可以看出,前缀和解法不仅在时间上有优势,代码结构也足够清晰,面试时优先写这一版更稳妥。
5.3 从“过用例”到“过边界”的检查顺序
我平时提交前会按这个顺序自我检查:先检查空树;再检查根节点单节点;然后构造一条链状树,验证递归深度不会爆栈;最后构造一个含有正负数且目标值为0的树,验证查询顺序和回溯逻辑。特别是目标值为0的情况,用三个节点值为1, -1, 1的链状树手算一遍,能迅速暴露“先更新后查询”的错误。
6. 举一反三:前缀和+哈希表还能解决哪些同类题
6.1 回顾经典变式:一维连续子数组求和
前缀和+哈希表最原始的形态就是“和为K的连续子数组数量”。把树的路径问题理解成一维问题的“树上版本”之后,你会发现两道题的配对数表几乎一模一样,唯一区别就是树遍历需要回溯,数组遍历不需要。如果拿这两道题一起刷,收获会非常大。刷题时遇到“连续子数组”、“连续子序列”、“向下路径”这类字眼,先想想能不能用前缀和套路。
6.2 如果题目改成“打印所有满足条件的路径”怎么办
有些公司面试会在437基础上加一个要求:不仅要统计数量,还要把所有路径打印出来。这时候前缀和哈希表就有点力不从心了,因为哈希表只存次数不存具体位置。一个可行方案退回到DFS:维护一条“从根到当前节点”的路径列表,每进入一个节点,从当前路径的末尾向前遍历,枚举所有以当前节点为终点的子路径,满足条件就记录。由于要输出路径本身,复杂度至少是O(n^2)量级,因为输出数量本身可能就有这么多,这和统计数量问题有本质区别。在面试中遇到这种扩展,一定要先说清楚“如果需要输出具体路径,复杂度无法避免地会变高”。
6.3 二维矩阵前缀和也是同一套思想
再往外延伸一点,二维矩阵中求子矩阵和为target的问题,也可以用二维前缀和预处理,再对每一行区间枚举列边界,配合哈希表降到O(m * n^2)或更好。这个思路和一维情形一脉相承,只是细节更多。真正理解“前缀和之差 = 区间和”这个等式后,从数组到树再到矩阵,都是同一套底层逻辑。
6.4 我建议的练习顺序
不要一上来就背最优解。先按“暴力DFS -> 发现复杂度问题 -> 推前缀和思路 -> 手写前缀和代码 -> 验证边界”的顺序走一遍。这个过程走下来,你会对递归、回溯、状态管理有很具体的体感,而不是停留在背模板。等遇到其他变形题时,至少知道往哪个方向想。
我个人在写这类题时还有一个习惯:在纸上把树画出来,把每个节点的“根到该节点的前缀和”标在旁边。标完之后你会发现,任意两个节点之间的路径和,就是它们前缀和之差,整棵树的信息一下就清晰了。437这道题之所以能成为热题100里的常客,就是因为它把“前缀和、哈希表、回溯、递归”四个高频考点全揉在了一起,一道题吃透,相当于同时复习了二叉树和数组两类经典套路。