先问大家一个问题:你第一次看到“前缀和”“子矩阵的和”“差分”“差分矩阵”这四个词时,有没有觉得是把简单事情说复杂了?我第一次学前缀和的时候,确实有过这种想法——心想不就是把数组从头到尾加一遍吗,也算一个知识点?后来在笔试、刷题和给学弟讲题的过程里被反复教育,才发现自己当初太天真。前缀和、子矩阵求和、差分、差分矩阵这四个概念,本质上是解决同一类问题的两套思想,分别适用于“一维”和“二维”场景。
这篇文章我想把你一次性带明白:一维前缀和怎么从区间求和里长出来,二维前缀和(也就是子矩阵的和)怎么靠一个“容斥”公式实现秒查,一维差分怎么用两次修改替代整段循环,二维差分怎么靠四个标记点完成矩阵区域的批量加数。我会尽量用生活化的类比和完整的推导过程来讲,代码也直接给能跑的版本,所以你拿来写作业、准备算法比赛、面试突击都能用。如果你是刚接触算法的读者,建议把每个例子的中间结果都手算一遍,这一步省不了。
1. 先搞清楚这四个名词到底是什么关系
1.1 前缀和和差分是一对“逆运算”
先看一张最简单的图景:有一个数组 a[1] 到 a[n],我们想知道中间某一段的总和,最朴素的想法是循环累加,但这样每次查询都要跑一遍区间,数据大了就非常吃亏。前缀和的做法是先预处理出一个新数组 pre,pre[i] 表示“从第 1 个位置累加到第 i 个位置的和”,之后想求任意区间 [l, r] 的和,直接 pre[r] - pre[l-1] 就能得到。
差分的意思刚好反过来了。如果有一个差分数组 diff,对 diff 做一次“前缀和操作”,得到的数组就是原数组 a。也就是说:对原数组 a 求前缀和得到数组 pre,那么 pre 的差分又是 a。前缀和与差分互为逆运算。这个关系在一维和二维情况下都成立,是整个知识体系的地基。
我教过的很多读者会问:既然它们互为逆运算,学一个不就行了吗?当然不行。因为前缀和擅长做“区间查询”,而差分擅长做“批量修改”。你手里是查询需求多还是修改需求多,决定了用哪种工具。这个权衡在后面章节会讲清楚。
1.2 从一维到二维,难度在哪里
把前缀和从一维推广到二维,会出现“子矩阵的和”这个概念。把差分从一维推广到二维,就得到了“差分矩阵”。二维和二维之间看似只是多了一个维度,但公式里的加加减减变复杂了,尤其是容斥那一步,特别容易漏项或者写反。
原因很简单:一维区间是一条线,删掉左边多余部分就好;二维矩形是很多行很多列拼起来的面积,你不能只去掉一行,要去掉上面部分、左面部分,还要把左上角被重复去掉的部分加回来。这就是“容斥原理”在算法里的经典应用。后文的第 4 节、第 5 节会把每个算子为什么是这样完整拆开。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 一维前缀和:区间求和的高效记账本
2.1 前缀和到底是怎么提出来的
想象一个非常朴素的场景。你记录了本学期每一天的学习时长,现在想知道从第 10 天到第 30 天一共学了多久。没有前缀和的时候,你会把第 10 天、第 11 天……一直加到第 30 天,每次询问都要重新加一遍。如果今天问一次、明天又问一次,这些重复劳动就非常浪费。
前缀和的思路是:既然“从头加到某一天”这个操作经常被用到,那我干脆提前把每天对应的累计值记下来。比如第 10 天的累计学习时长、第 30 天的累计学习时长,都先算好存好。等有人问“第 10 天到第 30 天一共多久”,我只需要用“前 30 天的累计时长”减去“前 9 天的累计时长”,一步就得出结果,不用重新循环。
这就是前缀和的核心逻辑:把频繁使用的“区间求和”操作,从每次 O(n) 的时间复杂度,降低到预处理一次 O(n)、之后每次查询 O(1)。如果你要处理一批静态数据上的大量区间查询,这个性价比非常高。
2.2 公式推导和核心代码
如果数组下标从 1 开始,前缀和数组 pre 的定义是:
pre[i] = pre[i - 1] + a[i]
对应的区间求和公式是:
sum(l, r) = pre[r] - pre[l - 1]
这里最容易被新手问住的是:为什么区间左端是 l 的时候,要减 pre[l-1] 而不是 pre[l]?因为 pre[r] 表示从第 1 个元素累加到第 r 个元素,pre[l] 表示从第 1 个元素累加到第 l 个元素。如果我们想留下第 l 到第 r 个元素的和,应该把前 l-1 个元素全部减掉,也就是减去 pre[l-1]。多减了一个 pre[l] 就会把区间左侧那个不要的元素不小心丢掉。
我建议所有读者第一次实现时都用下标从 1 开始的方式。很多新手从 0 开始写,结果区间边界总是对不上,debug 半天也找不到问题,就是因为 pre[l-1] 在 l 等于 0 的时候会访问 pre[-1]。下标从 1 开始让所有边界都变得非常自然。
完整代码示例我写了一个 C++ 版本:
cpp复制#include <bits/stdc++.h>
using namespace std;
int main() {
int n, q;
cin >> n >> q;
vector<int> a(n + 1, 0);
vector<long long> pre(n + 1, 0);
for (int i = 1; i <= n; ++i) {
cin >> a[i];
pre[i] = pre[i - 1] + a[i];
}
while (q--) {
int l, r;
cin >> l >> r;
cout << pre[r] - pre[l - 1] << '\n';
}
return 0;
}
我用 long long 来存前缀和是因为累加结果很容易超过 int 范围,尤其数组长度和数值都很大时。虽然这个例子里数据未必会爆,但竞赛中养成用 long long 的习惯能少踩很多坑。
2.3 一个真实场景的感受
我曾经给一个做打卡
