使用前缀和与哈希表高效解决二叉树路径和问题

28 阅读15分钟

在二叉树问题中,路径和是一个常见且重要的主题。LeetCode 437题"Path Sum III"要求我们找出二叉树中所有路径和等于给定目标值的路径数量,且路径必须向下(父→子),但不要求从根节点开始或在叶子节点结束。这使得问题复杂度显著增加,因为需要考虑所有可能的路径起点。本篇学习笔记将深入讲解如何使用前缀和与哈希表相结合的高效算法来解决这一问题,帮助您理解算法原理、实现细节及优化策略。

一、问题背景与要求

问题描述

给定一个二叉树的根节点root和一个整数targetSum,求该二叉树中所有路径和等于targetSum的路径数目。这些路径必须向下(父→子),但不要求从根节点开始或在叶子节点结束。

示例

输入:root = [10,5,-3,3,2,null,11,3,-2,null,1], targetSum = 8 输出:3 解释:和等于8的路径有3条:

  1. 5 → 3
  2. 5 → 2 → 1
  3. -3 → 11

约束条件

  • 二叉树节点数量在0到1000之间
  • 节点值范围在-1000000到1000000之间
  • 路径必须向下(父→子),但不要求从根或叶子节点开始/结束

二、前缀和算法原理

1. 前缀和的基本概念

前缀和是一种预处理技术,用于快速计算序列中任意区间的和。在二叉树路径和问题中,前缀和定义为从根节点到当前节点路径上所有节点值的累加和。通过维护前缀和,可以避免重复计算,提高效率。

2. 差值原理

假设我们从根节点到当前节点的前缀和为currentSum,如果存在一个祖先节点,其前缀和为currentSum - targetSum,那么从该祖先节点的下一个节点到当前节点的路径和就是targetSum。这个差值原理是算法的核心,它允许我们在O(1)时间内判断是否存在符合条件的路径。

3. 哈希表的作用

哈希表(Map对象)用于记录前缀和的出现次数,其键(key)为前缀和值,值(value)为该前缀和出现的次数。这种映射关系使得我们可以快速查找是否存在特定的前缀和,从而确定路径和是否符合条件。

4. 算法流程

  1. 初始化一个Map对象,记录前缀和出现的次数,初始时Map中包含{0:1},表示空路径的和为0,出现1次。
  2. 使用深度优先搜索(DFS)遍历二叉树,计算从根节点到当前节点的前缀和。
  3. 在每一步计算当前节点的前缀和后,检查Map中是否存在currentSum - targetSum的前缀和,若存在,则累加对应的次数到结果中。
  4. 将当前前缀和添加到Map中(或更新其出现次数)。
  5. 递归处理当前节点的左右子树。
  6. 回溯:在处理完当前节点的子树后,从Map中移除当前前缀和的记录,确保不影响其他路径的统计。

三、Map对象在算法中的作用

1. 存储前缀和出现次数

Map对象主要用于记录从根节点到当前节点路径上所有可能的前缀和及其出现次数。这种映射关系使得我们可以快速查找是否存在特定的前缀和,从而确定路径和是否符合条件。

2. 初始化空路径

Map初始化时设置prefixSumCount.set(0, 1),表示空路径的和为0,出现1次。这一步非常重要,因为它允许我们正确统计从根节点开始且和为目标值的路径。例如,如果根节点的值正好等于targetSum,那么currentSum - targetSum = 0,Map中存在0的记录,计数就会增加1。

3. 动态维护与回溯

在DFS遍历过程中,Map需要动态维护当前路径的前缀和记录。当进入一个节点时,将当前前缀和添加到Map中;当离开该节点时,需要从Map中移除当前前缀和的记录(回溯)。这确保了Map只包含当前路径上的前缀和信息,不会污染其他路径的统计。

4. 与普通对象的对比

使用Map而非普通对象有以下优势:

  • 键的多样性:Map的键可以是任意类型,包括数值,而普通对象的键会自动转换为字符串。
  • 高效查找:Map的get()set()操作在平均情况下为O(1)时间复杂度。
  • 有序性:Map会记住键的原始插入顺序,虽然这在本题中不是必须的。

四、JavaScript代码实现详解

