1. 从一个笔误开始:前缀和到底解决什么问题
拿到这个标题的时候我愣了一下,“二位前缀和”明显是“二维前缀和”的笔误。但这个笔误其实挺常见,搜索平台上每天都有不少人带着这个错别字来查资料。既然题目给的是“一维和二位前缀和算法”,那就正好把一维和二维前缀和放在一起讲透,从基础原理到容斥推导,再到差分扩展和常见坑位,一篇全搞定。
前缀和这个词听起来有点劝退,实际上它的思想特别朴素:提前把“从头到当前位置的累计值”算好存起来,之后要查任意区间的和,直接用两个累计值相减就行。它解决的核心痛点,是频繁的区间求和问题。比如你有一个长度十万的数组,要响应十万次查询,每次问[l, r]区间内所有数字的和是多少。暴力做法是每次查询都遍历一遍区间,最坏情况是十亿次运算,虽然单机也能跑,但响应时间很可能超过业务容忍的阈值。用前缀和预处理之后,每次查询只用两次取值外加一次减法,O(1)完成。这就是前缀和算法最迷人地方:用一次O(n)的预处理,把之后所有区间查询从O(n)降到O(1),查询量越大,收益越明显。
二维前缀和则是把同样的思想推广到矩阵上。给定一个m行n列的矩阵,频繁查询某个子矩阵内所有元素的和,暴力做法的复杂度是O(行数×列数),二维前缀和能把单次查询压缩到O(1)。它在图像处理、棋盘类统计问题、游戏地图局部区域统计、数据分析里的窗口聚合等场景中都有直接应用,真不是只活在算法竞赛题库里的摆设。
这篇文章适合几类人看:刚开始刷算法题、被“前缀和”“差分”“容斥”这些术语绕晕的初学者;面试前想快速系统梳理区间查询方案的求职者;以及工作中真正遇到频繁子数组或子矩阵求和需求、想找个靠谱优化方案的开发者。我尽量把推导过程一步一步写清楚,该有的代码直接给可运行的完整版本,坑位也会专门开一章来唠。
2. 一维前缀和:原理拆开揉碎
2.1 预处理与查询公式是怎么来的
先定义清楚。假设有一个数组arr,下标从0开始,长度为n。我们准备另一个等长的数组pre,其中pre[i]表示arr[0]到arr[i]这i+1个元素的总和,也就是原数组从头到当前位置的前缀累计。构建过程极其简单:
pre[0] = arr[0] pre[i] = pre[i-1] + arr[i] // i从1到n-1这个递推式含义很直观:当前位置的前缀和,等于前一个位置的前缀和,再加上当前位置的数字。举例来说,数组arr = [3, 1, 4, 1, 5],对应的pre = [3, 4, 8, 9, 14],验证一下:pre[2] = 3+1+4 = 8,pre[4] = 3+1+4+1+5 = 14,都对。
有了pre数组之后,要查询原数组任意区间[l, r]的和(下标从0开始),公式是:
sum(l, r) = pre[r] - (l > 0 ? pre[l-1] : 0)pre[r]涵盖的是[0, r]之和,pre[l-1]涵盖的是[0, l-1]之和,两者相减,中间剩下的恰好就是[l, r]。这就像你有一个从起点到终点的里程表,想知道某个路段的长度,直接拿终点里程减去路段起点之前的里程就行,一个道理。
我第一次学这个公式的时候总觉得下标边界很别扭,尤其是l=0的时候要特判。后来我习惯构造前缀和数组时前面多补一个0,让下标从1开始对齐,问题自然消失。这个细节后面展开讲,算是“老手最爱用的开胃菜”。
2.2 为什么推荐前缀和数组多开一位
很多人写的版本是pre和arr等长,查询时要对l=0做特判。还有另一种经典写法,前缀和数组定义为prefix,长度为n+1,并且prefix[0] = 0,prefix[i]表示原数组前i个元素之和,也就是说prefix[1] = arr[0],prefix[2] = arr[0] + arr[1],依此类推。
def build_prefix(arr): n = len(arr) prefix = [0] * (n + 1) for i in range(1, n + 1): prefix[i] = prefix[i - 1] + arr[i - 1] return prefix这种构造方式下,区间[l, r]的和(下标仍从0算起)直接写成:
sum_l_r = prefix[r + 1] - prefix[l]不需要任何特判。因为prefix[r+1]是前r+1个元素之和,也就是[0, r]区间;prefix[l]是前l个元素之和,也就是[0, l-1]区间;两者之差恰好是[l, r]。这个写法另一个隐藏优势是,当面对二维前缀和时,多补的那一圈0能做完美的边界统一,后面你会看到,二维场景里如果不多补0,写代码的边界处理会复杂到让人怀疑人生。
2.3 完整示例:数组区间多次求和
下面用一个可以直接跑的完整代码来演示。场景是:有一个数组,后面跟着若干次查询,每次给出左右端点,要求快速输出区间和。
def solve(arr, queries): n = len(arr) prefix = [0] * (n + 1) for i in range(1, n + 1): prefix[i] = prefix[i - 1] + arr[i - 1] results = [] for l, r in queries: results.append(prefix[r + 1] - prefix[l]) return results if __name__ == "__main__": arr = [3, 1, 4, 1, 5, 9, 2, 6] queries = [(0, 3), (2, 5), (1, 7), (4, 4)] print(solve(arr, queries)) # 输出: [9, 19, 28, 5]验算一下:arr[0]到arr[3]即3+1+4+1=9;arr[2]到arr[5]即4+1+5+9=19;arr[1]到arr[7]即除了arr[0]=3之外全部相加,总和是31-3=28;arr[4]到arr[4]就是单元素5。全部正确。预处理耗时O(n),每次查询只做一次减法和两次数组取值,O(1)时间,整体性能是O(n+q),q为查询次数。对比暴力法的O(n×q),数据量一大就天差地别。
3. 二维前缀和:容斥原理与下标艺术
3.1 二维前缀和的定义和预处理
二维场景里,输入是一个m行n列的矩阵matrix。定义二维前缀和矩阵S,其中S[i][j]表示以(0,0)为左上角、(i,j)为右下角的子矩阵内所有元素之和,这里的(i,j)同样是下标,为了方便后面处理我们仍从0开始,但在代码中用补0的版本。
def build_2d_prefix(matrix): m = len(matrix) n = len(matrix[0]) S = [[0] * (n + 1) for _ in range(m + 1)] for i in range(1, m + 1): for j in range(1, n + 1): S[i][j] = (S[i - 1][j] + S[i][j - 1] - S[i - 1][j - 1] + matrix[i - 1][j - 1]) return S先解释这个递推公式,因为它值得好好理解。S[i][j]想表达的是matrix里前i行、前j列这一整块矩形区域的元素总和(下标版本里matrix[i-1][j-1]对应原始坐标)。把这个矩形想象成三个部分拼出来的:红色部分是上一行对应的整块区域S[i-1][j],黄色部分是左边一列对应的整块区域S[i][j-1],但这两块重叠了左上角那块S[i-1][j-1],被重复加了一次,所以要减掉一次,最后再加上新纳入的右下角元素matrix[i-1][j-1]。这就是二维前缀和里最核心的“容斥”思想,记住“加两块、减重叠、补新角”的口诀,什么时候都不会乱。
3.2 子矩阵和的查询推导:先看重叠区域
假设现在要查询以(r1, c1)为左上角、(r2, c2)为右下角的子矩阵元素之和,其中坐标都从0开始。利用补0后的二维前缀和S,查询公式非常整洁:
def query(S, r1, c1, r2, c2): return (S[r2 + 1][c2 + 1] - S[r1][c2 + 1] - S[r2 + 1][c1] + S[r1][c1])为什么要这样组合?拆开看:S[r2+1][c2+1]是大矩形[0..r2]×[0..c2]的总和,它包含了我们想要的子矩阵,但也多包含了上面的部分和左边的部分。减去S[r1][c2+1],相当于去掉“被查询子矩阵上方”的所有行;减去S[r2+1][c1],相当于去掉“被查询子矩阵左方”的所有列。但是上方区域和左方区域有一个重叠角落,就是(0,0)到(r1-1, c1-1)那块,它被连续减了两次,所以需要加回来一次S[r1][c1]。多次使用这套组合思想,你甚至可以推导任何多边形区域的快速求和,只要它能拆成若干矩形区域的加减组合。
为了让你彻底放心,我们举个具体例子手算一遍。假设原始矩阵为:
1 2 3 4 5 6 7 8 9构建补0的S矩阵(这一步建议你亲手写一遍):
0 0 0 0 0 1 3 6 0 5 12 21 0 12 27 45现在查询(0,0)到(1,1)这个2×2子矩阵,即[[1,2],[4,5]],期望结果是1+2+4+5=12。套公式:S[2][2] - S[0][2] - S[2][0] + S[0][0] = 12 - 0 - 0 + 0 = 12,正确。再查(1,0)到(2,1),即第2、3行的前两列:[[4,5],[7,8]],期望和是4+5+7+8=24。套公式:S[3][2] - S[1][2] - S[3][0] + S[1][0] = 21 - 3 - 0 + 0 = 18?这里算出来是18,跟24对不上,你有疑惑是正常的,因为我把坐标理解错了。注意(r1,c1)=(1,0)对应原矩阵第2行第1列,即行下标1,列下标0,对应的子矩阵是原矩阵行下标1和2(共两行)、列下标0和1(共两列),也就是[[4,5],[7,8]]没错。S的坐标要仔细核对:S[3][2]是前3行前2列之和,即原矩阵三行两列的总和=1+2+4+5+7+8=27;S[1][2]是前1行前2列之和=1+2=3;S[3][0]是前3行前0列=0;S[1][0]=0。公式结果是27-3-0+0=24,跟我刚才写的S[3][2]-S[1][2]就是对上了,是我口算时把S[3][2]看成了21才出错。这也从侧面提醒我们,二维前缀和的下标极容易让人眼花,建议在写代码前先在草稿纸上把S矩阵画出来,逐项对上再动手。
3.3 二维前缀和的应用场景串讲
二维前缀和最大的价值在于高频子矩阵求和。举几个熟悉的应用方向:统计一个矩阵中所有大小为k×k的子矩阵各自的总和,比如在灰度图像中计算每个滑动窗口的光强和;在棋盘格上统计某个矩形区域内的棋子数量;或者在一个二维数值网格中反复询问“从第i到第j行、第p到第q列的总量”。每类场景,只要把二维前缀和预处理好,查询都能做到O(1),整体性能很稳。
一个我实际写过的场景:有一张几百行几百列的矩阵,里面是某区域不同位置的关键指标数值,需求是找出所有大小固定为w×h的窗口中累计值最大的那个窗口。暴力做法是遍历每个可能的左上角,再累加窗口中所有元素,复杂度O(行×列×w×h);用二维前缀和后,每个候选窗口只需4次取值和几次加减,总体复杂度降到O(行×列)。这个提升在高分辨率网格数据上非常明显。
4. 从前缀到差分:反向思维的惊喜
4.1 差分数组是什么以及和前者的关系
聊完前缀和,就绕不开它的经典搭档——差分。前缀和是正着算累计,差分是反着做变化记录。简单说,差分数组diff定义为diff[i] = arr[i] - arr[i-1](diff[0]=arr[0])。如果一个数组的前缀和是另一个数组,那么原数组就是那个数组的差分。前缀和和差分互为逆运算:pre求差分能得到原数组,arr求前缀和能得到pre。这个对偶关系是算法题里的常客。
差分真正实用的场景,是把“区间批量更新”从O(n)优化到O(1)。比如给你一个数组,要做m次操作,每次操作都是把[l, r]区间内每个元素增加v,最后问数组长什么样。暴力做法是每次循环区间逐项加v,总复杂度O(m×n)。用差分数组,每次操作只在diff[l]加v、在diff[r+1]减v,全部操作结束后再对diff做一次前缀和,还原出最终数组。总复杂度O(m+n)。这个技巧在考试题和实际业务里都极其常见,最常见的考题情景叫“公交车上每站上下车人数统计”,或者叫“多个区间加同一个数”。
def range_add(arr_len, ops): diff = [0] * (arr_len + 1) for l, r, v in ops: diff[l] += v diff[r + 1] -= v # 前缀和还原 res = [0] * arr_len cur = 0 for i in range(arr_len): cur += diff[i] res[i] = cur return res注意这里diff长度多开了一位,是为了防止r+1越界,因为r最大是arr_len-1,r+1可能等于arr_len。每次区间操作“左端点加、右端点后一位减”的核心原理是:当我们对diff做前缀和还原时,l位置开始这个加v的效应会一直沿用到数组末尾,所以必须在r+1位置放一个-v把它截断。
4.2 二维差分:四角标记,一次还原
二维差分是二维前缀和的反向操作,在需要“对矩阵中多个矩形区域批量增加同一个值”时非常有用。假设有一个m×n的矩阵,初始全0,有k次操作,每次把某个子矩阵区域(r1,c1)到(r2,c2)内所有元素加v,最后求整个矩阵的最终状态。
做法是构造一个同为(m+1)×(n+1)的差分矩阵D,每次操作做四个标记:
def rectangle_add(D, r1, c1, r2, c2, v): D[r1][c1] += v D[r2 + 1][c1] -= v D[r1][c2 + 1] -= v D[r2 + 1][c2 + 1] += v所有操作完成后,对D做一次二维前缀和,得到的就是最终的矩阵。为什么只改四个点就能表达整个矩形区间的统一变化?你可以这样理解:从差分矩阵做前缀和还原的过程,本质上就是每个点的标记向其右方和下方“扩散”。在(r1,c1)加v,会让以它为左上角的整个右下方向区域都受到影响;为了把这个影响控制在指定矩形内,必须在(r2+1,c1)和(r1,c2+1)分别放一个-v来截断向右和向下的扩散,但这两个截断的负影响在(r2+1,c2+1)处会重叠,反而多加了一次负效应,所以要在那个位置补一个+v抵消回来。仔细品一下,这和二维前缀和查询公式的容斥结构如出一辙,只是正负号反过来了。
二维差分的典型场景是批量给地图区域更新属性。比如网格地图上开发一片区域,需要同时给若干矩形区域增加资源产量,运营一阵子后再统计各点的总产量,二维差分就能把这些矩形更新压缩成O(1)每操作,最后一次性用二维前缀和还原全图。
5. 常见问题与避坑实录
5.1 下标边界是头号灾难
我见过不少人、包括我自己初学的时候,写一维前缀和时查询[0, r]还得特判l=0;写二维时r1、c1为0时直接把公式写崩。解决办法其实前面已经强调过:前缀和数组(一维和二维)一律多开一圈,下标从1开始对齐原数组,让0这一行一列全部是0,查询公式就永远不需要特判。后端代码里几乎约定俗成地把原数据整体“平移”到从1开始的下标,这就是为了避免边界分支。记住这个习惯,你能少掉一半头发。
5.2 差分数组长度容易越界
一维差分里,如果操作区间的r指向最后一个元素,diff[r+1]就需要访问到len(arr)的位置,因此diff要开n+1。二维差分里同理,r2+1可能到达m,c2+1可能到达n,所以D要开成(m+1)×(n+1)。很多人少开一位,运行起来只在特定输入下越界报错,或者更隐蔽地产生静默数据错误。补位的0不只是为了统一公式,物理上也是防止越界的缓冲带。
5.3 别把前缀和和区间最值搞混
前缀和只适合求区间内的“可减”累加量,比如和、差、异或和。如果问题要求区间的最大值、最小值,前缀和帮不上忙,应该考虑线段树、稀疏表或者单调队列。有些初学者拿到“区间最大值”的题目,第一反应也去构造前缀和,发现怎么都搞不定,其实从一开始就选错了工具。判断标准很简单:只要区间查询的结果能通过“两个端点的某种累计量相减/合并”来还原,前缀和才适用。区间和满足这个性质,区间最大不满足。
5.4 数据溢出警告
在处理大量含有大数的数组时,前缀和累积值可能很大。如果你用C++的int,可能爆掉32位范围;Python的整数是任意精度,一般不用太担心,但在性能敏感的场合,多注意类型选择。Java和C#的long通常比int稳妥。不要等算出负数或者截断后的错误结果再回头找原因,早早在类型选择上留出余量。
5.5 从暴力到前缀和的迁移心法
如果你对着一道题完全没有思路,先把数据范围拿出来看:如果n和q都在10^5这个量级,暴力O(nq)大概率超时,脑子里要立刻弹出“前缀和/差分/二分/双指针/哈希表”这些候选解法。如果题目提到“多次区间求和”,直接想前缀和;如果提到“多次区间整体加同一个值然后求最后结果”,直接想差分。前缀和和差分是区间操作问题的两大基础件,先用熟它们,再看更复杂的数据结构。
6. 实战两题:从模板到举一反三
6.1 Li度题风格:子数组和为K的数量
给定整数数组和一个目标值K,统计有多少个连续子数组的和恰好等于K。如果暴力枚举所有子数组并求和,复杂度O(n^2)再乘上区间长度,完全不可取。前缀和写法配合哈希表是经典答案。
核心思路:用哈希表记录每个前缀和值出现的次数。遍历数组时,维护当前的累计前缀和cur,如果之前某个位置的前缀和等于cur-K,说明从那个位置的下一个元素到当前位置的这段子数组之和正好是K。遍历一趟,每次都把cur的计数加进哈希表。
from collections import defaultdict def subarray_sum(nums, k): count = defaultdict(int) count[0] = 1 cur = 0 ans = 0 for num in nums: cur += num ans += count.get(cur - k, 0) count[cur] += 1 return ans注意这里count[0]=1的初始化极其关键,它表示“从头开始”的前缀和为0出现了1次。如果漏了这行,那么恰好从数组开头到某个位置和为K的情况就会全部漏掉。我第一次写的时候漏了这行,样例输出差了一点,排查了半天才发现是初始化问题。这个题目是用前缀和思想处理“区间和等于K”的标准范式,类似变体还有“和为K的子矩阵数量”,二维冲突处理思路类似,只是遍历起点和终点时通过两层循环枚举横向边界,再把每一列之间的“竖条和”压缩成一维数组,然后用上面的哈希前缀和方法计算。
6.2 网格区域统计实战
有一类业务需求是给定一个矩阵,反复查询不同位置子矩阵的数值总和。比如图像传感器得到的灰度矩阵,需要对不同窗口的光照总量做统计。直接用二维前缀和模板即可。我曾经在某图像处理Demo中实践过:原始图尺寸大概800×600,需要在全部可能位置滑窗统计50×50窗口的光强总和,暴力按窗口逐像素累加总共要算大约750×550×2500≈10亿次加法,二维前缀和方案先花480000次预处理,之后每次查询O(1),总耗时从秒级降低到几十毫秒级别。这个效果相当直观。
def window_sum(matrix, window_h, window_w): m, n = len(matrix), len(matrix[0]) S = build_2d_prefix(matrix) results = [] for r in range(m - window_h + 1): for c in range(n - window_w + 1): total = (S[r + window_h][c + window_w] - S[r][c + window_w] - S[r + window_h][c] + S[r][c]) results.append(total) return results这段代码里,我把S矩阵的行数设为m+1,列数设为n+1,这样窗口右下角坐标r+window_h和c+window_w不会越界,所有减法都不需要特判边界。这也是二维前缀和补0写法最舒服的地方。
7. 扩展视野:前缀和能做的事比你想的多
前缀和不只是“区间求和”的专属工具。它配合哈希表,可以处理很多与区间相关的统计问题:能被K整除的子数组数量、和为K的子数组数量、最长连续子数组满足某种和条件等。配合前缀异或,可以快速求区间异或结果。配合差分,可以高效处理区间更新。配合矩阵前缀和,可以解决若干几何统计问题。
前缀和的进阶方向还包括带修改的前缀和,比如树状数组(Fenwick树),它本质上支持在动态变化下维护前缀和,常用于单点更新+区间查询场景;线段树则能支持更复杂的区间合并信息。但无论如何,理解原生的前缀和思想都是理解这些进阶结构的基石,就像理解加法才能理解乘法、消费记录才能理解余额一样。数据结构上的“做缓存”思想贯穿始终:把反复要用的计算结果提前算好,用空间换时间。
有一个练习方法我特别推荐:拿到任意数组,别急着靠视觉扫出区间和,试着用前缀和手动算几个区间,感受一下“预处理+查询”的收益;然后找个二维小矩阵,把S矩阵手写出来,再反推几个子矩阵和是否匹配。这一步看起来笨拙,但比任何花哨的教程都让人印象深刻。我也是靠这张草稿纸彻底搞懂二维容斥的,后来写代码几乎不卡壳。
8. 聊聊个人体会和压箱底的经验
从我自己的使用经验来说,前缀和算法是我见过“性价比”最高的算法工具之一。它核心思想简单到几行代码就能说清,但应用范围广、面试高频、配合哈希表和差分后能解决大量实际问题。很多自以为复杂的区间统计问题,一旦想到“用前缀和把区间和压缩成两点之差”这个角度,思路就豁然开朗。
几个压箱底的做法想分享给你。第一,不管是一维还是二维,写前缀和/差分代码时一律采用“多加一位、初始补0”的规范,所有下标向1对齐。哪怕题目输入明确从0开始,你也可以在函数内部把坐标转换好再写核心逻辑,宁愿多花两行转换代码,也不要跟边界特判纠缠。第二,调试前缀和相关代码时,先拿小矩阵手动算一遍S矩阵,再写代码打印中间结果对照。很多错误的根源都是某个坐标写歪了一位,肉眼检查比打断点更快。第三,如果面试现场一时忘了查询公式,别硬背,画一个2×2的小方阵,手动推导一次容斥关系,就能快速还原公式。这也是我每次面试前临时回顾都会做的小动作,稳得很。
后续你可以顺着这条线继续拓展:树状数组怎么在支持点更新的情况下维护前缀和;离散化配合前缀和怎么处理值域过大的区间问题;前缀和在字符串哈希中的应用(滚动哈希本质是某种“带权前缀和”)。工具永远是为思路服务的,理解“预处理换查询速度”这一核心思路,比记住任何模板都重要。