news 2026/10/22 23:28:54

二维前缀和与子矩阵求和:从暴力遍历到O(1)查询的优化实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
二维前缀和与子矩阵求和:从暴力遍历到O(1)查询的优化实战

1. 子矩阵求和为什么值得专门学一下

先聊个实际场景。你手里有一张数字表,比如一张灰度图、一份销售数据矩阵,或者游戏里的地形数值表,想快速知道某个矩形区域内所有数字的总和。最直觉的做法是双重循环挨个加,一次查询就是 O(n*m) 的时间,如果查询次数多、矩阵又大,很快就会被卡死。

我第一次写这类题时也被这个 O(n*m) 的复杂度坑过。当时要做热力图区域统计,一个 2000×2000 的矩阵,查一万个子区域,循环加法的耗时直接跑到几秒开外,调到后面整个人都麻了。后来换成了二维前缀和预处理,查询直接变成 O(1),同样的数据量跑完只用了原来零头的时间。

这里的核心思路就是"用空间换时间"。二维前缀和本质上是一张和原始矩阵同尺寸的"累计和表",每个位置存的是从左上角 (1,1) 到当前点 (i,j) 这个矩形区域里所有数字的总和。有了这张表,任意子矩阵的和都可以通过四个查表值的加减组合算出来,不再需要遍历矩阵里的每一个元素。

这个技巧不止在算法题里有用。图像处理里做快速区域亮度统计、数据分析里做时间窗口聚合、游戏开发里做地图网格覆盖计算,凡是"频繁求矩形区域总和"的场景,基本都是这套东西在打底。经典题目 796. 子矩阵的和 就是把这个思路练熟的最佳模板,搞懂它之后,你会发现很多看起来很吓人的题,底层其实都是前缀和变种。

2. 二维前缀和的核心原理拆解

2.1 从一维前缀和推过去

先复习一维前缀和。给你一个数组 a[1] 到 a[n],定义 prefix[i] = a[1] + a[2] + ... + a[i]。那么区间 [l, r] 的和就是 prefix[r] - prefix[l-1]。预计算 O(n),之后每次查询 O(1),原理很简单:把前 r 项的总和减去前 l-1 项的总和,中间那一段自然就剩下了。

二维前缀和就是把这个思想升级一个维度。定义 S[i][j] = 从 (1,1) 到 (i,j) 这个矩形区域所有元素之和。我们想知道左上角坐标为 (x1, y1)、右下角坐标为 (x2, y2) 的任意子矩阵的和,能不能只靠 S 表里的几个值算出来呢?答案是可以。

如果你把 S[x2][y2] 想象成"从起点到右下角"的全部区域,那它既包含了我们想要的子矩阵,也包含了子矩阵上方、左方、以及左上角那一大块。想要精确地留下中间这一块,就得用容斥的思想:先减去上方部分,再减去左方部分,但左上角那块被减了两次,需要加回来一次。

写成公式就是:

子矩阵和 = S[x2][y2] - S[x1-1][y2] - S[x2][y1-1] + S[x1-1][y1-1]

这个公式是整个二维前缀和的灵魂。你不需要强行背它,想清楚"减多了要加回来"就永远不会乱。

提示:为了不单独处理边界,通常让矩阵坐标从 1 开始计数,把 0 行 0 列全部留成 0。这样 x1-1 或 y1-1 为 0 时,对应的 S 值就是 0,公式依旧成立,代码不用写一堆 if 判断。

2.2 预处理表怎么算出来

有了公式之后,关键问题变成:S[i][j] 这张表本身怎么高效生成?如果每个点都重新累加,复杂度又回到 O(n*m) 每格,那预处理就失去意义了。正确的做法是利用递推关系。

S[i][j] 表示从 (1,1) 到 (i,j) 的矩形和。它其实可以看成三部分相加:

  • 上面的矩形:从 (1,1) 到 (i-1,j),即 S[i-1][j]
  • 左边的矩形:从 (1,1) 到 (i,j-1),即 S[i][j-1]
  • 当前位置的值 a[i][j]

但 S[i-1][j] 和 S[i][j-1] 有重叠部分,就是左上角从 (1,1) 到 (i-1,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]

整个过程只需要从左到右、从上到下地扫一遍矩阵,每个位置做三次加减运算,时间复杂度 O(n*m)。这张表建好之后,所有子矩阵查询就都是 O(1) 了。

3. 经典模板题 796 的完整解析

3.1 题目到底在问什么

