☰
路径总和III最优解:前缀和+哈希表一次遍历降复杂度至O(n)
2026/10/1 23:10:14 网站建设 项目流程

1. 从“路径总和”到“路径总和III”:先搞清楚题目边界

题目编号437,全名叫“路径总和III”,英文是Path Sum III。很多刷过LeetCode的朋友看到这个题号会下意识觉得它只是前两题的升级版,但实际上它和前两题的思路差异非常大。Path Sum I是判断根节点到叶子节点是否存在一条路径,使路径总和等于targetSum;Path Sum II是收集所有从根到叶子满足条件的路径。而437这道题的问法变成了“路径总和等于给定值的路径总数”,并且路径起点和终点都不再受限。

先看一次题干里最关键的一句:路径不需要从根节点开始,也不需要在叶子节点结束。这句话意味着什么?意味着我们在遍历过程中不能只沿着“根到叶子”这条固定线去看,任何一段向下的连续节点序列,只要它们的值加起来等于targetSum,就算一条有效路径。同时题目还给出了两个隐含约束:路径方向必须向下,即从父节点指向子节点;二叉树节点的值可能为负数。

我见过不少人第一次做这道题时,第一反应是“那我先找到所有从根到叶子的路径,然后对每条路径做滑窗”。这个思路本身没有错,但如果树的深度比较大,路径数量会呈指数增长,把所有路径先枚举出来再滑动窗口,时间和空间都很不划算。后文我会详细讲为什么前缀和是更优的方向,但在此之前需要把题目的边界彻底想清楚,否则后面写代码很容易出现“差一个节点”“重复计数”之类的错误。

先明确三个“不一定”。路径的起点不一定是根节点,路径的重点不一定是叶子节点,路径的总和只是“恰好等于targetSum”而不是“最大”或“最小”。此外,题目还特别说明了“路径方向必须向下”,所以路径必须沿着父节点到子节点的方向延伸,不能出现从子节点往父节点回溯后再折向另一条分支的情况。这个限制实际上让问题简单了很多,也让我们可以用深度优先遍历配合前缀和来求解。

还有一个小细节值得注意:二叉树的节点值范围在-1000到1000之间,目标值targetSum的范围则更宽,可能是负数也可能是正数。负数的存在意味着我们不能简单地“一看到当前和大于targetSum就剪枝”,因为后面加上负数可能让总和重新回到目标值。这一点也是很多人在写递归时容易踩的坑,后面我们会在代码中专门处理。

把题目边界梳理清楚之后,再来看两类解法。第一类是暴力DFS套DFS,也叫双重递归,思路简单直接,适合作为面试时的第一种回答;第二类是前缀和加哈希表,一次遍历就能解决问题,时间复杂度从O(n²)降到O(n),是这道题的推荐解法。我会先把暴力解法的思维过程讲透,再推导前缀和解法的来龙去脉,这样你不仅能写出代码,还能说出每个步骤背后的理由。

2. 双重递归先解题:从每个节点出发重新求和

2.1 暴力递归的思路拆解

暴力解法的核心思想是:把每个节点都当成一条路径的起点,然后从这个起点出发向下遍历,每走到一个节点就累加一次,只要累加值等于targetSum就把计数加一。因为起点可以是任意节点,所以需要对每个节点都执行一遍这样的“向下统计”过程。

整个逻辑可以拆成两个递归函数。外层递归负责遍历整棵树,把每个节点轮流当作起点;内层递归负责从指定的起点出发,沿着子节点方向往下走,随时累加并判断是否等于targetSum。外层递归就是我们熟悉的二叉树先序遍历,每访问一个节点就调用一次内层统计函数,然后再递归处理它的左子树和右子树。

这里有一个容易迷糊的地方:内层递归做了几次“起始节点到当前节点的路径值比较”,但内层递归并不限制终点。换句话说,从起点A走到节点B时,如果累加和等于targetSum,则计一次数;继续从B往下走到C,如果累加和又等于targetSum,则再计一次数。这两次计数代表的是两条不同路径,一条是A到B,另一条是A到C,合法且独立。

