【码道初阶-Hot100】LeetCode 560. 和为 K 的子数组:从前缀和到哈希计数,彻底讲透为什么“统计前缀和”就等价于统计子数组个数

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 的子数组个数,本质上是在统计满足

java 复制代码
pre[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) 表示空前缀和
  • 必须先查历史前缀和,再加入当前前缀和

把这些点真正理解透之后,这道题就不再只是一个模板,而会成为前缀和思维最典型、最漂亮的应用之一。


相关推荐
tankeven2 小时前
HJ134 1or0
c++·算法
keep intensify2 小时前
寻找重复数
数据结构·算法·leetcode
圣保罗的大教堂2 小时前
leetcode 3070. 元素和小于等于 k 的子矩阵的数目 中等
leetcode
dgfhf2 小时前
高性能计算资源调度
开发语言·c++·算法
x_xbx2 小时前
LeetCode:19. 删除链表的倒数第 N 个结点
算法·leetcode·链表
weixin_307779132 小时前
OpenClaw-CN 安全增强方案:从理念到落地的全面剖析
开发语言·人工智能·算法·安全·语言模型
CoovallyAIHub2 小时前
Agency-Agents(52k+ Stars):140+ 个角色模板,让 AI 编程助手变成一支专业团队
前端·算法·编程语言
nananaij2 小时前
【LeetCode-05 好数对的数目 python解法】
python·算法·leetcode
季远迩2 小时前
73.矩阵置零(中等)
算法