☰
和为K的子数组:从暴力到前缀和+哈希表O(n)优化
2026/10/9 7:00:50 网站建设 项目流程

刷题的人应该都体会过这么一种状态:看到“子数组”“连续区间”这种词,第一反应是双指针或者滑动窗口,结果题目里出现负数,直接给你来个回马枪。LeetCode hot100里的第560题“和为K的子数组”,就是这个套路的典型代表。它表面上是求连续子数组的和等于某个目标值,实际上考的是前缀和配合哈希表的经典优化,这也是面试里出现频率极高的一种组合拳。这道题适合所有正在刷题备战面试的人,不管你是刚入门还是复习到中期,把它吃透,对你理解“区间求和”这一类问题会有质的提升。

这道题我前前后后遇到过好几次,最开始用暴力解法,时间复杂度高得离谱,后来学聪明了用前缀和,再从O(n²)优化到O(n)。整个过程走下来,我发现它不只是让你背一个模板,更像是在训练一种思维:遇到区间求和,能不能换个角度,用“减法”而不是“加法”去看问题。这篇文章我会把从暴力到最优解的完整推导过程、代码写法、边界条件、还有我自己踩过的坑全部摊开讲,保证你读完能直接上手,遇到变体题也能举一反三。

1. 题目拆解:搞清楚它在考什么

1.1 题目到底在问什么

原题描述很简洁:给你一个整数数组nums和一个整数k,请你统计并返回该数组中和为k的连续子数组的个数。需要注意几个关键词:第一是“连续”,也就是说子数组必须是数组里连续的一段,不能打乱顺序,也不能挑着选;第二是“个数”,不是让你返回具体是哪几个子数组,也不是求最长长度,只要求数量;第三是“整数数组”,这意味着数组里可能有正数、负数,甚至零,这点非常关键,后面你会看到它直接决定了哪些算法是可行的。

举个最简单的例子,nums = [1, 1, 1],k = 2,那么连续子数组中和为2的有[1, 1](从索引0开始)和[1, 1](从索引1开始),所以答案是2。再举一个带负数的例子,nums = [1, -1, 1],k = 1,这里就需要仔细数一下:[1](索引0)、[1](索引2)、[1, -1, 1](整个数组和是1),一共三个。如果不小心漏掉最后一个,那就是对“连续子数组”这个定义还没完全拿捏。

这道题在hot100里的定位是“经典中的经典”,它不涉及什么高级数据结构,也不需要动态规划,但非常考验你对基础工具(前缀和、哈希表)的熟练程度。很多公司的笔试和面试都喜欢出类似题,因为它既能筛选出“背过模板”的人,也能筛选出“真正理解原理”的人。

1.2 暴力解为什么不行

我第一次做这道题的时候,脑子里冒出来的最直接想法就是枚举所有子数组。一个连续子数组可以用两个索引i和j来表示,其中i是左端点,j是右端点,那么所有可能的(i, j)组合有 n(n+1)/2 个。接下来再算每个子数组的和,如果每次都从头加到尾,那整体复杂度就是O(n³),n稍微大一点就直接爆了。

稍微优化一下,可以先预处理一个前缀和数组preSum,来快速得到任意区间的和。预处理本身是O(n),之后每次查询区间和就是O(1),所以整体复杂度降到了O(n²)。听起来好像还行,但要注意题目的数据范围:nums的长度最多能到2万左右(具体看版本,有的版本是10^4),O(n²)意味着最坏情况下要执行4亿次操作,这在大多数在线判题系统里都会超时。

暴力解还有一个更隐蔽的问题:它让你停留在“计算区间和”的思维定式里,完全没有意识到这道题其实可以转化成“寻找两个前缀和之间的关系”。这就是为什么说刷题不能只满足于AC,一定要去理解最优解是怎么想到的。暴力解的价值不在于能过题,而在于它给你提供了一个基线,让你知道优化的空间有多大。

2. 前缀和:把区间和变成一次减法

2.1 什么是前缀和