举个最简单的例子,假设有一条链1 -> 2 -> 3,targetSum为3。外层递归先以根节点1为起点,内层递归依次计算路径[1]、[1,2]、[1,2,3],发现[1,2]的和等于3,计数加一;然后外层递归以节点2为起点,内层递归计算[2]、[2,3],发现[2,3]的和等于5,不匹配;最后外层递归以节点3为起点,内层递归计算[3],匹配,计数加一。最终结果是2。

2.2 代码实现与复杂度瓶颈

如果用Java写,双重递归大概是这个样子:

class Solution { private int count = 0; public int pathSum(TreeNode root, int targetSum) { if (root == null) { return 0; } // 以当前节点为起点,统计符合条件的路径 dfs(root, targetSum, 0); // 递归处理左右子树 pathSum(root.left, targetSum); pathSum(root.right, targetSum); return count; } private void dfs(TreeNode node, int targetSum, long currentSum) { if (node == null) { return; } currentSum += node.val; if (currentSum == targetSum) { count++; } dfs(node.left, targetSum, currentSum); dfs(node.right, targetSum, currentSum); } }

这段代码看起来清晰,但它的复杂度隐含在两层递归嵌套里。假设二叉树有n个节点,外层需要对每个节点都做一次完整的内层遍历。对于一棵比较平衡的树,每个节点向下遍历的代价大约与其高度相关,总体复杂度大约为O(n log n);但对于一条链表形状的树,即每个节点只有一个子节点时,外层每走一步,内层都要遍历剩余的所有节点,整体复杂度就退化为O(n²)。

如果你说复杂度高一点也没关系,数据量小时确实没关系,LeetCode的测试用例里树节点数最多是1000,所以双重递归在最坏情况下是O(10^6)级别的计算量,现代机器跑下来也就几毫秒,OJ完全可以接受。但面试官通常不会满足于这个答案,紧接着一定会追问一句:“能不能把复杂度降到O(n)?”这就是我们接下来要讨论的,用前缀和把“每个起点单独算”变成“一次遍历共享历史信息”。

双重递归还有一个局部问题:currentSum需要用long类型保存。因为节点值可能是负数,总和可能超出int范围吗?实际上这里路径长度最多1000,每个节点值绝对值不超过1000,理论上累加范围在-1,000,000到1,000,000之间,int类型其实也不会溢出,但后面用前缀和做减法时,currentPrefixSum - targetSum的过程中可能出现中间差值超过int范围的情况吗?同样很小,但为了稳妥,用long是更安全的习惯。

3. 前缀和思路:把“路径能否成段”变成“历史累积量的差”

3.1 从一个一维数组的问题说起

要理解树上的前缀和,先想一个更简单的问题:给定一个整数数组nums和一个目标值k,请找出所有和为k的连续子数组的个数。这个问题你应该不陌生,经典的解法就是用前缀和配合哈希表。

前缀和定义为从数组开头累加到当前位置的总和,记作prefixSum[i]。那么任意连续子数组nums[j...i]的和就可以表示为prefixSum[i] - prefixSum[j-1]。我们想知道这个差值是否等于k,等价于判断在当前位置i之前,是否存在某个历史前缀和prefixSum[j-1],使得prefixSum[j-1] = prefixSum[i] - k。如果用哈希表记录每个历史前缀和出现的次数,那么每走到一个位置,只需在O(1)时间内查一次表,就能知道以当前位置结尾的满足条件的子数组有多少个。

这就是经典的“和为K的子数组”解法。回到树上,二叉树的路径本质上就是一个“从某个祖先节点到某个后代节点”的连续序列,它和一维数组里的连续子数组非常相似。区别在于数组的顺序是线性的,而树有分支,路径会沿着不同分支分开。但只要方向始终向下,我们可以用深度优先遍历模拟“从左到右”的顺序。

3.2 把树的路径翻译成前缀和的语言

先选定一个起点:根节点。定义一个变量currentSum,记录从根节点到当前遍历节点的路径上所有节点值的总和。走到任意节点node时,currentSum的值就是“根到node”的前缀和。

现在考虑任意一段路径,假设它的起点是某个祖先节点ancestor,终点是当前节点node。这段路径的和如何用前缀和表示?很简单:把“根到node”的总和,减去“根到ancestor的父节点”的总和。为什么要减到父节点而不是减到ancestor?因为路径包含ancestor本身的节点值,所以需要刨除掉ancestor之上的部分,也就是从根到ancestor的父节点结束。

更形式化一点:如果ancestor的父节点是parent,那么这段路径和 =prefixSum[node] - prefixSum[parent]。如果我们直接在遍历时用currentSum - targetSum去哈希表里查找,那么找到的其实是“某个历史前缀和等于currentSum - targetSum”的次数,而每个这样的历史前缀和恰好对应一个满足条件的路径起点。

这个思想对比双重递归就能看出变化。双重递归是“每个起点一次旅行”,前缀和是“记录所有历史前缀和,在走到每个节点时一次性回答所有以该节点为终点的路径”。前者重复计算了大量公共路径,后者通过共享前缀和消除了重复。

3.3 为什么哈希表里存的是“次数”不是“索引”

一维数组的解法中,哈希表的键是前缀和,值是该前缀和出现的次数。树上同样如此。因为题目只要求输出路径总数,并不要求列出具体路径,所以我们只需知道“有多少个历史位置的前缀和等于某个值”,而无须关心确切位置。如果题目改成“输出所有满足条件的路径”,哈希表里就得存索引列表了,但这次不需要。

这里我再强调一个容易理解偏的点:哈希表里存的前缀和包括哪些节点?答案是“从根节点出发,到当前节点之前的某个节点”的路径前缀和。之所以要包含“到当前节点之前的”,是因为我们在每到达一个新节点时,应该把当前节点本身纳入到currentSum里,再去查历史前缀和。这样才能保证路径长度的最小情况,也就是单节点路径node本身,也能被正确统计。具体来说,当currentSum更新后等于targetSum时,我们需要在哈希表中找到currentSum - targetSum = 0,因此哈希表里必须预先放入{0: 1},代表空路径前缀和,否则单节点路径就会被漏掉。

可以这么记忆:哈希表的初始值里一定要有map.put(0, 1),这个1代表“根节点之前的空前缀”。不管树的结构怎么变,这个初始化都不能省。

4. 深度优先遍历中的动态维护:哈希表怎么配合递归

4.1 一次遍历里的完整流程

采用前缀和方法时,我们只需要一次深度优先遍历。每进入一个节点,做四件事:

