1. 问题背景与理解
今天遇到一道挺有意思的二叉树题目,LeetCode 437题"路径总和III"。这道题乍看简单,但实际写起来发现有不少细节需要注意。题目要求我们找出二叉树中路径和等于给定值的路径数量,这里的路径不需要从根节点开始,也不需要在叶子节点结束,但必须是从父节点到子节点的方向。
举个例子,给定二叉树:
code复制 10
/ \
5 -3
/ \ \
3 2 11
/ \ \
3 -2 1
当目标和为8时,我们需要返回3。因为存在以下三条路径:
- 5 → 3
- 5 → 2 → 1
- -3 → 11
2. 解题思路分析
2.1 暴力解法:双重递归
最直观的想法是使用双重递归。外层递归遍历每个节点,内层递归计算以当前节点为起点的所有路径和。
javascript复制function pathSum(root, targetSum) {
if (!root) return 0;
// 计算以当前节点为起点的路径数
function countPaths(node, currentSum) {
if (!node) return 0;
currentSum += node.val;
return (currentSum === targetSum ? 1 : 0)
+ countPaths(node.left, currentSum)
+ countPaths(node.right, currentSum);
}
// 遍历每个节点作为起点
return countPaths(root, 0)
+ pathSum(root.left, targetSum)
+ pathSum(root.right, targetSum);
}
这个解法的时间复杂度是O(n²),因为对于每个节点,我们都可能遍历它的所有子节点。空间复杂度是O(n),由递归栈的深度决定。
2.2 优化思路:前缀和+哈希表
更高效的解法是使用前缀和配合哈希表。这个思路借鉴了数组前缀和的思想,但在树上实现需要特别注意回溯。
javascript复制function pathSum(root, targetSum) {
const prefixSumMap = new Map();
prefixSumMap.set(0, 1); // 初始前缀和为0出现1次
function dfs(node, currentSum) {
if (!node) return 0;
currentSum += node.val;
// 查找是否有前缀和等于currentSum - targetSum
const count = prefixSumMap.get(currentSum - targetSum) || 0;
// 更新当前前缀和的出现次数
prefixSumMap.set(currentSum, (prefixSumMap.get(currentSum) || 0) + 1);
// 递归处理左右子树
const res = count
+ dfs(node.left, currentSum)
+ dfs(node.right, currentSum);
// 回溯,恢复前缀和计数
prefixSumMap.set(currentSum, prefixSumMap.get(currentSum) - 1);
return res;
}
return dfs(root, 0);
}
这个解法的时间复杂度降到了O(n),因为我们只需要遍历树一次。空间复杂度也是O(n),主要是哈希表的空间和递归栈的深度。
3. 关键点解析
3.1 前缀和的应用
前缀和技巧通常用于数组问题,但在树上同样适用。核心思想是:
- 从根节点到当前节点的路径和记为currentSum
- 如果存在一个祖先节点,使得currentSum - ancestorSum = targetSum,那么从该祖先节点到当前节点的路径和就是targetSum
- 我们用哈希表记录每个前缀和出现的次数
3.2 回溯的重要性
在树的递归遍历中,当我们处理完一个节点的子树后,必须将该节点对应的前缀和从哈希表中移除(减少计数)。这是因为树的结构决定了路径必须是单向的(父到子),不能跨分支。
3.3 初始条件的设置
prefixSumMap.set(0, 1)这一行很关键。它表示在开始遍历前,前缀和为0已经出现过一次。这相当于考虑了从根节点开始的路径。
4. 边界情况与测试用例
4.1 常见边界情况
- 空树:应该返回0
- 单节点树:
- 节点值等于targetSum:返回1
- 不等于:返回0
- 所有节点值相同:
- 如所有节点值为1,targetSum=3,深度为3的树应该有4条路径(3层完全二叉树)
4.2 测试用例示例
javascript复制// 用例1:题目示例
const tree1 = {
val: 10,
left: {
val: 5,
left: {
val: 3,
left: { val: 3, left: null, right: null },
right: { val: -2, left: null, right: null }
},
right: {
val: 2,
right: { val: 1, left: null, right: null }
}
},
right: {
val: -3,
right: { val: 11, left: null, right: null }
}
};
console.log(pathSum(tree1, 8)); // 应该输出3
// 用例2:空树
console.log(pathSum(null, 8)); // 0
// 用例3:单节点匹配
console.log(pathSum({ val: 1, left: null, right: null }, 1)); // 1
5. 性能优化与变种问题
5.1 迭代实现
递归解法虽然简洁,但在极端情况下可能导致栈溢出。我们可以用迭代方式实现:
javascript复制function pathSum(root, targetSum) {
const prefixSumMap = new Map();
prefixSumMap.set(0, 1);
let total = 0;
let currentSum = 0;
const stack = [];
let node = root;
let lastVisited = null;
while (node || stack.length) {
if (node) {
currentSum += node.val;
total += prefixSumMap.get(currentSum - targetSum) || 0;
prefixSumMap.set(currentSum, (prefixSumMap.get(currentSum) || 0) + 1);
stack.push(node);
node = node.left;
} else {
node = stack[stack.length - 1];
if (node.right && node.right !== lastVisited) {
node = node.right;
} else {
prefixSumMap.set(currentSum, prefixSumMap.get(currentSum) - 1);
currentSum -= node.val;
lastVisited = stack.pop();
node = null;
}
}
}
return total;
}
5.2 变种问题
- 要求路径必须从根节点开始:简化问题,不需要前缀和技巧
- 要求路径必须在叶子节点结束:在判断条件中增加是否为叶子节点的检查
- 输出所有符合条件的路径:需要记录路径而不仅仅是计数
6. 实际应用场景
这类路径和问题在实际开发中有多种应用:
- 文件系统中查找特定大小的目录
- DOM树中查找满足特定条件的节点组合
- 游戏中的技能树或天赋树中寻找特定加成的路径
- 组织结构图中分析特定属性的团队组合
7. 常见错误与调试技巧
7.1 常见错误
- 忘记回溯:导致前缀和计数错误
- 初始条件设置错误:漏掉
prefixSumMap.set(0, 1) - 路径方向理解错误:认为可以任意方向(实际必须父到子)
7.2 调试技巧
- 打印前缀和哈希表的变化
- 对小型测试用例手动计算预期结果
- 使用可视化工具观察树的遍历过程
javascript复制// 调试打印示例
function dfs(node, currentSum) {
if (!node) return 0;
currentSum += node.val;
console.log(`到达节点 ${node.val}, 当前和: ${currentSum}`);
console.log('当前前缀和映射:', [...prefixSumMap.entries()]);
// ...其余代码不变
}
8. 复杂度对比总结
| 方法 | 时间复杂度 | 空间复杂度 | 适用场景 |
|---|---|---|---|
| 双重递归 | O(n²) | O(n) | 树深度不大时简单直观 |
| 前缀和+哈希表 | O(n) | O(n) | 大规模数据更高效 |
| 迭代实现 | O(n) | O(n) | 避免递归栈溢出 |
在实际面试中,建议先解释暴力解法,然后逐步优化到前缀和解法。理解前缀和的应用场景和回溯机制是解决这类问题的关键。