和为 K 的子数组 图解题解
前缀和一减、哈希表一查,O(n) 数清所有和为 k 的连续子数组。
就像记账:走到某一天,账本上的累计收支是 prefix。想知道某一段的净额是不是 k,就去历史上翻翻有没有哪天的累计值等于 prefix−k——两天的差正好是那段的净额。扫之前先记一笔「第 0 天账面为零」,一遍走完,中途先查历史、再记今天,不多算也不漏。
这道题到底在问什么
- 输入
- nums=[1,2,3,-1,1,1,2,3], k=3
- 输出
- 6
先想最直接的笨办法
回看整条路径:指针只走一遍,每个位置查一次表、存一次前缀和。这就是 O(n)——比「枚举每个起点再往后逐个累加」的 O(n²) 快得多,且天然支持负数。(动画第 26 步)
最优解:为什么这么做
一句话答案:LeetCode 560 和为 K 的子数组的标准解是前缀和加哈希表一次遍历:子数组和等于两个前缀和之差,所以边扫边查 preSum - k 此前出现过几次、答案就加几,再把当前前缀和计入表中。数组含负数导致滑动窗口失效,这个方法不受影响,时间 O(n)、空间 O(n)。
这道题真正在问什么
题目给数组 nums 和整数 k,统计「连续子数组」——也就是一段下标相邻的元素——的和恰好等于 k 的个数。两个关键点:数组可能含负数,子数组之间可以重叠;要的只是个数,不需要返回具体位置。示例 nums = [1,2,3,-1,1,1,2,3]、k 取 3 时答案是 6,可见同一个位置可以同时属于多段答案。
为什么滑动窗口在这题会失效
很多人先想到滑动窗口:和小了扩右边、和大了缩左边。但窗口伸缩依赖一个前提——往里加数窗口和一定变大、往外扔数一定变小。数组一旦含负数,这个单调性就没了:扩窗可能让和变小,缩窗可能让和变大,指针不知道该往哪动。退回暴力做法「枚举每个起点、往后逐个累加」,时间是 O(n²),数组一大就太慢。
子数组和为什么等于前缀和之差
关键观察:记前缀和 preSum 为从头累加到某个位置的总和,那么任意一段子数组的和,都等于「右端点处的前缀和」减「左端点前一位的前缀和」。于是「以当前位置结尾、和为 k 的子数组有几个」,等价于「更早的位置里,前缀和恰好等于 preSum - k 的有几个」。这一步把「枚举区间」翻译成「查一个确定的值出现过几次」,而后者正是哈希表 O(1) 能回答的——和两数之和里查 target - x 是同一招,只是把「值」换成了前缀和、把「下标」换成了出现次数。
解法随之成形:建一张表记录「前缀和的值 → 出现次数」,从左到右扫数组,每到一个数先累加出当前前缀和,查表里 preSum - k 出现过几次、答案就加几,然后把当前前缀和的计数加一,留给后面的位置查。
为什么要预置 0 出现 1 次、先查后存
表里一开始必须放一条「前缀和 0 出现 1 次」,它代表空前缀。少了它,「从下标 0 开始、整段恰好等于 k」的子数组会被漏数——这种情况下要查的 preSum - k 恰好等于 0,表里必须能查到。
循环体内先查 preSum - k、再把 preSum 存进表,顺序不能反。先存后查会把自己也当成候选:当 k 为 0 时 preSum - k 就等于 preSum 本身,先存就会查到自己、凭空多数一段长度为 0 的「子数组」。先查后存保证查到的永远是严格更早的前缀,对应的子数组长度至少为 1。
复杂度怎么算,还有哪些坑
时间 O(n):每个数只做一次累加、一次查表、一次计数,均摊都是 O(1)。空间 O(n):最坏时几乎所有不同的前缀和都要进表。另一个常见错误是往表里存前缀和出现的下标——本题只数个数,存「出现次数」就够了;存下标是「返回具体位置或最长长度」那类变体才需要的。如果题目改成「和能被 k 整除的子数组个数」,把哈希键换成前缀和对 k 取模的余数,同一套框架照用。
▶ 动画逐步走查(共 24 步)——想跟着动画一帧帧对照就展开
- 3记住「子数组和 = 两个前缀和之差」——下面每一帧都在套:查 preSum − k 出现过几次,就加几条。
- 4关键的起手式:哈希表先放一条 {前缀和 0 → 出现 1 次}。它代表「空前缀」——这样「从下标 0 开始、整段正好等于 k」的子数组也能被数到。
- 5当前前缀和 preSum = 1,need = 1 − 3 = -2。表里没有 -2(出现 0 次),所以以下标 0 结尾、和为 3 的子数组这一步一条也没有。
- 6处理完查找,把当前前缀和 1 记进表里(出现次数 +1)。注意是「先查后存」:先用旧表回答了当前位置,再把自己加进去,给后面的数当依据。
- 7当前前缀和 preSum = 3,要找的另一头 need = 3 − 3 = 0。表里 0 出现过 1 次——说明有 1 个更早的位置,从那之后到这里这一段的和正好是 3。
- 8绿色高亮的就是新数出来的 1 段:起点 0 到下标 1,每段加起来都正好 3。把这 1 条计入答案,cnt 从 0 变成 1。
- 9处理完查找,把当前前缀和 3 记进表里(出现次数 +1)。注意是「先查后存」:先用旧表回答了当前位置,再把自己加进去,给后面的数当依据。
- 10当前前缀和 preSum = 6,要找的另一头 need = 6 − 3 = 3。表里 3 出现过 1 次——说明有 1 个更早的位置,从那之后到这里这一段的和正好是 3。
- 11绿色高亮的就是新数出来的 1 段:起点 2 到下标 2,每段加起来都正好 3。把这 1 条计入答案,cnt 从 1 变成 2。
- 12处理完查找,把当前前缀和 6 记进表里(出现次数 +1)。注意是「先查后存」:先用旧表回答了当前位置,再把自己加进去,给后面的数当依据。
- 13当前前缀和 preSum = 5,need = 5 − 3 = 2。表里没有 2(出现 0 次),所以以下标 3 结尾、和为 3 的子数组这一步一条也没有。
- 14处理完查找,把当前前缀和 5 记进表里(出现次数 +1)。注意是「先查后存」:先用旧表回答了当前位置,再把自己加进去,给后面的数当依据。
- 15当前前缀和 preSum = 6,要找的另一头 need = 6 − 3 = 3。表里 3 出现过 1 次——说明有 1 个更早的位置,从那之后到这里这一段的和正好是 3。
- 16绿色高亮的就是新数出来的 1 段:起点 2 到下标 4,每段加起来都正好 3。把这 1 条计入答案,cnt 从 2 变成 3。
- 17处理完查找,把当前前缀和 6 记进表里(出现次数 +1)。注意是「先查后存」:先用旧表回答了当前位置,再把自己加进去,给后面的数当依据。
- 18当前前缀和 preSum = 7,need = 7 − 3 = 4。表里没有 4(出现 0 次),所以以下标 5 结尾、和为 3 的子数组这一步一条也没有。
- 19处理完查找,把当前前缀和 7 记进表里(出现次数 +1)。注意是「先查后存」:先用旧表回答了当前位置,再把自己加进去,给后面的数当依据。
- 20当前前缀和 preSum = 9,要找的另一头 need = 9 − 3 = 6。表里 6 出现过 2 次——说明有 2 个更早的位置,从那之后到这里这一段的和正好是 3。
- 21绿色高亮的就是新数出来的 2 段:起点 3、5 到下标 6,每段加起来都正好 3。把这 2 条计入答案,cnt 从 3 变成 5。
- 22处理完查找,把当前前缀和 9 记进表里(出现次数 +1)。注意是「先查后存」:先用旧表回答了当前位置,再把自己加进去,给后面的数当依据。
- 23当前前缀和 preSum = 12,要找的另一头 need = 12 − 3 = 9。表里 9 出现过 1 次——说明有 1 个更早的位置,从那之后到这里这一段的和正好是 3。
- 24绿色高亮的就是新数出来的 1 段:起点 7 到下标 7,每段加起来都正好 3。把这 1 条计入答案,cnt 从 5 变成 6。
- 25处理完查找,把当前前缀和 12 记进表里(出现次数 +1)。注意是「先查后存」:先用旧表回答了当前位置,再把自己加进去,给后面的数当依据。
- 26回看整条路径:指针只走一遍,每个位置查一次表、存一次前缀和。这就是 O(n)——比「枚举每个起点再往后逐个累加」的 O(n²) 快得多,且天然支持负数。
⚠️ 容易写错的地方
✗ 错:哈希表不预置 {0:1}
✓ 对:一开始就放 cnt[0]=1
少了它,「从下标 0 开始、整段正好为 k」这类子数组(need=0)会漏数
✗ 错:存的是前缀和的下标
✓ 对:存「前缀和值 → 出现次数」
本题只数个数,要的是次数;存下标只适合返回具体位置那类题
✗ 错:先存当前 preSum 再查
✓ 对:先查 preSum−k,再把 preSum 计数 +1
先存可能把自己算进去(k=0 时尤其会多数),必须查在前、存在后
完整代码(Python / C++ / Java)
Python
def subarraySum(nums, k):
from collections import defaultdict
cnt = defaultdict(int)
cnt[0] = 1 # 空前缀: 前缀和0出现1次
preSum = ans = 0
for x in nums:
preSum += x
ans += cnt[preSum - k] # 查 need 出现几次
cnt[preSum] += 1 # 再把当前前缀和存进去
return ansC++
int subarraySum(vector<int>& nums, int k){
unordered_map<int,int> cnt;
cnt[0] = 1; // 空前缀
int preSum = 0, ans = 0;
for(int x : nums){
preSum += x;
ans += cnt[preSum - k]; // 查 need 出现几次
cnt[preSum] += 1; // 存当前前缀和
}
return ans;
}Java
public int subarraySum(int[] nums, int k) {
Map<Integer, Integer> cnt = new HashMap<>();
cnt.put(0, 1); // 空前缀: 前缀和0出现1次
int preSum = 0, ans = 0;
for (int x : nums) {
preSum += x;
ans += cnt.getOrDefault(preSum - k, 0); // 查 need
cnt.merge(preSum, 1, Integer::sum); // 存自己
}
return ans;
}复杂度
时间
O(n)
每个数只累加一次、查一次、存一次哈希,遍历一遍
空间
O(n)
最坏要把几乎所有不同的前缀和都存进哈希表
看不够?换成动画再走一遍
上面的推演每一步都对应一帧动画。点开交互动画版,能一步步看着 和为 K 的子数组 的数据怎么变、指针怎么走,还能切 Python / Java / C++ 跟着练。
面试官可能追问
为什么不用滑动窗口?+
滑动窗口要求「扩窗和单调变化」(全正数才行)。本题有负数,窗口和不单调,缩窗逻辑失效,所以用前缀和+哈希。
如果要求的是「和能被 k 整除」的子数组个数呢?+
把哈希键从「前缀和」换成「前缀和 mod k」(LC974),同样的框架,预置 {0:1},统计相同余数出现次数。
这题和两数之和的关系?+
同一套「边遍历边查哈希、查另一半、先查后存」的思路:两数之和查 target−x,这题查 preSum−k,只是把「值」换成「前缀和」、把「下标」换成「出现次数」。
想听吴师兄把这道题讲给你听?
文字版和动画都随便看。开通图解算法年卡,可以听吴师兄把 和为 K 的子数组 一步步讲透(全站已上线 905 份讲透,持续扩充), 卡住的地方还有 AI 私教小欧就着动画帮你拆到懂。
本平台为独立第三方培训机构,与华为技术有限公司无任何关联;课程的服务内容与权益以购买协议为准,学习效果因个人情况而异。「华为 OD」「华为可信」等仅为对岗位与考试方向的客观描述,相关商标归各自权利人所有。