前缀和这个概念,凡是刷过几道数组题的人应该都不陌生。它的定义很简单:新开一个数组preSum,其中preSum[i]表示原数组从第一个元素到第i个元素(通常用preSum[i] = nums[0] + nums[1] + ... + nums[i-1]这种左闭右开的定义)的累加和。这样定义有个好处:preSum[0] = 0,代表空数组的和,后面计算区间和的时候边界很干净。

举个例子,nums = [1, 2, 3, 4],那么preSum = [0, 1, 3, 6, 10]。注意preSum的长度是nums.length + 1,因为多了一个“什么也没加”的0。为什么凭空多出这个0?因为在数学上,空区间的前缀和是0,它代表“从起点前一位开始”的位置。这一点在后面的哈希表优化中变得极其重要,很多人就是在这里懵掉的。

2.2 如何用前缀和表示任意子数组的和

有了preSum之后,任意连续子数组nums[i..j]的和都可以用一步减法得到了:

sum(i..j) = preSum[j+1] - preSum[i]

这个式子的含义是:从开头加到第j个元素的总和,减去从开头加到第i-1个元素的总和,剩下的自然就是中间那一段的和。你可以把它理解成一段一段切绳子:总长度减去前面一截,剩下的就是你要的那一段。这个逻辑清晰简单,也是前缀和解决区间求和问题的核心优势:把区间和问题,变成了两个前缀和相减的问题。

有了这个公式,暴力解里的第二步就可以优化成O(1),整体复杂度降到O(n²)。但我们要的不是O(n²),而是O(n),所以还需要再往前走一步。这一步的关键在于转变视角:不再枚举左右端点,而是枚举右端点,看看左边有没有满足条件的前缀和。

2.3 为什么双指针在这道题里失效

很多人在看到“连续子数组”和“求和”两个关键词后,第一反应是滑动窗口(双指针)。在数组元素全部为正数的时候,滑动窗口确实完美:窗口和小于k就右扩,大于k就左缩,因为新增元素只会让和变大,缩掉元素只会让和变小,窗口的移动方向是唯一的。

但这道题里明确指出数组元素是整数,也就是会包含负数。一旦出现负数,窗口的移动方向就不唯一了。举个例子,假设当前窗口和小于k,你右扩了一下,结果加进来一个负数,和反而更小了;又比如窗口和已经大于k,你左缩了一下,结果缩掉的是一个很大的负数,和反而变大了。在这种情况下,“什么时候移动右边界、什么时候移动左边界”根本没法确定,双指针的单调性被彻底破坏。

所以这道题必须换思路。这也解释了为什么面试官喜欢考这道题:它能很干净地测试出你是否真的理解双指针的适用条件,而不是看到“连续”就无脑上窗口。

3. 哈希表优化:从O(n²)到O(n)

3.1 核心等式与倒推思路

回到前缀和的公式:子数组的和等于preSum[j+1] - preSum[i]。题目要求这个差值等于k,那么我们就得到了一个等式:

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

把未知数移到一边,变成:

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

这看起来平平无奇,但妙就妙在它的“倒推”含义:当我们从左到右遍历数组,计算到位置j的时候,preSum[j+1]是已知的(当前累计和),我们要做的是寻找“之前出现过多少个preSum[i],它的值等于preSum[j+1] - k”。只要找到多少个这样的i,就等于找到了多少个以当前元素j结尾的、和为k的子数组。

为什么是“以当前元素结尾”?因为i <= j是子数组连续性的必要条件。如果i > j,那nums[i..j]根本不是一个合法区间。因此,我们必须保证在统计某个前缀和出现过多少次的时候,只统计当前索引之前已经出现过的值,这自然导向了“边遍历边统计”的单次循环结构。

3.2 哈希表里到底存什么

既然要快速回答“之前出现过多少次某个值”,最直接的数据结构就是哈希表(HashMap / dict)。键是前缀和的值,值是到目前为止,这个前缀和值出现的次数。

