先问大家一个问题:你第一次看到“前缀和”“子矩阵的和”“差分”“差分矩阵”这四个词时,有没有觉得是把简单事情说复杂了?我第一次学前缀和的时候,确实有过这种想法——心想不就是把数组从头到尾加一遍吗,也算一个知识点?后来在笔试、刷题和给学弟讲题的过程里被反复教育,才发现自己当初太天真。前缀和、子矩阵求和、差分、差分矩阵这四个概念,本质上是解决同一类问题的两套思想,分别适用于“一维”和“二维”场景。
这篇文章我想把你一次性带明白:一维前缀和怎么从区间求和里长出来,二维前缀和(也就是子矩阵的和)怎么靠一个“容斥”公式实现秒查,一维差分怎么用两次修改替代整段循环,二维差分怎么靠四个标记点完成矩阵区域的批量加数。我会尽量用生活化的类比和完整的推导过程来讲,代码也直接给能跑的版本,所以你拿来写作业、准备算法比赛、面试突击都能用。如果你是刚接触算法的读者,建议把每个例子的中间结果都手算一遍,这一步省不了。
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 一个真实场景的感受
我曾经给一个做打卡数据统计的朋友写过一段小工具,他每天会收到几千条打卡记录,然后要根据用户 ID 找出某个时间段的打卡次数。如果用最原始的方法,每个用户查一次就遍历一次记录,几千个用户查下来,性能很糟糕。
我拿到数据后,先把所有打卡记录按时间排序,然后为每个用户生成一个前缀和数组。这样无论哪个用户来查任意时间区间,都是两次数组访问,不管这个区间跨了多久都毫秒级返回。他当时很惊讶,说就这么简单?是的,就这么简单。前缀和不是什么高深理论,它就是一种空间换时间、把重复计算提前做掉的思想。
3. 一维差分:对区间批量加数的高效方法
3.1 差分是什么,从一次修改说起
现在换个场景。假设有一个初始全 0 的数组,我们要进行很多次操作,每次给某个区间内的所有数都加上一个常数 c,最后再把整个数组输出。最朴素的做法是每次操作都循环一遍区间里的每个位置,依次加 c。这样做的问题很明显:如果有 n 个位置、m 次操作,最坏复杂度是 O(n*m),数据一大就不可承受。
差分数组的做法非常巧妙。我们不再直接修改原数组,而是维护一个 diff 数组,每次区间加的操作只需要改变两个位置:
- diff[l] += c
- diff[r + 1] -= c
等所有这些操作执行完后,再对 diff 数组求一次前缀和,就能恢复出真正的数组结果。
为什么两个端点就能代表“一整段区间都加了 c”?你可以把 diff 数组想象成一排沿着地面的高度标尺。在 l 位置给 diff[l] 加上 c,相当于在标尺的起点放了一块“向上的台阶”;在 r+1 位置给 diff[r+1] 减去 c,相当于在区间结束的下一格放了一块“向下的台阶”。之后做前缀和时,从 l 开始你遇到的每个位置都会因为之前加了 c 而被抬高,直到 r+1 处遇到减掉的 c,高度又恢复原状。这样中间整段自然都加了 c,而区间外完全不受影响。
3.2 差分数组怎么构造出来
有个细节很容易让人迷糊:如果我们手上已经有一个非全零的原数组 a,要怎么构造它的差分数组 diff?
一种朴素想法是:diff 的第一个位置就等于 a[1],第二个位置等于 a[2] - a[1],依次做差。这个方法没错,一维差分确实可以这样直接构造:
diff[i] = a[i] - a[i - 1]
但这里我更推荐一个通用化思路:利用刚才的区间加操作来构造。我先把 diff 全初始化为 0,然后对每个位置 i 单独执行一次“单点加”操作 add(i, i, a[i])。也就是调用了 add 函数在区间 [i, i] 上加上元素 a[i] 的值。这样做的好处是,不管是一维还是二维,构造差分数组的逻辑都是一样的,你不用单独记一套求差公式,只要会写 add 操作,初始化就自然完成了。之后如果再有什么区间批量加的操作,继续调用同一个 add 函数就好。
3.3 完整例子和代码
假设数组长度 n=5,原数组 a = [0, 1, 3, 2, 4, 5](下标从 1 开始)。先把 diff 数组清零,然后依次调用 add(i, i, a[i]),就相当于把原数组的信息写入了 diff。接着执行一次区间加:把 [2, 4] 整体加 1。
cpp复制#include <bits/stdc++.h>
using namespace std;
// 一维差分区间加:给 [l, r] 每个位置加上 c
void add(vector<int>& diff, int l, int r, int c) {
diff[l] += c;
diff[r + 1] -= c;
}
int main() {
int n = 5;
vector<int> a = {0, 1, 3, 2, 4, 5};
vector<int> diff(n + 2, 0);
// 通过单点插入初始化差分数组
for (int i = 1; i <= n; ++i) {
add(diff, i, i, a[i]);
}
// 区间 [2, 4] 整体加 1
add(diff, 2, 4, 1);
// 对差分数组求前缀和,得到真正的数组结果
vector<int> res(n + 1, 0);
for (int i = 1; i <= n; ++i) {
res[i] = res[i - 1] + diff[i];
cout << res[i] << ' ';
}
// 输出结果为:1 4 3 5 5
return 0;
}
我强烈建议你在这个例子里手动执行一遍 diff 的变化。原始 a = [1, 3, 2, 4, 5],给 [2, 4] 加 1 后,正确结果应该是 [1, 4, 3, 5, 5]。通过差分恢复出的结果也一样,说明两次端点修改确实等效于整段区间修改。这个“试算一遍”的过程能帮你把差分从“背公式”变成“理解为什么”。
4. 子矩阵的和:二维前缀和的容斥与实现
4.1 二维就是从“线”走向“面”
一维前缀和管的是一个线性的区间,二维前缀和面对的是矩阵。很多人第一次学二维时,会觉得不过是把公式多加一个维度,结果真做题时又容易在边界上翻车。原因在于二维前缀和公式里出现了加一项又减一项的容斥操作,光看公式很难一次记住,更别说灵活应用。
我们先定义清楚:有一个 n 行 m 列的矩阵,下标从 (1,1) 开始。二维前缀和数组 S[i][j] 表示:以 (1,1) 为左上角、(i,j) 为右下角的整个矩形区域中所有元素的和。求 S[i][j] 的时候,你有两个已知部分:S[i-1][j] 是 (1,1) 到 (i-1, j) 的矩形和,S[i][j-1] 是 (1,1) 到 (i, j-1) 的矩形和。
如果把这两个加起来,你会把 S[i-1][j-1] 那块区域重复计算了一次,因为它在两个已知矩形中都出现过。所以正确的递推公式是:
S[i][j] = S[i-1][j] + S[i][j-1] - S[i-1][j-1] + a[i][j]
这非常像面积的容斥:先算上整行和整列,再把多算的左上角小方块减去,最后把当前位置的元素加进去。你可以画一个 4x4 的方格图,用阴影标出 S[i-1][j] 和 S[i][j-1],然后观察重叠部分,就能自然理解为什么减 S[i-1][j-1]。
4.2 查询子矩阵的和:四个点的容斥
假设想查询的矩阵左上角是 (x1, y1),右下角是 (x2, y2),用前缀和数组计算的方法是:
result = S[x2][y2] - S[x1-1][y2] - S[x2][y1-1] + S[x1-1][y1-1]
这个公式的意思是:先从整个大矩形 S[x2][y2] 开始,减去上方超出查询区域的那部分 S[x1-1][y2],再减去左方超出查询区域的那部分 S[x2][y1-1]。但是左上角那个小矩形 (1,1) 到 (x1-1, y1-1) 被减了两次,所以需要加回来一次。
很多网上的资料直接列出公式,很多学习者就硬背。我建议你用另外的方式记忆:始终把“多减的部分加回来”当成检查手段。如果你发现结果明显大于预期,很可能就是加了太多容斥项;如果结果偏小,很可能是忘了把左上角加回来。反复检查几次,形成手感以后就不容易错。
4.3 完整实现:二维前缀和的建表与查询
二维前缀和的建表可以边读入边计算。下面的 C++ 代码中,我让二维数组下标从 1 开始,这样公式里的 x1-1、y1-1 就不会出现负数下标。
cpp复制#include <bits/stdc++.h>
using namespace std;
int main() {
int n, m, q;
cin >> n >> m >> q;
vector<vector<int>> a(n + 1, vector<int>(m + 1));
vector<vector<long long>> s(n + 1, vector<long long>(m + 1, 0));
for (int i = 1; i <= n; ++i) {
for (int j = 1; j <= m; ++j) {
cin >> a[i][j];
s[i][j] = s[i - 1][j] + s[i][j - 1] - s[i - 1][j - 1] + a[i][j];
}
}
while (q--) {
int x1, y1, x2, y2;
cin >> x1 >> y1 >> x2 >> y2;
long long ans = s[x2][y2] - s[x1 - 1][y2] - s[x2][y1 - 1] + s[x1 - 1][y1 - 1];
cout << ans << '\n';
}
return 0;
}
实现时需要注意:外层循环先枚举行 i,内层循环再枚举列 j。若反过来遍历,也不影响最终结果,因为公式依赖的 S[i-1][j] 和 S[i][j-1] 分别来自上一行和左侧列,只要外层先 i、内层先 j,每次计算时需要的数值已经算好。写程序时一定要保持这个顺序,否则会拿到尚未初始化的值。
4.4 二维前缀和与差分的关系
如果你注意到,原矩阵 a 其实就是 S 的“二维差分”。什么意思呢?给定一个二维前缀和矩阵 S,你想还原出原矩阵 a,可以对 S 分别在行方向和列方向做差分:
a[i][j] = S[i][j] - S[i-1][j] - S[i][j-1] + S[i-1][j-1]
这个公式从数理逻辑上看,是二维前缀和递推公式的逆向。所以二维前缀和与二维差分也是一对互逆操作。这句话在第 5 节讲差分矩阵构造时会有大用处。
5. 差分矩阵:区域批量加数的四个关键点
5.1 二维差分解决什么问题
有一类经典题目:给定一个 n 行 m 列的网格,初始全为 0。接下来有 q 次操作,每次把左上角 (x1, y1)、右下角 (x2, y2) 的矩形区域所有格子加上一个数 c。所有操作结束后,要求输出整个矩阵。
如果朴素地执行,每次操作都要遍历矩形范围内的所有格子,复杂度 O(nmq)。而二维差分能做到:每次操作只修改 4 个位置,最后统一恢复一次前缀和,就能得到最终矩阵。
二维差分数组我们叫 D,它的定义是:对 D 做二维前缀和后得到的数组,就是最终数组。也就是说,如果某个最终数组是 A,那么 A[i][j] = sum_{p=1..i, q=1..j} D[p][q]。所以 D 的作用方式和一维差分完全一致,只是范围从一维区间变成二维矩形。
5.2 区域更新的公式是怎么来的
要在矩阵 A 的某个子矩形区域 [x1, x2] × [y1, y2] 上统一加 c,我们只需要对差分数组 D 做如下 4 步修改:
- D[x1][y1] += c
- D[x2+1][y1] -= c
- D[x1][y2+1] -= c
- D[x2+1][y2+1] += c
为了说明这四个修改各自的角色,可以把后续的“二维前缀和恢复”想象成向四周扩散的涟漪。D[x1][y1] 加 c,会让以 (x1,y1) 为起点、向右下方向的一片区域都受到 c 的影响。我们希望这个影响覆盖到 (x2,y2) 为止,于是从 x2+1 行开始往下全部减掉 c,从 y2+1 列开始往右全部减掉 c。但这样操作后,[(x2+1), (y2+1)] 往后右下角那块区域就被减了两次,所以还要在那里补回一个 +c。
换一种更直观的说法:只需要画一个坐标轴,把矩形区域当成你要覆盖的“正方形补丁”,然后在补丁的四角分别放上 +c、-c、-c、+c 四个标记,之后做二维前缀和时,这些标记自然会把差分效果铺满整个补丁、并让补丁外围保持为 0。
5.3 先理解 D 再做初始化
二维差分矩阵最麻烦的其实不是区域更新,而是初始化。假设原矩阵已经有一些非零信息,你要把这些信息写入 D,后面还要继续做若干次矩形区域加。能直接调用上面的区域更新函数吗?可以。
具体做法是让 D 初始全 0,然后遍历原矩阵的每个格子 (i, j),把它当作一个 1x1 的小矩形,执行一次带 c = a[i][j] 的插入操作:add(i, j, i, j, a[i][j])。这样做虽然看起来多了一层循环,但复杂度仍是 O(n*m),和矩阵规模线性相关。好处是你完全不需要单独背二维差分的构造公式,也不用担心二阶差分拆错位置。
这种把“初始化”和“操作”统一起来处理的思路,在写算法程序时特别有用。一旦你只需要维护一个 add 操作,所有逻辑都被收敛到一个函数里,不容易出错,也方便后期改成其他数据结构。
5.4 完整代码:差分矩阵的插入与恢复
下面的代码先读入 n、m 和矩阵 a,然后通过逐个单点插入初始化 D。然后读入 q 次区域加操作。最后对 D 原地做二维前缀和,输出最终的 A。
cpp复制#include <bits/stdc++.h>
using namespace std;
int n, m;
vector<vector<long long>> d;
void add(int x1, int y1, int x2, int y2, int c) {
d[x1][y1] += c;
d[x2 + 1][y1] -= c;
d[x1][y2 + 1] -= c;
d[x2 + 1][y2 + 1] += c;
}
int main() {
cin >> n >> m;
vector<vector<int>> a(n + 1, vector<int>(m + 1));
d.assign(n + 2, vector<long long>(m + 2, 0));
for (int i = 1; i <= n; ++i)
for (int j = 1; j <= m; ++j)
cin >> a[i][j];
// 初始化差分矩阵:把每个元素当成 1x1 矩形插入
for (int i = 1; i <= n; ++i)
for (int j = 1; j <= m; ++j)
add(i, j, i, j, a[i][j]);
int q;
cin >> q;
while (q--) {
int x1, y1, x2, y2, c;
cin >> x1 >> y1 >> x2 >> y2 >> c;
add(x1, y1, x2, y2, c);
}
// 对 d 求二维前缀和,原地恢复最终矩阵
for (int i = 1; i <= n; ++i) {
for (int j = 1; j <= m; ++j) {
d[i][j] += d[i - 1][j] + d[i][j - 1] - d[i - 1][j - 1];
cout << d[i][j] << ' ';
}
cout << '\n';
}
return 0;
}
注意恢复时我使用了原地更新:d[i][j] 更新后它本身已经变成“以当前位置为右下角的前缀和”,后续计算其他位置时引用的 d[i-1][j]、d[i][j-1]、d[i-1][j-1] 实际上已经是前缀和结果,正好符合二维前缀和公式的要求。这种原地写法非常节省空间,但如果想长期保留 d 的原始信息做更多操作,就不应该覆盖原数组。
5.5 一个经典场景:地毯覆盖次数
二维差分在算法题里有个很出名的应用,就是洛谷的 P3397“地毯”这道题。题目大致意思是:有一块 n*n 的网格,给出若干张地毯以及每张地毯覆盖的矩形区域,要统计每个格子被多少张地毯覆盖。初始矩阵全为 0,所以不需要读取 a,直接把 d 清零,然后对每张地毯执行 add(x1, y1, x2, y2, 1),最后统一做一次二维前缀和输出。这套思路完全不需要建图、不需要暴力染色,代码量短,效率还高。
如果你刚学二维差分,最适合用这种“全 0 初始”问题入手,因为本质上它只考察 add 操作和恢复前缀和两个环节,能让你把注意力集中在二维差分的更新逻辑上。等熟练了再去看那些初始矩阵非零、需要综合构造的题目,会轻松很多。
6. 常见问题排查与实用速查
6.1 边界条件和下标错误
数组边界是最容易出问题的地方。二维差分更新涉及 x2+1、y2+1 这类越界风险点。所以在开数组时,我会习惯性多开一些维度,比如 d 开到 (n+2) x (m+2),而不是 (n+1) x (m+1)。因为 add 可能访问到 x2+1 或 y2+1,如果那一位恰好超过 n 或 m,不额外开一维就可能越界。对于一维差分也是同理,diff 数组多开两个位置,可以避免访问 r+1 时出现数组越界。
另一个常见问题是查询矩阵和时把公式里的下标写错。比如查询左上角 (x1,y1) 到右下角 (x2,y2),很多人写 S[x2][y2] - S[x1][y2] - S[x2][y1] + S[x1][y1],然后得到错误答案。正确做法是减 S[x1-1][y2] 和 S[x2][y1-1]。你可以通过一个简单例子验证:查询一个 1x1 的单元格,即 x1=x2, y1=y2。这时结果应该等于 a[x1][y1]。你代入正确公式会发现 S[x2][y2] - S[x1-1][y2] - S[x2][y1-1] + S[x1-1][y1-1] 正好能剥离出这个单点元素;而错误公式无论如何也得不出正确单点值。
6.2 前缀和与差分用错场景
前缀和适合解决“静态数据,多次查询任意区间/子矩形的和”的问题。它的问题是:如果原数组不停地修改,每次修改后都要重新维护 pre 数组,要么退化成 O(n) 复杂度,要么得升级成树状数组等数据结构。差分适合解决“多次修改,最后才需要查询”的问题,一次性把所有区间的增减记录到 diff 里,最后做一次前缀和恢复。如果操作过程中需要频繁查询当前某个值,差分就不能直接满足,因为它本质上是在累积修改信息,直到最后统一生效。
我画过一张小表,帮助初学者选型:
| 问题场景 | 推荐工具 | 预处理/初始化 | 每次查询/修改成本 |
|---|---|---|---|
| 静态多次区间求和 | 一维前缀和 | O(n) | O(1) 查询 |
| 静态多次子矩阵求和 | 二维前缀和 | O(n*m) | O(1) 查询 |
| 多次区间批量加,最后输出 | 一维差分 | O(n) | O(1) 修改,O(n) 恢复 |
| 多次子矩阵批量加,最后输出 | 二维差分 | O(n*m) | O(1) 修改,O(n*m) 恢复 |
| 修改和查询都频繁 | 树状数组/线段树等 | 取决于实现 | O(log n) 或更高 |
严格来说,差分并不支持“任意时刻查询单个点当前值”这种操作,除非你对差分再额外维护一个实时前缀和结构,但那样复杂度就不只是 O(1) 了。看到任何题目,先判断操作顺序:是先全部加完再问结果,还是边改边问。前者多考虑差分,后者要上更高级的数据结构。
6.3 数据溢出和 long long 的取舍
前缀和和差分在累加过程中非常容易超出 int 的范围。比如数组长度为 10 万,每个元素值也在 10 万左右,前缀和最高能到 100 亿,早就超过 32 位 int 了。很多题目故意用大数据卡人,如果你没有用 long long,最后答案就会溢出成负数或变成完全错误的结果。
我的习惯是:只要数组长度或数据范围超过 1e5,后续累加和就直接用 long long 定义。除非题目明确说明结果一定在 int 范围内,否则不要赌数据。在二维情况中,矩阵规模 n*m 可能达到 1e6 甚至更大,前缀和的数值增长得更夸张,long long 几乎是标配。
6.4 构造差分矩阵的两种方法对比
二维差分初始化看起来比一维复杂,但其实有两条路可走。
第一种是直接公式法。根据差分与前缀和互逆的关系,给定原矩阵 a,可以用二阶差分公式初始化 D:
D[i][j] = a[i][j] - a[i-1][j] - a[i][j-1] + a[i-1][j-1]
这个方法效率高,遍历一次矩阵即可,但需要你对一维做差推广到二维容斥非常熟悉。
第二种是插入法。把 D 先清零,然后对每个格子 (i,j) 调用 add(i, j, i, j, a[i][j]),相当于把原矩阵看成许多 1x1 的小矩形累加进去。这个方法复杂度稍高一点,但仍是 O(n*m),通常足够快,而且思路统一、容易理解,特别适合做题现场。
如果在竞赛现场怕写错公式,我一般会选第二种。你只需要把 add 函数写对,初始化只是循环嵌套多调用几次而已,调试时间远少于推公式出错后反复查错的时间。
6.5 三个容易忽视的细节
第一,二维前缀和建表时,输入矩阵的坐标一定要全部按 1 开始。题目如果给你的是 0-based 下标,你可以在读入前把每个下标加 1,或者干脆让数组多开一些空间。我见过很多同学在从 0 基转换到 1 基的过程中出现遗漏,比如忘了给输入坐标加 1,最后导致查询结果总是少一行或者多一列。
第二,前缀和与差分数组的维度分配要留足余量。尤其是二维差分,多次 add 操作中可能访问到 n+1 行、m+1 列的位置。所谓“多开一些空间”不是矫情,而是实实在在地保护你免受越界崩溃或者奇怪内存覆盖的影响。
第三,二维差分的恢复和普通计算要区分开。如果你在恢复前还打算再次使用动态维护,就不要把原差分数组覆盖掉。建议先复制一份,或者用单独数组做前缀和恢复。这个细节在需要先统计一段差分结果、再叠加另一波操作的问题里经常出现。
7. 做题时的整体思路和个人经验
你现在已经知道前缀和、差分、二维前缀和、二维差分各自的公式和适用场景,但真正到题里,脑子还会容易乱。我分享一个分析套路:拿到与数组或矩阵有关的问题时,先问三个问题。
第一,题目是查询多还是修改多?如果修改只是一些铺垫,真正频繁的操作是查询区间和或子矩阵和,你应该考虑前缀和。第二,是不是所有修改都发生在查询之前,最终只想得到一份结果?如果是,差分几乎是天然解法。第三,维度是多少?一维就用一维版本,二维就套二维版本。如果题目把维度升到三维,思路依然可以推广,只是容斥项数会变多,公式也更复杂。
从个人经验来看,二维前缀和与二维差分最容易做错的地方都不是算法本身,而是容斥和下标。二维前缀和的递推公式里为什么要减 S[i-1][j-1]?很多人背了公式却没有真正理解,结果题目一改维度就不知所措。二维差分的四个 add 为什么右下角要加回来?同样是因为减重之后需要补回。建议你把“加一项、减一项、再补回一项”的容斥逻辑吃透,而不只是背下四个代码片段。
遇到不会做的矩阵覆盖题,我习惯先在脑子里模拟一次小小的 2x2 或 3x3 矩阵,手算一遍 add 后的 diff 和恢复后的结果,再套到代码中进行验证。这个手工演练的过程往往比看十篇题解都管用。毕竟公式可以忘,但只要理解了背后的面积加减关系,哪怕在考场上都能临时推出来。
最后分享一个我自己的做题顺序:一维前缀和做熟之后,先不要急着看二维前缀和的结论,而是自己尝试从“行累加再列累加”的角度推导二维前缀和。然后自己实现一维差分,再实现二维差分。每写完一个模块,就去刷一道对应的基础题,比如区间求和、区间加、子矩阵求和、地毯覆盖。真正动手写一遍、调一遍、错一遍,这组思想就会变成你的肌肉记忆。之后再碰到前缀和、差分、差分矩阵相关的复杂题目,你看到的第一反应就不再是“我不会”,而是“这题可以先差分,最后前缀和还原”。
