路径总和 III 图解题解
同一条路走几遍太浪费——前缀和让你只走一次就数清所有答案。
想象你在一条公路上开车,随手记着「从出发到现在的累计里程」。想知道有没有某一段路程正好是 target 公里,不必每次都从头量——只要看看有没有某个更早的里程碑,让「现在的里程减去那时的里程」恰好等于目标。这就是前缀和的逻辑:路上每个节点不重复计算,拿哈希表记住走过的所有里程碑,一次查表立刻得出答案。
这道题到底在问什么
- 输入
- tree=[5,4,8,3,-1,6,2,...], target=8
- 输出
- 4
先想最直接的笨办法
一趟 DFS 走完,累计的路径数就是答案:和为 8 的向下路径共 4 条。每个节点只访问一次、哈希查/改都是 O(1),所以整体时间 O(n),远优于暴力的 O(n²)。(动画第 29 步)
最优解:为什么这么做
一句话答案:LeetCode 437 路径总和 III 的最优解是前缀和加哈希表的一趟 DFS:把根到当前节点的累加和记作前缀和 prefix,以当前节点结尾、和为 targetSum 的路径数,等于祖先链上前缀和 prefix - targetSum 出现的次数。用哈希表边走边查边登记、回溯时撤销,时间 O(n)、空间 O(n)。
这道题的路径和普通路径有什么不同
题目求树中和等于 targetSum 的路径条数,但这里的路径被重新定义了:方向只能向下(父到子)、必须连续,起点和终点却都任意——不必从根出发,也不必到叶子结束。另外节点值可以是负数,这意味着「和已经超了就提前剪枝」之类的想法不成立,也让同一条链上可能出现多段和相同的情况。
为什么暴力枚举起点是 O(n²)
直觉做法是双重递归:外层枚举每个节点作为路径起点,内层从这个起点向下累加、每凑够一次 targetSum 就计一条。每个节点会被它的所有祖先各扫一遍,链状树时总工作量是 1 加到 n,时间 O(n²)。慢的根源是重复累加:从根到某个深处节点的这段和,被沿途每个起点反反复复地重新算。
前缀和为什么能把树上路径变成一次查表
把「根到节点 X 的累加和」叫作 X 的前缀和 prefix(X)。同一条根链上,从祖先 A 的下一个节点走到当前节点 cur 的这段路径,和恰好等于 prefix(cur) - prefix(A)——两段根链相减,公共部分抵消。想让这段和等于 targetSum,等价于在 cur 的祖先里找前缀和等于 prefix(cur) - targetSum 的位置。
于是「向下枚举所有起点」被翻转成「向上数一次祖先」:用哈希表记录当前根链上每个前缀和出现的次数,走到 cur 时查一下 prefix - targetSum 出现过几次,就是以 cur 结尾的合法路径数。这正是数组版两数之和的查表思想,原封不动搬到了树的根链上。
哈希表为什么要预置 0 出现 1 次,回溯为什么必须撤销
表里要先放一条「前缀和 0 出现 1 次」,代表空前缀:当 prefix(cur) 本身就等于 targetSum 时,要找的是前缀和为 0 的祖先——对应从根一路走到 cur 的整段路径,不预置就会漏数这一整类。
更关键的是回溯撤销:递归离开一个节点时,必须把它的前缀和从表里减掉。因为差值公式只对同一条根链上的祖先成立,若左子树登记的前缀和残留在表里,右子树查表时会把跨分支的组合误当成路径,而题目要求路径必须连续。哈希表在任意时刻只保存根到当前节点这条链上的前缀和——这就是算法全程维持的不变量。
复杂度怎么算,容易错在哪
每个节点访问一次,查表、登记、撤销都是 O(1),总时间 O(n);哈希表加递归栈的空间是 O(n)。相比 O(n²) 的暴力,树一大差距悬殊。
三个高频坑:忘了预置空前缀,从根开始的路径全部漏掉;递归返回时不撤销登记,跨分支误判多算;以及哈希的值要存出现次数而不是存在与否——节点有负数时,同一个前缀和可能在一条链上出现多次,每一次都对应一条不同的路径。
▶ 动画逐步走查(共 27 步)——想跟着动画一帧帧对照就展开
- 3把「根到节点」的累加和叫前缀和。那么从祖先 A 的下一个节点走到当前节点,这段路径的和 = prefix(当前) − prefix(A)。想让它等于 8,就要找 prefix(A) = prefix(当前) − 8。于是:把沿途每个前缀和记进哈希,查 prefix−target 出现过几次,就有几条以当前结尾的合法路径。
- 4前缀和 DFS 起点 = 根 5,哈希 = {0:1}先看清这棵树(含负数节点)。我们从根 5 出发做一趟 DFS,维护「根到当前」的前缀和,并把出现过的前缀和记进哈希表。哈希一开始放一个 {0: 1}——代表「空前缀」,专门用来命中那些从根直接开始的路径。
- 5prefix = 5,要找 prefix-target = 5-8 = -3深度优先走到节点 5。把根到这里一路加起来,得到前缀和 = 5。要数「以当前节点结尾、和为 8」的路径:等价于在祖先里找前缀和等于 5 − 8 = -3 的位置——中间那段的和就正好是 8。下一步去哈希表里查 -3 出现了几次。
- 6hash[-3] = 0,累计 0;把 5 记入哈希哈希里没有 -3,说明以节点 5 结尾没有和为 8 的路径,累计仍是 0。照例把当前前缀和 5 记进哈希(次数 +1),让更深的子孙能查到它。
- 7prefix = 9,要找 prefix-target = 9-8 = 1深度优先走到节点 4。把根到这里一路加起来,得到前缀和 = 9。要数「以当前节点结尾、和为 8」的路径:等价于在祖先里找前缀和等于 9 − 8 = 1 的位置——中间那段的和就正好是 8。下一步去哈希表里查 1 出现了几次。
- 8hash[1] = 0,累计 0;把 9 记入哈希哈希里没有 1,说明以节点 4 结尾没有和为 8 的路径,累计仍是 0。照例把当前前缀和 9 记进哈希(次数 +1),让更深的子孙能查到它。
- 9prefix = 12,要找 prefix-target = 12-8 = 4深度优先走到节点 3。把根到这里一路加起来,得到前缀和 = 12。要数「以当前节点结尾、和为 8」的路径:等价于在祖先里找前缀和等于 12 − 8 = 4 的位置——中间那段的和就正好是 8。下一步去哈希表里查 4 出现了几次。
- 10hash[4] = 0,累计 0;把 12 记入哈希哈希里没有 4,说明以节点 3 结尾没有和为 8 的路径,累计仍是 0。照例把当前前缀和 12 记进哈希(次数 +1),让更深的子孙能查到它。
- 11prefix = 13,要找 prefix-target = 13-8 = 5深度优先走到节点 1。把根到这里一路加起来,得到前缀和 = 13。要数「以当前节点结尾、和为 8」的路径:等价于在祖先里找前缀和等于 13 − 8 = 5 的位置——中间那段的和就正好是 8。下一步去哈希表里查 5 出现了几次。
- 12hash[5] = 1,累计 1;把 13 记入哈希哈希里 5 出现了 1 次——意味着有 1 个祖先位置,从它的下一个节点走到当前正好凑出 8,于是路径数 += 1,累计变成 1。再把当前前缀和 13 记进哈希,供下面更深的子孙查询。
- 13prefix = 14,要找 prefix-target = 14-8 = 6深度优先走到节点 2。把根到这里一路加起来,得到前缀和 = 14。要数「以当前节点结尾、和为 8」的路径:等价于在祖先里找前缀和等于 14 − 8 = 6 的位置——中间那段的和就正好是 8。下一步去哈希表里查 6 出现了几次。
- 14hash[6] = 0,累计 1;把 14 记入哈希哈希里没有 6,说明以节点 2 结尾没有和为 8 的路径,累计仍是 1。照例把当前前缀和 14 记进哈希(次数 +1),让更深的子孙能查到它。
- 15prefix = 8,要找 prefix-target = 8-8 = 0深度优先走到节点 -1。把根到这里一路加起来,得到前缀和 = 8。要数「以当前节点结尾、和为 8」的路径:等价于在祖先里找前缀和等于 8 − 8 = 0 的位置——中间那段的和就正好是 8。下一步去哈希表里查 0 出现了几次。
- 16hash[0] = 1,累计 2;把 8 记入哈希哈希里 0 出现了 1 次——意味着有 1 个祖先位置,从它的下一个节点走到当前正好凑出 8,于是路径数 += 1,累计变成 2。(命中的是基底 0,代表「从根一路下来」这条路径本身就等于 target。)再把当前前缀和 8 记进哈希,供下面更深的子孙查询。
- 17prefix = 11,要找 prefix-target = 11-8 = 3深度优先走到节点 3。把根到这里一路加起来,得到前缀和 = 11。要数「以当前节点结尾、和为 8」的路径:等价于在祖先里找前缀和等于 11 − 8 = 3 的位置——中间那段的和就正好是 8。下一步去哈希表里查 3 出现了几次。
- 18hash[3] = 0,累计 2;把 11 记入哈希哈希里没有 3,说明以节点 3 结尾没有和为 8 的路径,累计仍是 2。照例把当前前缀和 11 记进哈希(次数 +1),让更深的子孙能查到它。
- 19prefix = 13,要找 prefix-target = 13-8 = 5深度优先走到节点 8。把根到这里一路加起来,得到前缀和 = 13。要数「以当前节点结尾、和为 8」的路径:等价于在祖先里找前缀和等于 13 − 8 = 5 的位置——中间那段的和就正好是 8。下一步去哈希表里查 5 出现了几次。
- 20hash[5] = 1,累计 3;把 13 记入哈希哈希里 5 出现了 1 次——意味着有 1 个祖先位置,从它的下一个节点走到当前正好凑出 8,于是路径数 += 1,累计变成 3。再把当前前缀和 13 记进哈希,供下面更深的子孙查询。
- 21prefix = 19,要找 prefix-target = 19-8 = 11深度优先走到节点 6。把根到这里一路加起来,得到前缀和 = 19。要数「以当前节点结尾、和为 8」的路径:等价于在祖先里找前缀和等于 19 − 8 = 11 的位置——中间那段的和就正好是 8。下一步去哈希表里查 11 出现了几次。
- 22hash[11] = 0,累计 3;把 19 记入哈希哈希里没有 11,说明以节点 6 结尾没有和为 8 的路径,累计仍是 3。照例把当前前缀和 19 记进哈希(次数 +1),让更深的子孙能查到它。
- 23prefix = 15,要找 prefix-target = 15-8 = 7深度优先走到节点 2。把根到这里一路加起来,得到前缀和 = 15。要数「以当前节点结尾、和为 8」的路径:等价于在祖先里找前缀和等于 15 − 8 = 7 的位置——中间那段的和就正好是 8。下一步去哈希表里查 7 出现了几次。
- 24hash[7] = 0,累计 3;把 15 记入哈希哈希里没有 7,说明以节点 2 结尾没有和为 8 的路径,累计仍是 3。照例把当前前缀和 15 记进哈希(次数 +1),让更深的子孙能查到它。
- 25prefix = 22,要找 prefix-target = 22-8 = 14深度优先走到节点 7。把根到这里一路加起来,得到前缀和 = 22。要数「以当前节点结尾、和为 8」的路径:等价于在祖先里找前缀和等于 22 − 8 = 14 的位置——中间那段的和就正好是 8。下一步去哈希表里查 14 出现了几次。
- 26hash[14] = 0,累计 3;把 22 记入哈希哈希里没有 14,说明以节点 7 结尾没有和为 8 的路径,累计仍是 3。照例把当前前缀和 22 记进哈希(次数 +1),让更深的子孙能查到它。
- 27prefix = 13,要找 prefix-target = 13-8 = 5深度优先走到节点 -2。把根到这里一路加起来,得到前缀和 = 13。要数「以当前节点结尾、和为 8」的路径:等价于在祖先里找前缀和等于 13 − 8 = 5 的位置——中间那段的和就正好是 8。下一步去哈希表里查 5 出现了几次。
- 28hash[5] = 1,累计 4;把 13 记入哈希哈希里 5 出现了 1 次——意味着有 1 个祖先位置,从它的下一个节点走到当前正好凑出 8,于是路径数 += 1,累计变成 4。再把当前前缀和 13 记进哈希,供下面更深的子孙查询。
- 29合法路径总数 = 4一趟 DFS 走完,累计的路径数就是答案:和为 8 的向下路径共 4 条。每个节点只访问一次、哈希查/改都是 O(1),所以整体时间 O(n),远优于暴力的 O(n²)。
⚠️ 容易写错的地方
✗ 错:哈希忘了预置 {0:1}
✓ 对:必须先放 {0:1}
否则「从根开始」的路径无法命中(need=0 查不到)
✗ 错:递归回来不撤销 prefix
✓ 对:回溯时 cnt[prefix]--
不撤销会把别的分支前缀算进来,路径必须同一条根链
✗ 错:只数从根到叶的路径
✓ 对:起点终点都任意
中间任意一段连续向下都算,正是前缀和差值的意义
完整代码(Java / Python / C++)
Java
class Solution {
public int pathSum(TreeNode root, int targetSum) {
Map<Long, Integer> cnt = new HashMap<>();
cnt.put(0L, 1); // 基底:空前缀出现 1 次
return dfs(root, 0L, targetSum, cnt);
}
private int dfs(TreeNode node, long prefix, int target,
Map<Long, Integer> cnt) {
if (node == null) return 0;
prefix += node.val; // 根到当前的前缀和
int paths = cnt.getOrDefault(prefix - target, 0); // 命中=合法路径
cnt.merge(prefix, 1, Integer::sum); // 记入当前前缀
paths += dfs(node.left, prefix, target, cnt);
paths += dfs(node.right, prefix, target, cnt);
cnt.merge(prefix, -1, Integer::sum); // 回溯撤销
return paths;
}
}Python
class Solution:
def pathSum(self, root: TreeNode, targetSum: int) -> int:
from collections import defaultdict
cnt = defaultdict(int)
cnt[0] = 1 # 基底:空前缀
def dfs(node, prefix):
if not node:
return 0
prefix += node.val # 根到当前前缀和
paths = cnt[prefix - target] # 命中=合法路径数
cnt[prefix] += 1 # 记入当前前缀
paths += dfs(node.left, prefix)
paths += dfs(node.right, prefix)
cnt[prefix] -= 1 # 回溯撤销
return paths
target = targetSum
return dfs(root, 0)C++
class Solution {
public:
int pathSum(TreeNode* root, int targetSum) {
unordered_map<long, int> cnt;
cnt[0] = 1; // 基底:空前缀
return dfs(root, 0L, targetSum, cnt);
}
int dfs(TreeNode* node, long prefix, int target,
unordered_map<long, int>& cnt) {
if (!node) return 0;
prefix += node->val; // 根到当前前缀和
int paths = cnt.count(prefix - target) ? cnt[prefix - target] : 0;
cnt[prefix]++; // 记入当前前缀
paths += dfs(node->left, prefix, target, cnt);
paths += dfs(node->right, prefix, target, cnt);
cnt[prefix]--; // 回溯撤销
return paths;
}
};复杂度
时间
O(n)
每个节点访问一次,哈希查/改 O(1)
空间
O(n)
哈希表 + 递归栈,最坏(链状树)O(n)
看不够?换成动画再走一遍
上面的推演每一步都对应一帧动画。点开交互动画版,能一步步看着 路径总和 III 的数据怎么变、指针怎么走,还能切 Python / Java / C++ 跟着练。
面试官可能追问
这道题为什么用「前缀和」,换最直接的暴力解会差在哪?+
前缀和抓住了本题的结构特征,把暴力解里重复的工作省掉;暴力解通常要多嵌套一层枚举,数据一大就超时。具体对比见上文「暴力解及其卡点」与「最优解逐步推演」两节。
时间复杂度为什么是 undefined?怎么推出来的?+
按上文复杂度小节的推导,时间复杂度为 undefined。
想听吴师兄把这道题讲给你听?
文字版和动画都随便看。开通图解算法年卡,可以听吴师兄把 路径总和 III 一步步讲透(全站已上线 905 份讲透,持续扩充), 卡住的地方还有 AI 私教小欧就着动画帮你拆到懂。
本平台为独立第三方培训机构,与华为技术有限公司无任何关联;课程的服务内容与权益以购买协议为准,学习效果因个人情况而异。「华为 OD」「华为可信」等仅为对岗位与考试方向的客观描述,相关商标归各自权利人所有。