☰
前缀和+哈希表:LeetCode 560 和为K的子数组解法
2026/10/15 5:47:40 网站建设 项目流程

1. 先把这个题目看懂:什么叫“和为 k 的子数组”

LeetCode 560这个题目,我在面试前刷到的时候第一反应是“这不就是个两层循环的事”,真等动手做才发现,事情没那么简单。题目给一个整数数组nums和一个整数k,要求统计“和为 k 的连续子数组”的个数。

先花点时间把“子数组”这个概念掰清楚。子数组必须是连续的,也就是说你只能从原数组里取一段紧挨着的元素。这和“子序列”不一样,子序列可以不连续、只要保持相对顺序就行;更不是“子集”,子集连顺序都不管。举个例子,nums = [1, 2, 3],它的子数组有[1]、[2]、[3]、[1,2]、[2,3]、[1,2,3],但[1,3]不算子数组,因为中间跳过了2。

测试样例也很直白:nums = [1, 1, 1], k = 2,答案是 2,因为[1,1]出现的位置不同,一个是下标 0~1,一个是下标 1~2,算两个不同的子数组;nums = [1, 2, 3], k = 3,答案是 2,因为[1,2]和[3]都满足条件。

这个题适合谁来刷?刚开始练算法题、想搞清楚“前缀和”和“哈希表优化”这两个高频考点的同学,值得花时间吃透它。面试里它经常作为中等题出现,而且往往是后续很多“连续子数组”问题的基础模板。

注意:子数组的个数统计的是“位置组合”,不是“值组合”。也就是说即使两个子数组的元素完全一样,只要起点或终点不同,就要分别计数。这个理解贯穿全文。

2. 先从暴力解法开始,摸清题目的脾性

2.1 最直接的枚举法:固定起点扩展终点

拿到一个数组题,最朴素的想法就是把所有可能的子数组都枚举一遍。固定每个起点,然后让终点一路往后跑,每跑一步累加一下和,遇到等于 k 的就计数。

def subarray_sum_brutal(nums, k): n = len(nums) count = 0 for start in range(n): total = 0 for end in range(start, n): total += nums[end] if total == k: count += 1 return count

这段代码的思路很直接:起点start定住,total记录nums[start]到nums[end]的累加和,终点每往后走一格就更新一次 sum。时间复杂度是 O(n²),空间复杂度是 O(1)。

我实测了一下,n = 10^4时这个解法就开始明显卡顿,n = 10^5基本跑不动。但别急着否定它,暴力解法的意义在于帮我们确认题意、验证后续优化版本的正确性。我在本地写题的时候,经常把暴力解法留着当“基准测试”,拿它和优化版本对拍,能瞬间暴露优化版里的边界 bug。

2.2 枚举左右边界:另一种暴力形态

如果不固定起点,改成先枚举右边界、再枚举左边界,也能得到同样的效果。不同的是我们可以先用一个“前缀和”数组预处理,让子数组和的求解变成 O(1) 的减法。虽然整体还是 O(n²),但这已经是向前缀和思路过渡的重要一步。

def subarray_sum_with_prefix(nums, k): n = len(nums) prefix = [0] * (n + 1) for i in range(n): prefix[i + 1] = prefix[i] + nums[i] count = 0 for i in range(n): for j in range(i + 1, n + 1): if prefix[j] - prefix[i] == k: count += 1 return count

这里的prefix[i]表示nums[0]到nums[i-1]的和。那么nums[i]到nums[j-1]的和就是prefix[j] - prefix[i]。为什么前缀和下标要错开一位?因为这样prefix[0] = 0能自然表示“空数组”,处理边界时不用额外加判断。

跑到这一步,大多数人都能感觉到问题所在:枚举左右边界本身就是 O(n²) 的遍历,怎么都躲不掉。那真正的优化点到底在哪?

2.3 为什么滑动窗口在这里行不通

很多人一看到“连续子数组和”就条件反射想到滑动窗口(双指针)。但我要泼一盆冷水:只要数组里有负数,滑动窗口思路就是错的。这是 560 这道题最有意思的陷阱之一。

