【LeetCode Hot100 刷题日记 (48/100)】路径总和 III —— 前缀和 + DFS / 双重递归🌳

6 阅读6分钟

📌 题目链接:437. 路径总和 III - 力扣(LeetCode)

🔍 难度:中等 | 🏷️ 标签:树、深度优先搜索(DFS)、前缀和、哈希表、回溯

⏱️ 目标时间复杂度:O(N)(前缀和优化法)

💾 空间复杂度:O(N)


📖 题目分析

给定一棵二叉树的根节点 root 和一个整数 targetSum,要求统计所有向下路径(从父到子,不必从根开始,也不必在叶结束)中,节点值之和等于 targetSum 的路径数目

关键点:

  • 路径必须是连续且向下的(即只能从祖先到后代)。
  • 起点可以是任意节点,终点也可以是任意节点(只要在起点下方)。
  • 节点值可为负数!这意味着路径和可能先增大后减小,甚至多次命中目标值。
  • 不能只考虑从根出发的路径,这是与“路径总和 I/II”的核心区别。

💡 举个例子:若某条路径上存在 [10, 5, -3, 3],而 targetSum = 8,那么:

  • 5 → 3(5+3=8)
  • 10 → 5 → -3 → 3 → -2 中的 5 → 2 → 1(5+2+1=8)
  • 10 → -3 → 11(10-3+11=18 ≠ 8,但其他组合可能成立)

因此,暴力枚举每个起点再向下搜索是一种可行思路,但效率低;更优解是借鉴数组中“和为 K 的子数组” (LeetCode 560)的思想——前缀和 + 哈希表


⚙️ 核心算法及代码讲解

本题有两种主流解法:

✅ 方法一:双重递归(朴素 DFS)—— O(N²)

  • 对每个节点,都当作起点,向下搜索所有可能路径。
  • 时间复杂度高,但在面试中容易写出,适合保底。

✅✅ 方法二:前缀和 + DFS + 哈希表(推荐)—— O(N)

  • 核心思想:在一条从根到当前节点的路径上,若存在两个前缀和 prefix[i]prefix[j](i < j),满足 prefix[j] - prefix[i] = targetSum,则从第 i+1 个节点到第 j 个节点的路径和为 targetSum
  • 使用哈希表记录当前路径上各前缀和出现的次数
  • 关键细节:进入子树前记录当前前缀和,退出时要回溯(remove 或 decrement) ,避免不同分支互相干扰。

⚠️ 注意:由于节点值范围大(±1e9),且路径长可达 1000,总和可能溢出 int,必须使用 long long

下面重点讲解方法二的 C++ 实现(带详细行注释):

class Solution {
public:
    unordered_map<long long, int> prefix; // 哈希表:记录当前路径上前缀和的出现次数

    // curr: 从根到当前节点的路径和(不含当前节点?不!这里包含!)
    // 实际上,curr 在进入函数后立即 += root->val,所以代表包含当前节点的前缀和
    int dfs(TreeNode* root, long long curr, int targetSum) {
        if (!root) return 0;

        int ret = 0;
        curr += root->val; // 更新当前前缀和(包含当前节点)

        // 检查是否存在前缀和 = curr - targetSum
        // 若存在,说明从那个前缀和之后到当前节点的路径和为 targetSum
        if (prefix.count(curr - targetSum)) {
            ret = prefix[curr - targetSum]; // 累加所有满足条件的路径数
        }

        // 将当前前缀和加入哈希表(用于后续子节点查询)
        prefix[curr]++;

        // 递归左右子树
        ret += dfs(root->left, curr, targetSum);
        ret += dfs(root->right, curr, targetSum);

        // 回溯!退出当前节点前,移除其对哈希表的影响
        // 否则右子树会错误地看到左子树的前缀和
        prefix[curr]--;

        return ret;
    }

    int pathSum(TreeNode* root, int targetSum) {
        prefix[0] = 1; // 初始化:空路径的前缀和为 0,出现 1 次
        // 这样当某条路径从根开始恰好等于 targetSum 时,也能被正确计数
        return dfs(root, 0, targetSum);
    }
};

🔑 为什么初始化 prefix[0] = 1
假设从根到某节点的路径和正好是 targetSum,那么 curr - targetSum = 0。如果没有 prefix[0]=1,就无法计数这条完整路径。


🧩 解题思路(分步骤)

方法二前缀和步骤详解:

  1. 定义前缀和:从根节点到当前节点(含)的路径上所有节点值之和。

  2. 建立映射:用哈希表 prefix 记录当前 DFS 路径上所有前缀和及其出现次数。

  3. 初始化prefix[0] = 1,表示“空路径”的前缀和为 0(用于匹配从根开始的合法路径)。

  4. DFS 遍历

    • 进入节点,更新当前前缀和 curr += root->val

    • 查询 curr - targetSum 是否存在于 prefix 中:

      • 若存在,说明有 prefix[curr - targetSum] 条路径以当前节点为终点、和为 targetSum
    • 将当前 curr 加入 prefix

    • 递归处理左右子树。

    • 回溯:从 prefix 中移除当前 curr(避免影响兄弟子树)。

  5. 返回结果:累计所有满足条件的路径数。