题目"796. 子矩阵的和"很直接:给定一个 n 行 m 列的整数矩阵,再给你 q 次询问,每次问一个矩形区域的元素和,矩形用左上角坐标 (x1, y1) 和右下角坐标 (x2, y2) 标定。n、m 可以上千,q 也可能上千甚至上万,如果每次暴力遍历,最坏情况是 O(q * n * m),直接超时。

这类题就是专门为二维前缀和准备的。先读入矩阵,构建前缀和表,然后每次询问用容斥公式四步算出结果,复杂度从 O(q * n * m) 降到 O(n*m + q),肉眼可见的质变。

3.2 带注释的参考代码

下面这份是 C++ 的模板写法,也是这套题最标准的打开方式。

#include <iostream> using namespace std; const int N = 1010; int a[N][N]; // 原始矩阵 int s[N][N]; // 二维前缀和表 int main() { int n, m, q; cin >> n >> m >> q; // 读入矩阵,坐标从 1 开始,方便处理边界 for (int i = 1; i <= n; i++) for (int j = 1; j <= m; j++) cin >> a[i][j]; // 构建二维前缀和表 for (int i = 1; i <= n; i++) for (int j = 1; j <= m; j++) s[i][j] = s[i-1][j] + s[i][j-1] - s[i-1][j-1] + a[i][j]; // 处理 q 次询问 while (q--) { int x1, y1, x2, y2; cin >> x1 >> y1 >> x2 >> y2; // 容斥求子矩阵和 cout << s[x2][y2] - s[x1-1][y2] - s[x2][y1-1] + s[x1-1][y1-1] << endl; } return 0; }

这份代码里最值得注意的就是下标从 1 开始这个约定。别小看这个习惯,它让"差值为 0 的边界"自动变成了 0 值,省掉了一整类边界条件判断,写起来清爽,也不容易出错。

3.3 Python 版本参考

Python 写这个题也很方便,注意一下读入方式就行。

n, m, q = map(int, input().split()) # 多开一行一列,所有下标从 1 开始 a = [[0] * (m + 1) for _ in range(n + 1)] s = [[0] * (m + 1) for _ in range(n + 1)] for i in range(1, n + 1): row = list(map(int, input().split())) for j in range(1, m + 1): a[i][j] = row[j - 1] s[i][j] = s[i-1][j] + s[i][j-1] - s[i-1][j-1] + a[i][j] for _ in range(q): x1, y1, x2, y2 = map(int, input().split()) result = s[x2][y2] - s[x1-1][y2] - s[x2][y1-1] + s[x1-1][y1-1] print(result)

这里我把读入和构建前缀和放在同一个循环里完成了,因为 s[i][j] 只依赖上一行、左一列和左上角的值,这三者都已经被算出来了。写在一遍里既省代码,又不会错乱。

4. 手把手推导一次查询过程

光看代码可能还不够,我拿一个实际例子走一遍全过程。假设原始矩阵是:

1 2 3 4 5 6 7 8 9

坐标从 1 开始,即 a[1][1]=1,a[1][2]=2,a[1][3]=3,a[2][1]=4,以此类推。

构建前缀和表。先算第一行:

  • s[1][1] = 0 + 0 - 0 + 1 = 1
  • s[1][2] = 1 + 0 - 0 + 2 = 3
  • s[1][3] = 3 + 0 - 0 + 3 = 6

第二行:

  • s[2][1] = 0 + 1 - 0 + 4 = 5
  • s[2][2] = s[1][2] + s[2][1] - s[1][1] + 5 = 3 + 5 - 1 + 5 = 12
  • s[2][3] = s[1][3] + s[2][2] - s[1][2] + 6 = 6 + 12 - 3 + 6 = 21

第三行:

  • s[3][1] = 0 + 5 - 0 + 7 = 12
  • s[3][2] = s[2][2] + s[3][1] - s[2][1] + 8 = 12 + 12 - 5 + 8 = 27
  • s[3][3] = s[2][3] + s[3][2] - s[2][2] + 9 = 21 + 27 - 12 + 9 = 45

所以前缀和表是:

1 3 6 5 12 21 12 27 45

现在来问一个子矩阵:左上角 (2,2),右下角 (3,3),也就是数字 5、6、8、9 这个 2×2 的方块。肉眼算一下:5 + 6 + 8 + 9 = 28。

代入公式:

  • s[3][3] = 45
  • s[1][3] = 6
  • s[3][1] = 12
  • s[1][1] = 1