滑动窗口的核心依赖是单调性:窗口右移时和变大,左移时和变小。于是当窗口和大于 k 时收缩左边界,小于 k 时扩展右边界,这个逻辑在全是正数的前提下成立。但一旦有负数,右移扩大窗口可能让和变小,左移收缩窗口也可能让和变大,单调性被破坏,两指针就再也没法正确地“追”出所有解了。

我举个例子你就明白:nums = [1, 2, -2, 1],k = 1,正确答案有 3 个:[1](下标 0)、[1](下标 3)、[2, -2, 1](下标 1 到 3)。滑动窗口从下标 0 开始,窗口和 1 已经等于 k,计数一次。继续右移,和变成 3,大于 k,于是收缩左边界,窗口变成[2],和还是大于 k,再收缩,窗口变成[-2],小于 k,再右移,和变成[-1],小于 k,再右移,窗口到头了。整个过程完全漏掉了从下标 3 开始的[1],更漏掉了[2, -2, 1]。这就是负数对滑动窗口的致命打击。

所以看到“连续子数组”就无脑上滑动窗口,是老手也会犯的直觉错误。先确认数组元素是否全为正,再决定能不能用双指针。

3. 核心优化思路:前缀和搭配哈希表

3.1 从公式推导中找出重复工作

前面提到,子数组nums[j..i]的和可以写成prefix[i+1] - prefix[j]。我们要求的是这个值等于k,也就是:

prefix[i+1] - prefix[j] = k

移项,变成:

prefix[j] = prefix[i+1] - k

这个改写是整道题的题眼。它把“找子数组”变成了一个“查找”问题:当我站在当前位置i,已经算好了当前前缀和current,我只需要回头看看,在之前的位置里,有多少个前缀和的值恰好等于current - k。有多少个这样的前缀和,就有多少个以当前位置结尾的、和为 k 的子数组。

这个视角打通之后,优化就顺理成章了:我们并不需要把所有前缀和存下来再逐一比较,而是用一个哈希表,在遍历过程中实时记录“某个前缀和值出现了多少次”。每到一个新位置,查询一下current - k在不在哈希表里,如果存在,就把对应次数加到答案里。

3.2 一次遍历,边算边查边存

算法的时间复杂度从 O(n²) 降到 O(n),逻辑上只做三件事:

  1. 维护当前累加和current;
  2. 查哈希表:current - k出现的次数;
  3. 把current作为键,写入哈希表,次数加 1。

“边算边存”是关键。这里有个容易理解错的地方:为什么要在同一轮循环里既查哈希表又更新哈希表?因为在查询current - k的时候,我们只关心位于当前位置之前的前缀和,不能把包含当前元素的前缀和也算进去。而更新哈希表这个动作,是留给后续位置来查询用的。先后顺序一旦搞反,结果就错了。

3.3 哈希表的初始值为什么是 {0: 1}

第一次看到hashmap = {0: 1}的时候,很多人都会愣一下。这个初始化的含义是:在还没有遍历任何元素之前,已经存在一个前缀和,值为 0,并且它出现过一次。

什么场景会用到这个“空前缀”?当current - k == 0,也就是current == k时,说明从数组开头到当前位置这一整段子数组的和正好等于 k。此时哈希表里如果查不到 0,就会把这个答案漏掉。

假设nums = [1, 2, 3], k = 3,遍历到下标 1 时,current = 3,current - k = 0。哈希表里必须有{0: 1}才能匹配到[1, 2]这个子数组。同理,nums = [3], k = 3,单元素数组也能靠{0: 1}正确计数。

这里有个细节值得强调:hashmap[0] = 1里的“1”表示空前缀出现了一次,不要理解为“数组里有一个值为 0 的元素”。它们是两码事。

4. 完整代码实现与逐步拆解

4.1 可运行的 Python 实现

直接给最终版本,我习惯在本地写题时保留几分注释,方便回看思路:

