大家好,我是程序员牛奶。区间求和如果还在写 for 循环,时间复杂度就低到尘埃里了。本文用前缀和把单次查询从 O(N) 干到 O(1),覆盖力扣 303 和 304 两道经典题------一维和二维一套思路通吃。
本文涉及题目:
| LeetCode | 力扣 | 难度 |
|---|---|---|
| 303. Range Sum Query - Immutable | 303. 区域和检索 - 数组不可变 | 🟢 |
| 304. Range Sum Query 2D - Immutable | 304. 二维区域和检索 - 矩阵不可变 | 🟡 |
前置知识
- 你需要阅读我数组相关的文章
- 一点点循环不变量的思维
全局地图
前缀和的核心思想只有一句话:用空间换时间,预先算好"从起点到 i 的累加和",把任意区间的和拆成两个端点前缀和的差。
按维度分两类:
- 一维前缀和 :
preSum[i] = nums[0] + nums[1] + ... + nums[i-1] - 二维前缀和 :
preSum[i][j] = matrix中以(0,0)为左上角、(i-1, j-1)为右下角的子矩阵之和
两者的查询都通过几次加减完成,时间 O(1)。
算法模板
一维前缀和
ini
// 构造:preSum 长度为 n+1,多出来的 0 是哨兵
preSum = new int[n + 1];
for (int i = 1; i <= n; i++) {
preSum[i] = preSum[i - 1] + nums[i - 1];
}
// 查询区间 [left, right] 的和
int sum = preSum[right + 1] - preSum[left];
二维前缀和
ini
// 构造:preSum 大小为 (m+1) × (n+1)
preSum = new int[m + 1][n + 1];
for (int i = 1; i <= m; i++) {
for (int j = 1; j <= n; j++) {
preSum[i][j] = preSum[i - 1][j] + preSum[i][j - 1]
- preSum[i - 1][j - 1] + matrix[i - 1][j - 1];
}
}
// 查询子矩阵 [(x1,y1), (x2,y2)] 的和
int sum = preSum[x2 + 1][y2 + 1]
- preSum[x1][y2 + 1]
- preSum[x2 + 1][y1]
+ preSum[x1][y1];
记这两个公式之前,先理解一句话:所有任意区间的和,都等于"包含它的最大前缀区间"减掉"前面多余的部分"。 一维只需要减一刀,二维需要做容斥。
一、303. 区域和检索 - 数组不可变
题意精简
给一个数组 nums,反复查询任意闭区间 [left, right] 内元素的和。数组初始化后不变,最多查 10⁴ 次。
朴素思路为什么不行
最直接的写法:每次查询都 for 一遍。
sql
public int sumRange(int left, int right) {
int sum = 0;
for (int i = left; i <= right; i++) sum += nums[i];
return sum;
}
单次查询 O(N),10⁴ 次查询配上 10⁴ 长度的数组,最坏 10⁸------会超时。我们需要把查询降到 O(1)。
但在动手优化之前,先想一个问题:这个暴力到底浪费在哪里?
假设我连续查了 sumRange(0, 5)、sumRange(0, 4)、sumRange(0, 3),是不是把 nums[0] 加了三遍、nums[1] 也加了三遍?再看 sumRange(2, 5) 和 sumRange(0, 5),前者完全可以由后者减去 nums[0] + nums[1] 得到。
重复加法就是暴力的真正成本。前缀和的全部思路就一句话:把所有可能用到的"前缀和"提前算好,存起来,查询时直接做减法。
前缀和怎么用上
第一步:定义 preSum 存什么
最自然的定义是 preSum[i] = nums[0] + nums[1] + ... + nums[i],但这会带来一个边界问题------查询 sumRange(0, r) 的时候,左端点是 0,没有"前一项"可以减。代码里会写出 left == 0 ? preSum[r] : preSum[r] - preSum[left-1] 这种带特判的丑写法。
第二步:用哨兵消除特判
我们让 preSum 长度多 1 位,定义改成:
css
preSum[0] = 0
preSum[i] = nums[0] + nums[1] + ... + nums[i-1] (i ≥ 1)
注意 preSum[i] 现在表示的是「前 i 个元素的和」,不包含 nums[i]。preSum[0] = 0 就是塞在最前面的哨兵------它代表"前 0 个元素的和",逻辑上完全合理。
这样设计之后,所有查询统一为 preSum[right + 1] - preSum[left],没有任何特例。
第三步:用题目样例验证一遍
拿题目给的 nums = [-2, 0, 3, -5, 2, -1] 走一遍构造:
ini
i: 0 1 2 3 4 5 6
preSum: 0 -2 -2 1 -4 -2 -3
↑
preSum[5] = nums[0]+nums[1]+nums[2]+nums[3]+nums[4] = -2
现在试查询 sumRange(0, 2),期望是 (-2) + 0 + 3 = 1:
ini
preSum[2 + 1] - preSum[0] = preSum[3] - preSum[0] = 1 - 0 = 1 ✓
再试 sumRange(2, 5),期望是 3 + (-5) + 2 + (-1) = -1:
scss
preSum[5 + 1] - preSum[2] = preSum[6] - preSum[2] = -3 - (-2) = -1 ✓
第四步:循环不变量与正确性
构造完成后,对任意 0 ≤ i ≤ n,preSum[i] 恒等于 nums[0..i-1] 的累加和。基于这个不变量:
css
nums[left] + nums[left+1] + ... + nums[right]
= (nums[0] + ... + nums[right]) - (nums[0] + ... + nums[left-1])
= preSum[right + 1] - preSum[left]
这就是 O(1) 查询的全部秘密------两个端点处的前缀和一减,中间那一段就出来了。
代码
ini
class NumArray {
private int[] preSum;
public NumArray(int[] nums) {
// preSum[0] = 0 是哨兵;不变量:preSum[i] = nums[0..i-1] 之和
preSum = new int[nums.length + 1];
for (int i = 1; i < preSum.length; i++) {
preSum[i] = preSum[i - 1] + nums[i - 1];
}
}
public int sumRange(int left, int right) {
return preSum[right + 1] - preSum[left];
}
}
复杂度:构造 O(N),单次查询 O(1)。
细节点拨
为什么是
right + 1不是right? 因为preSum的下标比nums多一个偏移量 1。nums[right]在preSum里对应的位置是right + 1。为什么是
preSum[left]不是preSum[left - 1]? 同样的偏移量。preSum[left]已经是nums[0..left-1]的和,正好是要被减掉的那一段。这两个偏移记不清的话,画一张图比死记公式靠谱得多。
二、304. 二维区域和检索 - 矩阵不可变
题意精简
给一个二维矩阵 matrix,反复查询任意子矩阵 [(row1, col1), (row2, col2)] 内元素的和。矩阵初始化后不变,最多查 10⁴ 次。
和上一题的关系
这道题就是 303 升一维。区别只有一个:原来的"区间"变成了"子矩阵",原来的"一刀切"变成了"容斥四项" 。其他思路、+1 偏移、哨兵设计,全部沿用。
如果你完全理解了 303 的「前 i 个元素之和」,那这里只需要把它泛化成「以 (0,0) 为左上角、(i-1, j-1) 为右下角的子矩阵之和」即可。一维退化为"前缀线段",二维就是"前缀矩形"。
前缀和怎么用上
第一步:从一维迁移到二维,难点在哪里?
一维的时候,preSum[r] - preSum[l-1] 减一刀就完事,因为一维区间只有两个端点。
但二维的子矩阵有四个角。如果只减一刀------比如只减去"左边的矩形"------你会发现"上面那一部分"还残留着,没减干净。所以二维需要一种"组合减法",这就是容斥原理登场的地方。
第二步:用图理解容斥
想象一下,要求左上角 (x1, y1)、右下角 (x2, y2) 的子矩阵之和。我们能直接拿到的是「以 (0,0) 为左上角的某个矩形之和」。所以策略是:
- 先取一个包含目标的最大矩形:左上 (0,0)、右下 (x2, y2)。它显然多了上面一条横和左边一条竖。
- 减掉上面那条横:左上 (0,0)、右下 (x1-1, y2)。
- 减掉左边那条竖:左上 (0,0)、右下 (x2, y1-1)。
- 但这两条横竖在左上角 那块小矩形上重叠了------它被减了两次,要加回一次:左上 (0,0)、右下 (x1-1, y1-1)。
四块加加减减,目标子矩阵的和就出来了。这就是「容斥四项」。
第三步:preSum 的定义和偏移
和一维一样,我们用 +1 偏移和哨兵让边界查询不需要特判:
preSum大小(m+1) × (n+1),第 0 行和第 0 列全是 0preSum[i][j]表示「以(0,0)为左上角、(i-1, j-1)为右下角的子矩阵之和」
这样原矩阵的 matrix[i][j] 对应到 preSum 里就是位置 (i+1, j+1),所有查询的 x1, y1 即使等于 0 也不需要特判。
第四步:构造的递推也是一次容斥
构造 preSum[i][j] 的时候,我们要把"以 (0,0) 为左上、(i-1, j-1) 为右下的矩形之和"算出来。最自然的拆法是:
css
当前矩形 = 上方矩形 + 左方矩形 - 重复部分(左上) + 当前格
preSum[i-1][j] preSum[i][j-1] preSum[i-1][j-1] matrix[i-1][j-1]
为什么要减 preSum[i-1][j-1]?因为「上方矩形」和「左方矩形」都包含了左上那块------加两次了,要减回去一次。构造和查询本质是同一个容斥思路用在不同地方。
第五步:查询的容斥公式
照着上面第二步的图,把每一块写成 preSum 的项:
less
sum = preSum[x2+1][y2+1] // 包含目标的最大矩形
- preSum[x1][y2+1] // 减去上方那条横
- preSum[x2+1][y1] // 减去左边那条竖
+ preSum[x1][y1] // 加回左上角被减两次的部分
每一项都比对应的目标边界多 +1------这就是哨兵带来的偏移。
第六步:用题目样例小验证
题目里 sumRegion(1, 1, 2, 2) 期望返回 11(绿色矩形),对应原矩阵中:
ini
matrix[1][1] + matrix[1][2] + matrix[2][1] + matrix[2][2]
= 6 + 3 + 2 + 0
= 11 ✓
按公式应该是 preSum[3][3] - preSum[1][3] - preSum[3][1] + preSum[1][1],自己用 m × n 矩阵推一遍构造,结果同样是 11。这一步建议你拿张草稿纸亲手画一遍,胜过看十遍公式。
代码
ini
class NumMatrix {
// 不变量:preSum[i][j] = matrix 中以 (0,0) 为左上角、(i-1,j-1) 为右下角的子矩阵之和
private int[][] preSum;
public NumMatrix(int[][] matrix) {
int m = matrix.length, n = matrix[0].length;
preSum = new int[m + 1][n + 1];
for (int i = 1; i <= m; i++) {
for (int j = 1; j <= n; j++) {
preSum[i][j] = preSum[i - 1][j] + preSum[i][j - 1]
- preSum[i - 1][j - 1] + matrix[i - 1][j - 1];
}
}
}
public int sumRegion(int x1, int y1, int x2, int y2) {
return preSum[x2 + 1][y2 + 1] - preSum[x1][y2 + 1]
- preSum[x2 + 1][y1] + preSum[x1][y1];
}
}
复杂度:构造 O(M·N),单次查询 O(1)。
细节点拨
加减号怎么记? 不要硬背。每次写之前画一个 2×2 的小图,标出 4 个点,对照容斥原理推一遍------只要把"包含目标的最大矩形"和"多余的两条 + 重叠的左上"画清楚,符号自然就对了。
为什么构造和查询都用
+1偏移? 同 303,让x1 = 0或y1 = 0这种边界查询不需要特判。
总结
速查表
| 题目 | preSum 定义 | 查询公式 | 时间 |
|---|---|---|---|
| 303 一维 | preSum[i] = nums[0..i-1] 之和 |
preSum[r+1] - preSum[l] |
构造 O(N),查询 O(1) |
| 304 二维 | preSum[i][j] = 左上角子矩阵之和 |
preSum[x2+1][y2+1] - preSum[x1][y2+1] - preSum[x2+1][y1] + preSum[x1][y1] |
构造 O(MN),查询 O(1) |
决策路径
遇到一个区间/区域查询问题时,按下面这条路径走:
前缀和的两个局限
第一,原数组不能变 。一旦某个元素改了,它后面所有的 preSum 都失效,要重新跑一遍 O(N) 构造。
第二,必须有逆运算 。求和有减法、求积有除法,所以前缀和能用;但 max/min 没有逆运算------你知道 max(x, 8) = 8,推不出 x 是多少。这种场景就要换工具。
掌握「+1 偏移 + 哨兵 + 区间差」这一套思路,区间求和类的题基本就没什么悬念了。