例如,遍历到某个位置时,当前累计和是sum,我们需要的目标值是target = sum - k。查一下哈希表里target对应的次数,就说明有多少个历史位置的前缀和等于target,也就意味着有多少个以当前位置结尾的子数组满足条件。然后,再把当前的前缀和sum的次数加1,继续往后遍历。

需要注意一个细节:sum(j)这个位置在计算完以它为右端点的子数组数量之后,才把它对应的前缀和值更新到哈希表里。这保证了每一个被统计的前缀和都位于当前右端点之前,不会出现“自己比自己还大”这种荒谬的事。这个顺序问题看起来是小细节,实际上特别容易出错,尤其当k = 0时,一旦先更新再查询,就会多出很多错误的计数。

3.3 为什么必须初始化map[0] = 1

这是这道题最经典的坑,没有之一。很多第一次接触前缀和优化的人会疑惑:为什么哈希表一开始就要放一个(0, 1)?

假设数组是[1, 2, 3],k = 3,遍历到索引2时,当前累计和sum = 6,我们需要在前面找有没有sum - k = 3这个值。此时哈希表里已经存过1、3这两个前缀和,所以能查到3出现过一次,对应的子数组就是nums[2..2](也就是[3])。这没问题。

但假如数组是[1, 2],k = 3,遍历到索引1时,sum = 3,我们需要找sum - k = 0是否出现过。0这个前缀和意味着“从开头之前的位置开始算”,也就是说整个数组[1, 2]本身就是一个解。如果哈希表里没有初始化的(0, 1),这个解就找不到了,答案会从2变成1,直接WA。

所以这个(0, 1)的本质是:为了正确统计从数组头部开始的子数组。它代表“空数组”这个虚拟的前缀和,在还没开始遍历之前就已经出现了一次。有些人会写成map[0] = 1,也有些人会写成map.put(0, 1),意思完全一样,但一定要记得写。

3.4 代码实现(Python / Java)

思路清晰之后,代码其实非常简洁。我给出Python和Java两个版本,都是我能想到的最直接的写法,方便不同语言背景的人对照。

Python版本:

def subarraySum(nums, k): # 前缀和 -> 出现次数 preSumCount = {0: 1} cur_sum = 0 ans = 0 for num in nums: cur_sum += num # 查找有多少个之前的前缀和等于 cur_sum - k ans += preSumCount.get(cur_sum - k, 0) # 将当前前缀和的出现次数加一 preSumCount[cur_sum] = preSumCount.get(cur_sum, 0) + 1 return ans

Java版本:

public int subarraySum(int[] nums, int k) { Map<Integer, Integer> preSumCount = new HashMap<>(); preSumCount.put(0, 1); int curSum = 0; int ans = 0; for (int num : nums) { curSum += num; ans += preSumCount.getOrDefault(curSum - k, 0); preSumCount.put(curSum, preSumCount.getOrDefault(curSum, 0) + 1); } return ans; }

代码核心逻辑就是三句话:累加当前和,查哈希表,更新哈希表。没有if嵌套,没有冗长的条件判断,但每一步都要想清楚“为什么这么做”。比如ans += preSumCount.get(cur_sum - k, 0),是在累加之前出现过的所有能凑成k的前缀和次数;再比如preSumCount[cur_sum] += 1是把这个位置的前缀和作为一种“历史情况”记录下来,供后续的子数组使用。

4. 实操验证与边界条件

4.1 测试用例设计

写完代码之后,先别急着提交。我习惯先在本地跑几个测试用例,尤其是带负数、带零、目标值为零、重复元素比较多的场景。这里列几个我用过的关键用例:

  1. nums = [1, 1, 1],k = 2,期望结果2。
  2. nums = [1, 2, 3],k = 3,期望结果2([1, 2]和[3])。
  3. nums = [1, -1, 1],k = 1,期望结果3(这个用例最容易暴露出对“以当前元素结尾的子数组”理解不到位的问题)。
  4. nums = [1, -1, 0],k = 0,期望结果3([1, -1],[0],[1, -1, 0],这三个子数组的和都是0)。
  5. nums = [0, 0, 0],k = 0,期望结果6(所有非空连续子数组,n(n+1)/2 = 6)。
  6. nums = [-1, -1, 1],k = 0,期望结果1([-1, 1],从索引1开始到索引2结束)。

