1. 问题理解与需求分析
路径总和 III 是 LeetCode 上经典的二叉树问题(编号 437)。题目要求我们找出二叉树中所有满足节点值之和等于给定目标值的路径数量。这里的路径定义比较特殊:
- 路径不需要从根节点开始
- 路径不需要在叶子节点结束
- 路径方向必须向下(父节点到子节点)
这种路径定义大大增加了问题的复杂度,因为我们需要考虑所有可能的起点和终点组合。以示例1为例,当 targetSum=8 时,存在三条有效路径:
- 5 → 3
- 5 → 2 → 1
- -3 → 11
2. 解题思路与算法选择
2.1 暴力搜索法
最直观的解法是使用双重递归:
- 外层递归遍历所有可能的起点(每个节点)
- 内层递归计算从当前节点出发的所有路径和
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 target) {
if (node == null) return 0;
int count = 0;
if (node.val == target) count++;
count += rootSum(node.left, target - node.val);
count += rootSum(node.right, target - node.val);
return count;
}
}
时间复杂度分析:
- 对于每个节点,rootSum 的时间复杂度是 O(n)
- 共有 n 个节点,所以总时间复杂度是 O(n²)
- 空间复杂度取决于递归深度,最坏情况是 O(n)
2.2 前缀和优化法
更高效的解法是使用前缀和+哈希表来优化。这个思路借鉴了数组中的前缀和技巧:
- 维护一个哈希表记录从根节点到当前节点的路径上,各个前缀和出现的次数
- 当前前缀和 - targetSum 如果在哈希表中存在,说明存在满足条件的路径
- 使用回溯思想在遍历时更新哈希表
java复制class Solution {
public int pathSum(TreeNode root, int targetSum) {
Map<Long, Integer> prefixSumCount = new HashMap<>();
prefixSumCount.put(0L, 1); // 初始前缀和为0出现1次
return dfs(root, 0, targetSum, prefixSumCount);
}
private int dfs(TreeNode node, long currentSum, int target,
Map<Long, Integer> prefixSumCount) {
if (node == null) return 0;
currentSum += node.val;
int res = prefixSumCount.getOrDefault(currentSum - target, 0);
prefixSumCount.put(currentSum, prefixSumCount.getOrDefault(currentSum, 0) + 1);
res += dfs(node.left, currentSum, target, prefixSumCount);
res += dfs(node.right, currentSum, target, prefixSumCount);
prefixSumCount.put(currentSum, prefixSumCount.get(currentSum) - 1);
return res;
}
}
时间复杂度优化到 O(n),空间复杂度 O(n)(哈希表和递归栈空间)
3. 关键实现细节解析
3.1 前缀和初始化的意义
java复制prefixSumCount.put(0L, 1);
这行代码非常关键,它表示在路径开始前,前缀和为0的情况出现了1次。这样当从根节点开始的路径和正好等于targetSum时,currentSum - target = 0 能在哈希表中找到对应计数。
3.2 回溯操作的必要性
java复制prefixSumCount.put(currentSum, prefixSumCount.get(currentSum) - 1);
这行代码在递归返回前执行,是为了保证在离开当前节点后,当前节点的前缀和不再被计入统计。这是典型的回溯思想,确保每个节点的前缀和只在它自身的子树中被使用。
3.3 处理大数溢出问题
题目中节点值范围是 [-10^9, 10^9],所以使用long类型存储currentSum:
java复制long currentSum
这样可以避免整数溢出问题,特别是在处理负数相加时。
4. 测试用例与边界情况
4.1 常规测试用例
java复制// 示例1
TreeNode root1 = new TreeNode(10,
new TreeNode(5,
new TreeNode(3,
new TreeNode(3),
new TreeNode(-2)),
new TreeNode(2,
null,
new TreeNode(1))),
new TreeNode(-3,
null,
new TreeNode(11)));
assertEquals(3, solution.pathSum(root1, 8));
// 示例2
TreeNode root2 = new TreeNode(5,
new TreeNode(4,
new TreeNode(11,
new TreeNode(7),
new TreeNode(2)),
null),
new TreeNode(8,
new TreeNode(13),
new TreeNode(4,
new TreeNode(5),
new TreeNode(1))));
assertEquals(3, solution.pathSum(root2, 22));
4.2 特殊边界情况
java复制// 空树
assertEquals(0, solution.pathSum(null, 0));
// 只有一个节点且等于target
assertEquals(1, solution.pathSum(new TreeNode(5), 5));
// 所有节点值相同
TreeNode uniformTree = new TreeNode(1,
new TreeNode(1,
new TreeNode(1),
new TreeNode(1)),
new TreeNode(1,
null,
new TreeNode(1)));
assertEquals(6, solution.pathSum(uniformTree, 1));
assertEquals(3, solution.pathSum(uniformTree, 2));
5. 算法优化与变种
5.1 迭代实现版本
递归实现虽然简洁,但在极端情况下可能导致栈溢出。我们可以用迭代实现:
java复制public int pathSumIterative(TreeNode root, int targetSum) {
if (root == null) return 0;
Map<Long, Integer> prefixSum = new HashMap<>();
prefixSum.put(0L, 1);
Deque<Pair<TreeNode, Long>> stack = new ArrayDeque<>();
stack.push(new Pair<>(root, 0L));
int count = 0;
while (!stack.isEmpty()) {
Pair<TreeNode, Long> pair = stack.pop();
TreeNode node = pair.getKey();
long currentSum = pair.getValue() + node.val;
count += prefixSum.getOrDefault(currentSum - targetSum, 0);
prefixSum.put(currentSum, prefixSum.getOrDefault(currentSum, 0) + 1);
if (node.right != null) {
stack.push(new Pair<>(node.right, currentSum));
}
if (node.left != null) {
stack.push(new Pair<>(node.left, currentSum));
}
// 需要手动回溯,所以需要额外处理
}
return count;
}
注意:迭代实现的前缀和回溯处理比较复杂,通常还是推荐递归实现。
5.2 多目标值查询优化
如果需要多次查询不同targetSum,可以预计算所有路径和:
java复制class PathSumFinder {
private Map<TreeNode, Map<Long, Integer>> nodeSumMap;
public PathSumFinder(TreeNode root) {
nodeSumMap = new HashMap<>();
buildSumMap(root);
}
private void buildSumMap(TreeNode node) {
if (node == null) return;
Map<Long, Integer> sumMap = new HashMap<>();
sumMap.put((long)node.val, 1);
if (node.left != null) {
buildSumMap(node.left);
for (Map.Entry<Long, Integer> entry : nodeSumMap.get(node.left).entrySet()) {
long sum = entry.getKey() + node.val;
sumMap.put(sum, sumMap.getOrDefault(sum, 0) + entry.getValue());
}
}
if (node.right != null) {
buildSumMap(node.right);
for (Map.Entry<Long, Integer> entry : nodeSumMap.get(node.right).entrySet()) {
long sum = entry.getKey() + node.val;
sumMap.put(sum, sumMap.getOrDefault(sum, 0) + entry.getValue());
}
}
nodeSumMap.put(node, sumMap);
}
public int query(int targetSum) {
int total = 0;
for (Map<Long, Integer> sumMap : nodeSumMap.values()) {
total += sumMap.getOrDefault((long)targetSum, 0);
}
return total;
}
}
这种预处理方式适合需要多次查询的场景,预处理时间O(n²),每次查询时间O(n)。
6. 常见错误与调试技巧
6.1 忘记回溯导致计数错误
java复制// 错误示例:缺少回溯操作
prefixSumCount.put(currentSum, prefixSumCount.getOrDefault(currentSum, 0) + 1);
res += dfs(node.left, currentSum, target, prefixSumCount);
res += dfs(node.right, currentSum, target, prefixSumCount);
// 缺少:prefixSumCount.put(currentSum, prefixSumCount.get(currentSum) - 1);
这种错误会导致前缀和计数不准确,特别是在子树之间有重叠路径时。
6.2 整数溢出问题
java复制// 错误示例:使用int存储currentSum
int currentSum; // 当节点值很大时可能溢出
应该始终使用long类型存储中间和。
6.3 初始前缀和设置错误
java复制// 错误示例:忘记初始化前缀和为0的情况
Map<Long, Integer> prefixSumCount = new HashMap<>();
// 缺少:prefixSumCount.put(0L, 1);
这会导致从根节点开始的路径无法被正确统计。
调试技巧:
- 打印递归过程中的currentSum和prefixSumCount状态
- 对小规模测试用例手动模拟算法执行过程
- 特别注意边界条件:空树、单节点树、所有节点值相同的情况
7. 实际应用与扩展
这个问题虽然来自算法题库,但其核心思想在实际开发中有广泛应用:
- 文件系统中查找特定大小的目录
- DOM树中查找满足特定条件的节点组合
- 组织架构树中分析特定属性的团队组合
- 电商分类树中统计满足条件的商品组合
扩展思考:
- 如果路径需要从根节点开始,如何修改算法?
- 如果路径需要在叶子节点结束,如何修改算法?
- 如果需要找出所有路径而不仅仅是计数,如何实现?
对于找出所有路径的变种,可以这样实现:
java复制class Solution {
public List<List<Integer>> pathSum(TreeNode root, int targetSum) {
List<List<Integer>> result = new ArrayList<>();
dfs(root, targetSum, new ArrayList<>(), result);
return result;
}
private void dfs(TreeNode node, int target, List<Integer> path, List<List<Integer>> result) {
if (node == null) return;
path.add(node.val);
long sum = 0;
// 从后向前检查可能的路径
for (int i = path.size() - 1; i >= 0; i--) {
sum += path.get(i);
if (sum == target) {
result.add(new ArrayList<>(path.subList(i, path.size())));
}
}
dfs(node.left, target, path, result);
dfs(node.right, target, path, result);
path.remove(path.size() - 1);
}
}
这个实现的时间复杂度是O(n²),空间复杂度O(n)。它通过维护当前路径,反向检查所有可能的子路径来找出所有解。