最近在准备算法面试的朋友又把这题翻出来了:给定一个整数数组 nums 和一个目标值 K,统计出数组里有多少个连续子数组的和恰好等于 K。别小看这句话,它就是 LeetCode 第 560 题,也是“前缀和 + 哈希表”这个套路里最经典的入门题。我记得自己第一次被问到的时候,第一反应是暴力枚举,两层循环先把所有区间和算出来再说;代码能跑,面试官紧接着一句“数组长度十万你怎么办”,我就知道自己想浅了。这篇文章不会只给一段标准答案,我想把一条完整的思考链讲出来:为什么暴力不可取、前缀和是怎么把区间和变成一次减法的、哈希表又是怎么把复杂度压到 O(n) 的,顺带把实际调试中容易踩的坑都点一遍。不管你是刚开始刷题的新手,还是准备面试前想快速整理套路,这篇都可以当作一次完整的思路复盘。
1. 题目拆解:先确认“子数组”和“和等于 K”到底在问什么
1.1 约定一个例子,后面全靠它说话
先拿最简单的场景举例:nums = [1, 2, 3],K = 3。符合条件的连续子数组有两段:
- 第一段是
nums[0..1],也就是[1, 2],元素和是 3; - 第二段是
nums[2..2],也就是只包含最后一个元素[3],单个元素的和也是 3。
所以答案应该是 2。这里有几个容易模糊的概念,我建议写代码前先在心里过一遍:
- “子数组”要求连续,必须是从原数组中截出来的一段,不能跳着选元素;
- 单个元素本身就可以构成一个长度为一的子数组,题目没有长度下限;
- 我们要统计的是“一共有多少个这样的区间”,不是返回区间的下标,也不是问“是否存在一个”;
- 空子数组一般不参与计数,后续代码里也要避免不小心把空区间算进去。
把这些边界想清楚,后面写代码时才不会在细节上反复纠结。
1.2 这题为什么值得单独拆开讲
从表面看,这只是一个数组求和题,但它背后串联起来的基础工具很多:区间和的计算方式、空间换时间的优化思路、还有对负数元素如何处理。很多人一开始只用两层循环累加,觉得“能跑就行”,但一旦数据规模上来就原形毕露。
更重要的是,“统计连续区间和等于某个数”这个模型在工作中非常常见。举个实际例子:你手头有某商品一年内每天的销量,想统计有多少个连续时间段的总销量正好等于一个库存阈值。数据量小的时候,Excel 拉一下能对付;数据量一大,就必须用算法思路来解决。也就是说,这题不只是在面试里出现,它本质上就是“连续区间统计”这一类问题的代表。
如果你顺着这一篇把思路搞明白,后面遇到“和能被 K 整除的子数组”“二维矩阵里和为 K 的子矩阵”这些变体,也能很快迁移过去。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 暴力解法先把问题跑通:从 O(n^3) 到 O(n^2)
2.1 最笨的办法:三重循环枚举所有区间
新手拿到题,第一反应一定是把所有可能的子数组都列出来,逐个算和。一个区间由起点和终点决定,所以可以先枚举起点 i,再枚举终点 j,然后从 i 到 j 做一次累加。写成代码是这个样子:
python复制def subarray_sum_brute(nums, k):
n = len(nums)
res = 0
for i in range(n):
for j in range(i, n):
total = 0
for m in range(i, j + 1):
total += nums[m]
if total == k:
res += 1
return res
这个版本的时间复杂度是 O(n^3)。为什么是三次方?因为区间大概有 n^2/2 个,而计算每个区间的和平均还要遍历 n/3 个左右的元素。当 n 只有几十时没问题,一旦 n 到一万,这个代码基本就跑不动了,更别说十万、百万。
2.2 稍微聪明一点:两重循环滚动累加
三重循环里最内层的累加其实是浪费的。想象一下:起点 i 固定以后,随着终点 j 往后移动,新增加的元素只有一个 nums[j]。所以我们完全没必要每次都重头加一遍,直接在上一轮的和上追加即可:
python复制def subarray_sum_quadratic(nums, k):
n = len(nums)
res = 0
for i in range(n):
total = 0
for j in range(i, n):
total += nums[j]
if total == k:
res += 1
return res
这样时间复杂度降到了 O(n^2),空间复杂度 O(1)。i 每固定一次,total 就表示从 i 到当前 j 的区间和;j 向右扩展时,区间和只增加 nums[j],所以一个循环就能覆盖所有以 i 开头的区间。
2.3 暴力法的真正瓶颈在哪里
很多人以为 O(n^2) 已经不错了,但在算法面试里,nums 的长度通常可以到 10^5 甚至 10^6,O(n^2) 意味着最多要执行 10^10 次操作。普通机器一秒大概能跑 10^8 次简单运算,所以这个量级根本不可能在规定时间内完成。
暴力的本质问题,是它把“每个区间”都当成独立对象重新计算了一遍。但区间和之间存在大量重复信息:nums[1..5] 的和,其实可以由更短的区间和扩展得到,也可以由两个前缀信息相减得到。我们需要一种方法,让每个位置的信息只被记录一次,后面所有区间查询都能直接复用。这就是前缀和登场的时机。
3. 前缀和为什么能把这题变简单:区间和只是两次前缀相减
3.1 用一个辅助数组记录“从头到当前位置的和”
设想我们额外准备一个数组 pre,其中 pre[i] 表示原数组 nums 前 i 个元素的和,特别地 pre[0] = 0。举个例子:
nums = [1, 2, 3]
那么:
pre[0] = 0pre[1] = 1pre[2] = 1 + 2 = 3pre[3] = 1 + 2 + 3 = 6
为什么要让 pre[0] = 0?因为这样能统一处理“从数组开头开始的子数组”。如果要求 nums[0..1] 的和,我们可以用 pre[2] - pre[0] = 3 - 0 = 3,这里的 pre[0] 就代表空区间。如果没有这个 0,那所有从 0 号位置开始的区间都会出现“越界”或者“少算”的情况,处理起来很麻烦。
更一般地,任意区间 nums[i..j] 的和都可以写成:
sum(nums[i..j]) = pre[j + 1] - pre[i]
这个公式是整个解法的地基。你可以把它理解成“两个累计读数的差”。就像你记录汽车仪表盘的里程数,从第 i 天到第 j 天跑了多少公里,只需要看这两个时间点的累计里程差,不需要回忆每一天各跑了多少。
3.2 把问题转化成“找两数之差等于 K”
当脑海里有了前缀和数组,原问题就完全变了模样。我们不再需要枚举区间,而是枚举所有可能作为“区间右端点”的前缀位置 j + 1,然后去问:前面有多少个前缀位置 i,满足:
pre[j + 1] - pre[i] = K
移项一下就是:
pre[i] = pre[j + 1] - K
这其实是一个“两数之差”问题。如果我们已经知道所有出现过的 pre[i],那每次只需要查一下 pre[j + 1] - K 这个值在之前出现过几次。每个等式的个数加在一起,就是最终答案。
沿用 nums = [1, 2, 3]、K = 3 的例子。前缀数组是 [0, 1, 3, 6]。我们从右端点的角度来扫:
- 右端点对应
pre[1] = 1,需要找前面1 - 3 = -2,没找到; - 右端点对应
pre[2] = 3,需要找前面3 - 3 = 0,找到pre[0],对应区间nums[0..1]; - 右端点对应
pre[3] = 6,需要找前面6 - 3 = 3,找到pre[2],对应区间nums[2..2]。
结果正好是 2,和肉眼数出来的一致。这时候已经能从 O(n^2) 降到 O(n),前提是每次查“某个前缀和出现过几次”足够快。数组范围不确定、可能出现负数,所以用哈希表来存是最自然的选择。
4. 用哈希表把前缀和优化成一次遍历:O(n) 解法
4.1 为什么哈希表里要存“次数”而不是“是否存在”
很多第一次接触这题的人会写一个 Set 来存已经出现过的前缀和,然后判断 cur - K 是否在集合里。但这样做有一个严重问题:它只能回答“有没有”,回答不了“有几个”。
举个例子,nums = [1, -1, 0],K = 0。前缀和依次是 0, 1, 0, 0。看到没有,同一个前缀和 0 在过程中出现了三次。这三次分别对应不同的起始位置,因此能组成的合法子数组数量不止一个。如果我们只记录“前缀和 0 出现过没有”,那很多可行区间都会被漏掉。
反过来,哈希表里的 value 记成次数就完全不一样了。每个前缀和每出现一次,就代表未来可能存在一个以此为起点的区间。当我们在某个右端点发现 cur - K = 0 时,直接把 0 出现的次数加到答案上,就能一次性统计出所有可行的起点。
4.2 核心代码与逐行讲解
下面这段是最终版的 Python 实现,时间复杂度 O(n),空间复杂度 O(n):
python复制def subarray_sum(nums, k):
prefix_count = {0: 1}
cur = 0
ans = 0
for num in nums:
cur += num
ans += prefix_count.get(cur - k, 0)
prefix_count[cur] = prefix_count.get(cur, 0) + 1
return ans
代码只有几行,但每一行都值得看懂:
prefix_count = {0: 1}:初始化哈希表,表示「前缀和为 0 的情况已经出现了一次」。它对应的就是数组开头之前那个空区间,几乎所有从下标 0 开始的合法子数组都靠它兜底;cur:实时维护“到目前为止的前缀和”,也就是pre数组里的最新值;ans += prefix_count.get(cur - k, 0):在已经出现过的前缀和里,找有多少个等于cur - k。找到多少个,就说明有多少个以当前位置结尾的区间和等于 K;prefix_count[cur] += 1:把当前前缀和也加入哈希表,供后面的位置查询使用。
注意最后两步的顺序非常重要:一定要先查询,再把自己插入哈希表。因为我们要找的区间左端点必须严格在右端点之前。如果你先把 cur 存进去再查询,k = 0 的时候可能会把自己当成左端点,凭空多算出一个长度为 0 的空区间,答案自然就错了。
4.3 手工跑一遍负数场景,验证逻辑不靠背
用 nums = [1, -1, 0]、K = 0 走一遍,能直观看到为什么这个算法能处理负数,也能看到哈希表里同一个前缀和出现多次时的效果:
| 当前元素 | cur | 需要查找 cur - k | 查到的次数 | 这步新增的答案 | 对应区间 |
|---|---|---|---|---|---|
| 初始 | 0 | - | - | - | 哈希表为 |
| 1 | 1 | 1 | 0 | 0 | 无 |
| -1 | 0 | 0 | 1 | 1 | [1, -1] |
| 0 | 0 | 0 | 2 | 2 | [1, -1, 0] 和 [0] |
每一步新增的“区间”都能具体指出来:第一次 cur 重新变成 0 时,对应前缀和 0 从 1 次变成 2 次,这意味着“从当前位置往前可以和之前的前缀和为 0 的位置构成一个和为 0 的区间”。这个过程完美处理了负数和重复前缀和,也是暴力法做不到的。
Java 版本同样很简洁,面试时如果需要写第二种语言,可以直接参考:
java复制public int subarraySum(int[] nums, int k) {
Map<Integer, Integer> prefixCount = new HashMap<>();
prefixCount.put(0, 1);
int cur = 0, ans = 0;
for (int num : nums) {
cur += num;
ans += prefixCount.getOrDefault(cur - k, 0);
prefixCount.put(cur, prefixCount.getOrDefault(cur, 0) + 1);
}
return ans;
}
5. 不只是这一题:常见变体和真实应用场景
5.1 如果题目限定“全是正整数”,可以改用双指针滑动窗口
面试官有时候会把条件改一改,告诉你 nums 中所有元素都是正整数。这时候问题有一个更简单的解法:滑动窗口,也叫双指针。因为所有数都是正的,所以窗口向右扩张时和会变大,左指针向右收缩时和会变小,整个和的变化是单调的,我们才能放心地用两个指针维护一个窗口。
python复制def subarray_sum_positive(nums, k):
left = 0
cur = 0
ans = 0
for right in range(len(nums)):
cur += nums[right]
while cur > k and left <= right:
cur -= nums[left]
left += 1
if cur == k:
ans += 1
return ans
这里的时间复杂度同样是 O(n),但空间复杂度降到了 O(1)。这个版本只适用于所有元素为正的场景,一旦数组里出现负数或者 0,窗口和的单调性就被破坏了,刚才的哈希表方案才是通用答案。所以刷题时一定要先看题目给的约束条件,再决定用什么工具。
5.2 从一维到二维:矩阵里找“和为 K 的子矩阵”
当你把一维数组理解透了,很多变体其实就是在此基础上加一层枚举。比如面试里可能升级成“给定一个二维矩阵,统计元素和等于 K 的子矩阵数量”。暴力枚举四个边界是完全不可行的,常见思路是枚举矩阵的上下边界,把多行压缩成一维数组,然后对每一列求前缀和,再调用我们上面那一套一维算法。
这一步压缩本质上是把“m 行 n 列”的问题拆成多次“长度为 n 的一维数组”问题,时间复杂度能做到 O(m^2 * n)。虽然看起来仍然有平方复杂度,但已经比四层循环优雅太多。这个思路在 LeetCode 第 1074 题里有完整应用,如果你接下来想进阶,很值得去做一遍。
5.3 同余类变体:把“等于 K”改成“能被 K 整除”
另一个高频变体是“和能被 K 整除的子数组数量”。此时关键不再是前缀和本身,而是前缀和对 K 取余的结果。两个子数组的差能被 K 整除,当且仅当它们的前缀和对 K 的余数相同。所以哈希表的键要换成 cur % k,而且处理负数取余时要额外小心,保证余数为正。
也就是说,这一系列题目的骨架非常相似:先把区间和转化成前缀信息,然后用哈希表记录“某种状态出现过的次数”,最后扫描过程中累加匹配项。只要你把第 4 节的核心逻辑理解透,这些变体都能在较短时间内推导出来。
在实际业务里,这类“连续区间统计”也很常见。比如统计网站日志中连续若干次请求的耗时合计恰好等于某个阈值的次数,或者分析一段连续时间内累计交易额命中指定金额的所有情况。算法题从来不是只活在题库里,只是换了一身衣服出现在工程问题里。
6. 上手前必须知道的坑与调试建议
6.1 我反复犯过的几个低级错误
这题代码越短,越容易在小地方翻车。以下问题我都亲眼见过、也亲手犯过:
- 忘记初始化
{0: 1}。这是最常见的错误。很多人哈希表一开始是空的,然后发现凡是“从下标 0 开始就满足条件”的区间全部漏统计。比如nums = [1, 2, 3]、K = 3,你可能会得到答案 1,而不是正确的 2; - 先更新哈希表再查询。前文已经说过,这在
K = 0时会导致空区间多算。请养成习惯:每一轮都是先查cur - K,再把cur存进去; - 认为哈希表里存“布尔值”就够了。如果只记录“这个前缀和出现过”,遇到重复前缀和就会漏解。一定要存出现次数;
- 在 Java、C++ 里用
int存前缀和。当数组长度和元素绝对值都很大时,前缀和可能溢出。稳妥做法是使用long。Python 不用担心整数溢出,但如果你在写 Java 版本,这点值得提前问自己一句。
把这些细节放到一起,你会发现它们大多是“区间左右端点开闭”和“计数而不是判存在”这两类问题的衍生品。
6.2 自测清单:用几个边界用例把代码钉死
调试算法题最有效的手段是一开始就准备一组边界用例。我自己通常会用下面这几个测试用例去验证解法:
nums = [1, 1, 1],K = 2,答案是 2。覆盖了连续多个正整数的情况;nums = [1, -1, 0],K = 0,答案是 3。测试负数、0、重复前缀和;nums = [1, -1, 1, -1],K = 0,答案是 4。前缀和反复出现,专门考验“存次数”的写法;nums = [-1, -1, 1],K = 0,答案是 1。数组全为负开头,测试边界;nums = [3, 1, 2],K = 3,答案是 2。既有单个元素直接命中,也有后续区间拼出的命中。
每跑错一个用例,都可以在纸上画出前缀和变化过程,比对哈希表的每一步更新。靠眼睛调试比靠猜要快得多。还有一个职业习惯可以分享:写完后把返回变量在关键位置打印出来,或者写一段小函数和暴力解法对拍,随机生成小数组,反复验证两种结果是否一致。这不只是为了这道题,更是以后所有算法实现里都值得保留的自测方法。
