题目描述
给定一个二叉树的根节点 root,和一个整数 targetSum,求该二叉树里节点值之和等于 targetSum 的路径的数目。
- 路径不需要从根节点开始
- 路径不需要在叶子节点结束
- 路径方向必须是向下的(只能从父节点到子节点)
示例 1:
输入:root = [10,5,-3,3,2,null,11,3,-2,null,1], targetSum = 8
输出:3
树结构:
10
/ \
5 -3
/ \ \
3 2 11
/ \ \
3 -2 1
三条路径:
1. 5 → 3 (5+3=8)
2. 5 → 2 → 1 (5+2+1=8)
3. -3 → 11 (-3+11=8)
示例 2:
输入:root = [5,4,8,11,null,13,4,7,2,null,null,5,1], targetSum = 22
输出:3
树结构:
5
/ \
4 8
/ / \
11 13 4
\ /
7 2
/ /
2 1
三条路径:
1. 5 → 4 → 11 → 2 (5+4+11+2=22)
2. 5 → 4 → 8 → 4 → 1 (5+4+8+4+1=22)
3. 4 → 11 → 7 (4+11+7=22)
提示:
- 二叉树的节点个数的范围是 [0, 1000]
- -10^9 <= Node.val <= 10^9
- -1000 <= targetSum <= 1000
解题思路总览
| 方法 | 思路 | 时间复杂度 | 空间复杂度 | 适用场景 |
|---|---|---|---|---|
| 方法一:前缀和 + HashMap | 前缀和思想,O(1) 查找路径和 | O(n) | O(h) | 面试首选 |
| 方法二:双重 DFS | 以每个节点为起点的 DFS | O(n^2) | O(h) | 容易想到,但不是最优 |
核心原理: 利用前缀和思想,将"路径和等于 targetSum"转化为"当前前缀和 - targetSum 是否在历史前缀和中"
方法一:前缀和 + HashMap(推荐)
思路
这是一道经典的前缀和题目。核心思想:
-
前缀和定义:从根节点到当前节点的路径上所有节点值之和
-
路径和转化:如果从节点 A 到节点 B 的路径和等于 targetSum,那么:
前缀和[B] - 前缀和[A的父节点] = targetSum- 即
前缀和[B] - targetSum应该在历史前缀和中存在
-
HashMap 记录:用 HashMap 记录从根到当前节点路径上,每个前缀和出现的次数
-
初始化 :添加
cnt[0] = 1,表示空路径(当前缀和等于 targetSum 时,需要用到)
完整代码
cpp
/**
* Definition for a binary tree node.
* struct TreeNode {
* int val;
* TreeNode *left;
* TreeNode *right;
* TreeNode() : val(0), left(nullptr), right(nullptr) {}
* TreeNode(int x) : val(x), left(nullptr), right(nullptr) {}
* TreeNode(int x, TreeNode *left, TreeNode *right) : val(x), left(left), right(right) {}
* };
*/
class Solution {
public:
void dfs(TreeNode* node, long long curSum, int targetSum,
unordered_map<long long, int>& cnt, int& ans) {
if (!node) return;
// 1. 计算当前节点的前缀和
curSum += node->val;
// 2. 如果存在一条从某个祖先到当前节点的路径和等于 targetSum
// 即:curSum - targetSum 在历史前缀和中存在
ans += cnt[curSum - targetSum];
// 3. 将当前前缀和加入 HashMap
cnt[curSum]++;
// 4. 递归处理左右子树
dfs(node->left, curSum, targetSum, cnt, ans);
dfs(node->right, curSum, targetSum, cnt, ans);
// 5. 回溯:移除当前前缀和(因为要返回上层继续处理)
cnt[curSum]--;
}
int pathSum(TreeNode* root, int targetSum) {
// 初始化:空路径的前缀和为 0,出现 1 次
unordered_map<long long, int> cnt = {{0, 1}};
int ans = 0;
dfs(root, 0, targetSum, cnt, ans);
return ans;
}
};
算法流程图
以示例 1 为例,root = [10,5,-3,3,2,null,11,3,-2,null,1], targetSum = 8:
树结构:
10(10)
/ \
5(15) -3(7)
/ \ \
3(18) 2(17) 11(18)
/ \ \
3 -2 1
括号内为从根开始的前缀和
DFS 遍历过程:
Step 1: node=10, curSum=0+10=10
cnt[10-8]=cnt[2]=0 → ans=0
cnt[10]++ → cnt[10]=1
递归左子树 5
Step 2: node=5, curSum=10+5=15
cnt[15-8]=cnt[7]=0 → ans=0
cnt[15]++ → cnt[15]=1
递归到 3
Step 3: node=3, curSum=15+3=18
cnt[18-8]=cnt[10]=1 → ans=1 (路径:10→5→3)
cnt[18]++ → cnt[18]=1
递归到 3 的左子节点
Step 4: node=3(叶子), curSum=18+3=21
cnt[21-8]=cnt[13]=0 → ans=1
cnt[21]++ → cnt[21]=1
回溯,cnt[21]--, cnt[18]--(继续递归其他分支)
...继续遍历所有节点...
最终 ans = 3
这 3 条路径分别是:
1. 节点5 → 节点3(5+3=8)
2. 节点5 → 节点2 → 节点1(5+2+1=8)
3. 节点-3 → 节点11(-3+11=8)
逐行解析
cpp
void dfs(TreeNode* node, long long curSum, int targetSum,
unordered_map<long long, int>& cnt, int& ans) {
if (!node) return;
- DFS 递归函数。
curSum:从根到当前节点的前缀和。cnt:HashMap,键是前缀和,值是该前缀和出现的次数。ans:满足条件的路径数量(引用传递,全局共享)。
cpp
curSum += node->val;
- 计算当前节点的前缀和 = 父节点前缀和 + 当前节点值。
cpp
ans += cnt[curSum - targetSum];
- 核心逻辑 :如果存在一个历史前缀和
s,使得curSum - s = targetSum(即s = curSum - targetSum),那么从该历史节点到当前节点的路径和就等于 targetSum。 cnt[curSum - targetSum]表示有多少个历史前缀和满足条件。- 这里利用了初始化时添加的
cnt[0] = 1,所以当curSum == targetSum时,cnt[0] = 1表示"从根到当前节点的路径自己就等于 targetSum"。
cpp
cnt[curSum]++;
- 将当前前缀和加入 HashMap,记录出现次数 +1。
- 这样在递归到子节点时,子节点就能找到这个前缀和。
cpp
dfs(node->left, curSum, targetSum, cnt, ans);
dfs(node->right, curSum, targetSum, cnt, ans);
- 递归处理左右子树,传递当前前缀和
curSum。
cpp
cnt[curSum]--;
- 回溯:递归返回时,需要将当前前缀和从 HashMap 中移除。
- 这是因为当前节点的路径只在其子树范围内有效,不能影响其他分支。
cpp
unordered_map<long long, int> cnt = {{0, 1}};
- 初始化 :
cnt[0] = 1表示空路径的前缀和为 0,出现了 1 次。 - 作用:当当前缀和等于 targetSum 时,
cnt[curSum - targetSum] = cnt[0] = 1,确保能计数到从根开始的路径。
复杂度分析
| 复杂度 | 值 | 说明 |
|---|---|---|
| 时间 | O(n) | 每个节点访问一次 |
| 空间 | O(h) | HashMap 存储路径上的前缀和,最多 h 个(树高) |
优点: 时间复杂度优秀,代码简洁
缺点: 需要理解前缀和 + 回溯的思想
方法二:双重 DFS
思路
以每个节点为起点,向下 DFS 累加路径和,统计等于 targetSum 的路径数量。
完整代码
cpp
class Solution {
public:
int ans = 0;
int pathSum(TreeNode* root, int targetSum) {
if (!root) return 0;
// 以 root 为起点的路径
dfs(root, targetSum);
// 递归处理左右子树作为新的起点
pathSum(root->left, targetSum);
pathSum(root->right, targetSum);
return ans;
}
void dfs(TreeNode* node, long long sum) {
if (!node) return;
sum -= node->val;
if (sum == 0) ans++; // 找到一条路径
if (node->left) dfs(node->left, sum);
if (node->right) dfs(node->right, sum);
}
};
复杂度分析
| 复杂度 | 值 | 说明 |
|---|---|---|
| 时间 | O(n^2) | 每个节点作为起点需要一次 DFS |
| 空间 | O(h) | 递归栈深度 |
优点: 思路直观,容易想到
缺点: 时间复杂度较高,不适合大规模数据
两种方法对比
| 维度 | 方法一 前缀和+HashMap | 方法二 双重 DFS |
|---|---|---|
| 代码复杂度 | 中等 | 简单 |
| 时间复杂度 | O(n) | O(n^2) |
| 空间复杂度 | O(h) | O(h) |
| 面试推荐度 | 首选 | 简化版/容易想到 |
面试追问 FAQ
| 问题 | 解答 |
|---|---|
Q1:为什么需要初始化 cnt[0] = 1? |
当当前缀和恰好等于 targetSum 时,说明从根到当前节点的路径本身就是一条满足条件的路径。此时 curSum - targetSum = 0,cnt[0] = 1 使得 ans += 1 能正确计数。 |
Q2:为什么在递归返回时需要 cnt[curSum]--? |
这是回溯操作。当前节点的路径只在其子树范围内有效。当递归返回到父节点后,父节点需要继续探索其他分支,此时当前节点的前缀和不应该再影响其他路径的计算。 |
| Q3:如何理解前缀和思想? | 从根到当前节点的路径可以看成一个数组,前缀和就是前 i 个元素的和。路径和等于 targetSum 意味着:存在 j < i,使得 前缀和[i] - 前缀和[j] = targetSum,即 前缀和[j] = 前缀和[i] - targetSum。 |
| Q4:为什么不用记录路径的起止位置? | 因为我们只关心前缀和的差值是否等于 targetSum,不需要具体知道是哪两个节点。只要前缀和的差值存在,就说明存在一条路径和等于 targetSum。 |
Q5:为什么使用 long long 而不是 int? |
题目中 Node.val 的范围是 -10^9 到 10^9,路径可能很长,累加后可能超过 int 的范围(32 位有符号整数最大约 2.1 * 10^9)。为了避免溢出,使用 long long。 |
| Q6:如果要找最短路径怎么办? | 需要额外记录最短路径长度,在找到满足条件的路径时更新。这需要在递归中传递当前路径长度。 |
相关题目
| 题目 | 难度 | 关键点 |
|---|---|---|
| 437. 路径总和 III | 中等 | 前缀和,HashMap |
| 112. 路径总和 | 简单 | 从根到叶子的路径和 |
| 113. 路径总和 II | 中等 | 返回所有路径 |
| 124. 二叉树中的最大路径和 | 困难 | 前缀和变形 |
| 560. 和为 K 的子数组 | 中等 | 一维版本的前缀和 |
总结
| 要点 | 说明 |
|---|---|
| 核心原理 | 前缀和思想:curSum - targetSum 在历史前缀和中存在,则存在满足条件的路径 |
| 关键技巧 | 初始化 cnt[0] = 1 计算空路径,cnt[curSum]++ 记录前缀和 |
| 回溯操作 | 递归返回时 cnt[curSum]--,移除当前节点的影响 |
| 时间复杂度 | O(n),每个节点访问一次 |
| 空间复杂度 | O(h),HashMap 存储路径上的前缀和 |