from typing import List class Solution: def subarraySum(self, nums: List[int], k: int) -> int: count = 0 current = 0 prefix_count = {0: 1} for num in nums: current += num target = current - k if target in prefix_count: count += prefix_count[target] prefix_count[current] = prefix_count.get(current, 0) + 1 return count

这版代码看着短,但每一行都有分量。current记录从数组开头到当前元素的前缀和;target是我们回头要找的前缀和;prefix_count统计每个前缀和出现的次数。循环结束后,count就是所有和为 k 的子数组个数。

同样的逻辑放到 Java 里也基本是平搬:

class Solution { public int subarraySum(int[] nums, int k) { int count = 0; int current = 0; Map<Integer, Integer> prefixCount = new HashMap<>(); prefixCount.put(0, 1); for (int num : nums) { current += num; int target = current - k; count += prefixCount.getOrDefault(target, 0); prefixCount.put(current, prefixCount.getOrDefault(current, 0) + 1); } return count; } }

不需要额外引入复杂的数据结构,一个普通的哈希表就够了。

4.2 为什么必须先查后更新

这个顺序问题,我用一个反例来验证。假设nums = [1], k = 0,正确答案显然是 0,因为没有任何子数组的和等于 0。

如果先把current写进哈希表、再做查询:

  • 第一轮循环:current = 1,先把1写进哈希表;
  • 查询current - k = 1,发现哈希表里有 1,于是count = 1。

这就错了。为什么会错?因为查询时把“当前位置”自己对应的前缀和也算进去了,等价于在计算一个长度为 0 的空子数组。空子数组既不是题目定义的子数组,也不该被计数。

先查后更新的本质是保证target只来自严格位于当前位置之前的那些前缀和。这个约束是数学推导里j < i的代码体现,少一行都会出事。我第一次写的时候就是先更新再查询,为此多花了半小时排查。

4.3 一套完整的测试用例清单

我拿这段最终代码跑了下面这些用例,基本能覆盖常见的边界条件:

测试用例k期望结果说明
[1, 1, 1]22常规正数用例
[1, 2, 3]32单元素与多元素混合
[0, 0, 0]06全零数组,每个子数组和都是 0
[1, -1, 0]03负数与零混合,检验前缀和正确性
[1]00单个元素但不匹配 k
[]00空数组的极端情况
[1, 2, -2, 1]13之前滑动窗口会漏解的例子

用暴力解法做对拍,这组用例在优化版上输出完全一致。

5. 实操中踩过的坑和排查实录

5.1 把“前缀和的值”和“前缀和的次数”搞混

哈希表里存的是某个前缀和出现的次数,而不是某个前缀和对应的下标。这个区别在刚开始学的时候容易绕晕。我见过有人把哈希表设计成{前缀和: 最早出现下标},然后只计数一次,结果漏掉了重复前缀和。

举个典型的场景:nums = [0, 1, -1], k = 0。前缀和依次是 0、1、0。值为 0 的前缀和出现了两次(下标 0 之前和下标 2 之前)。每遇到一次重复,就意味着多了一个可匹配的起点。如果只存“最早下标”,第二个 0 就完全被无视了。所以哈希表的 value 必须是次数,不是下标。

5.2 记住,这个题的答案可能超出 int 范围

虽然题目的数值范围通常不会让答案爆掉 32 位整数,但“子数组个数”的上限是n * (n + 1) / 2。当数组长度达到10^5,理论上最多有约 50 亿个子数组,这已经超出 Javaint的范围了。

我在面试模拟的时候特意查过:LeetCode 原始题解多在个位数万级别的数组里运行,直接用int勉强够用,但为了稳妥,生产环境或笔试中我会直接用long接收count,Python 因为整数无限长没有这个问题,Java 里最好把计数变量声明为long。别在这种细节上吃暗亏。

5.3 负数不是 bug,但会引发剪枝幻觉

很多从正数数组题过来的同学,习惯性在暴力解法里遇到“当前和大于 k”就提前break。理由是再往后加都是正数,和只会更大。这个优化在全是正数时成立,但数组一旦有负数,提前break会直接漏解。

