Go 语言 LeetCode 560:前缀和+哈希表破解和为K的子数组
刷 LeetCode 最过瘾的瞬间就是你盯着 O(n²) 的暴力解法卡了两分钟突然意识到连续子数组的区间和可以用前缀和变成一次减法再用哈希表把“查历史次数”变成 O(1)——LeetCode Hot 100 里这题 560. 和为 K 的子数组题解标题写“降维打击”真的一点都不夸张。今天我用 Go 语言把这道题完整拆一遍核心套路就三个关键词前缀和、哈希表、和为 K 的子数组。这道题适合所有想系统性刷题的人尤其适合准备面试的 Go 后端开发。它表面上很“简单”但坑全藏在边界条件里值得认真抠一遍。1. 先看懂题目连续子数组、负数和暴力解的瓶颈1.1 题目到底在问什么LeetCode 560 的题干非常简短给定一个整数数组 nums 和一个整数 k返回数组中和为 k 的连续子数组的个数。示例里给的是 nums [1,1,1]k2结果是 2因为只有下标 0-1 和 1-2 这两个长度为 2 的连续片段满足条件。这里有两个容易被忽略的限定词。第一是“连续”子数组必须是在原数组里相邻的一段不能像子序列那样跳着取第二是“个数”而不是“最大长度”意味着我们要把所有满足条件的区间全部数出来。很多刷题新手一上来会把这题和“和为 k 的最长子数组”混为一谈后者虽然也可以用前缀和但哈希表里存的东西完全不一样——一个是计数一个是位置。想清楚题面在问什么比急着写代码更重要。nums 的取值范围也直接决定了这题的解法方向。题目没有限定 nums 只含正整数测试用例里负数和 0 都很常见这意味着数组中元素累加出来的前缀和并不是单调递增的。这个特性直接排除了很多“看起来很像”的优化方案。我见过不少人第一反应是用滑动窗口理由是“连续子数组直觉上就该用双指针”但遇到负数用例直接翻车原因我在 1.3 节里细说。1.2 暴力解法起点终点双重枚举为何超时先看最直白的做法。一个最没有理解成本的方案是枚举所有子数组的起点 i 和终点 j然后累加 i 到 j 的数值判断累加和是否等于 k。如果每次都重新累加复杂度是 O(n³)稍微优化一下在固定起点 i 之后让终点 j 向右移动时同步累加复杂度就降到 O(n²)这是很多人在面试时首先能想到的方案。func subarraySum(nums []int, k int) int { count : 0 for i : 0; i len(nums); i { sum : 0 for j : i; j len(nums); j { sum nums[j] if sum k { count } } } return count }这段代码的正确性不用怀疑但 LeetCode 上 n 可以达到 2 万O(n²) 意味着最多 4 亿次内层操作Go 再快也扛不住这种规模最终结果就是超时。暴力解法的价值在于帮我们建立“枚举起点、终点”这个直觉模型后面所有优化都是冲着减少这一层枚举去的。如果直接背优化解法而不理解暴力为什么慢遇到变种题很容易又回到 O(n²) 的老路。1.3 为什么双指针在数组含负数时直接失效提到连续子数组很多人的第一反应是滑动窗口。维护一个左指针和一个右指针窗口内和小于 k 时右指针右移扩大窗口和大于 k 时左指针右移缩小窗口直到窗口内和等于 k 就统计一次。这套逻辑在一个强约束下完全成立数组所有元素必须非负。这样窗口向右扩张时总和单调不减向左收缩时总和单调不增指针移动才有明确的方向性。一旦数组里出现负数整个窗口机制就崩了。比如 nums [-1, 1]k 0。右指针向右移动窗口内和从 -1 变成 0似乎正好命中可如果你想继续找下一个区间右指针再右移已经越界左指针右移后窗口内是 1和反而变大。此时你根本没法判断应该移动哪个指针才能更接近目标值因为负数让“扩大窗口和变大”这个直觉完全不成立了。在线性结构上追求 O(n) 没问题但前提是数据结构得具备“单调性”而这题的前缀和序列显然不具备。所以正确的优化思路必须跳出指针维护窗口的框架转到“用数学公式表达区间和”的前缀和体系上。2. 前缀和把区间和翻译成一次减法2.1 前缀和的账本思维前缀和这个概念本身并不复杂pre[i] 表示从数组开头累加到第 i 个元素的总和。用数学语言表达pre[0]0pre[i]pre[i-1]nums[i-1]。有了这个预计算的数组任意区间 [i, j) 的和就可以写成 pre[j] - pre[i]这里的区间是左闭右开ij。这个转换最大的好处是区间和不再需要逐个累加时间复杂度从 O(区间长度) 变成 O(1)。我习惯用一个账本的例子来理解它。假设记录餐厅每天的营业额我要知道某个月里第三周到第五周一共赚了多少最笨的办法是翻出这三周每天的流水一笔笔加但如果我提前维护了一份“累计营业额表”问题就变成“截至第五周的累计营业额”减去“截至第二周的累计营业额”。一天一天地加是暴力查累计表是前缀和。这个类比几乎适用于所有区间求和问题面试时用这个例子和面试官沟通对方通常立刻就能明白你在说什么。Go 语言里实现前缀和没有特别的语法技巧就是用一个变量或者切片在遍历时累加。不过有一类值得注意的情况如果 nums 中存在负数pre 的值并不是递增的这反而更考验我们对公式本身的理解而不是对递增性的依赖。2.2 核心公式pre[j]-pre[i]k 是怎么来的我们要求 nums[i:j] 的和等于 k也就是 pre[j] - pre[i] k。把这个等式换个位置pre[i] pre[j] - k。现在式子左边是“某个历史前缀和”右边是“当前前缀和减目标值”。当我们遍历到 j 时pre[j] 是已知的k 是给定的所以 pre[j]-k 就是一个确定的数。我们只需要回答一个计数问题在 j 之前的所有前缀和中有多少个等于这个数这个改写是整个题目的灵魂。原来我们要面对的是一堆区间区间有起点有终点是一个二维的枚举问题改写之后终点 j 变成了遍历过程的当前位置起点 i 退化成“某个前缀和的取值”。枚举空间从二维压到了一维这就是前缀和带给我们的第一个降维。题解标题里的“降维打击”击打的就是那个 O(n²) 的起点-终点二层循环。举个例子。nums [3, 4, 7, 2]k 7。pre 序列是 0, 3, 7, 14, 16。遍历到 j2 时 pre7pre-k0需要在历史 pre 里找 0——pre[0]0 出现过这就计数了从 0 到 1 的区间 [3,4]遍历到 j3 时 pre14pre-k7历史中 pre[2]7 出现过计数了区间 [7]遍历到 j4 时 pre16pre-k9历史中没有 9。所以答案是 2。2.3 哈希表降维把“查历史”变成 O(1)公式推导到这里直观的做法是保持一个 pre 数组然后每到一个 j 就回头扫描一遍历史前缀和看看有多少个等于 pre[j]-k。这样虽然省去了一层显式的起点枚举但扫历史仍然是 O(n)总复杂度还是 O(n²)没有任何本质变化。真正让复杂度降低的是哈希表。哈希表的任务非常单一记录遍历过程中每一种 pre 值出现的次数。因为我们要做的操作只有两种一是“插入当前 pre”二是“查询某个 pre 值出现了几次”这两种操作在哈希表上都是平均 O(1)。于是整个算法变成遍历数组每一步维护 pre 并查询哈希表整体复杂度降到 O(n)。空间上我们用了一个哈希表最多存 n1 个 pre 值空间复杂度 O(n)。这是非常经典的“空间换时间”。这里的“降维”更准确地说是把“枚举起点”这一维转化为“统计起点个数”我们不再需要知道每个起点具体在哪里只要知道满足条件的起点有多少个。这个思路比具体代码重要得多因为同样的思想能迁移到一大堆子数组问题上把“需要找的东西”变成哈希表的键把“要找多少次/多长”变成哈希表的值这就是我对“哈希表降维打击”的理解。2.4 那个必须写的初始化 m[0] 1我见过不少人在看题解时对 m[0]1 这个初始化一头雾水甚至有人觉得它是为了处理“和为 0 的子数组”而存在的。真正的原因比这更朴素前缀和序列 pre 是从 0 开始的也就是说 pre[0]0 代表“一个元素都不取”的空前缀。当我们遍历到某个 j发现 pre[j]-k0 时说明存在一个从数组开头到 j 的子数组正好和为 k这个子数组对应的起点就是“空前缀”所在的位置 i0。如果不把 m[0] 初始化为 1这类从下标 0 开始的子数组就会全部被漏掉。用示例验证一下。nums [1,2,3]k3。pre 序列为 0,1,3,6。遍历到 j2 时 pre3pre-k0历史上 pre[0]0 出现过于是统计出子数组 [1,2]遍历到 j3 时 pre6pre-k3历史上 pre[2]3 出现过统计出子数组 [3]。如果 m[0] 没初始化第一个计数就会丢结果从 2 变成 1。很多题解把这句话一笔带过但它恰恰是边界条件里最容易出问题的地方。3. Go 语言实现完整代码与逐行解析3.1 最终版题解func subarraySum(nums []int, k int) int { count : 0 pre : 0 prefixCount : make(map[int]int, len(nums)) prefixCount[0] 1 for _, num : range nums { pre num if c, ok : prefixCount[pre-k]; ok { count c } prefixCount[pre] } return count }这个实现只有 13 行左右但每一行都有存在的理由。pre 用变量而不是切片是因为我们只关心“当前前缀和”这个瞬间值哈希表已经把历史信息记全了不需要把整个 pre 序列都留在内存里。map 的容量参数 len(nums) 是一个小优化——Go 的 map 在频繁插入时会触发扩容扩容涉及重新哈希提前按 n 的规模预分配容量可以减少这部分开销。对单个 LeetCode 用例可能差别不大但作为一个追求质量的 Go 开发者这种一眼能看出来的优化我习惯顺手写上。还需要注意变量命名的可读性。我没有把 map 叫做 m而是用 prefixCount因为这个名字直接传达了“这个哈希表存的是前缀和出现的次数”这个语义。LeetCode 题解里大家为了省字符经常写 m、mp自己刷着玩无所谓但如果是在团队分享或者写技术博客可读性应该排在字符数前面。提示这个解法的时间复杂度是 O(n)空间复杂度是 O(n)。在 Hot 100 同类题里这已经是最优方案。3.2 三行核心代码的执行顺序为什么不能换for 循环里的顺序是我特别想强调的先 pre num再查询 prefixCount[pre-k]最后才把当前的 pre 写进 map。这个顺序不是随意的一旦颠倒会出现重复计数。最直观的反例是 nums[0]k0。先更新的错误写法会产生什么结果遍历时 pre0第一步没有查历史直接 prefixCount[0]把 map[0] 从初始的 1 变成 2然后再查询 prefixCount[pre-k]map[0]2count 直接加 2。实际上 nums[0] 里只有一个和为 0 的子数组正确结果应该是 1。多出来的那一次是把“空前缀”和“当前前缀”混在了一起——当前 pre0 这个瞬间自己也被算成了历史 pre。正确的顺序保证了“先看历史再登记现在”语义上完全符合公式推导我是在问“在我当前位置之前出现过多少个 pre[j]-k”而不是“算上我自己之后一共有多少个”。对哈希表类的题目这个顺序九成情况下都是“先查后写”可以当成一条通用经验记下来只要你的查询条件依赖当前计算值并且该值恰好也是后续要写入的键先写就会污染查询结果。3.3 两个实践中值得关注的性能细节第一点是 map 容量预分配。我前面提过用 make(map[int]int, len(nums)) 来预分配本质上是要避免 map 在插入过程中因为负载因子达到阈值而触发 rehash。虽然 Go 的 map 扩容是均摊 O(1)但 LeetCode 上偶尔会因为大量扩容产生可观察的耗时抖动。预分配一个接近 n 的容量通常能让 map 在遍历期间完全不扩容。我实际测过在 n10^5 规模下预分配版本普遍能快个 5%-10%虽然不大但它是免费的。第二点是关于读取不存在的 key。Go 的 map 读取不存在的键会返回零值所以直接写 c : prefixCount[pre-k] 也能跑凭借 map[0]1 的初始化甚至不会漏统计。但我在实际代码里仍然推荐用 val, ok : map[key] 的两值接收形式。一方面语义清晰让读者明确这是“有则取值无则忽略”另一方面在某些需要区分“不存在”和“值为零”的场景比如后面要迁移到别的算法题、或把 count 改成存下标位置时两值形式是唯一正确的写法。一次养成习惯后面少踩很多坑。3.4 一道母题打天下560 的变种迁移560 堪称“前缀和哈希表”的母题掌握之后可以直接平移到好几道高频题。LeetCode 523. 连续的子数组和要求找长度至少为 2 的连续子数组其和为 k 的倍数。思路是把前缀和取模 k当 pre[j]%k 与某个历史 pre[i]%k 相同说明中间这段和能被 k 整除。区别在于哈希表要存“某个余数第一次出现的下标”因为题目要求的是是否存在且长度至少 2。LeetCode 525. 连续数组数组只含 0 和 1要统计 0 和 1 个数相等的连续子数组最长长度。把 0 看作 -1问题就变成找和为 0 的最长子数组pre 序列做差为 0 说明中间段 0/1 个数相等。哈希表同样存最早出现的 pre 位置。LeetCode 974. 和可被 K 整除的子数组与 523 类似但要求计数。这里要注意 Go 里负数取模永远是负数或 0所以要么老老实实用 (pre%kk)%k 把余数转成非负要么统一处理正负范围。统计优美子数组把奇数记为 1、偶数记为 0问题又变成“和为 k 的子数组计数”和 560 一字不差地套模板。能看出所有“连续子数组应满足某个关于和的等式”的题绝大多数内核都是这一套。4. 常见问题与排查技巧实录4.1 边界错误的排查速查表我在给别人 review 代码时总结了一张速查表几乎能覆盖所有写错的场景症状可能原因修复方式结果偏小总是漏掉从第 0 个元素开始的子数组没有初始化 prefixCount[0]1初始化空前缀k0 时结果异常偏大先更新 map 再查询调整为先查询再更新数组含负数时结果全错用了滑动窗口/双指针改用前缀和结果偏大疑似多算没注意连续子数组边界把 pre 当成了单元素回顾 i j 的区间定义map 读取频繁分配每次循环都新建 map只创建一次 map循环外声明每一条背后都是真实踩过的坑。比如最后一行看起来有点无厘头但我真的见过有人把 map 的声明写在 for 循环体内每轮循环都 new 一个 map结果不仅没有统计到累积信息连时间复杂度都炸了。遇到“本地跑得对一提交就 WA”的情况优先对照这张表逐条排查大部分问题都出在这五个位置。4.2 k0 的极端用例深度拆解很多人对 k0 有盲区因为直觉上“目标值是 0 不是更简单吗”实际上 k0 才是把前缀和坑暴露得最彻底的场景。nums [-1, 0, 1]k0手工数一数[-1,0,1] 整体和为 0、[0] 单独为 0共 2 个子数组。我用调试打印把这个过程完整还原一下。假设正确的代码在循环里加了 fmt.Printf 输出idx0 num-1 pre-1 target-1 hit0 map{0:1, -1:1} idx1 num0 pre-1 target-1 hit1 map{0:1, -1:2} idx2 num1 pre0 target0 hit1 map{0:2, -1:2}最终 count 0 1 1 2完全正确。这个打印过程最值得看的是第二行pre 在 idx1 时仍然是 -1target 也是 -1此时 map 里已经有一个历史 pre-1这个 pre 对应的是空前缀之后第一个元素结束时的前缀和它计数的是子数组 [0]。第三行 target0命中初始化的 map[0]1计数的是从数组开头到当前的整段 [-1,0,1]。两个不同来源的命中分别对应两种不同的起点类型想明白这一层边界问题就通了。4.3 现场调试法把 pre 和 map 可视化如果代码逻辑还是看不出来我的习惯是写一个临时的调试版本在循环里打印当前 pre、目标 pre-k、查询命中次数和当前 map 状态。上面 4.2 节展示的就是这种打印法的产物。看实际输出序列就能定位问题比如先更新后查询的错误版本在 k0 时你会发现 hit 的值总比真实值大因为每次命中都是把刚刚写进去的自己算了一遍又比如忘记初始化 map[0]1你会发现在所有 prek 的位置命中数都比预期少 1。打印调试法虽然原始但对理解和验证这类有状态的算法效果比单纯在脑内模拟好得多。尤其是面试复盘阶段我会把每个变量的变化轨迹画在纸上而不是只看最终的输出。这个方法帮我纠正了不少“感觉对了但其实理解偏了”的细节。4.4 Go 语言里负数的 % 运算是真的坑前缀和题目跟取模打交道时Go 的 % 运算符简直是个暗雷。Go 语言里 -3 % 5 的结果是 -3而 Python 里 -3 % 5 的结果是 2。很多从 Python 转 Go 的开发者在这里必定踩坑在 560 这道题里我们用不到取模但如果迁移到 974 “和可被 K 整除的子数组”就必须处理余数的归一化否则链式哈希查询会直接崩掉。标准解法是使用 r : ((pre % k) k) % k这样无论 pre 是正还是负最终余数都落在 [0, k-1] 区间。写代码时不妨再封装一个小函数 mod(x, k int) int 专门做这件事。这类语言层面的小差异面试官通常专挑你没准备的地方问提前总结一遍非常划算。5. 刷题心法与面试表达技巧5.1 面试时最好的表达顺序如果面试遇到这题我的表达习惯是分四步走。第一步明确题意主动强调“连续子数组”以及数组可能有负数。第二步给出暴力方案并分析 O(n²)说明为什么不可避免要枚举起点和终点。第三步引入前缀和现场推导 pre[j]-pre[i]k 的公式特别是在白板上写出变换后 pre[i]pre[j]-k。第四步讲清楚哈希表存的是“前缀和出现的次数”并解释初始 prefixCount[0]1 的原因。这套顺序的核心是让面试官看到你的思维过程而不是看你能不能默写答案。尤其在第三步到第四步的转折点上主动说出“前缀和把区间问题变成减法哈希表把减法查询变为 O(1)所以我们把二维枚举降成了一维遍历”这句话很难背但懂了自然说得出来而且面试官听到“降维”这个词通常都会心一笑。如果一个候选人能在十分钟里把 O(n²) 暴力逐步优化到 O(n) 并讲明白每一步的动机面试官基本不会在这题上卡你。5.2 我的刷题三遍法和这道题特别搭最后分享一下我自己刷题的习惯这套方法和 560 这类“母题”特别搭第一遍看完题解后自己实现一遍调试通过第二遍隔天完全不看代码从暴力开始重新推导逐步写出优化版本第三遍隔一周面对变种题我前面提的 523、525、974回忆这套思想的适用边界。这样做下来560 的模板几乎能嵌进所有区间和类问题里。我个人的体会是刷题目标不是记住某道题的代码而是建立“看到哪种约束条件就想到哪种数据结构”的反射。560 这道题教会我的反射是只要题目涉及“连续子数组 某种和/差/倍数的等式”第一反应就应该是前缀和再看第二个操作对象有没有可能用哈希表存状态。带着这个反射去刷题比每天无脑刷 10 道新题有效得多。这也是我做完这道题之后最想分享给你们的东西。