在上一篇文章里,我们把一维前缀和的基本思想、标准模板和一些基础应用都过了一遍。如果你已经掌握了那部分内容,这篇“前缀和(2)”就可以放心往下看了。这篇要解决的是几个更进阶的问题:二维数组里的区间求和怎么做、前缀和的互补数据结构差分数组到底怎么用、以及面试题里那些“一看就知道要用前缀和”的套路长什么样。
这一篇更适合已经能独立写出第一种前缀和模板、但是面对二维数组或者需要“区间多次修改再查询”的题目时还没把握的读者。如果你正在准备算法面试,或者刷LeetCode、牛客、力扣的时候遇到了前缀和相关的中等题,这篇内容可以直接当作参考手册用。
1. 从一维到二维:二维前缀和到底解决了什么问题
1.1 为什么二维数组的区间求和不能继续用一维模板
先回顾一个最简单的场景:给你一个长度是 n 的数组,要求频繁查询某一段 [l, r] 的和。一维前缀和的做法很自然,预处理出一个 prefix 数组,prefix[i] 表示前 i 个元素的和,查询时直接用 prefix[r] - prefix[l-1] 得出结果。这个思路的核心是“用预处理换查询时间”。
但是当数据从一维数组变成二维矩阵之后,问题就变了。假设给你一个 m 行 n 列的矩阵,要求频繁查询某个子矩阵(第 r1 行到第 r2 行、第 c1 列到第 c2 列,行列范围都闭区间)内所有元素的和。最暴力的做法是每次查询都双重循环遍历这个子矩阵,单次查询的时间复杂度是矩阵面积的量级。如果查询次数很多,比如 10 万次查询、矩阵规模又很大,这种暴力求和基本上就宣告超时了。
有人会想:那我能不能对每一行都做一个一维前缀和,然后按行累加?可以,这样单次查询的复杂度会变成行数级别的,在行数比较少的时候是可行的。但一旦矩阵本身是正方形而且规模上千,这个方案还是会退化。真正的解法是用“二维前缀和”,把子矩阵和查询的时间复杂度压到 O(1)。
1.2 前缀和矩阵的构造公式与容斥原理
二维前缀和的定义其实是一维定义的直接推广。一个预处理好的二维前缀和数组 S,其中 S[i][j] 表示从矩阵左上角 (0,0) 到 (i,j) 这个矩形区域内所有元素的和。注意这里的边界是包含第 i 行和第 j 列的。
关键问题来了:怎么高效地构造出这个 S 数组?
如果你一个一个格子去累加,每个格子都重新从 (0,0) 加到 (i,j),那预处理的复杂度就是 O(n^3),完全失去意义。正确的做法是“递推”,用一个基于容斥原理的公式:
code复制S[i][j] = S[i-1][j] + S[i][j-1] - S[i-1][j-1] + A[i][j]
这里的 A[i][j] 是原始矩阵中第 i 行第 j 列的元素。这个公式怎么理解?S[i-1][j] 覆盖的是上方的矩形,S[i][j-1] 覆盖的是左方的矩形,但是这两个矩形重叠了左上角那块 S[i-1][j-1],所以要减掉一次,最后再把当前格子的值加上。
容斥原理在算法题目里其实很常见。别把它想得多玄乎,其实和求两个集合的交并集是一个道理:A 集合人数加 B 集合人数,减去 A 和 B 都算了两次的人数,才能得到真正的并集人数。这里只是把这个想法用在了矩阵加法上。
1.3 查询任意子矩阵和的 O(1) 公式推导
预处理完 S 之后,查询就是套公式。我想求矩阵从 (r1, c1) 到 (r2, c2) 这个闭区间子矩阵的和,公式是:
code复制sum = S[r2][c2] - S[r1-1][c2] - S[r2][c1-1] + S[r1-1][c1-1]
这个公式同样可以通过画图来理解:先用 S[r2][c2] 拿到从原点到右下角整个矩形的和,然后把上面一行以上的区域减掉(S[r1-1][c2]),再把左边一列以左的区域减掉(S[r2][c1-1])。这时左上角那块被减了两次的区域要加回来(S[r1-1][c1-1]),所以最后是这个样子。
实际编码的时候,一个常见的做法是让 S 数组的下标从 1 开始,也就是申请 (m+1) x (n+1) 的数组,第 0 行第 0 列全部置为 0。这样 r1-1 = 0 的情况直接取到 0,省掉了大量边界判断,代码既干净又不容易出错。这个技巧在算法竞赛和面试里几乎是约定俗成的。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 二维前缀和的 Java 实现与边界细节
2.1 经典模板代码(预处理 + 查询)
把上面的公式翻译成 Java 代码,一个完整的二维前缀和模板大概是这样的:
java复制public class PrefixSum2D {
private int[][] S;
private int rows;
private int cols;
// 预处理,matrix 是原始矩阵
public PrefixSum2D(int[][] matrix) {
rows = matrix.length;
cols = matrix[0].length;
S = new int[rows + 1][cols + 1];
for (int i = 1; i <= rows; i++) {
for (int j = 1; j <= cols; j++) {
S[i][j] = S[i - 1][j] + S[i][j - 1] - S[i - 1][j - 1] + matrix[i - 1][j - 1];
}
}
}
// 查询闭区间 [r1, c1] 到 [r2, c2] 的子矩阵和
public int query(int r1, int c1, int r2, int c2) {
// 这里传入的是原始矩阵的 0-based 行号列号
r1++;
c1++;
r2++;
c2++;
return S[r2][c2] - S[r1 - 1][c2] - S[r2][c1 - 1] + S[r1 - 1][c1 - 1];
}
}
注意上面代码里 query 的时候做了一个下标转换。如果你选择接口层面统一使用 0-based 坐标,那在方法内部转成 1-based 坐标即可。另一种常见做法是接口直接接收 1-based 坐标,这样省掉转换,但调用方更容易搞混。我个人建议对外统一用 0-based,内部用 1-based,这个约定在写复杂业务逻辑的时候能省掉很多不必要的误解。
2.2 从 0 开始还是从 1 开始:索引设计的坑
这个坑值得单独提出来说,因为我自己就踩过不只一次。二维前缀和一旦处理不好边界,很容易出现数组越界或者结果错误。
先说你直接套一维的思路,创建一个和原矩阵一模一样大小的 S 数组,然后写循环:
java复制for (int i = 0; i < rows; i++) {
for (int j = 0; j < cols; j++) {
int up = (i > 0) ? S[i - 1][j] : 0;
int le