举个简单的例子:nums = [1, -1, 0], k = 0。暴力解法里如果当前累加和变成 3、大于 k 就break,那后面出现的[1, -1]就永远统计不到。这个坑我用了一个下午才定位出来,因为报错的不是程序崩溃,而是答案偏小,非常隐蔽。

5.4 哈希表初始化遗漏的连锁反应

{0: 1}漏掉之后,表现是“结果比正确答案少”。不是全部报错,而是只漏那些“从开头到某个下标”的子数组。排查这类问题最快的方式是拿全 0 数组跑一遍,比如nums = [0, 0, 0], k = 0。全 0 数组下,正确答案是 6(每个子数组都满足条件),如果少了{0: 1},结果大概率是 3 或者更少,问题立刻暴露。

我自己的习惯是写算法题时先准备几个“恶心”用例,把边界情况全部覆盖到,再开始优化。全 0 数组、负数混合数组、空数组永远是优先级最高的三个测试样例。

6. 从这题延伸出去:一套通用的连续子数组解法

6.1 前缀和加哈希表的通用模板

560 不只是孤立的一道题,它背后是一个可以迁移到很多场景的模板。思路提炼成一句话:“当前位置的前缀和,减去历史某个前缀和,得到中间一段连续子数组的和;要判断差是否为 k,就等价于查历史前缀和中有没有current - k。”

这个模板在别的题里也经常出现:

  • 和为 k 的子矩阵:把二维矩阵按行压缩成一维数组,每一列的和作为一个元素,然后对每个可能的行区间调用 560 的解法,复杂度 O(n²m),核心还是这个模板。
  • 可以被 k 整除的子数组:前缀和对 k 取余,记录每个余数出现的次数,一次遍历搞定。
  • 乘积等于 k 的子数组:因为乘积不像加法有逆运算,需要额外处理 0 和正负号,但思路依然是边遍历边记录历史状态。

6.2 面试时的表达顺序建议

如果面试官让你讲这题,建议按这个顺序组织语言:先讲暴力解,再引出前缀和消除区间和的计算成本,最后用哈希表把查找历史前缀和的时间从 O(n) 降到 O(1)。每步都要说明“为什么这么做”——不是为了秀技巧,而是为了展示你理解性能瓶颈在哪里。

我发现很多面试官会格外关注一个点:当数组里有负数时,为什么不能提前终止。这恰恰是这道题区分“背题”和“真懂”的分水岭。把滑动窗口失效的例子讲清楚,比把代码默写出来更有说服力。

6.3 本地对拍验证的工具性写法

最后分享一个我常用的本地测试思路。写算法题不要只在脑子里转,我会把暴力解法和优化解法放在同一个文件里,用随机数组跑对拍:

import random def brute(nums, k): count = 0 for i in range(len(nums)): total = 0 for j in range(i, len(nums)): total += nums[j] if total == k: count += 1 return count def optimized(nums, k): count = 0 current = 0 prefix_count = {0: 1} for num in nums: current += num target = current - k if target in prefix_count: count += prefix_count[target] prefix_count[current] = prefix_count.get(current, 0) + 1 return count for _ in range(1000): nums = [random.randint(-5, 5) for _ in range(random.randint(1, 10))] k = random.randint(-3, 3) if brute(nums, k) != optimized(nums, k): print("error", nums, k, brute(nums, k), optimized(nums, k)) break

跑几千组随机数据都没问题,基本就可以放心提交了。这个方法看起来笨,但特别管用,尤其是处理这种边界条件藏得很深的数组题。

回到开头那个判断:这题真有看起来那么简单吗?答案是否定的。它的代码量很少,真正值钱的是那个“移项后用哈希表查历史”的思路。我个人觉得,把 560 吃透,胜过刷十道同类型的简单题。这道题背后藏着的是连续子数组问题的通用解法骨架,理解了它,后面遇到相似的题目会从容很多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询