和为 K 的子数组:前缀和之差交给哈希
以 j 为右端的合法段,左端是某个满足 pre = 当前前缀 − k 的旧位置。把旧前缀的出现次数记在表里,一次查询就能数完。
pre 是扫到当前位置的前缀和。子数组和为 k 等价于过去出现过前缀 pre−k。哈希表 freq 记录每个前缀出现了几次:先把 freq[pre−k] 加进答案,再把当前 pre 登记进去,保证左端严格在右端之前。freq 先放入 0 一次,对应空前缀。时间 O(n),空间 O(n)。
这是 LeetCode 560. Subarray Sum Equals K。大白话:给你整数数组 nums 和整数 k,数有多少个连续子数组的和恰好等于 k。数组里可以有零和负数,子数组至少要有一个数。
主例 nums = [1, 1, 1],k = 2。前两个 1 是一段,后两个 1 是另一段,答案是 2。三个 1 加起来是 3,不是 2。
枚举左右端再求和是平方级。元素可负,窗口和不再随左端单调,LC209 那套滑窗在这里会漏、会错。缺口是把「一段的和」写成「两个前缀之差」,再问差为 k 的前缀对有多少。
子数组和 = 两个前缀和之差
定义 pre[j] 为前 j 个数的和,pre[0] = 0。闭区间 [i, j) 也就是下标 i 到 j−1 的和,等于 pre[j] − pre[i]。要求这段和为 k,就是 pre[j] − pre[i] = k,也就是 pre[i] = pre[j] − k,并且 i < j。问题从「数子数组」变成「数前缀对」。
主例的前缀是 pre[0]=0,pre[1]=1,pre[2]=2,pre[3]=3。pre[2]−pre[0]=2,对应 [1,1] 前两个;pre[3]−pre[1]=2,对应 [1,1] 后两个。演示两帧就是这两对。没有第三对:pre[3]−pre[0]=3≠2,pre[1]−pre[0]=1≠2。
暴力仍是对每个 j 回头扫所有 i,还是平方。缺的是:扫到 j 时,能不能 O(1) 问出「过去有多少个前缀恰好等于 pre[j]−k」。这张问句就是哈希表。
先查 pre−k 的次数,再登记当前前缀
表 freq 记录「到目前为止」每个前缀值出现了几次。一开始只放 freq[0]=1,表示空前缀出现过一次,这样从下标 0 开始的整段也能被数到。每读一个数 x:先 pre += x,再 ans += freq[pre−k],最后 freq[pre]++。先查后记,当前这个前缀不会和自己配对;k=0 时若先记后查,每个位置都会把自己当空段,答案会多算。
用手走主例。初始 freq={0:1},pre=0,ans=0。读第一个 1,pre=1,查 freq[1−2]=freq[−1]=0,ans 仍 0,登记 freq[1]=1。读第二个 1,pre=2,查 freq[0]=1,ans=1,这一次就是 [0,2),前两个 1。演示计数第一帧:pre=2,表里是 0 和 1 各一次,ans=1,当前 2 还没登记。登记 freq[2]=1。读第三个 1,pre=3,查 freq[1]=1,ans=2,这一次是 [1,3),后两个 1。演示第二帧:pre=3,表里多了 2,ans=2。登记 freq[3] 之后循环结束。
同一个前缀值可能出现多次,比如数组里有正有负会绕回来。freq 记的是次数不是下标,一次查询把所有合法左端都加上。元素可负,前缀不单调,所以不能改回双指针;哈希就是为这个缺口准备的。
先查后记表里只应有当前位置之前的前缀。主例若在 pre=2 时先登记再查,k 若是 0 会把自己算进去。空前缀的 0 必须预先放进表,否则从下标 0 起的那一段永远数不到。
七行 Go:前缀和 + 哈希
func subarraySum(nums []int, k int) int {freq := map[int]int{0: 1}pre, ans := 0, 0for _, x := range nums {pre += xans += freq[pre-k]freq[pre]++}return ans}
1freq 先放 0:1。主例第一次命中靠的就是这个空前缀,对应前两个 1。
2pre += x 之后立刻查 pre−k。主例 pre=2 查到 0,pre=3 查到 1。
3查完再 freq[pre]++。这一行和上一行不能对调。
4Go 对缺失键读到 0,freq[pre−k] 不存在就加 0,不必先判断。
总结
和为 k ⇔ 过去有过前缀 pre−k。先查次数再登记。主例两段 [1,1],答案 2。
- 可负数组让滑窗失效。缺口是前缀之差,不是窗口伸缩。
- freq[0]=1 必须预置,否则从下标 0 起的段数不到。主例第一段就靠它。
- 先查后记保证左端在右端之前;k=0 时先记后查会把空段算进去。