这些用例覆盖了几种最容易犯错的情况:从起点开始的子数组、负数场景、零目标值、全零数组。你可以把subarraySum跑一遍,看看结果是不是跟预期一致。尤其是第6个例子,它能帮你确认哈希表的存储顺序是否正确:在索引2时,cur_sum = -1,需要找cur_sum - k = -1是否出现过,而-1这个前缀和在索引1之后就已经出现了,所以能正确匹配到[1..2]这个区间。

4.2 关于更新顺序的坑

我在3.2节里提到“先查询,再更新”这个顺序,这里展开说说。很多人写的时候觉得无所谓,先更新再查询不也一样吗?其实差别很大,尤其是在k = 0的时候。

例如数组[1, -1],k = 0。正确答案是[1, -1],也就是1个。如果先更新再查询,会发生什么?遍历索引0,cur_sum = 1,先把map[1] = 1更新好,然后查询cur_sum - k = 1,查到1次,误以为有一个以索引0结尾、和为0的子数组,这显然是错的,[1]的和是1不是0。于是答案变成了2。

反过来,先查询再更新,索引0时先查cur_sum - k = 1,哈希表里此时只有{0:1},没有1,查不到,答案仍然为0;然后再更新map[1] = 1。索引1时cur_sum = 0,查0 - 0 = 0,哈希表里有0,出现了1次,答案变为1。正确。这一个小小的顺序差异,就是很多WA的根源。

4.3 复杂度与空间取舍

时间复杂度方面,整个数组只需要遍历一次,每个元素的处理都是哈希表的插入和查询操作,平均下来都是O(1),所以总时间复杂度是O(n)。空间复杂度是O(n),因为哈希表里最多会存n+1个不同的前缀和值(实际上去重之后可能会少于n+1),这在绝大多数情况下是可接受的。

有些追求极致空间的人会问,能不能不用哈希表?在数组全部为正数时可以改用二分查找加前缀和数组,或者干脆用滑动窗口,前者是O(n log n)的复杂度,后者是O(n)但只适用于全正数。在允许负数存在的情况下,哈希表是唯一能同时保证O(n)时间和相对简洁代码的方案。这也是为什么专业题解普遍采用哈希表的根本原因。

5. 常见错误与排查实录

5.1 忘记初始化map[0] = 1

这个错误我见过不止一次,自己做题的时候也犯过。表现非常典型:大部分用例都能过,但遇到像[1, 2, 3]、k=3这种需要统计整个数组作为子数组的用例时,答案会少1。排查方法很简单:在代码里加一句print(preSumCount),在遍历过程中观察哈希表的变化,你会发现当cur_sum == k时,查询cur_sum - k恰恰需要0这个键存在,而你没有加它。

这里也侧面说明了一个方法论:遇到“答案总是差一点”的题,优先检查边界初始化,尤其是带0的初始状态。

5.2 用双指针或者排序导致WA

前面在2.3节已经解释过为什么双指针在这里失效。但还有一种更隐蔽的错误:想当然地对前缀和数组排序,然后使用二分查找或者双指针去找和为k的差值。排序确实能让你快速找到匹配的数值,但问题是前缀和数组一旦排序,原来“位置在前”和“位置在后”的对应关系就被打乱了。而子数组要求i <= j,也就是两个前缀和的原始先后顺序必须满足约束。排序后这个约束就丢失了,你统计出来的数量会包含那些“位置倒挂”的非法组合,导致答案偏大。

所以,前缀和排序只适用于不需要区分先后顺序的统计问题,一旦涉及“连续子数组”,就一定不能破坏原始顺序信息。

5.3 误用HashSet代替HashMap

还有一种常见写法是使用HashSet来记录出现过的前缀和。这会带来一个问题:如果同一个前缀和在历史中出现过多次,HashSet只保留了“出现过”这个布尔信息,丢掉了“出现几次”的关键信息。而这道题要求的是子数组的个数,同一个前缀和值出现多次,意味着有多个不同的起点可以跟当前终点组成多个合法子数组。因此必须使用HashMap(或Counter等能计数的结构),而不能用Set。