结果 = 45 - 6 - 12 + 1 = 28。

完全正确。你可以从这个例子里看到,查询过程真的只有四次查表、三次减法一次加法,完全不需要碰矩阵里任何一个原始元素。这就是 O(1) 查询的含义。

5. 从模板题到实战场景的迁移

5.1 图像的局部特征统计

图像处理里这种需求非常常见。比如你要做人脸区域的亮度均值分析,或者做细胞图像里某个矩形区域的像素强度累加,这张图本质上就是一个巨大的矩阵,每个像素的灰度值就是矩阵元素。先对整张图构建二维前缀和,之后你无论框出多少个不同区域,都能瞬间得到区域总和,再做除法就是均值。批量检测几百个候选区域时,这个速度提升完全能感受到。

5.2 正方形子矩阵的延伸技巧

热词里提到了"正方形子矩阵",这其实是二维前缀和最常见的延伸考点。题目要求"求所有边长为 k 的正方形子矩阵的和"或者"求最大全 1 正方形面积"时,前缀和依然是底层工具。前者只需要枚举每个可能的左上角坐标,用 O(1) 查表拿区域和做统计;后者是配合二分答案或者动态规划来做,但验证某个 k×k 区域是否满足条件时,仍然离不开区域和的快速查询。

我印象很深的是做过一道地图题,要求在网格地图里找出所有尺寸为 L×L 的平坦区域(区域内高度差不超过某个阈值)。暴力做法是每个区域都遍历一遍,复杂度 L² 乘以区域数,几乎没法跑。后来我先用前缀和快速算区域总和、区域平方和,然后通过方差公式判断平坦程度,整个地图几分钟就能全部筛完。这就是同一种思维在不同载体上的复用。

5.3 可能踩到的几个坑

第一,数组维度不够。有些同学在刷题平台上报"数组越界",多半是只开了 n×m 却用了 n+1 行 m+1 列的下标。前缀和的 0 行 0 列是必须存在的,不然递推公式会访问到未定义的值。现场写代码时,建议直接把数组维度定义为 N+1、M+1,后面就不用提心吊胆。

第二,坐标输入顺序搞错。有些题目给的是 (x1, y1, x2, y2),有些给的是 (x1, y1, x2, y2) 但行列顺序反着来。拿到题先看几遍样例,确认清楚哪个是行、哪个是列,再代入公式。我就见过有人把 x 和 y 反着用,样例过了但提交全错,后来才发现是读入顺序理解反了。

第三,数据范围很大时用 int 会爆。一千乘一千的矩阵,如果每个元素是 10 的 9 次方级别,前缀和早就超过 int 上限了。C++ 里用 long long,Python 虽然随便写,但也要注意性能,尽量用 sys.stdin 的批量读入方式,避免逐行 input 拖慢速度。

6. 新手最容易犯的错和排查思路

6.1 边界条件导致答案错误

我最初写二维前缀和时,最喜欢犯的错就是查询时忘了把左上角从 (x1, y1) 对应到公式里的 (x1-1, y1-1)。总想着"我求的就是从 x1 开始,那减 x1 行不行"?还真不行。你要减的是"子矩阵上方和左方的所有区域",而上方区域的右边界是整个矩阵的右边界,左方区域的下边界是整个矩阵的下边界,不是子矩阵边上那一两根线。

如果发现结果莫名其妙偏小或者偏大,先检查公式里的两个减法和一个加法是不是抄对了。我习惯在草稿纸上画一个矩形,把 S[x2][y2]、S[x1-1][y2]、S[x2][y1-1]、S[x1-1][y1-1] 四个区域分别涂色,用颜色的重叠关系去验证公式,比干瞪眼调试快得多。

6.2 预处理写错导致全盘皆输

递推公式 S[i][j] = S[i-1][j] + S[i][j-1] - S[i-1][j-1] + a[i][j] 也是不少人写错的地方。有人会把中间的减号写成加号,有人会把 a[i][j] 的位置搞混。这里有个自检技巧:算完前缀和后,手动验证几个位置,比如 s[1][1] 应该等于 a[1][1],s[1][2] 应该等于 a[1][1] + a[1][2],s[2][1] 应该等于 a[1][1] + a[2][1]。只要这几个基础位置对得上,递推公式基本没问题。

6.3 常见问题速查