  1. 更新currentSum,加上当前节点的值;
  2. 计算need = currentSum - targetSum,在哈希表中查找need出现的次数,把次数累加到答案中;
  3. 把currentSum在哈希表中的计数加一;
  4. 递归处理左子树和右子树;
  5. 递归返回后,把currentSum在哈希表中的计数减一,恢复现场。

第5步是整个算法的灵魂,它保证了我们在遍历左子树时统计的前缀和不会“泄露”到右子树。因为树的分支是独立的,一条路径不能跨越两个不同的分支,所以在从一个分支回到父节点、再去另一个分支之前,必须撤销当前节点对哈希表的影响。

举一个具体的例子。假设目标值是8,根节点值为5,左子节点值为3。遍历过程:

  • 根节点:currentSum = 5,need = 5 - 8 = -3,哈希表里目前只有{0:1},所以查不到;然后更新哈希表{0:1, 5:1}。
  • 左子节点:currentSum = 5 + 3 = 8,need = 8 - 8 = 0,哈希表里0出现1次,答案加一;更新哈希表{0:1, 5:1, 8:1}。

这1次计数对应的路径就是根节点5 -> 左节点3这一段,因为currentSum - 0 = 8,而0对应的是“根节点之前”的空前缀。

4.2 为什么查询发生在“更新currentSum之后”而不是之前

我们必须在currentSum包含当前节点后再查询。因为每一条路径的终点都是当前节点,路径的和要包含当前节点的值。如果先查询再更新,等于把当前节点排除在所有候选路径之外,单节点路径以及以当前节点结尾的所有路径都会被漏掉。

整个过程和数组前缀和的顺序完全一致:先计算prefixSum[i],再查历史前缀和,最后把prefixSum[i]放入哈希表。树的遍历只是把“从左到右”的顺序换成了“深度优先”,但每个节点处的逻辑顺序保持不变。

4.3 时间复杂度为什么能降到O(n)

每个节点在DFS过程中只会被访问一次,访问时哈希表的插入和查询都是均摊O(1)操作,因此总体复杂度为O(n)。空间复杂度方面,递归栈的深度在最坏情况下为O(n),哈希表的大小也不会超过路径上节点数,因此总体空间复杂度为O(n)。相比暴力解法最坏O(n²)的时间复杂度,前缀和方法有本质提升,这也是面试官最希望听到的答案。

可以这样理解:暴力解法中,节点x到它下面某个后代节点y的路径被反复计算了多次,因为每次外层递归换一个起点,路径都会被重新累加。而在前缀和法中,每个节点只需要计算一次“根到该节点的总前缀和”,任意路径和都能通过一次减法得到,计算被复用了。

5. 回溯时的计数恢复:最容易出Bug的环节

5.1 如果不恢复现场会发生什么

网上很多代码看起来差不多,但运行结果却不对,十有八九是回溯时忘了把哈希表里的计数减回去。我们来看一下不恢复的后果。

假设一棵树有三个节点:根节点值5,左子节点值3,右子节点值1,targetSum = 8。遍历过程:

