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 暴力解法:双重递归
最直观的想法是使用双重递归:
- 第一层递归遍历每个节点
- 对每个节点,再进行一次递归计算以该节点为起点的路径和
这种方法时间复杂度是O(n²),在最坏情况下(比如链表形式的树)性能会比较差。但优点是实现简单,适合作为思考的起点。
javascript复制function pathSum(root, targetSum) {
if (!root) return 0;
function countPath(node, sum) {
if (!node) return 0;
const current = (node.val === sum) ? 1 : 0;
return current +
countPath(node.left, sum - node.val) +
countPath(node.right, sum - node.val);
}
return countPath(root, targetSum) +
pathSum(root.left, targetSum) +
pathSum(root.right, targetSum);
}
2.2 优化思路:前缀和
更高效的解法是使用前缀和+哈希表,将时间复杂度降到O(n)。这个思路借鉴了数组子数组求和问题的解法:
- 使用一个哈希表存储从根节点到当前节点的路径上各个前缀和出现的次数
- 在递归遍历时,计算当前前缀和,并查找哈希表中是否存在
currentSum - targetSum - 回溯时需要及时清理哈希表,避免影响其他路径的计算
javascript复制function pathSum(root, targetSum) {
const map = new Map();
map.set(0, 1); // 初始前缀和为0出现1次
return dfs(root, 0);
function dfs(node, currentSum) {
if (!node) return 0;
currentSum += node.val;
let count = map.get(currentSum - targetSum) || 0;
// 更新哈希表
map.set(currentSum, (map.get(currentSum) || 0) + 1);
// 递归处理子节点
count += dfs(node.left, currentSum);
count += dfs(node.right, currentSum);
// 回溯,恢复哈希表状态
map.set(currentSum, map.get(currentSum) - 1);
return count;
}
}
3. 关键点解析
3.1 前缀和的应用
前缀和技巧在这里的应用非常巧妙。在二叉树路径问题中,从根到任意节点的路径是唯一的。如果我们记录下从根到当前节点的和(前缀和),那么:
当前前缀和 - 目标值 = 某中间前缀和
这意味着从那个中间节点到当前节点的路径和就是目标值。哈希表帮助我们快速查找这样的中间前缀和是否存在。
3.2 回溯的重要性
在递归返回时,必须及时将当前前缀和从哈希表中减去。这是因为二叉树有多条分支,我们需要保证哈希表中记录的前缀和只属于当前正在处理的路径分支。
3.3 初始条件的设置
map.set(0, 1)这个初始条件很关键。它表示在路径开始前,前缀和为0的情况出现了一次。这样当某条路径从根节点开始的和正好等于targetSum时,我们就能正确计数。
4. 复杂度分析
- 时间复杂度:O(n),每个节点只访问一次
- 空间复杂度:O(n),哈希表在最坏情况下需要存储n个不同的前缀和
5. 边界情况与测试用例
在实现时需要考虑以下边界情况:
- 空树(直接返回0)
- 单节点树(检查节点值是否等于目标值)
- 所有节点值相同的情况
- 目标值为0的情况
- 包含负数的树
几个有用的测试用例:
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:单节点
const tree2 = { val: 1, left: null, right: null };
console.log(pathSum(tree2, 1)); // 应该输出1
// 用例3:全负数
const tree3 = {
val: -1,
left: { val: -2, left: null, right: null },
right: { val: -3, left: null, right: null }
};
console.log(pathSum(tree3, -3)); // 应该输出2
6. 实际编码中的注意事项
- 哈希表的初始化:不要忘记设置初始值
map.set(0, 1) - 回溯时的减操作:在递归返回前必须减少当前前缀和的计数
- 节点值为0的情况:需要正确处理,因为JavaScript中
map.get(0)和undefined是不同的 - 大数问题:虽然LeetCode的测试用例不会超出Number范围,但在实际应用中可能需要考虑大数问题
7. 算法扩展思考
这个前缀和的思路可以推广到其他树形路径问题:
- 找出路径和等于目标值的所有路径(而不仅仅是计数)
- 找出平均值等于目标值的路径
- 在N叉树中寻找满足条件的路径
对于更复杂的变种,比如路径方向不限(可以从子节点到父节点),就需要使用更复杂的图算法来解决了。
8. 个人实现心得
在实际编码时,我最初尝试了双重递归的暴力解法,虽然通过了测试,但运行时间确实比较长(击败了约30%的提交)。后来学习前缀和解法时,最困惑的是为什么要设置map.set(0, 1)这个初始条件。经过多次调试才理解,这是为了处理从根节点开始的路径。
另一个容易出错的地方是回溯时的状态恢复。有次忘记在递归返回前减少计数,导致哈希表中的计数一直累积,结果完全错误。这也提醒我,在递归中使用全局数据结构时,必须仔细管理状态。
最后,对于这类问题,画图分析特别有帮助。在纸上画出树结构,手动计算几个例子的前缀和变化,能帮助更好地理解算法原理。