说实话,LeetCode 560这道题我一开始很不屑。题目短得可怜,逻辑看起来也就那样,无非"找连续子数组,和等于k,返回个数"。结果第一次提交,我写了个O(n²)的暴力,直接被大数组教做人。后来学习前缀和的写法,代码短得离谱,但每次看都有一种"妙是妙,但下次我还能不能想出来"的虚浮感——直到我把"统计前缀和出现次数"和"统计子数组个数"这两件事在纸面上等价推了一遍,才真正觉得把这题吃透了。
这篇文,我不打算只甩一份标准题解。我会把这条思考路径完整走一遍:暴力的浪费在哪、前缀和改写了问题的什么、哈希计数为什么是"唯一合理"的下一步、以及那个经典的map[0]=1到底救了多少次场。无论你用的是Python还是C++,这篇都能直接落地。
1. 暴力解法的两个层次:为什么穷举会慢,慢在哪里
1.1 O(n³)到O(n²):第一次优化是"记账"
先看最原始的暴力。题目给你一个数组nums和一个整数k,要统计所有连续子数组中有多少个的和等于k。最直接的办法当然是枚举所有子数组,算每个子数组的和:
python复制def subarraySum(nums, k):
n = len(nums)
count = 0
for i in range(n):
for j in range(i, n):
total = 0
for t in range(i, j + 1):
total += nums[t]
if total == k:
count += 1
return count
三层for循环,时间复杂度O(n³)。在LeetCode上,这种写法连第50个用例都撑不过去。但这版代码的价值在于它完整定义了什么叫做"子数组":连续的、由下标i到j围成的一段,不是排列,不是组合。
第一层优化也很自然:反正每次都要从i加一圈到j,不如在和的过程中顺便记录。于是有了所谓的"枚举起点滑到终点":
python复制def subarraySum(nums, k):
n = len(nums)
count = 0
for i in range(n):
total = 0
for j in range(i, n):
total += nums[j]
if total == k:
count += 1
return count
这个优化并不复杂,但它确认了一个核心事实:我们每进入一个内层循环,就是在枚举所有"以i为左端点"的子数组的和。如果把数组长度看作n,这个版本的时间是O(n²),空间是O(1)。
1.2 O(n²)的瓶颈在哪里
O(n²)看起来已经"能跑",但对LeetCode的压测数据来说,n一旦到10⁴,就是10⁸次加法,Python直接超时。问题的本质是:我们仍然在做大量重复求和。
怎么消除重复求和?两个方向最自然:
- 用二维前缀和表,提前把所有区间和算好,查询时O(1),但建表仍是O(n²)。
- 用一维前缀和,每个位置只存从0到i的累计和,区间和变成两个前缀和相减。
事实证明,第二种方向才是通往O(n)的正路。因为它的数据结构只是一维数组,天然适合一次遍历后多次查询。而二维前缀和表虽然在"求任意区间和"的单个问题上很快,可遇到"统计有多少区间和等于k"这种计数问题,它没有减少枚举次数,查询再快也不行。
所以,从暴力到最优,真正的分水岭不是"怎么把区间和算得更快",而是"怎么把枚举次数本身降下来"。这就要引出前缀和。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 前缀和被发明之前:区间和,本质上就是两个端点的差
2.1 前缀和的定义与直觉
设原数组为nums,定义前缀和数组preSum,其中preSum[i]表示nums[0]到nums[i-1]的累加和。注意这个定义里i从0到n,preSum[0]=0是空数组的和。这是一个非常"反直觉"的细节:前缀和数组比原数组多一个元素。
为什么约定preSum[0]=0?因为只有这样才能优雅地表达"从0开始的子数组"。比如nums[0..2]的和,如果不用preSum[0]这个"空前缀",就必须单独判断边界,而这在后面的公式推导中会造成很多不必要的分支。
有了preSum之后,任意子数组nums[i..j]的和可以写成:
text复制sum(i..j) = preSum[j+1] - preSum[i]
这里i是左端点,j是右端点。这个式子看起来只是移项,但它把"一段连续区间求和"这样一个累积过程,变成了两个端点值的相减。在数据结构的语言里,就是从"区间查询"变成了"两点查询"。
打个生活化的比方。假如你记了每个月月底的银行存款余额,想知道3月到7月总共存了多少,不需要把4、5、6、7每个月的流水翻出来,直接用7月底余额减去2月底余额就行。这个"余额表"就是前缀和数组,月底节点就是下标。
2.2 从"区间和等于k"到"两数之差等于k"
回到题目。给定k,要找子数组个数,使得sum(i..j)=k。用前缀和替换:
text复制preSum[j+1] - preSum[i] = k
这里i是子数组左端点,j是右端点,且满足0 <= i <= j < n。如果不想纠结下标偏移,很多人更习惯写成:
text复制preSum[right] - preSum[left] = k
其中right是当前右端点对应的前缀和下标,left是左端点之前的前缀和下标(即preSum[0]到preSum[right-1]之间的某个值)。注意left一定小于等于right,因为子数组至少要包含一个元素,空子数组不计入。
于是问题从"找多少对(i,j)满足区间和等于k"变成了"找多少对(left, right)满足preSum[right] - preSum[left] = k"。这个变形很关键,因为现在它只关乎两个前缀和数值之间的关系,不再关心原数组里具体的数长什么样。换句话说,我们成功把问题降维到了一维数值匹配。
如果还是觉得抽象,可以这样想:原数组是"流水账",前缀和数组是"累计账本"。题目问"哪一段流水加起来等于k",等价于问"累计账本里哪两个数差值是k"。账本上的每个数都代表一个时间点的累计状态,两个状态之间的差值,就是那一段区间发生的变化。
2.3 这个改写给优化留下了什么口子
如果只写出preSum[right] - preSum[left] = k,那最直接的观察是:枚举右端点right时,我们需要知道它左边有多少个preSum[left]恰好等于preSum[right] - k。这句话是本篇最重要的一个转折点。
为什么这么说?因为枚举right是不可避免的——每个前缀和都可能成为某个子数组的右边界,一共n个,必须都走一遍。但"统计左边有多少个符合条件的preSum[left]"这件事情,未必需要每次都从头扫一遍。
"左边有哪些值、每个值出现了多少次"——这简直是为哈希表量身定做的问题。遍历过程中,右指针每移动一位,我们可以用O(1)的时间把新的前缀和登记到哈希表里;同样可以用O(1)的时间查一次"preSum[right] - k出现了几次"。两者相加,总的复杂度就是O(n)。
如果说前缀和是把区间和问题转化为"两点差"问题,那么哈希表就是把"两点差的匹配"问题转化为"一边遍历、一边登记、一边查询"的在线问题。两步加在一起,才构成了标准解法。
3. 哈希计数的神来之笔:统计前缀和出现次数为何等价于统计子数组个数
3.1 公式移项,一切豁然开朗
现在我们把核心公式再写一遍:
text复制preSum[right] - preSum[left] = k
移项,得到:
text复制preSum[left] = preSum[right] - k
这个式子看起来只是把k挪到了右边,但它改变了我们的视角。原来我们在找"两个前缀和的差值是一个固定值",现在变成了:遍历到某个right时,它左边出现过多少个值等于preSum[right]-k的preSum[left]?
为什么说"统计前缀和出现次数"就等价于"统计子数组个数"?因为每一个满足preSum[left] = preSum[right] - k的left,都能和当前right唯一构成一个子数组nums[left..right-1](注意原数组下标和前天下标的关系,这里不再纠结偏移,理解为一段连续区间即可)。一个left对应一个子数组,互不重叠,也互不重复。所以,左边出现的符合条件的preSum[left]的次数,恰好就是以当前right为右端点、满足和等于k的子数组个数。
把这个过程对每个right都执行一遍并累加,得到的就是全局答案。
3.2 用一个小例子把过程走一遍
拿题目里最常见的示例:nums = [1, 1, 1],k = 2。
初始化哈希表hashmap = {},并设置hashmap[0] = 1(后面会解释为什么)。
遍历过程如下表:
| 当前元素 | preSum(当前前缀和) | preSum - k | 哈希表中该值出现次数 | 子数组累计答案 | 哈希表更新 |
|---|---|---|---|---|---|
| 1 | 1 | -1 | 0 | 0 | |
| 1 | 2 | 0 | 1 | 1 | |
| 1 | 3 | 1 | 1 | 2 |
注意看第二个元素遍历完之后,答案变成了1。这是因为preSum=2时,preSum-k=0,而哈希表里0出现过一次,代表存在一个left,它的preSum[left]=0,这正是preSum[0],也就是从数组开头到当前右端点的子数组[1,1]的和等于2。第三个元素的时候,preSum=3,preSum-k=1,哈希表里1出现一次,代表left处的preSum=1,即子数组从第2个元素到第3个元素,和也是2。
最终答案为2。简单数一下也对:子数组[1,1](前两个)和[1,1](后两个),共两个。整个过程没有枚举任何子数组,只借助前缀和数值的匹配完成了计数。
3.3 为什么"只统计次数"就够了,不需要记录下标
有人可能会问:既然要构成子数组,难道不应该记录"具体哪几个下标"产生了合适的差值吗?毕竟如果只记录次数,万一两个left指向同一个位置,或者left在right之后,岂不是出错?
这里的关键在于遍历的先后顺序。我们是一边遍历一边记录哈希表的,也就是说,当处理到right时,哈希表里只包含所有小于right的left位置的前缀和(包括preSum[0])。左边一定在右边之前,天然满足left < right的合法性。因此,只要前缀和数值匹配,构成的一定是合法子数组,不需要再额外检查下标。而且,同一个前缀和数值可能出现在多个位置,每个位置对应一个不同的left,每多出现一次,就多构成一个不同的子数组。所以"次数"本身就是"子数组个数",完全不需要下标信息。
这个洞察是理解本题"为什么能O(n)"的核心。一旦你需要在哈希表里存下标列表,那就退化了,复杂度会重新变成O(n)乘一个不小的系数;而只存次数,则让计数变为真正的O(1)操作。
3.4 关于初始值hashmap[0] = 1的两种理解方式
初学的时候很容易对hashmap[0] = 1产生疑惑:前缀和数组里明明有preSum[0]=0这个元素,我还没有遍历任何nums元素呢,它凭什么出现在哈希表里?
- 理解方式一(从公式出发):当某个子数组从下标0开始时,我们需要的left是"开头的虚拟前缀",它的前缀和是0。在递推式中,这意味着preSum[left] = preSum[right] - k要成立,而左边存在一个preSum[0]=0,所以哈希表必须预先登记这个0。
- 理解方式二(从实际例子出发):假设nums=[1, -1, 0],k=1。存在子数组[1](下标0)、[1, -1, 0](下标0到2)。如果不预设hashmap[0]=1,在遍历到下标1时,preSum=0,preSum-k=-1,哈希表里没有-1,忽略;遍历到下标2时,preSum=0,preSum-k=-1,忽略;就漏掉了两个答案。只有当哈希表里有0,遍历到下标0时preSum=1,preSum-k=0,才能找到那个"从开头开始的子数组"。所以初始值不是随意设置的,而是"为了能统计从位置0开始的子数组"的必要条件。
两种理解方式其实指向同一个结论:preSum[0]=0这个虚拟前缀,代表的是一个空前缀,它是所有原始子数组的潜在左边界。
4. 代码落地与边界陷阱:map[0]=1、遍历顺序、k=0场景
4.1 Python实现:最自然的写法
python复制def subarraySum(nums, k):
count = 0
pre_sum = 0
hash_map = {0: 1} # 虚拟前缀和 0 出现 1 次
for num in nums:
pre_sum += num
# 如果 pre_sum - k 在哈希表中出现过,说明存在若干子数组和为 k
count += hash_map.get(pre_sum - k, 0)
# 将当前前缀和登记到哈希表
hash_map[pre_sum] = hash_map.get(pre_sum, 0) + 1
return count
这段代码里,最关键的一行是count += hash_map.get(pre_sum - k, 0)。它必须先于hash_map[pre_sum] = hash_map.get(pre_sum, 0) + 1执行。为什么?因为子数组至少包含一个元素,left必须严格小于right。如果先登记当前前缀和再查询,当前的前缀和也会被当成left,造成"空子数组"的误算(后面会证实这个误算在k=0时尤其严重)。
4.2 C++实现:容器选择的细节
C++里可能最自然的写法是使用unordered_map<int, int>:
cpp复制class Solution {
public:
int subarraySum(vector<int>& nums, int k) {
unordered_map<int, int> prefixCount;
prefixCount[0] = 1;
int sum = 0;
int count = 0;
for (int num : nums) {
sum += num;
if (prefixCount.count(sum - k)) {
count += prefixCount[sum - k];
}
prefixCount[sum]++;
}
return count;
}
};
这里有一个很小的性能注意事项:prefixCount[sum]++在sum不存在时会默认构造0再自增,所以不需要额外判断。但如果你先执行了prefixCount[sum]++再执行count += prefixCount[sum - k],在k=0时结果会整体偏大,因为每次都会把刚加入的当前前缀和算进去。
如果你追求极致性能,C++里还可以用std::unordered_map的find代替count再加索引,减少一次哈希查找。但对本题的数据量来说,两种方式差距不大,可读性优先即可。
4.3 三个极易踩的坑
坑一:把hashmap[0] = 1漏掉。
最常见的错误。不写这一行,意味着所有从数组头部开始的子数组全部丢失。尤其当题目数据里有大量k等于某个前缀和时,答案会少一大截。
坑二:查询和登记的先后顺序搞反。
如果把hash_map[pre_sum] += 1写在查询前,那么在k=0时,每一个位置都会把自己当成"left",导致多算一次。实际测试中,nums=[1,-1,0], k=0这种用例会立刻暴露问题:正确答案是3([1,-1], [-1,0], [0]),但如果先登记后查询,会得到4。多出来的那个是哪来的?是把空前缀当作子数组计算了。
坑三:认为可以用滑动窗口/双指针。
这题和"最大连续子数组和"这类问题不一样,它没有单调性。因为数组中可能存在负数,窗口的"和"不会随着右指针增大而单调递增。一旦你用双指针试图收缩窗口,你无法判断收缩是应该停止还是继续。所以这题的前缀和解法不仅仅是"一种可行解",而是面对负数时的必然选择。
4.4 复杂度的完整说明
时间复杂度:只需要从头到尾遍历一次数组,每次操作是O(1)的哈希表读写,所以整体O(n)。
空间复杂度:哈希表中最多存储n+1个不同的前缀和值(包括虚拟前缀0),因此是O(n)。
到这里,算法本身已经没有悬念。但真正让我觉得"这题值得写一篇题解"的,是它背后的推广价值——前缀和与哈希计数的组合,几乎是LeetCode上"连续子数组统计"类问题的通用范式。
5. 从560到一类题:"前缀和 + 哈希计数"的题型套路与后续扩展
5.1 同类题型的识别特征
当你拿到新题,如何判断它是否属于这一类?我的判断标准有三条:
- 题目要求的是连续子数组(subarray)的某种性质,不是子序列(subsequence)。
- 性质本身可以表示成区间和、区间差、区间模等数值运算。
- 要求的是计数或最长长度,而不是具体的区间列表。
只要同时满足这三点,几乎都可以往"前缀和 + 哈希"框架上靠。
5.2 典型变体一览
变体一:和可以被k整除的子数组个数(LeetCode 974)
题目:给定数组nums和整数k,返回和可被k整除的连续子数组的个数。
解法思路:把前缀和对k取模。只要两个前缀和的模相同,它们的差就能被k整除。因此,哈希表里存的不是"某个前缀和出现了几次",而是"某个模数出现了几次":
python复制def subarraysDivByK(nums, k):
count = 0
pre_sum = 0
mod_map = {0: 1}
for num in nums:
pre_sum += num
mod = pre_sum % k
count += mod_map.get(mod, 0)
mod_map[mod] = mod_map.get(mod, 0) + 1
return count
唯一要注意的是Python对负数的取模行为和C++不同,需要统一归一化处理((pre_sum % k + k) % k),否则负数场景会出错。
变体二:和为k的最长子数组长度(LeetCode 325)
要求返回最长子数组长度。核心思路类似,但哈希表里存的不再是次数,而是某个前缀和第一次出现的位置。因为要长度最长,左边界越靠前越好。遍历时,只记录每个前缀和最早出现的下标;后续遇到相同前缀和,只查不更新。
变体三:0和1个数相同的最长连续子数组(LeetCode 525)
题目:给定二进制数组,找含有相同数量0和1的最长连续子数组。解法:把0看成-1,1看成1,问题转化为"和为0的最长子数组"。此时又回到前缀和+首次出现位置的框架。
变体四:每个元音包含偶数次的最长子字符串(LeetCode 1371)
这个题更巧妙,用状态压缩记录五个元音字母出现次数的奇偶性,把状态本身看作"前缀状态",然后找相同状态的最远距离。虽然已经脱离了单纯的前缀和,但思想脉络完全一致:用某个累计状态的相等关系,表达区间内某种性质的满足条件。
5.3 "存次数"还是"存下标",取决于问法
对比560和525,你会发现哈希表里存什么,完全由题目问法决定:
- 问个数,存次数:560、974都是如此。因为每多出现一次,就多一个合法子数组。
- 问最长长度,存首次下标:525、325都是如此。因为需要算两个端点之间的距离,且左端点越靠左越好。
这条经验可以帮你快速判断新题的解法方向,避免陷入"为什么这题不能直接套上一题的模板"的困惑。
5.4 什么时候不能用这套框架
任何算法都有边界。前缀和+哈希也不是万能的:
- 如果数组元素全是正数,且要求"和等于某个值的子数组个数或长度",可以直接用滑动窗口,空间O(1)更好。
- 如果要求输出所有满足条件的子数组具体内容,哈希计数只能告诉你个数,还是需要回到前缀和+二分的方案来枚举左右端点。
- 如果区间要求满足某种复杂的约束(例如"和大于k且长度小于m"),前缀和+哈希只能解决其中一部分,可能需要结合树状数组或线段树。
判断好这些边界,比背下模板更有价值。
6. 写在最后:一道题讲透一类题,收益不止于560
回顾整道题的解题链路:暴力O(n³)到O(n²)的优化,是因为发现了重复求和;O(n²)到O(n)的跨越,是因为前缀和把区间和变成两端之差;而哈希表则让"匹配left"的查询做到O(1)。每一步都没有跳步,每一步改动都有明确的动机——这是我认为LeetCode试题最有价值的学习方式。
我自己的经验是,光看题解而不动笔推导,三天后遇到525题还是会卡。建议你拿一张纸,不要看代码,自己从preSum[right] - preSum[left] = k这个式子出发,试着写出遍历过程,手动走一遍nums=[1, -1, 0], k=0的用例,再把hashmap[0] = 1这个初始化去掉看看会漏掉什么。走完这一遍,你对"统计前缀和就等价于统计子数组个数"这句话的理解,会比看十篇题解都扎实。
之后再把974、525、1371这组题按同样的思路做一遍,你会发现它们本质上是同一道题换了几种外壳。这时你掌握的就不再是"560的题解",而是一种可以迁移到很多场景的思维工具:当一段连续区间的性质可以用端点的某种累计状态之差表示时,就用哈希把它变成在线匹配问题。 这个思路,在以后的很多算法题里,大概率还会帮到你。
