1. 问题理解与解法概述
在二叉树中寻找路径和等于给定值的路径数量是一个经典的算法问题。题目LeetCode 437要求我们统计所有满足以下条件的路径数量:路径上的节点值之和等于targetSum,且路径方向必须是从父节点指向子节点(不需要从根节点开始,也不需要在叶子节点结束)。
1.1 常规思路的局限性
最直观的解法是使用深度优先搜索(DFS)遍历所有可能的路径,并计算每条路径的和。具体来说,可以从每个节点出发,向下遍历其所有子节点,累加路径上的节点值,当和等于targetSum时计数。这种方法的时间复杂度为O(n^2),对于较大的二叉树效率较低。
注意:这种暴力解法在节点数较多时(例如树退化为链表)会超时,因为需要重复计算大量子路径的和。
1.2 前缀和解法的优势
前缀和解法将这个问题的时间复杂度优化到O(n)。其核心思想借鉴了数组中的前缀和技巧:在遍历二叉树时,记录从根节点到当前节点的路径和(称为前缀和),然后通过检查哈希表中是否存在当前前缀和 - targetSum的值,来快速判断是否存在符合条件的路径。
这种方法的优势在于:
- 只需要遍历树一次
- 利用哈希表实现O(1)时间复杂度的查询
- 避免了重复计算子路径的和
2. 前缀和解法详解
2.1 前缀和的定义与应用
在二叉树中,一个节点的前缀和定义为从根节点到该节点的路径上所有节点值的和。例如:
- 根节点的前缀和就是其本身的值
- 某个子节点的前缀和是其父节点前缀和加上该子节点自身的值
前缀和的关键性质是:两个节点之间的路径和等于它们前缀和的差值。具体来说,如果节点A的前缀和是prefix_A,节点B的前缀和是prefix_B,且B是A的子孙节点,那么从A到B的路径和就是prefix_B - prefix_A。
2.2 哈希表的运用
我们使用哈希表来记录遍历过程中遇到的前缀和及其出现的次数。这样可以在O(1)时间内查询是否存在某个特定的前缀和值。
哈希表的初始化需要包含一个键值对(0,1),表示空路径的前缀和为0,出现了1次。这是为了处理从根节点开始的路径。
2.3 递归遍历与状态维护
算法采用深度优先搜索(DFS)的方式遍历二叉树:
- 对于当前节点,计算从根节点到当前节点的前缀和(curSum)
- 检查哈希表中是否存在
curSum - targetSum的键:- 如果存在,说明找到了符合条件的路径,将对应的值累加到结果中
- 将当前前缀和更新到哈希表中(出现次数+1)
- 递归处理左右子树
- 在返回前,从哈希表中移除当前前缀和(状态恢复)
状态恢复是必须的,确保在回溯时不会将不属于当前路径分支的前缀和计入统计。
3. 代码实现与解析
3.1 完整代码实现
java复制class Solution {
Map<Long, Integer> prefixMap;
int target;
public int pathSum(TreeNode root, int targetSum) {
prefixMap = new HashMap<>();
target = targetSum;
prefixMap.put(0L, 1); // 初始化空路径
return recur(root, 0L);
}
private int recur(TreeNode node, Long curSum) {
if (node == null) {
return 0;
}
int res = 0;
curSum += node.val; // 计算当前前缀和
// 检查是否存在满足条件的前缀和
res += prefixMap.getOrDefault(curSum - target, 0);
// 更新当前前缀和到哈希表
prefixMap.put(curSum, prefixMap.getOrDefault(curSum, 0) + 1);
// 递归处理子树
int left = recur(node.left, curSum);
int right = recur(node.right, curSum);
res += left + right; // 汇总结果
// 状态恢复
prefixMap.put(curSum, prefixMap.get(curSum) - 1);
return res;
}
}
3.2 关键代码解析
-
初始化部分:
prefixMap.put(0L, 1):处理从根节点开始的路径- 使用
Long类型存储前缀和,防止整数溢出
-
递归函数recur:
curSum参数表示从根节点到当前节点父节点的前缀和curSum += node.val计算当前节点的完整前缀和res += prefixMap.getOrDefault(curSum - target, 0):查找满足条件的前缀和数量- 更新哈希表后递归处理左右子树
- 最后进行状态恢复,确保不影响其他分支的统计
-
状态恢复:
prefixMap.put(curSum, prefixMap.get(curSum) - 1):在返回父节点前,将当前前缀和的计数减1- 如果计数减到0,理论上应该从哈希表中移除该键,但Java的HashMap允许值为0的键存在,不影响功能
4. 复杂度分析与边界条件
4.1 时间复杂度
- 每个节点只被访问一次,每次访问时的哈希表操作都是O(1)时间复杂度
- 因此总体时间复杂度为O(n),其中n是树中的节点数
4.2 空间复杂度
- 哈希表在最坏情况下需要存储O(n)个前缀和(例如树退化为链表)
- 递归调用栈的空间复杂度在最坏情况下也是O(n)
- 因此总体空间复杂度为O(n)
4.3 边界条件处理
- 空树:直接返回0
- 单个节点:检查节点值是否等于targetSum
- 节点值为负数:算法依然适用,因为前缀和差值原理不受数值正负影响
- 大数情况:使用
Long类型避免整数溢出
5. 实际应用与变种
5.1 实际应用场景
这种前缀和解法可以应用于:
- 文件系统中查找特定大小的目录结构
- 组织结构中满足特定条件的汇报路径统计
- 任何需要统计树形结构中满足特定条件的路径的场景
5.2 算法变种与扩展
- 输出所有符合条件的路径:而不仅仅是计数,需要记录路径节点
- 路径方向不限:如果允许从子节点到父节点的路径,问题会更复杂
- 多叉树版本:算法可以很容易扩展到多叉树的情况
- 带权图的最短路径:类似思想可以用于某些特定图算法
6. 常见问题与调试技巧
6.1 常见错误
- 忘记初始化空路径:会导致无法统计从根节点开始的路径
- 状态恢复不正确:会导致统计结果包含不属于当前分支的路径
- 整数溢出:未使用long类型处理大数情况
- 重复计数:在递归返回时错误地累加了结果
6.2 调试建议
- 对小规模树(3-5个节点)手动计算预期结果
- 打印遍历过程中的前缀和和哈希表状态
- 检查状态恢复是否正确执行
- 验证边界条件(空树、单节点树等)
6.3 测试用例设计
好的测试用例应包括:
- 常规二叉树
- 退化为链表的树
- 所有节点值相同的情况
- 包含正负数和零的节点值
- 大型随机生成的树
例如:
java复制// 测试用例1:常规二叉树
// 10
// / \
// 5 -3
// / \ \
// 3 2 11
// / \ \
// 3 -2 1
TreeNode root1 = buildTree1();
assert pathSum(root1, 8) == 3;
// 测试用例2:空树
assert pathSum(null, 0) == 0;
// 测试用例3:单节点树
TreeNode root3 = new TreeNode(100);
assert pathSum(root3, 100) == 1;
7. 性能优化与替代方案
7.1 性能优化
- 使用原生类型哈希表:如果确定数值范围,可以使用更高效的哈希表实现
- 迭代代替递归:对于极深的树,可以改用迭代方式避免栈溢出
- 并行处理:对于非常大的树,可以考虑并行处理不同子树
7.2 替代方案比较
-
双重递归暴力法:
- 优点:实现简单直观
- 缺点:时间复杂度O(n^2),不适合大规模数据
-
前缀和+哈希表法:
- 优点:时间复杂度O(n),适合大规模数据
- 缺点:实现稍复杂,需要理解前缀和原理
-
动态规划思想:
- 可以尝试自底向上的DP解法
- 但实现起来不如前缀和方法直观
在实际应用中,前缀和解法在大多数情况下都是最优选择,特别是在处理大规模数据时。