举个例子,数组[1, -1, 1, -1, 1],k = 1。在前缀和的历史中,1这个值出现了好几次,每个位置对应一个不同的起点,所以这里需要计数。如果你用Set,统计结果会少了不止一个。

5.4 常见错误速查表

错误类型可能原因排查方向
答案少1忘记初始化map[0] = 1检查哈希表初始状态
答案多算先更新哈希表再查询确认查询和更新的先后顺序
答案错乱对前缀和排序导致位置关系丢失去掉排序,保持原始顺序
答案偏小用HashSet而非HashMap计数检查是否记录了次数而不是布尔值
边界数组异常全零、重负数、大k值未考虑补充针对性测试用例

6. 进阶变体与面试延伸

6.1 若要求返回具体子数组

有些面试官会追问:如果把题目改成“返回所有和为k的连续子数组”,应该怎么做?思路仍然基于前缀和,但哈希表的value要从“次数”换成“索引列表”。具体来说,遍历时把每个前缀和对应的索引位置都存到一个List里。当遇到cur_sum - k在哈希表中存在时,遍历它对应的所有索引位置,每个索引位置i都能对应一个子数组nums[i..当前索引-1](或者按定义处理索引边界)。此时时间复杂度会变成O(n + 答案数量),因为返回所有解本身就是输出敏感的。

这类变体在代码实现上没有太多新东西,但能考察你对哈希表存“索引信息”的感觉。面试碰到这个追问,基本就是在看你是背模板还是真理解。

6.2 全正数数组的特殊解法

如果题目条件变成“数组中全是正数”,那这道题有一个更简单的O(n)解法:滑动窗口。因为全正数保证了窗口和是单调递增的,右指针移动会让和变大,左指针移动会让和变小。维护一个窗口,当窗口和小于k时右扩,大于k时左缩,等于k时计数。代码比哈希表版本还要简洁,大概十行就能写完。

但要注意认清适用边界。这个技巧在560题的“进阶版·全正数版”里能用,在原本题里用不了。我在面试里也问过候选人类似的问题,很多人一上来就写滑动窗口,我反手给他一个包含负数的用例,他就懵了。

6.3 二维矩阵的子矩阵和为k

如果面试官还想加码,可能会把问题升级到二维:给你一个矩阵,求有多少个子矩阵的和等于k。这类题属于“二维前缀和+枚举上下边界+一维哈希表”的组合思路。具体做法是固定上边界和下边界,把每一列的元素加总成一个一维数组,然后在这个一维数组上跑560题的哈希表解法。整体复杂度是O(n²·m),在面试中可以作为一个很好的加分项,展示你对“降维思路”的掌握。

6.4 最长连续子数组和为k

比“个数”更进一步的考法是“最长长度”。思路几乎一样,但哈希表里的value不再是次数,而是“第一次出现该前缀和的位置”。遍历时,如果cur_sum - k在哈希表里存在,就用当前索引 - 第一次出现位置来更新最大长度;同时,只有当cur_sum不在哈希表里时才将其存入,这样能保证存的是最早的位置,从而让长度尽可能大。这种变体在LeetCode上也有对应例题,是面试中常见的追问方向。

最后分享一点我的使用体会

这道题我刷过好几遍,每次重做都会有一点新的收获。第一次是死记硬背模板,第二次理解了前缀和,第三次才真正搞懂为什么map[0]=1和“先查后更新”如此关键。如果你正在准备面试,我的建议是别急着直接看答案,先自己从暴力解开始推一遍,一步步走到最优解。做完这题之后,再做几道前缀和的变体题,比如“连续数组和为k的个数”“二维矩阵子矩阵和为k”等,把这套思路固化下来。等你把“区间和问题 => 前缀和差分 => 哈希表加速”这条链路变成肌肉记忆,这类题在面试中就不会再成为你的拦路虎了。

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

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

立即咨询