  • 进入根节点:currentSum = 5,更新哈希表{0:1, 5:1}。
  • 递归进入左子节点:currentSum = 8,need = 0,答案加一,更新哈希表{0:1, 5:1, 8:1}。
  • 左子节点递归返回。此时如果不恢复现场,哈希表仍然是{0:1, 5:1, 8:1}。
  • 递归进入右子节点:currentSum = 5 + 1 = 6,need = 6 - 8 = -2,查不到。更新哈希表为{0:1, 5:1, 8:1, 6:1}。

看起来似乎没什么问题?但如果右子树更深,问题就会出现。假设右子节点下面还有一个右孙子节点值2。继续遍历:

  • 进入右孙子节点:currentSum = 5 + 1 + 2 = 8,need = 0,此时哈希表里0出现1次,答案加一。这条路径“根5 -> 右1 -> 右孙子2”确实是有效路径,没问题。
  • 但如果右孙子节点下面还有一个右曾孙节点值-2,targetSum仍为8。继续遍历:
    • currentSum = 5 + 1 + 2 - 2 = 6,need = 6 - 8 = -2,哈希表里-2没有记录,查不到。

到这里好像也没有出错。真正出错的情况出现在“跨分支复用”的场景。考虑另一种树:根节点值5,左子树路径中产生过前缀和3,右子树中存在一个节点,它的currentSum - targetSum恰好也等于3。如果不恢复现场,左子树记录下的前缀和3就会在右子树中被误查,导致我们错误地认为“存在一条从左子树某个祖先出发到右子树某个节点的路径”。但二叉树路径方向只能向下,一条路径不可能从左子树跨到右子树,这种计数就是错的。

所以恢复现场的本质是:保证哈希表里存放的永远是“从当前路径的根到当前节点的父节点”这些前缀和,不掺杂已遍历过的其他分支的历史。

5.2 恢复的正确写法与时机

恢复操作放在递归返回之后,也就是处理完左子树和右子树之后,再执行哈希表的减一操作。顺序必须是先减,再返回到上一层。Java代码中对应的结构是:

map.put(currentSum, map.getOrDefault(currentSum, 0) + 1); dfs(node.left, ...); dfs(node.right, ...); map.put(currentSum, map.getOrDefault(currentSum, 0) - 1); if (map.get(currentSum) == 0) { map.remove(currentSum); }

最后两步可以合并成一步:把计数减一后,如果值为0,就移除这个键。这不仅能节省空间,也能避免值为0的键对后续查询产生干扰,虽然值为0时查询结果也只是0,但保留一个键值对会让哈希表变得臃肿。我习惯写成一个统一的后处理函数,方便代码复用。

这里有另一个小细节:currentSum在递归过程中是作为函数参数传递的,所以每次进入子节点时,子节点拿到的是更新后的值,而当前层的currentSum不会因为子节点的修改而变化。这也是为什么我们能在递归返回后依然使用当前层的currentSum值去做恢复操作。如果用成员变量或者引用类型来保存currentSum,恢复操作就会出现复杂的加减问题,所以最好用基本类型参数传递。

5.3 恢复操作的时间复杂度影响

每次恢复只是哈希表的O(1)操作,总的额外开销也是O(n),完全可以接受。相比暴力解法节省下来的O(n²)计算量,这几次哈希表操作的代价几乎可以忽略不计。

6. 完整代码实现与复杂度分析

6.1 Java版本

import java.util.HashMap; import java.util.Map; class TreeNode { int val; TreeNode left; TreeNode right; TreeNode() {} TreeNode(int val) { this.val = val; } TreeNode(int val, TreeNode left, TreeNode right) { this.val = val; this.left = left; this.right = right; } } class Solution { private int count = 0; public int pathSum(TreeNode root, int targetSum) { Map<Long, Integer> prefixSumMap = new HashMap<>(); // 空前缀,表示从根节点到根节点之前的位置 prefixSumMap.put(0L, 1); dfs(root, targetSum, 0L, prefixSumMap); return count; } private void dfs(TreeNode node, int targetSum, long currentSum, Map<Long, Integer> map) { if (node == null) { return; } // 1. 更新当前路径前缀和 currentSum += node.val; // 2. 查找历史前缀和,统计有效路径 long need = currentSum - targetSum; count += map.getOrDefault(need, 0); // 3. 记录当前前缀和 map.put(currentSum, map.getOrDefault(currentSum, 0) + 1); // 4. 递归处理左右子树 dfs(node.left, targetSum, currentSum, map); dfs(node.right, targetSum, currentSum, map); // 5. 回溯,撤销当前节点的影响 map.put(currentSum, map.getOrDefault(currentSum, 0) - 1); if (map.get(currentSum) == 0) { map.remove(currentSum); } } }

代码中需要注意的一点是,prefixSumMap的键类型我用了Long而不是Integer。虽然节点值范围不大,但currentSum - targetSum时如果中间过程产生超出int范围的中间值,用long会更稳妥。虽然这道题的数据范围用int也不会溢出,但刷题时养成使用long的习惯,遇到类似更宽范围的题就不用再改。

6.2 Python版本

class TreeNode: def __init__(self, val=0, left=None, right=None): self.val = val self.left = left self.right = right class Solution: def pathSum(self, root: TreeNode, targetSum: int) -> int: self.count = 0 prefix_sum_map = {0: 1} def dfs(node: TreeNode, current_sum: int) -> None: if not node: return current_sum += node.val need = current_sum - targetSum self.count += prefix_sum_map.get(need, 0) prefix_sum_map[current_sum] = prefix_sum_map.get(current_sum, 0) + 1 dfs(node.left, current_sum) dfs(node.right, current_sum) prefix_sum_map[current_sum] -= 1 if prefix_sum_map[current_sum] == 0: del prefix_sum_map[current_sum] dfs(root, 0) return self.count

Python版本里有一点需要注意:prefix_sum_map是以字典形式存在的,递归函数内部直接修改它时,闭包机制能保证修改发生在同一个字典上,不需要声明nonlocal。因为我们对字典的修改是原地操作,不涉及重新绑定变量名,所以不用加nonlocal。但如果某段代码意外写了prefix_sum_map = {...}重新赋值,Python会认为这是一个新变量,需要使用nonlocal声明,这一点我在初学时踩过坑。

6.3 两种复杂度的对比

解法时间复杂度空间复杂度适用场景
双重递归O(n²),最坏情况下树退化为链表O(n),递归调用栈节点数较少,面试时的暴力解法铺垫
前缀和 + 哈希表O(n),每个节点仅访问一次O(n),哈希表存储前缀和大规模数据,面试期望的最优解

如果是面试场景,我通常先讲暴力解法,用一两分钟说清楚思路,然后立刻提出前缀和优化。这样既展示了基础功底,也展示了优化意识。实际编码过程中,前缀和版本的核心逻辑不过十几行,面试官看到你熟练地写出回溯恢复,一般就会点头认可。

7. 实战刷题中的常见坑与细节补充

7.1 边界用例:空树和单节点

空树处理很简单,pathSum里直接判断root为null就返回0,因为没有任何路径。单节点树则要特别注意{0:1}的初始化。假设节点值为5,targetSum为5,那么遍历时currentSum = 5,need = 0,哈希表里0出现1次,答案正确加一。如果忘了初始化{0:1},这个用例就会返回0,整体错误。

7.2 节点值为负数时的特殊考虑

我之前说过,负数的存在让我们不能简单地在currentSum > targetSum时剪枝。但使用前缀和法时天然规避了这个问题,因为我们从不做大小比较,只做差值查找。无论节点值是正是负,只要currentSum - targetSum曾在历史中出现过,就说明存在一条以当前节点结尾的路径满足条件。所以前缀和法对负数场景的适应是内置的,不需要额外处理。

这里有一个很典型的测试用例:root = [1,-2,-3], targetSum = -1。从根节点1出发,currentSum = 1,need = 1 - (-1) = 2,查不到;进入左子节点-2,currentSum = -1,need = -1 - (-1) = 0,哈希表里0出现1次,答案加一。这条路径[1, -2]的和是-1,正确。如果没有初始化{0:1},这一步就会出错。

7.3 为什么不能在找到一条路径后停止递归

因为路径终点不唯一。假设targetSum = 0,节点值全部为0,那么任意一段向下的路径都满足条件,路径总数等于所有节点作为起点、所有后代节点作为终点的组合数。如果我们在某个节点发现currentSum == targetSum后就停止继续向下遍历,会漏掉大量以更深处节点为终点的路径。所以内层递归必须一路走到底,不能提前返回。

7.4 双递归写法中的参数传递与状态污染

如果面试时先写了暴力解法,注意不要用成员变量保存“累加和”,而是把它作为递归参数传递。因为每次调用时累加和只与当前路径有关,一旦用成员变量,左子树遍历后累加和的变化会影响到右子树的遍历,必须手动恢复了才能继续,极容易出错。

用前缀和法时同理,currentSum作为参数传递时,每个递归分支都有自己的局部值,不需要额外处理。真正需要手动恢复的只有哈希表里的计数。

7.5 关于题目数值范围的工程化建议

LeetCode的测试用例中,targetSum可以大到10^9,而节点值和节点数决定了currentSum的实际范围远小于这个值,但currentSum - targetSum可能产生一个很大的负数或正数。如果键类型是int,在哈希表的hashCode计算时不会出问题,但从语义上讲,用long更合理。在Python中int本身就是任意精度,所以不用纠结。

7.6 从这道题延伸出去的知识点

437这道题的前缀和解法,本质上和“和为K的子数组”是同一套思路,只是把一维数组换成了树形结构。理解了它之后,再遇到其他“统计子树和”“寻找异或路径”之类的题目,也能很快联想到前缀和加哈希表的框架。再往深走一步,如果把“路径总和等于K”改成“路径总和能被K整除”,那么思路就转向取模前缀和,和前缀和的性质绑定得更紧。如果你在准备面试,建议把这几个变体放在一起对比着刷,效率会更高。

8. 一条路径的终点并不是代码的终点

写这道题的时候,我最大的体会是:一个看似简单的递归问题,真正考查的其实是对“状态复用”和“状态清理”的理解。暴力解法每一个人都能快速想到,但如果能主动从O(n²)优化到O(n),说明你对问题本质的把握已经超过了“能AC就行”的层面。

我在实际面试中遇到过候选人把前缀和解法背得很熟,但当我问他“为什么map.put(0, 1)必不可少”时,他愣住了。这个细节恰恰是考察的重点,因为它直接体现了你是否真正理解了前缀和中的“历史”是从哪里开始的。理解了这一点,不管题目是数组、链表还是树,你都能举一反三。

最后再分享一个小技巧:在LeetCode上提交之前,可以先用[-1000, 1000]范围内的小样例手动走一遍递归过程,尤其是检查哈希表的恢复步骤。我靠这个方法在几分钟内就能排查掉大部分逻辑错误,比反复提交试错要快得多。

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

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

立即咨询