var pathSum = function(root, targetSum) {
    // 创建Map对象记录前缀和出现次数
    const prefixSumCount = new Map();
    prefixSumCount.set(0, 1); // 初始化空路径

    // DFS函数,参数为当前节点和当前前缀和
    const dfs = (node, currentSum) => {
        // 递归终止条件:空节点返回0
        if (!node) return 0;

        // 更新当前前缀和:加上当前节点的值
        currentSum += node.val;

        // 查找是否存在符合条件的前缀和
        let count = prefixSumCount.get(currentSum - targetSum) || 0;

        // 将当前前缀和加入Map
        prefixSumCount.set(currentSum, (prefixSumCount.get(currentSum) || 0) + 1);

        // 递归处理左右子树
        count += dfs(node.left, currentSum);
        count += dfs(node.right, currentSum);

        // 回溯:移除当前前缀和的记录
        prefixSumCount.set(currentSum, prefixSumCount.get(currentSum) - 1);

        return count;
    };

    // 从根节点开始DFS,初始前缀和为0
    return dfs(root, 0);
};

1. Map初始化

const prefixSumCount = new Map();
prefixSumCount.set(0, 1); // 初始化空路径
  • prefixSumCount:创建一个Map对象,用于记录前缀和出现的次数。
  • prefixSumCount.set(0, 1):初始化Map,设置前缀和为0出现1次。这对应空路径的情况,确保当路径和正好等于targetSum时能被正确统计。

2. DFS函数定义

const dfs = (node, currentSum) => {
    // 函数体...
};
  • node:当前处理的树节点。
  • currentSum:从根节点到当前节点的路径上所有节点值的累加和(即当前前缀和)。

3. 递归终止条件

if (!node) return 0;
  • 作用:当遍历到空节点时,没有路径可以继续,直接返回0。

4. 更新当前前缀和

currentSum += node.val; // ✅ 修复:+=不能有空格
  • 作用:将当前节点的值累加到前缀和中,得到新的前缀和。

5. 计算符合条件的路径数

let count = prefixSumCount.get(currentSum - targetSum) || 0;
  • 原理:根据差值原理,如果存在祖先节点的前缀和等于currentSum - targetSum,则从该祖先节点的下一个节点到当前节点的路径和为targetSum
  • 实现:使用Map的get()方法查找currentSum - targetSum的前缀和出现次数,如果不存在则返回0。

6. 将当前前缀和加入Map

// 将当前前缀和记录到哈希表中(用于后续子节点查询)
prefixSumCount.set(currentSum,
    (prefixSumCount.get(currentSum) || 0) + 1);
  • 作用:将当前前缀和添加到Map中,或者更新其出现次数。这为后续节点的路径和计算提供参考。

7. 递归处理子树

count += dfs(node.left, currentSum);
count += dfs(node.right, currentSum);
  • 作用:递归遍历当前节点的左子树和右子树,累加找到的路径数。

8. 回溯操作

// ⚠️ 回溯:离开当前子树前,撤销当前前缀和的记录
prefixSumCount.set(currentSum, prefixSumCount.get(currentSum) - 1);
  • 作用:在DFS回溯时,减少当前前缀和的计数,确保其他分支(如右子树)不会受到当前分支(左子树)的干扰。

9. 返回结果

return count;
  • 作用:返回当前节点及其子树中找到的符合条件的路径总数。

10. 启动DFS

// 从根节点开始DFS,初始前缀和为0
return dfs(root, 0);
  • 作用:从根节点开始进行DFS遍历,初始前缀和为0。

五、算法执行过程示例

示例树结构

      10
     /  \
    5   -3
   / \    \
  3   2   11
 / \   \
3  -2   1

目标值

targetSum = 8

算法执行步骤

步骤 1: 初始化Map

Map: {0: 1}

步骤 2: 处理根节点10

currentSum = 0 + 10 = 10
查找currentSum - targetSum = 10 - 8 = 2 → Map中不存在,count=0currentSum=10加入Map → Map: {0:1, 10:1}

步骤 3: 处理左子节点5

currentSum = 10 + 5 = 15
查找currentSum - targetSum = 15 - 8 = 7 → Map中不存在,count=0currentSum=15加入Map → Map: {0:1, 10:1, 15:1}

步骤 4: 处理左子节点3

currentSum = 15 + 3 = 18
查找currentSum - targetSum = 18 - 8 = 10 → Map中存在,count=1currentSum=18加入Map → Map: {0:1, 10:1, 15:1, 18:1}

步骤 5: 处理左子节点3(叶子节点)

