[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 偏移 + 哨兵 + 区间差」这一套思路,区间求和类的题基本就没什么悬念了。

相关推荐
moMo13 小时前
前后端模块化分离,web盒子布局思维
前端·后端
计算机安禾13 小时前
【算法设计与分析】第7篇:01背包问题的动态规划建模与空间优化
算法
Tina学编程13 小时前
[HOT 100]今日一练------字母异位词分组
算法·hot 100
BileiX13 小时前
CC-Switch的安装与使用
后端
澈20713 小时前
图论入门:拓扑排序实战指南
算法·拓扑排序·有向图
覆东流13 小时前
Python变量与数值类型
开发语言·后端·python
程序员cxuan13 小时前
Codex 官方:/goal 的正确打开方式
人工智能·后端·程序员
雨辰AI13 小时前
人大金仓慢 SQL 根治方法论:问题定位 - 分析 - 优化全流程
数据库·后端·sql·mysql·政务
Cthy_hy13 小时前
Python 算法竞赛:快速IO+字符串常用方法一站式整理
开发语言·python·算法