问题表现可能原因排查方向
小样例通过,大数据超时查询时仍在循环累加确认查询代码没有二次遍历矩阵
结果比预期大容斥公式里加法写多或减法写漏手算小矩阵验证
结果比预期小漏了左上角 S[x1-1][y1-1] 加回来核对公式四个项是否完整
越界或访问异常数组没预留 0 行 0 列维度开成 (n+1)*(m+1)
坐标含义反了行列读入顺序理解错误对照题目样例输入输出

6.4 版本管理的"模板化"习惯

这个技巧有点特殊,但我觉得值得提一下。很多同学刷题喜欢背模板,但模板一定要背成"自己能改"的版本,而不是死记硬背一串代码。我的做法是维护一个代码模板文件,把预处理和查询公式用注释标清楚:

// 预处理: // s[i][j] = s[i-1][j] + s[i][j-1] - s[i-1][j-1] + a[i][j] // 查询 (x1,y1) 到 (x2,y2): // ans = s[x2][y2] - s[x1-1][y2] - s[x2][y1-1] + s[x1-1][y1-1]

这样在面试或者比赛时,不用现场推导,一眼就能找到对应的代码块。模板写多了以后,你会发现二维前缀和的"可复用性"比想象的高得多,换几个变量名就能适配大部分同类型题。

7. 前缀和思路还能延伸到哪

二维前缀和只是前缀和思想的一个方向,熟练之后还能继续扩展。比如三维前缀和可以处理三维体块的区域和,原理是从二维向前再推一层,容斥项从 4 个变成 8 个,递推公式也会多出几个符号,但思路完全一脉相承。还有差分数组配合前缀和,可以在 O(1) 时间内完成矩形区域的增量更新,适合"多次修改 + 一次查询"的题目类型。

我个人在实际刷题中最大的体会是:前缀和的核心不是那几个公式,而是"预先算好累计信息,用容斥思想快速求局部和"这种思维方式。一旦你习惯带着这种视角去看题目,很多看似无从下手的矩阵题都会突然变得清晰——先预处理,再查表,复杂度肉眼可见地掉下来。

所以如果你正在为子矩阵求和这类题发愁,不用焦虑,拿起纸笔画一张 3×3 的小矩阵,亲手算一次前缀和,再亲手验证一次查询公式,整个过程最多花十分钟,但比背十遍模板都管用。毕竟这个技巧的核心从来不是记忆,而是理解之后可以随时按需重写。等你想明白了这一步,后面的三维版本、差分版本、动态维护版本,其实都是同一棵树上长出来的新枝。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/22 23:27:59

Agent-Reach 实战:CLI 下的 AI Agent 开发与部署指南

1. 项目缘起与核心定位第一次看到 Agent-Reach 这个标题&#xff0c;我下意识把它拆成了两个部分来理解&#xff1a;Agent 和 Reach。Agent 在当下的技术语境里指向很明确&#xff0c;就是 AI Agent&#xff0c;也就是能自主感知环境、做出决策并执行动作的智能体程序。Reach 这…

作者头像 李华
网站建设 2026/10/18 5:32:09

最小路径和全解析:从二维DP到滚动数组与原地修改

直接说结论&#xff1a;这是一道拉开差距的题。同样是“最小路径和”&#xff0c;有人只会背二维DP模板&#xff0c;有人能顺手写出滚动数组加原地修改&#xff0c;面试官对后者的评价完全不一样。力扣hot100榜单里把它放在第95位&#xff0c;难度标注为中等&#xff0c;但我觉…

作者头像 李华
网站建设 2026/10/18 0:03:40

微信小程序毕业设计仓储管理系统:Java后端与MySQL联调实战指南

简介&#xff1a;面向毕业设计或课程设计的微信小程序仓储管理系统源码包&#xff0c;基于Java、小程序与MySQL实现&#xff0c;由单一管理员维护&#xff0c;覆盖供应商、员工、商品分类、商品入库出库、货物采购、在线沟通等管理模块&#xff0c;适合搭建可演示项目的计算机专…

作者头像 李华
网站建设 2026/10/16 8:26:35

基于Node.js和Vue的短剧推荐系统设计与实现:从选题到答辩全流程

这几天又有人私信我&#xff1a;毕业设计到底做什么题好&#xff1f;说实话&#xff0c;每次看到“XX管理系统”“XX商城”这类题目&#xff0c;我都想多问一句&#xff1a;你要是答辩老师&#xff0c;连续看了八个一模一样的管理系统&#xff0c;还会觉得这个选题有价值吗&…

作者头像 李华