LeetCode 560. 和为 K 的子数组:从前缀和到哈希计数,彻底讲透为什么"统计前缀和"就等价于统计子数组个数
摘要
LeetCode 560. 和为 K 的子数组(Subarray Sum Equals K) 是前缀和题目中的经典代表,也是面试中高频出现的一道题。很多人第一次看到这题时,最容易产生两个疑问:
- 题目要求的是"和为
k的子数组个数",为什么解法却在统计"前缀和"的个数? - 子数组不一定从下标
0开始,很多子数组明明位于数组中间,为什么还能用前缀和来做?
除此之外,这题还有两个非常关键、也非常容易被忽视的细节:
- 为什么初始化要写
hash.put(0, 1)? - 为什么循环中必须先更新答案,再把当前前缀和放入哈希表?
这篇文章就围绕这些核心问题展开,系统讲清楚这题的数学本质、代码逻辑以及所有容易写错的细节。
目录
文章目录
- [LeetCode 560. 和为 K 的子数组:从前缀和到哈希计数,彻底讲透为什么"统计前缀和"就等价于统计子数组个数](#LeetCode 560. 和为 K 的子数组:从前缀和到哈希计数,彻底讲透为什么“统计前缀和”就等价于统计子数组个数)
-
- 摘要
- 目录
- 一、题目描述
- 二、最容易想到的暴力做法
- 三、这道题真正的突破口:前缀和
-
- [1. 先定义前缀和](#1. 先定义前缀和)
- [2. 任意子数组都能写成两个前缀和的差](#2. 任意子数组都能写成两个前缀和的差)
- 四、为什么题目求子数组个数,却能转化成前缀和问题
- [五、题目要求的是所有 `(l, r)`,为什么代码却在"按右端点统计"](#五、题目要求的是所有
(l, r),为什么代码却在“按右端点统计”) - 六、一个更直观的理解方式
- [七、为什么代码里不用前缀和数组,而是只用一个变量 `sum`](#七、为什么代码里不用前缀和数组,而是只用一个变量
sum) - [八、HashMap 在这题里到底存的是什么](#八、HashMap 在这题里到底存的是什么)
- 九、标准代码
- 十、代码逐行详细解释
-
- [1. 初始化哈希表](#1. 初始化哈希表)
- [2. `sum` 表示当前前缀和](#2.
sum表示当前前缀和) - [3. 为什么要查 `sum - k`](#3. 为什么要查
sum - k) - [4. 为什么要更新当前前缀和出现次数](#4. 为什么要更新当前前缀和出现次数)
- [十一、为什么一开始要 `hash.put(0, 1)`](#十一、为什么一开始要
hash.put(0, 1)) -
- [1. 它表示什么](#1. 它表示什么)
- [2. 为什么必须有它](#2. 为什么必须有它)
- [3. 举个例子](#3. 举个例子)
- [十二、为什么 `for` 循环里要先更新 `ret`,再把当前前缀和放进哈希表](#十二、为什么
for循环里要先更新ret,再把当前前缀和放进哈希表) -
- [1. 正确含义](#1. 正确含义)
- [2. 为什么不能先放再查](#2. 为什么不能先放再查)
- [3. 一个最简单的反例](#3. 一个最简单的反例)
- [十三、一个完整例子手推:`nums = [1,1,1], k = 2`](#十三、一个完整例子手推:
nums = [1,1,1], k = 2) -
- [遍历第一个 1](#遍历第一个 1)
- [遍历第二个 1](#遍历第二个 1)
- [遍历第三个 1](#遍历第三个 1)
- 十四、整道题的本质到底是什么
- 十五、复杂度分析
- 十六、面试高频追问总结
-
- [1. 为什么任意中间子数组也能用前缀和表示](#1. 为什么任意中间子数组也能用前缀和表示)
- [2. 题目要求的是 `(l, r)` 个数,为什么代码只在看 `r`](#2. 题目要求的是
(l, r)个数,为什么代码只在看r) - [3. 为什么 `hash` 要存出现次数而不是布尔值](#3. 为什么
hash要存出现次数而不是布尔值) - [4. 为什么要初始化 `hash.put(0,1)`](#4. 为什么要初始化
hash.put(0,1)) - [5. 为什么必须先查再放](#5. 为什么必须先查再放)
- 十七、推荐的面试写法
- 十八、结语
一、题目描述
给定一个整数数组 nums 和一个整数 k,请统计并返回该数组中和为 k 的连续子数组的个数。
示例 1:
java
输入:nums = [1,1,1], k = 2
输出:2
示例 2:
java
输入:nums = [1,2,3], k = 3
输出:2
二、最容易想到的暴力做法
最直接的思路是:
- 枚举每个左端点
l - 再枚举每个右端点
r - 计算
nums[l...r]的和 - 如果等于
k,答案加 1
代码结构大概会是两层循环,时间复杂度为:
java
O(n^2)
如果每次区间和还现算,甚至会更慢。
这个做法虽然能做出来,但在数据量较大时会超时,因此必须进一步优化。
三、这道题真正的突破口:前缀和
1. 先定义前缀和
设:
java
pre[i] = nums[0] + nums[1] + ... + nums[i]
表示从下标 0 到下标 i 的元素和。
例如:
java
nums = [1, 2, 3, 4]
那么:
java
pre[0] = 1
pre[1] = 3
pre[2] = 6
pre[3] = 10
2. 任意子数组都能写成两个前缀和的差
这是整道题最核心的公式。
对于任意子数组:
java
nums[l...r]
它的和可以表示为:
java
nums[l...r] = pre[r] - pre[l - 1]
如果 l = 0,那么就直接等于:
java
nums[0...r] = pre[r]
也就是说:
任意连续子数组的和,本质上都可以写成两个前缀和的差。
四、为什么题目求子数组个数,却能转化成前缀和问题
这是这题最关键的理解点。
题目要求的是:
有多少个下标对
(l, r),满足nums[l...r] = k
根据前缀和公式:
java
nums[l...r] = pre[r] - pre[l - 1]
如果这个子数组和等于 k,那么必然有:
java
pre[r] - pre[l - 1] = k
移项得到:
java
pre[l - 1] = pre[r] - k
这句话的含义非常重要:
对于固定的右端点
r,只要前面存在某个位置l - 1,满足
pre[l - 1] = pre[r] - k,那么就存在一个子数组
nums[l...r]的和为k。
也就是说:
想统计以
r为右端点、和为k的子数组个数,等价于统计前面有多少个前缀和等于pre[r] - k。
五、题目要求的是所有 (l, r),为什么代码却在"按右端点统计"
这个问题很容易让人困惑。
题目最终要求的确实是:
java
所有满足 nums[l...r] = k 的 (l, r) 对的个数
这点完全没错。
但代码采用的是一种更高效的统计方式:
按右端点
r分组统计。
具体来说:
- 先固定右端点
r - 统计有多少个左端点
l能和它构成和为k的子数组 - 把这个数量加入总答案
- 再继续处理下一个右端点
于是总答案就可以写成:
java
Ans = 所有右端点 r 对应的合法左端点数量之和
这并不是改变了题目的目标,而只是换了一种统计视角。
六、一个更直观的理解方式
如果把所有满足条件的子数组看成若干个 (l, r) 键值对,那么:
- 每一个合法子数组都有唯一的右端点
r - 所以这些
(l, r)对可以按照右端点r进行分类 - 每一类内部统计"有多少个
l可以配当前这个r" - 再把每类数量加起来
这样统计既不会漏,也不会重。
所以代码虽然表面上是在处理"当前右端点",但本质上统计的仍然是:
java
所有合法 (l, r) 对的总数
七、为什么代码里不用前缀和数组,而是只用一个变量 sum
这是一个实现层面的简化。
理论上可以显式构造前缀和数组:
java
pre[0], pre[1], pre[2], ..., pre[n-1]
但其实没有必要。
因为在遍历数组时,当前遍历到下标 r,只需要知道当前的前缀和 pre[r] 就够了。于是可以用一个变量动态维护:
java
sum += nums[r]
这时:
java
sum == pre[r]
所以代码虽然没有写出完整的前缀和数组,但逻辑上 sum 就是"当前右端点的前缀和"。
八、HashMap 在这题里到底存的是什么
哈希表定义为:
java
Map<Integer, Integer> hash
它存储的是:
某个前缀和出现过多少次
例如:
java
hash.get(5) = 3
表示:
前缀和为 5 的情况,一共出现过 3 次
这意味着什么?
意味着当前如果:
java
sum - k = 5
那么就说明:
- 在当前右端点之前
- 一共有 3 个位置,它们的前缀和等于 5
- 于是就有 3 个不同的左边界,可以和当前右端点组成和为
k的子数组
所以哈希表里不能只记录"是否出现过",而必须记录:
出现过多少次
因为同一个前缀和可能会重复出现,而每出现一次,都代表一个不同的合法起点选择。
九、标准代码
下面是这道题最经典的写法:
java
import java.util.HashMap;
import java.util.Map;
class Solution {
public int subarraySum(int[] nums, int k) {
// hash:记录某个前缀和出现过多少次
Map<Integer, Integer> hash = new HashMap<>();
// 初始化:空前缀和 0 出现 1 次
hash.put(0, 1);
int sum = 0; // 当前前缀和
int ret = 0; // 和为 k 的子数组个数
for (int x : nums) {
// 更新当前前缀和
sum += x;
// 如果之前存在前缀和 sum - k
// 则说明存在若干个子数组,以当前元素结尾,和为 k
ret += hash.getOrDefault(sum - k, 0);
// 将当前前缀和加入哈希表,供后续位置使用
hash.put(sum, hash.getOrDefault(sum, 0) + 1);
}
return ret;
}
}
十、代码逐行详细解释
1. 初始化哈希表
java
Map<Integer, Integer> hash = new HashMap<>();
hash.put(0, 1);
这里表示:
- 当前还没开始遍历数组
- 存在一个"空前缀"
- 它的前缀和是 0
- 这个前缀和出现了 1 次
这个初始化非常关键,后面会单独详细解释。
2. sum 表示当前前缀和
java
int sum = 0;
在遍历过程中,每处理一个新元素:
java
sum += x;
那么此时的 sum 就等价于当前右端点对应的前缀和。
如果当前处理到下标 r,那么:
java
sum = pre[r]
3. 为什么要查 sum - k
java
ret += hash.getOrDefault(sum - k, 0);
因为对于当前右端点 r:
如果某个子数组 nums[l...r] 的和等于 k,则必有:
java
pre[l - 1] = pre[r] - k
而:
java
pre[r] = sum
所以就变成了:
java
pre[l - 1] = sum - k
因此只要知道:
在当前之前,有多少个前缀和等于
sum - k
就知道:
有多少个子数组以当前元素为右端点,并且和为
k
4. 为什么要更新当前前缀和出现次数
java
hash.put(sum, hash.getOrDefault(sum, 0) + 1);
因为当前这个前缀和,在后面的元素看来,也会成为"历史前缀和"。
所以每遍历到一个位置,都要把当前前缀和记录下来,供后续右端点查询使用。
十一、为什么一开始要 hash.put(0, 1)
这是整道题最关键的细节之一。
1. 它表示什么
它表示:
在遍历开始之前,存在一个"空前缀和" 0,并且它出现了 1 次。
也可以理解为人为定义:
java
pre[-1] = 0
这个"空前缀"表示:
- 还没有取任何元素
- 当前总和为 0
2. 为什么必须有它
因为如果某个合法子数组恰好是从下标 0 开始的,比如:
java
nums[0...r]
它的和等于 k,那么就有:
java
pre[r] = k
写成统一的形式就是:
java
pre[r] - 0 = k
这个 0 就是"空前缀和"。
如果不提前把它放进哈希表,那么所有"从 0 开始的合法子数组"都会漏掉。
3. 举个例子
java
nums = [1, 2]
k = 3
合法子数组显然是:
java
[1, 2]
遍历到第二个元素时:
java
sum = 3
sum - k = 0
如果哈希表里有:
java
hash[0] = 1
那么就能正确统计到这个子数组。
如果没有 hash.put(0,1),这个答案就会漏掉。
十二、为什么 for 循环里要先更新 ret,再把当前前缀和放进哈希表
这也是一个非常关键的细节。
当前代码顺序是:
java
sum += x;
ret += hash.getOrDefault(sum - k, 0);
hash.put(sum, hash.getOrDefault(sum, 0) + 1);
必须是这个顺序,不能反。
1. 正确含义
在当前位置,哈希表里应该存的是:
当前右端点之前出现过的所有前缀和
也就是说,当当前前缀和 sum = pre[r] 被计算出来时:
- 先用它去找历史前缀和中有多少个
sum - k - 找完之后,再把当前这个
pre[r]加入历史记录
这才符合题意。
2. 为什么不能先放再查
如果先写成:
java
sum += x;
hash.put(sum, hash.getOrDefault(sum, 0) + 1);
ret += hash.getOrDefault(sum - k, 0);
那么当前前缀和会先被放入哈希表,然后再参与查询。
这样会导致:
当前前缀和自己和自己配对
等价于把:
java
pre[r] - pre[r] = 0
这样的"空区间"也统计进去。
但题目要求的是非空子数组,显然不允许这样。
3. 一个最简单的反例
java
nums = [1]
k = 0
正确答案应该是 0,因为没有任何非空子数组和为 0。
如果错误地"先放再查":
- 当前
sum = 1 - 先放入
hash[1] = 1 - 再查
sum - k = 1 - 会得到
hash[1] = 1
于是答案变成 1,这显然是错的。
因为它相当于统计了:
java
pre[r] - pre[r] = 0
也就是空区间。
十三、一个完整例子手推:nums = [1,1,1], k = 2
初始化:
java
hash = {0:1}
sum = 0
ret = 0
遍历第一个 1
java
sum = 1
sum - k = -1
hash[-1] = 0
ret = 0
加入当前前缀和:
java
hash = {0:1, 1:1}
遍历第二个 1
java
sum = 2
sum - k = 0
hash[0] = 1
ret = 1
说明以当前位置结尾,有 1 个合法子数组:
java
[1,1]
加入当前前缀和:
java
hash = {0:1, 1:1, 2:1}
遍历第三个 1
java
sum = 3
sum - k = 1
hash[1] = 1
ret = 2
说明又找到 1 个合法子数组:
java
[1,1]
最终答案:
java
2
正确。
十四、整道题的本质到底是什么
可以把这道题总结成一句话:
统计和为
k的子数组个数,本质上是在统计满足
javapre[r] - pre[l - 1] = k的前缀和下标对个数。
而代码的做法就是:
- 枚举每个右端点
r - 用当前前缀和
pre[r] - 去找前面有多少个
pre[l - 1] = pre[r] - k - 把这些数量全部加起来
所以虽然题目表面上在求"子数组个数",但本质上已经被转化成了:
前缀和出现次数统计问题
十五、复杂度分析
时间复杂度
数组只遍历一次,每次哈希表查询和插入平均都是 O(1),因此总时间复杂度为:
java
O(n)
空间复杂度
哈希表最坏情况下会存储 n 个不同的前缀和,所以空间复杂度为:
java
O(n)
十六、面试高频追问总结
1. 为什么任意中间子数组也能用前缀和表示
因为任意子数组 nums[l...r] 都满足:
java
nums[l...r] = pre[r] - pre[l - 1]
所以前缀和方法不仅适用于从 0 开始的区间,而适用于所有连续子数组。
2. 题目要求的是 (l, r) 个数,为什么代码只在看 r
因为代码是在"按右端点 r 分组统计":
- 固定
r - 统计有多少个
l能和它组成合法区间 - 对所有
r求和
这正好等价于统计所有合法 (l, r) 对的总数。
3. 为什么 hash 要存出现次数而不是布尔值
因为同一个前缀和可能出现多次,每出现一次都代表一个不同的位置,也就可能对应一个不同的左边界。
4. 为什么要初始化 hash.put(0,1)
为了表示一个"空前缀和" 0,从而统一处理从下标 0 开始的合法子数组。
5. 为什么必须先查再放
因为当前前缀和只能和"之前的前缀和"配对,不能和自己配对,否则会把空区间错误统计进去。
十七、推荐的面试写法
下面是一版适合面试书写和口述解释的标准代码:
java
import java.util.HashMap;
import java.util.Map;
class Solution {
public int subarraySum(int[] nums, int k) {
Map<Integer, Integer> hash = new HashMap<>();
// 空前缀和
hash.put(0, 1);
int sum = 0;
int ret = 0;
for (int num : nums) {
sum += num;
// 统计当前右端点能配出的合法左端点个数
ret += hash.getOrDefault(sum - k, 0);
// 当前前缀和加入哈希表
hash.put(sum, hash.getOrDefault(sum, 0) + 1);
}
return ret;
}
}
十八、结语
LeetCode 560. 和为 K 的子数组 是一道非常经典的前缀和 + 哈希表题目。它真正锻炼的,不只是"会不会写代码",而是能否建立起这样一个关键转换:
子数组和问题
→ 两个前缀和之差问题
→ 某个前缀和出现次数统计问题
这道题最值得真正吃透的,不只是公式本身,而是下面这几个细节:
- 任意子数组都能表示成两个前缀和之差
- 总答案可以按右端点分组统计
hash.put(0,1)表示空前缀和- 必须先查历史前缀和,再加入当前前缀和
把这些点真正理解透之后,这道题就不再只是一个模板,而会成为前缀和思维最典型、最漂亮的应用之一。