1. 先说清楚:前缀和到底在解决什么问题
刷题刷到一定阶段,你会发现有一类题反复出现:给你一个数组,让你求某个区间和、找连续子数组满足某种和的条件,或者给你一个矩阵让你反复查子矩阵的和。如果每次都老老实实暴力遍历,复杂度基本是 O(n²) 甚至 O(n³),题目数据稍微大一点就直接超时。这时候,前缀和就是最常用的一把钥匙。
前缀和的核心思想其实特别朴素:把数组从头到尾扫一遍,记录下从起点到当前位置的累计和,这样任意区间的和就可以用两个前缀和做一次减法得到。比如数组是 [1, 2, 3, 4, 5],我先把前缀和数组算出来:[0, 1, 3, 6, 10, 15](第一个 0 一般是占位,方便处理边界)。想求下标 2 到 4 的和,就是 pre[5] - pre[2] = 15 - 3 = 12,也就是 3+4+5。一次减法搞定,查询从 O(n) 变成 O(1)。
这篇文章我把前缀和的常用套路一次性讲透,覆盖“和为 K”“被 K 整除”“连续数组”“矩阵区域和”这几个经典场景,顺便聊聊滑动窗口、差分数组这些跟前缀和经常一起出现的知识点。适合刚接触前缀和的读者,也适合想系统梳理题型的人。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 一维前缀和:和为 K 的三种写法与心路历程
2.1 经典前缀和数组:构建与区间求和
先说最基础的写法。假设数组 nums 长度为 n,我习惯用一个长度 n+1 的数组 pre,其中 pre[0] = 0,然后:
python复制n = len(nums)
pre = [0] * (n + 1)
for i in range(n):
pre[i + 1] = pre[i] + nums[i]
这样 pre[i] 表示 nums[0] 到 nums[i-1] 的和。以后想求 nums[l] 到 nums[r](闭区间)的和,公式就是:
python复制sum_lr = pre[r + 1] - pre[l]
为什么 pre 要多开一位?因为当 l = 0 时,pre[0] 可以直接参与计算,不用单独判断边界。这个细节在二维前缀和里会更明显,一维先养成习惯。
构建的时间复杂度 O(n),每次查询 O(1)。如果只是偶尔查一次区间和,暴力也差不多,但如果是多次查询,比如 LeetCode 303 这种“区域和检索 - 数组不可变”,前缀和的优势就完全体现出来了。
2.2 和为 K:从 O(n²) 到 O(n) 的哈希表优化
“和为 K 的子数组”是前缀和里最经典的题,LeetCode 560。题目问你,数组中有多少个连续子数组的和恰好等于 k。
暴力做法是先枚举所有区间,再用前缀和快速求和,两层循环 O(n²)。n 到 10^5 级别就废了。这时候要换个思路:我们不用枚举区间右端点,而是考虑“以当前位置结尾的合法子数组有多少个”。
假设 pre[i] 是前缀和,那么子数组 [j..i-1] 的和等于 pre[i] - pre[j]。我们要找 pre[i] - pre[j] == k,也就是 pre[j] == pre[i] - k。换句话说,当我在某个位置 i 时,只需要知道之前出现过多少个前缀和等于 pre[i] - k,这些位置都能和当前 i 组成合法的子数组。
于是问题变成了:一边遍历数组,一边用一个哈希表记录“前缀和的值 -> 出现的次数”。
python复制def subarraySum(nums, k):
count = 0
pre_sum = 0
hashmap = {0: 1} # 前缀和为0出现了一次,代表空数组
for num in nums:
pre_sum += num
# 之前有多少个前缀和等于 pre_sum - k
if pre_sum - k in hashmap:
count += hashmap[pre_sum - k]
hashmap[pre_sum] = hashmap.get(pre_sum, 0) + 1
return count
这里有个容易忽略的关键点:hashmap 初始化时要放进 {0: 1}。因为如果整个前缀和本身就是 k,比如数组是 [1, 2, 3],k 是 3,那么当遍历到数字 2 时,pre_sum 等于 3,此时 pre_sum - k = 0,需要在哈希表里能找到 0 才能计数。这个 0 就是“空数组”的前缀和,代表从数组开头到当前这一整段是符合条件的一个子数组。
2.3 完整代码与边界处理
上面的代码是 Python 版本,逻辑已经很精简。如果用 C++ 写,其实结构一样,只是 unordered_map 做哈希表:
cpp复制int subarraySum(vector<int>& nums, int k) {
unordered_map<int, int> mp;
mp[0] = 1;
int pre = 0, ans = 0;
for (int num : nums) {
pre += num;
if (mp.count(pre - k)) ans += mp[pre - k];
mp[pre]++;
}
return ans;
}
边界情况有几类需要注意。第一种是数组里有负数,这不会影响算法正确性,因为前缀和可能变小,但哈希表的逻辑是通用的,不需要额外处理。第二种是 k 等于 0,此时找的是“和为 0 的子数组”,同样能用这套逻辑跑通。第三种是 nums 全为空数组?题目一般不允许,但空数组逻辑上存在,{0: 1} 会让它参与计数,需要根据题意确认是否要排除空数组。
我在实际刷题中的体会是,这类问题只要把“当前前缀和”和“历史前缀和”的关系想清楚,代码基本不会写错。最常翻车的地方反而是初始化 {0: 1} 漏掉,导致整个数组和恰好等于 k 的情况统计不到。
3. 被 K 整除与“连续数组”的进阶套路
3.1 被 K 整除的子数组:同余定理的正确打开方式
“和为 K 的倍数”这类题,典型代表是 LeetCode 974(和可被 K 整除的子数组)。题目让你统计有多少个子数组的和能被 K 整除。
如果你套用“和为 K”的思路,想找 pre[i] - pre[j] 能被 K 整除,那等价于 pre[i] 和 pre[j] 对 K 取模的结果相同。原因很简单:两个数相减能被 K 整除,当且仅当它们除以 K 的余数相等。这就是同余定理。
所以算法变成:在遍历过程中,记录每个余数出现的次数。每到一个位置,算出当前前缀和对 K 的余数,看看之前有没有出现过相同的余数,有的话就能组成合法子数组。
python复制def subarraysDivByK(nums, k):
count = 0
pre_sum = 0
mod_map = {0: 1}
for num in nums:
pre_sum += num
mod = pre_sum % k
if mod in mod_map:
count += mod_map[mod]
mod_map[mod] = mod_map.get(mod, 0) + 1
return count
注意一个细节:如果 K 可能是负数,我们要先对 K 取绝对值,因为取模运算中除数一般是正数。另外在 C++ 里,负数取模结果也是负数(C++11 以前是负数,C++11 以后向 0 截断,仍是负数),所以经常会看到有人写 ((pre % k) + k) % k 来把余数转成非负数。Python 的 % 本身就是数学意义上的取模,结果恒为非负,倒是省了这个麻烦,但也因此和 C++ 的行为不一致,跨语言写的时候要格外小心。
3.2 连续数组:和为 K 的倍数且长度至少为 2
LeetCode 523 的题名就叫 Continuous Subarray Sum,中文通常翻译成“连续的子数组和”,题目要求:是否存在一个长度至少为 2 的连续子数组,其和恰好是 K 的倍数。
这题和 974 长得几乎一样,但多了一个“长度至少为 2”的限制。如果你直接套 974 的计数逻辑,会把长度为 1 的子数组也算进去,就会出错。
我的做法是稍微改一下数据结构:哈希表里存的不再是“余数出现的次数”,而是“余数最早出现的下标”。因为我们要确保 j - i >= 2,也就是两个相同余数的前缀和位置之间至少隔了 1 个元素(pre[i] 和 pre[j] 下标差大于等于 2)。
python复制def checkSubarraySum(nums, k):
pre_sum = 0
# 余数 -> 最早出现该余数的前缀和下标
earliest = {0: -1}
for i, num in enumerate(nums):
pre_sum += num
if k != 0:
mod = pre_sum % k
else:
mod = pre_sum
if mod in earliest:
if i - earliest[mod] >= 2:
return True
else:
earliest[mod] = i
return False
注意这里有个额外问题:K 可能等于 0。如果 K 为 0,就不能取模,直接判断是否存在两个前缀和相等且下标差大于等于 2。LeetCode 原题里 K 是正数,但面试时可能会被追问 K=0 的情况,这个分支要写清楚。earliest 初始化成 {0: -1},是因为前缀和为 0 的“起点位置”是 -1(也就是数组开始之前),这样才能正确处理从下标 0 开始的子数组。
3.3 负数取模的坑,我在这里翻过车
前面提过 Python 和 C++ 取模行为不同,这里展开说一下。Python 里 -1 % 5 = 4,但 C++ 里 -1 % 5 = -1。如果你用 C++ 写 974 这类题,不统一余数范围,就会出现“同样两个前缀和余数一个 4 一个 -1,明明能整除却匹配不上”的 bug。
统一写法是:
cpp复制int mod = ((pre % k) + k) % k;
先把余数转为非负,再进行哈希判断。这个坑我建议你在本地跑一遍测试用例,比如 nums = [-1, 2, 3], k = 5,你会直观地看到加不加 +k 的区别。另外,如果 K 很大,前缀和也可能很大,注意用 long long 存储前缀和,避免溢出。
4. 矩阵区域和:二维前缀和的构建与查询
4.1 二维前缀和怎么构建
一维前缀和搞明白了,二维其实是在“前缀和的前缀和”上扩展。ps[i][j] 表示从矩阵左上角 (0, 0) 到 (i-1, j-1) 这个矩形内所有元素的和(这里继续用 1-based 的技巧,让 ps[0][j] 和 ps[i][0] 都是 0,避免边界判断)。
构建公式:
text复制ps[i][j] = ps[i-1][j] + ps[i][j-1] - ps[i-1][j-1] + matrix[i-1][j-1]
为什么要把 ps[i-1][j-1] 减掉?因为 ps[i-1][j] 和 ps[i][j-1] 两个矩形相加时,左上角那块重叠区域被算了两次,需要扣除一次。这个“加两块、减一块、再加当前格”的容斥关系,是所有二维前缀和的基石。
python复制def build(matrix):
if not matrix:
return []
m, n = len(matrix), len(matrix[0])
ps = [[0] * (n + 1) for _ in range(m + 1)]
for i in range(1, m + 1):
for j in range(1, n + 1):
ps[i][j] = ps[i - 1][j] + ps[i][j - 1] - ps[i - 1][j - 1] + matrix[i - 1][j - 1]
return ps
在构建的时候我习惯把 ps 的行和列都比原矩阵多一行一列,这样初始全是 0,后面查询子矩形时不需要任何 if 判断。
4.2 快速求任意子矩形和
有了二维前缀和,求任意矩形 (row1, col1) 到 (row2, col2) 的和,公式就是:
text复制sum = ps[row2+1][col2+1] - ps[row1][col2+1] - ps[row2+1][col1] + ps[row1][col1]
这里的 row1, col1, row2, col2 是原始矩阵的下标(0-based),而前缀和数组是 1-based,所以转移的时候都要 +1。同样用容斥:大矩形减掉左边一块,减掉上边一块,再加回左上角重叠被减两次的那块。
LeetCode 304(二维区域和检索 - 矩阵不可变)就是直接考这个。构建一次前缀和,之后每次查询 O(1),完美的空间换时间。
4.3 顺手解决“矩阵区域和”相关变体
除了 304,还有些题是“给你矩阵和 k,求有多少个子矩阵的和等于 k”或者“和不超过 k”,比如 LeetCode 1074(元素和为目标值的子矩阵数量)。这种题通常套路是枚举子矩阵的上下边界,把二维问题降成一维,再利用一维前缀和 + 哈希表做 O(n) 统计。
我举个例子:固定上边界 top 和下边界 bottom,把每一列从 top 到 bottom 的元素和压缩成一维数组 col_sum[j],然后问题就变成了“在这个一维数组里有多少个子数组和等于 k”。这一步把二维矩阵问题变成了一维前缀和问题,复杂度是 O(n² × m) 或 O(m² × n),取决于你枚举哪一维作为边界。这个技巧在矩阵类前缀和题里出现频率极高。
5. 前缀和的变形家族:滑动窗口、差分与最小长度连续数组
5.1 长度最小的连续数组:直接给“和大于等于 limit”的最短子数组
前缀和经常和“找一个满足条件的子数组”混合在一起考,最典型的就是“长度最小的连续子数组,其和大于等于 target”(LeetCode 209)。
如果数组元素全是正数,其实滑动窗口是最优解:右指针不断向右扩展,当窗口内和大于等于 target 时,尝试移动左指针缩小窗口,同时更新最小长度。这个做法是 O(n),不需要前缀和。但如果数组里有负数,滑动窗口的单调性就没了,此时前缀和 + 二分查找是更通用的思路:对每个右端点 i,找到最小的左端点 j,使得 pre[i+1] - pre[j] >= target,即 pre[j] <= pre[i+1] - target。由于前缀和不一定单调(有负数时),不能直接二分,需要维护一个有序结构,或者用单调栈/线段树。
我在实际做题时的判断标准是这样的:数组全正 -> 直接滑动窗口,简单快速;数组存在负数 -> 前缀和 + 有序表,或者设计单调队列方案。好多同学一看到“连续子数组”就无脑滑窗,遇到负数用例直接 WA,就是没想清楚这个前提条件。
5.2 数组中每个元素都小于等于 limit:约束下的前缀和策略
有时候题目给了一个额外限制:“数组每个元素小于等于 limit”,这个条件搭配前缀和能玩出不少花样。最经典的是配合二分查找:如果所有元素都是非负且 <= limit,那么前缀和数组是单调不减的,你可以快速二分某一堆和的位置。
举个例子:给定一个正整数数组,每个元素 <= limit,要求找一段连续子数组,使和刚好等于 K 且长度最大/最小。这时可以先构建前缀和,然后用哈希表记录每个前缀和第一次出现的位置,遍历时直接查 pre[i] - K 是否存在。由于有元素大小限制,前缀和不会出现特别离谱的值,哈希表的空间也能控制住。
如果题目反而要求“子数组中的每个元素都 <= limit”,那通常意味着一旦遇到 > limit 的元素,窗口就要重置,这跟前缀和本身关系不大,更像是在滑动窗口里加一个分段条件。我第一次遇到这种题时就被绕了一下,后来总结出套路:先判断限制条件是不是“分段条件”,如果是,就拆成若干段分别处理;如果不是,再考虑前缀和怎么优化。
5.3 前缀和与差分:经常一起出现的两兄弟
“前缀和与差分”是算法里固定搭配,一维前缀和的逆运算就是差分数组。差分数组 diff[i] = nums[i] - nums[i-1],它的前缀和就是原数组。差分数组的意义在于:如果想对原数组的某个区间 [l, r] 统一加上一个值 v,只需要 diff[l] += v,diff[r+1] -= v,最后再做一次前缀和还原。这样一次区间更新从 O(n) 变成 O(1)。
很多区间批量加法、区间合并、日程安排类的题目,都是“差分构造 + 前缀和还原”的套路。二维差分和二维前缀和也是一对,思路完全对偶。我建议你把前缀和和差分放一起学,因为它们本质上都是“累积”和“累差”的关系,理解了其中一个,另一个就是镜像。
5.4 额外提醒:前缀和与前中后缀表达式不是一回事
前缀和这个名字容易让人联想到编译原理里的前缀表达式、中缀表达式、后缀表达式,它们完全是两码事。前者是算法里的累计和技巧,后者是表达式求值的波兰表示法。我在群里看到过有人把这两个概念搞混,搜索“前缀和”误入表达式解析的教程,所以这里顺手提一句,遇到术语先看上下文,别被名字带偏。
6. 常见坑位与调试实录:我把踩过的坑都记在这里
写了这么多年代码,在处理前缀和类题目时,反复踩的坑基本集中在这几个方面,我整理成一张表,方便你自查:
| 坑位 | 出现场景 | 原因 | 解决方案 |
|---|---|---|---|
| 前缀和数组长度没加 1 | 一维/二维前缀和 | 边界判断麻烦,容易越界 | 统一开 n+1 长度,下标 i 对应前 i 个元素 |
| 哈希表初始化漏掉 | 和为 K、被 K 整除 | 无法统计从开头开始的子数组 | 初始化时手动加入 0 / 余数 0 |
| 负数的取模符号不同 | C++ 写被 K 整除 | 负数取模结果为负,和数学定义不一致 | 统一用 ((pre % k) + k) % k |
| K = 0 时直接取模 | 连续数组、被 K 整除 | 对 0 取模非法 | 单独处理 K=0 分支 |
| 二维前缀和下标转换错误 | 矩阵区域和 | 0-based 和 1-based 混用 | 构建时用 1-based,查询时都要 +1 再代入公式 |
| 用滑动窗口处理存在负数的数组 | 最小长度子数组 | 窗口滑动依赖单调性 | 数组全正才能滑窗,否则换前缀和+有序结构 |
| C++ 前缀和应用 int 溢出 | 大数据量下相加 | 累计和可能超过 int 范围 | 前缀和变量统一用 long long |
最后一个排查技巧:前缀和类的题,如果代码逻辑看起来没问题但结果不对,先别急着怀疑算法,在本地手动构造一个包含负数、包含 0、包含重复前缀和的小样例跑一遍。很多哈希表计数类题的错误,都是因为“重复前缀和”没有正确处理。比如数组 [1, -1, 1, -1],k=0,你用纸笔手动模拟一遍,会发现哈希表里前缀和 0 出现了多次,计数逻辑是否正确一目了然。
我个人在实际操作中的体会是,前缀和题的“代码量”其实很小,真正的难点全在“转换思路”这一步:能不能把题目条件翻译成 pre[i] - pre[j] 的形式,能不能想到用哈希表记录历史信息。把“和为 K”“被 K 整除”“二维矩阵”“最小长度”这四种题型吃透,前缀和这块就基本通关了。最后再分享一个小技巧:刷题时遇到新题,先问自己三句话——能否用前缀和把区间求和变成 O(1)?能否把题目条件转成两个前缀和之间的关系?如果需要计数或找位置,哈希表里该存次数还是下标?这三句话能帮你少走很多弯路。