💡 类比数组问题:这本质上就是把树的一条根到叶的路径看作一个数组,求其中“和为 targetSum 的连续子数组个数”。而 DFS 保证我们遍历了所有可能的“数组”(每条根到叶路径)。


📊 算法分析

方法时间复杂度空间复杂度适用场景
双重递归O(N²)O(N)(递归栈)节点少、面试快速实现
前缀和 + 哈希O(N)O(N)(哈希表 + 递归栈)最优解,面试加分项

🎯 面试建议

  • 先写双重递归,说明思路清晰。
  • 再提出优化:能否避免重复计算?引出前缀和思想。
  • 强调回溯的必要性(否则哈希表污染不同分支)。
  • 提醒整数溢出问题(必须用 long long)。

💻 代码

✅C++

#include <bits/stdc++.h>
using namespace std;
using ll = long long;

// Definition for a binary tree node.
struct TreeNode {
    int val;
    TreeNode *left;
    TreeNode *right;
    TreeNode() : val(0), left(nullptr), right(nullptr) {}
    TreeNode(int x) : val(x), left(nullptr), right(nullptr) {}
    TreeNode(int x, TreeNode *left, TreeNode *right) : val(x), left(left), right(right) {}
};

class Solution {
public:
    unordered_map<long long, int> prefix;

    int dfs(TreeNode* root, long long curr, int targetSum) {
        if (!root) return 0;
        int ret = 0;
        curr += root->val;
        if (prefix.count(curr - targetSum)) {
            ret = prefix[curr - targetSum];
        }
        prefix[curr]++;
        ret += dfs(root->left, curr, targetSum);
        ret += dfs(root->right, curr, targetSum);
        prefix[curr]--;
        return ret;
    }

    int pathSum(TreeNode* root, int targetSum) {
        prefix[0] = 1;
        return dfs(root, 0, targetSum);
    }
};

// 测试
signed main(){
    ios::sync_with_stdio(false);
    cin.tie(nullptr);
    cout.tie(nullptr);

    // 构建示例1: [10,5,-3,3,2,null,11,3,-2,null,1]
    TreeNode* root = new TreeNode(10);
    root->left = new TreeNode(5);
    root->right = new TreeNode(-3);
    root->left->left = new TreeNode(3);
    root->left->right = new TreeNode(2);
    root->right->right = new TreeNode(11);
    root->left->left->left = new TreeNode(3);
    root->left->left->right = new TreeNode(-2);
    root->left->right->right = new TreeNode(1);

    Solution sol;
    cout << "Example 1: " << sol.pathSum(root, 8) << " (expected: 3)" << endl;

    // 构建示例2: [5,4,8,11,null,13,4,7,2,null,null,5,1]
    TreeNode* root2 = new TreeNode(5);
    root2->left = new TreeNode(4);
    root2->right = new TreeNode(8);
    root2->left->left = new TreeNode(11);
    root2->right->left = new TreeNode(13);
    root2->right->right = new TreeNode(4);
    root2->left->left->left = new TreeNode(7);
    root2->left->left->right = new TreeNode(2);
    root2->right->right->left = new TreeNode(5);
    root2->right->right->right = new TreeNode(1);

    cout << "Example 2: " << sol.pathSum(root2, 22) << " (expected: 3)" << endl;

    return 0;
}

✅JavaScript

/**
 * Definition for a binary tree node.
 * function TreeNode(val, left, right) {
 *     this.val = (val===undefined ? 0 : val)
 *     this.left = (left===undefined ? null : left)
 *     this.right = (right===undefined ? null : right)
 * }
 */

var pathSum = function(root, targetSum) {
    const prefix = new Map();
    prefix.set(0, 1); // 初始化空路径前缀和
    return dfs(root, prefix, 0, targetSum);
};

const dfs = (root, prefix, curr, targetSum) => {
    if (root == null) {
        return 0;
    }

    let ret = 0;
    curr += root.val;

    // 查找是否存在前缀和 = curr - targetSum
    ret = prefix.get(curr - targetSum) || 0;

    // 更新当前前缀和的计数
    prefix.set(curr, (prefix.get(curr) || 0) + 1);

    // 递归子树
    ret += dfs(root.left, prefix, curr, targetSum);
    ret += dfs(root.right, prefix, curr, targetSum);

    // 回溯:恢复哈希表状态
    prefix.set(curr, prefix.get(curr) - 1);

    return ret;
};

🌟 本期完结,下期见!🔥

👉 点赞收藏加关注,新文更新不迷路。关注专栏【算法】LeetCode Hot100刷题日记,持续为你拆解每一道热题的底层逻辑与面试技巧!

💬 欢迎留言交流你的解法或疑问!一起进步,冲向 Offer!💪

📌 记住:当你在刷题时,不要只看答案,要像写这篇文章一样,深入思考每一步背后的原理、优化空间和面试价值。这才是真正提升算法能力的方式!