从一次错误实现看懂前缀和解法:Path Sum III 深度解析
有些算法你以为自己会了,
直到它在几个边界条件上毫不留情地反咬你一口。
这篇文章不是从"标准答案"开始,而是从一次真实的错误实现出发,一步步拆解:
-
为什么思路是对的
-
为什么结果却是错的
-
错在哪里
-
正确解法为什么必须"长这样"
如果你已经刷过 LeetCode 437(Path Sum III),那这篇文章会把你从"会做"推到"真正理解"。
一、问题回顾:Path Sum III
题目要求:
给定一棵二叉树和一个目标值
targetSum,统计树中 路径和等于 targetSum 的路径数量。
路径可以从任意节点开始,到任意节点结束,
但必须是 向下的连续路径。
这道题最容易想到的做法是:
-
枚举起点
-
枚举终点
但时间复杂度是 O(n²),在大树上直接超时。
于是我们引出了真正的主角:前缀和(Prefix Sum)。
二、正确但危险的思路:树上的前缀和
1. 核心思想
在数组中,前缀和的经典公式是:
sum(i, j) = prefix[j] - prefix[i-1]
在树中,路径是从根向下的,因此我们可以把:
-
从根到当前节点的路径和
-
看作一个"树上的前缀和"
于是,对任意一个节点:
以该节点为结尾、和为 targetSum 的路径数量 =
当前前缀和 - targetSum出现过的次数
这一步,是整道题的灵魂。
三、第一次实现:思路对了,结果却错了
很多人(包括我)第一次写出来的代码,大致结构是这样的:
-
DFS 遍历整棵树
-
用
unordered_map统计前缀和出现次数 -
每到一个节点,就尝试统计答案
但实际运行后,你会发现:
-
有的用例少算
-
有的用例多算
-
有的用例看起来"随机错"
这说明:
错误不在思路,而在细节顺序和初始化上。
下面是三个最典型、也最容易被忽略的坑。
四、错误一:忘记初始化 prefixSum = 0
现象
从根节点开始、恰好等于 targetSum 的路径,永远统计不到。
原因分析
前缀和解法默认一个前提:
在遍历开始前,
存在一个前缀和为 0 的"虚拟起点"
如果不手动加入:
counts[0] = 1;
那么:
-
当
curSum == targetSum -
你实际在查的是
counts[0] -
但 map 里根本没有这个 0
本质原因
前缀和不是从第一个节点开始的,而是从"路径开始之前"开始的。
五、错误二:统计顺序写反了(这是最致命的)
错误写法
curSum += node->val;
counts[curSum]++;
res += counts[curSum - targetSum];
看似合理,实际很危险
你在做两件事:
-
把当前前缀和加入 map
-
用 map 来统计答案
但问题是:
你把"当前节点"也当成了"之前的路径"。
在某些情况下,会导致:
-
多统计一条路径
-
或者产生幽灵答案
正确顺序(必须背下来)
curSum += node->val;
// 先统计
res += counts[curSum - targetSum];
// 再记录当前前缀和
counts[curSum]++;
一句话记忆法:
先用历史,再写历史。
六、错误三:unordered_map::operator[] 的隐形副作用
常见写法
if (counts[x] > 0) {
res += counts[x];
}
实际发生了什么?
-
如果
x不存在 -
operator[]会 自动插入一个 key,值为 0
结果:
-
map 里出现大量无意义的 key
-
调试难度陡增
-
思维模型被悄悄污染
更干净的写法
res += counts[curSum - targetSum];
或者使用 find。
七、完整正确解法(回溯是关键)
把上面三个问题全部修正后,完整逻辑是:
-
DFS 遍历
-
维护当前路径前缀和
-
用 map 统计历史前缀和
-
回溯时撤销当前节点的影响
回溯这一步非常重要:
前缀和只对"当前路径"有效,
离开节点后必须撤销。
八、为什么这道题值得反复咀嚼?
因为它暴露了一个很残酷、也很真实的事实:
算法题的难点,往往不在公式,
而在状态的生命周期管理。
你不是不会前缀和,
你只是还没完全掌控:
-
状态什么时候生效
-
什么时候失效
-
哪些状态属于"历史"
九、总结一句话(送给正在刷题的你)
真正的算法能力,
不是"我知道这道题用什么",
而是:
"我知道每一行代码,
为什么必须写在这里。"
如果你能把这道题讲清楚,
二叉树 + 前缀和这一类问题,
基本就被你拿下了。
/**
* 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:
int targetSum;
int res;
std::unordered_map<double, int> counts;
int pathSum(TreeNode* root, int tS) {
targetSum = tS;
res = 0;
counts[0]=1;
traverse(root, 0.0);
return res;
}
void traverse(TreeNode* root, double curSum){
if(root == nullptr){
return;
}
curSum += root->val;
if(counts[curSum-targetSum] > 0){
res += counts[curSum-targetSum];
}
++counts[curSum];
traverse(root->left, curSum);
traverse(root->right, curSum);
--counts[curSum];
}
};
如果你愿意继续深入,这道题还能引出:
-
树上状态压缩
-
前缀和的可迁移模板
-
为什么高性能代码里要极力避免隐式副作用
这些,才是真正"工程级"的算法理解。