currentSum = 18 + 3 = 21
查找currentSum - targetSum = 21 - 8 = 13 → Map中不存在,count=0currentSum=21加入Map → Map: {0:1, 10:1, 15:1, 18:1, 21:1}
递归返回后回溯:
减少21的计数 → Map: {0:1, 10:1, 15:1, 18:1, 21:0} → 实际可能删除该键

步骤 6: 返回处理左子节点-2

currentSum = 18 + (-2) = 16
查找currentSum - targetSum = 16 - 8 = 8 → Map中不存在,count=0currentSum=16加入Map → Map: {0:1, 10:1, 15:1, 18:1, 16:1}
递归返回后回溯:
减少16的计数 → Map: {0:1, 10:1, 15:1, 18:1, 16:0} → 实际可能删除该键

步骤 7: 返回处理左子节点2

currentSum = 15 + 2 = 17
查找currentSum - targetSum = 17 - 8 = 9 → Map中不存在,count=0currentSum=17加入Map → Map: {0:1, 10:1, 15:1, 17:1}

步骤 8: 处理左子节点-2

currentSum = 17 + (-2) = 15
查找currentSum - targetSum = 15 - 8 = 7 → Map中不存在,count=0currentSum=15的计数增加 → Map: {0:1, 10:1, 15:2, 17:1}

步骤 9: 处理左子节点1

currentSum = 15 + 1 = 16
查找currentSum - targetSum = 16 - 8 = 8 → Map中不存在,count=0currentSum=16加入Map → Map: {0:1, 10:1, 15:2, 17:1, 16:1}
递归返回后回溯:
减少16的计数 → Map: {0:1, 10:1, 15:2, 17:1, 16:0} → 实际可能删除该键

步骤 10: 返回处理左子节点2

减少15的计数 → Map: {0:1, 10:1, 15:1, 17:1}
递归返回后回溯:
减少17的计数 → Map: {0:1, 10:1, 15:1, 17:0} → 实际可能删除该键

步骤 11: 返回处理左子节点5

减少15的计数 → Map: {0:1, 10:1, 15:0} → 实际可能删除该键
递归返回后回溯:
减少10的计数 → Map: {0:1, 10:0} → 实际可能删除该键

步骤 12: 处理右子节点-3

currentSum = 0 + (-3) = -3
查找currentSum - targetSum = -3 - 8 = -11 → Map中不存在,count=0currentSum=-3加入Map → Map: {-3:1, 0:1}

步骤 13: 处理右子节点11

currentSum = -3 + 11 = 8
查找currentSum - targetSum = 8 - 8 = 0 → Map中存在,count=1currentSum=8加入Map → Map: {-3:1, 0:1, 8:1}

步骤 14: 处理右子节点1(叶子节点)

currentSum = 8 + 1 = 9
查找currentSum - targetSum = 9 - 8 = 1 → Map中不存在,count=0currentSum=9加入Map → Map: {-3:1, 0:1, 8:1, 9:1}
递归返回后回溯:
减少9的计数 → Map: {-3:1, 0:1, 8:1, 9:0} → 实际可能删除该键

步骤 15: 返回处理右子节点11

减少8的计数 → Map: {-3:1, 0:1, 8:0} → 实际可能删除该键
递归返回后回溯:
减少-3的计数 → Map: {-3:0, 0:1} → 实际可能删除该键

步骤 16: 返回处理根节点10

减少0的计数 → Map: {0:0} → 实际可能删除该键

最终结果

整个算法执行过程中,共找到3条符合条件的路径,与示例输出一致。

六、算法复杂度分析

时间复杂度

O(N),其中N是二叉树的节点数量。每个节点仅被访问一次,而Map的查询、插入和删除操作在平均情况下均为O(1)时间复杂度。即使在最坏情况下,这些操作的复杂度也仅为O(logN),因此整体时间复杂度为线性。

空间复杂度

O(H),其中H是二叉树的高度。这主要由两部分组成:

  1. 递归栈空间:DFS递归的最大深度为树的高度H。
  2. Map存储空间:在最坏情况下(如所有节点值都相同),Map可能存储O(H)种不同的前缀和。

对于平衡二叉树,空间复杂度为O(logN);对于退化为链表的二叉树,空间复杂度为O(N)。

七、算法优化与注意事项

1. 回溯操作的优化

在回溯阶段,可以添加以下优化:

let currCount = prefixSumCount.get(currentSum);
if (currCount === 1) {
    prefixSumCount.delete(currentSum); // 清理无用键
} else {
    prefixSumCount.set(currentSum, currCount - 1);
}

这可以避免Map中保留大量值为0的键,提高空间利用率。

2. 路径和为0的情况

targetSum为0时,算法仍然有效。初始Map中0:1的设置确保了路径和为0的路径能够被正确统计。

3. 负数节点的处理

算法可以处理负数节点的情况,因为前缀和的差值原理不依赖于节点值的正负。这使得算法比那些依赖于节点值单调性的方法更为通用。

4. 路径起点的灵活性

算法可以处理任意节点作为路径起点的情况,而无需额外的遍历。这是因为前缀和差值原理允许我们在一次DFS遍历中同时考虑所有可能的路径起点。

5. 与普通对象的对比

虽然可以使用普通对象代替Map,但由于Map的键可以是任意类型且不会自动转换为字符串,因此在处理数值类型的前缀和时更为准确。例如,普通对象中1"1"会被视为同一个键,而Map则会将它们视为不同的键。

八、算法实现的常见错误与解决

1. 运算符错误

// 错误写法
currentSum + = node.val;
// 正确写法
currentSum += node.val;

JavaScript中不能在运算符中间添加空格,否则会导致语法错误。

2. 注释符号错误

// 错误写法
/ 将当前前缀和记录到哈希表中...
// 正确写法
// 将当前前缀和记录到哈希表中...

JavaScript中单行注释应使用//而非/,否则会导致语法错误。

3. Map操作顺序错误

// 错误顺序
prefixSumCount.set(currentSum, (prefixSumCount.get(currentSum) || 0) + 1);
count = prefixSumCount.get(currentSum - targetSum) || 0;

正确的顺序应该是先计算count再更新Map。如果顺序颠倒,当targetSum为0时,会导致当前节点的路径被重复计算。

4. 回溯遗漏

// 错误写法
count += dfs(node.left, currentSum);
count += dfs(node.right, currentSum);
// 没有回溯操作

必须在递归处理完子节点后执行回溯操作,否则Map会保留当前路径的前缀和信息,影响其他路径的统计。

九、算法变种与扩展思考

1. 找到所有路径而非统计数量

如果需要找到所有符合条件的路径而非仅仅统计数量,可以使用数组记录路径节点,并在找到符合条件的路径时将其保存。这会增加算法的空间复杂度,但思路类似。

2. 路径可以向上也可以向下

如果问题要求路径可以向上也可以向下,那么简单的前缀和方法就不再适用,需要考虑更复杂的算法,如使用双向DFS或记录所有可能的路径。

3. 路径长度限制

如果问题要求路径的长度(节点数量)不超过某个值,可以在DFS过程中记录当前路径的长度,并在递归时传递和更新这个长度信息。

4. 多路径起点统计

本算法已经可以处理所有可能的路径起点,这是其优势所在。如果需要统计从特定节点出发的路径,可以修改算法,在进入特定节点时才开始统计路径和。

十、总结与学习建议

1. 算法核心思想

前缀和与哈希表相结合的算法核心思想是通过记录前缀和的出现次数,利用差值原理快速判断是否存在符合条件的路径。这使得算法能够在O(N)时间复杂度内解决问题,相比暴力枚举方法的O(N^2)时间复杂度有显著优势。

2. 学习建议

  1. 理解前缀和原理:先掌握前缀和的基本概念和应用场景,再学习如何将其与哈希表结合使用。
  2. 实践代码实现:尝试自己实现该算法,理解每一步操作的意义和顺序。
  3. 分析边界条件:考虑各种边界条件,如空树、单节点树、节点值为负数等,确保算法的健壮性。
  4. 对比其他解法:了解暴力枚举解法和逐节点DFS回溯解法,比较它们的优缺点,加深对前缀和算法优势的理解。

3. 算法应用场景

该算法适用于以下场景:

  • 二叉树路径和统计问题
  • 数组子数组和统计问题
  • 其他需要快速判断区间和是否符合条件的问题

通过掌握前缀和与哈希表相结合的算法,您可以高效解决一系列与路径和相关的问题,不仅限于二叉树,还包括数组、图等多种数据结构。这种算法思想体现了算法设计中预处理差值原理的巧妙应用,值得深入学习和掌握。