[Algo-3]前缀和秒杀两道区间求和题:一维 + 二维统一模板

大家好,我是程序员牛奶。区间求和如果还在写 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 ≤ npreSum[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) 为左上角的某个矩形之和」。所以策略是:

  1. 先取一个包含目标的最大矩形:左上 (0,0)、右下 (x2, y2)。它显然多了上面一条横和左边一条竖。
  2. 减掉上面那条横:左上 (0,0)、右下 (x1-1, y2)。
  3. 减掉左边那条竖:左上 (0,0)、右下 (x2, y1-1)。
  4. 但这两条横竖在左上角 那块小矩形上重叠了------它被减了两次,要加回一次:左上 (0,0)、右下 (x1-1, y1-1)。

四块加加减减,目标子矩阵的和就出来了。这就是「容斥四项」。

第三步:preSum 的定义和偏移

和一维一样,我们用 +1 偏移和哨兵让边界查询不需要特判:

  • preSum 大小 (m+1) × (n+1),第 0 行和第 0 列全是 0
  • preSum[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 = 0y1 = 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)

决策路径

遇到一个区间/区域查询问题时,按下面这条路径走:

  1. 频繁查询 + 数据不变 + 求和(或求积) → 前缀和
  2. 数据会变(有更新) → 不在本文范围,需要 线段树 或树状数组
  3. 求最大/最小值(无逆运算) → 不在本文范围,需要 线段树 或 ST 表

前缀和的两个局限

第一,原数组不能变 。一旦某个元素改了,它后面所有的 preSum 都失效,要重新跑一遍 O(N) 构造。

第二,必须有逆运算 。求和有减法、求积有除法,所以前缀和能用;但 max/min 没有逆运算------你知道 max(x, 8) = 8,推不出 x 是多少。这种场景就要换工具。

掌握「+1 偏移 + 哨兵 + 区间差」这一套思路,区间求和类的题基本就没什么悬念了。

相关推荐
北域码匠14 小时前
奇偶归并排序:并行计算的排序利器
数据结构·算法·c#·排序算法
掘金者阿豪14 小时前
这本讲故事的数学科普书里,藏着AI背后的底层密码
后端
库拉AI小李14 小时前
# 数据清洗与分析:Gemini 3.5 处理 Excel 数据的实操体验
前端·人工智能·后端
成都易yisdong14 小时前
上海某平面坐标系与CGCS2000坐标互转详解(含全域拟合点、实战案例、保密规范)
大数据·人工智能·算法
techdashen14 小时前
Go 语言仓库 Top 100 贡献者分析报告
开发语言·后端·golang
何以解忧,唯有..14 小时前
Go 语言变量命名规范详解
开发语言·后端·golang
Python私教14 小时前
001 Pandas 的由来
后端·机器学习
Csvn14 小时前
磁盘与存储管理 — LVM 逻辑卷实战
后端
星轨zb14 小时前
[Corner项目实战]Spring Boot + LangChain4j Tool Calling实战:让AI自动选择推荐策略
人工智能·spring boot·后端·langchain4j
机智的大狸子14 小时前
我给一个仓库系统写了个"会自己点界面"的 AI 测试 Agent,踩平了 WPF 自动化的所有坑
后端