最近刷 LeetCode 周常题单的时候碰到了 1292 这道题,说实话第一眼看上去像是二维前缀和模板题,但真正动手写的时候发现里面藏着不少值得掰扯的细节。尤其是网上很多题解一上来就二分边长,我在本地跑了几组数据之后发现,这题根本不需要二分,直接从大到小枚举边长,第一次命中的就是答案,代码更短,思路也更直观。这篇就围绕二维前缀和怎么推、为什么这题可以不写二分、以及代码里那些容易让人翻车的边界问题,一次性讲清楚。
LeetCode 1292 的题意很直接:给一个 m x n 的矩阵 mat 和一个阈值 threshold,要我们找出最大的边长 s,使得矩阵中存在一个 s x s 的正方形,其元素和小于等于 threshold。如果不存在任何满足条件的正方形,返回 0。这题适合已经会一维前缀和、正在学二维前缀和的人,也适合想彻底搞懂“什么时候不用二分”的刷题党。看完这篇,你不仅能默写二维前缀和的代码,还能跟别人解释清楚为什么这题的二分是画蛇添足。
1. 题目拆解:先搞清楚“正方形元素和”怎么算
1.1 题意翻译成人话
矩阵里每个位置都是一个数字,我们要在这个矩阵里圈出一个正方形区域,要求这个区域里所有数字加起来不超过 threshold,然后让这个正方形的边长尽可能大。
这里有两个关键词:一个是“元素和”,一个是“正方形”。元素和不用说了,就是把一个区域里的所有数加起来。正方形意味着行数和列数相等,也就是说,如果我们枚举边长 s,那么所有可能的正方形就是所有大小为 s x s 的子矩阵。
举个例子,如果矩阵是:
text复制1 2 3
4 5 6
7 8 9
threshold 设为 15。那么边长为 2 的正方形有四个:左上角 1+2+4+5=12,右上角 2+3+5+6=16,左下角 4+5+7+8=24,右下角 5+6+8+9=28。其中左上角这个 2 x 2 正方形元素和为 12,小于等于 15,所以边长 2 是可行的。再看边长 3,整个矩阵元素和为 45,大于 15,所以答案就是 2。
这个例子虽然简单,但它说明了这道题的本质:我们要在很多个候选正方形中,判断“是否存在某个边长为 s 的正方形满足条件”,并且要找最大的那个 s。
1.2 暴力做法的时间账
如果不做任何预处理,最直接的办法就是暴力枚举所有可能的正方形。假设矩阵是 m x n,对于每个边长 s,需要枚举左上角的位置,一个有 (m-s+1) x (n-s+1) 种选择,然后对每个 s x s 的正方形重新累加一遍元素和。
这样做的复杂度是多少?枚举正方形左上角已经是 O(m x n) 级别,累加一个 s x s 的正方形又要 O(s^2),整体复杂度差不多是 O(min(m,n) x m x n x s^2),这个量级在 LeetCode 上完全不可接受。
如果矩阵是 300 x 300,光是一个 150 x 150 的正方形累加一遍就是 22500 次运算,再乘以可能的左上角数量,直接爆炸。
所以必须把“算某个正方形元素和”这个操作变成 O(1) 查询。这就是二维前缀和的用武之地。
1.3 前缀和是怎么把查询降到 O(1) 的
一维前缀和大家都熟,用一个数组 pre[i] 表示原数组前 i 个元素的和,那么任意区间 [l, r] 的和可以用 pre[r] - pre[l-1] 在 O(1) 时间内算出来。
二维前缀和就是把这个思想扩展到二维。用一个二维数组 pre[i][j] 表示从矩阵左上角 (0,0) 到 (i-1,j-1) 这个矩形区域内所有元素的和。只要 pre 数组构建好了,任意一个子矩阵的和都可以通过几个 pre 值的加减组合算出来,和子矩阵的边长无关,从而实现 O(1) 查询。
后面我会详细推导这个公式,这里先建立直觉:维数上升一维,查询方式从“两个数相减”变成“四个数加减”,但仍然是常数时间。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 二维前缀和:公式推导和实现细节
2.1 一维前缀和复习
我们先从一维开始,因为二维的公式就是一维的推广。
假设原数组是 a[0], a[1], ..., a[n-1],定义 pre[i] = a[0] + a[1] + ... + a[i-1],注意这里 pre 的长度是 n+1,pre[0] = 0。
那么区间 [l, r] 的和,也就是 a[l] + a[l+1] + ... + a[r],可以写成:
text复制sum(l, r) = pre[r+1] - pre[l]
为什么 pre 要开 n+1 而不是 n?因为当 l=0 时,我们需要 pre[0] = 0 来处理边界。这是前缀和里非常经典的操作,二维也一样。
2.2 二维前缀和递推公式
二维情况下,定义 pre[i][j] 表示矩阵 mat 中前 i 行、前 j 列所有元素的和,也就是从 mat[0][0] 到 mat[i-1][j-1] 这个矩形区域的和。
pre 数组的大小是 (m+1) x (n+1),这样当 i=0 或 j=0 时,pre 的值都是 0,方便统一处理边界。
那么 pre[i][j] 怎么由更小的 pre 值递推出来?
看 pre[i][j] 对应的区域,它可以拆成三部分:
- pre[i-1][j]:表示前 i-1 行、前 j 列的和,也就是去掉最后一行的那部分
- pre[i][j-1]:表示前 i 行、前 j-1 列的和,也就是去掉最后一列的那部分
- mat[i-1][j-1]:矩阵中第 i 行第 j 列那个元素本身(因为 pre 的行列和 mat 的下标差 1)
如果把 pre[i-1][j] 和 pre[i][j-1] 加起来,pre[i-1][j-1] 那部分区域被加了两次,所以要减去一次,最后再加上当前元素 mat[i-1][j-1]。
所以递推公式是:
text复制pre[i][j] = pre[i-1][j] + pre[i][j-1] - pre[i-1][j-1] + mat[i-1][j-1]
这个公式本质上就是容斥原理。你画一个矩形,就能直观看到为什么多算了一块需要减掉。
我见过不少初学者在这里搞混,主要原因是 pre 的索引和 mat 的索引差了一个 1。记住:pre[i][j] 里的 i 和 j 是“前 i 行前 j 列”的意思,而 mat 的下标从 0 开始,所以 mat[i-1][j-1] 正好是 pre[i][j] 包含的最后那个元素。
构建前缀和的代码很简单:
java复制int[][] pre = new int[m + 1][n + 1];
for (int i = 1; i <= m; i++) {
for (int j = 1; j <= n; j++) {
pre[i][j] = pre[i - 1][j] + pre[i][j - 1] - pre[i - 1][j - 1] + mat[i - 1][j - 1];
}
}
2.3 子矩阵和的 O(1) 查询
有了 pre 数组之后,怎么计算某个子矩阵的和?
假设我们想要求以 (x1, y1) 为左上角、以 (x2, y2) 为右下角的子矩阵的和,这里 x1、x2、y1、y2 都是 mat 的下标,从 0 开始。
使用 pre 数组,子矩阵和可以写成:
text复制sum = pre[x2+1][y2+1] - pre[x1][y2+1] - pre[x2+1][y1] + pre[x1][y1]
还是容斥原理。用整个大矩形 pre[x2+1][y2+1] 减去上边多出来的部分,减去左边多出来的部分,再把左上角被重复减掉的那块加回来。
如果你更习惯用边长 s 和右下角坐标 (i, j) 来表示,假设我们枚举的正方形右下角在 mat 中对应的索引是 (i-1, j-1),边长是 s,那么左上角对应的索引是 (i-s, j-s),于是:
text复制sum = pre[i][j] - pre[i-s][j] - pre[i][j-s] + pre[i-s][j-s]
这两种写法本质是一样的,只是下标形式不同。网上很多题解用的是第二种,因为它在枚举边长时写起来更顺手。
我建议大家在理解时都画一遍图。纸上画一个 4x4 的矩阵,标出 pre 每个格子代表哪块区域,动手推一次公式,比看十遍代码都管用。
3. 核心代码:从大到小枚举边长,一步到位
3.1 完整 Java 实现
直接上代码,我用的是从大到小枚举边长的写法:
java复制class Solution {
public int maxSideLength(int[][] mat, int threshold) {
int m = mat.length;
int n = mat[0].length;
// 构建二维前缀和
int[][] pre = new int[m + 1][n + 1];
for (int i = 1; i <= m; i++) {
for (int j = 1; j <= n; j++) {
pre[i][j] = pre[i - 1][j] + pre[i][j - 1] - pre[i - 1][j - 1] + mat[i - 1][j - 1];
}
}
// 从最大的可能边长开始往下找
int limit = Math.min(m, n);
for (int s = limit; s >= 1; s--) {
for (int i = s; i <= m; i++) {
for (int j = s; j <= n; j++) {
int sum = pre[i][j] - pre[i - s][j] - pre[i][j - s] + pre[i - s][j - s];
if (sum <= threshold) {
return s;
}
}
}
}
return 0;
}
}
这里我把内层循环写成从 i = s 到 m,j = s 到 n,意思是正方形右下角在 pre 中的坐标不能小于 s。因为 pre 是 (m+1) x (n+1) 的,i 最大取到 m,j 最大取到 n,对应 mat 的右下角。
如果觉得这个写法不够直观,可以换一种遍历左上角的方式:
java复制for (int s = limit; s >= 1; s--) {
for (int x = 0; x + s <= m; x++) {
for (int y = 0; y + s <= n; y++) {
int sum = pre[x + s][y + s] - pre[x][y + s] - pre[x + s][y] + pre[x][y];
if (sum <= threshold) {
return s;
}
}
}
}
两种写法都行,看哪个你更不容易写错。我个人更推荐第一种,因为它的循环边界和 pre 数组的语义一致,逻辑上少一层转换。
3.2 为什么这题根本不需要二分
这是题目最值得讨论的一点。
很多题解一看到“求最大边长”,条件反射就是二分答案。二分确实能解,思路是:边长 s 越大,正方形的元素和就越大,所以“存在一个边长 s 的正方形满足条件”这个判断具有单调性,可以用二分找到最大的可行 s。
单调性有没有问题?没有问题。问题是它在这里根本没有必要。
原因很简单:这题的矩阵规模非常小。题目限制 m 和 n 最多 300,min(m, n) 也就是 300。哪怕从大到小枚举所有可能的边长,最多也就枚举 300 轮。每一轮里,需要检查的正方形数量是 (m-s+1) x (n-s+1),在最坏情况下是 300 x 300 = 90000。所以满打满算,最坏情况下的检查次数大约是 300 * 90000 = 2700 万次。
2700 万次整数加减法,在现代 CPU 上就是个零头,Java 和 C++ 都能轻松跑进 1 秒。与其写一套二分 + check 函数,还不如直接从最大的边长往下找。因为一旦找到了一个满足条件的正方形,这个边长就是所有可行边长里最大的,直接 return 就行。
更关键的是,从大到小枚举在很多实际输入下是提前退出的,根本不会跑到 2700 万。比如 threshold 特别大,大到整个矩阵的和都小于等于阈值时,第一轮 s = min(m,n) 就命中了,后面的循环全部跳过。而二分的写法反而要老老实实跑 log n 轮 check,每次 check 都要完整遍历一遍所有起点,代码更复杂,计算量反而可能更多。
当然,这里“不需要二分”的前提是数据范围小。如果 m 和 n 都到了 10^5 量级,那枚举边长肯定超时,必须二分。刷题一定要根据数据范围选算法,不能背模板。
3.3 复杂度与数据范围分析
分析一下上面代码的复杂度。
前缀和构建部分:遍历每个格子一次,时间复杂度 O(m x n)。
边长枚举部分:对于每个边长 s,检查所有可能的左上角位置,数量是 (m-s+1) x (n-s+1),每个位置用前缀和 O(1) 计算。整体复杂度是:
text复制O(m x n x min(m, n))
最坏情况是 300 x 300 x 300,约 2700 万次运算。
空间复杂度:需要存一个 (m+1) x (n+1) 的前缀和数组,所以是 O(m x n),也就是 300 x 300 个 int,非常小。
这里说句题外话,我用 Python 也试过这个写法。Python 纯循环 2700 万次会慢一些,但实际测试最坏情况差不多 2 到 3 秒,LeetCode 上能过。如果你用 Python,可以加一个提前判断:如果整个矩阵的和已经小于等于 threshold,直接返回 min(m,n),能省掉很多无谓的循环。
python复制class Solution:
def maxSideLength(self, mat: List[List[int]], threshold: int) -> int:
m, n = len(mat), len(mat[0])
pre = [[0] * (n + 1) for _ in range(m + 1)]
for i in range(1, m + 1):
for j in range(1, n + 1):
pre[i][j] = pre[i-1][j] + pre[i][j-1] - pre[i-1][j-1] + mat[i-1][j-1]
if pre[m][n] <= threshold:
return min(m, n)
for s in range(min(m, n), 0, -1):
for i in range(s, m + 1):
for j in range(s, n + 1):
total = pre[i][j] - pre[i-s][j] - pre[i][j-s] + pre[i-s][j-s]
if total <= threshold:
return s
return 0
这里 pre[m][n] 就是整个矩阵的元素和,如果它都不超过 threshold,说明矩阵本身就是满足条件的最大正方形,直接返回 min(m, n) 即可。这个优化在任何语言里都值得写。
4. 常见坑、二分写法与延伸思考
4.1 边界和初始化问题
我在刷题群里见过好几个人在这道题上栽跟头,基本都是下面这几个原因。
第一个坑:前缀和数组开成 m x n,然后强行处理边界。比如有人写 pre[i][j] 表示前 i 行前 j 列,但数组只开 m 行 n 列,这样当 i=0 或 j=0 时,pre[i-1][j] 就直接越界了。解决办法很简单,数组开成 (m+1) x (n+1),让第 0 行和第 0 列全部是 0,所有边界情况都被自动处理掉。这是前缀和的标准姿势,不要去省那一行一列。
第二个坑:遍历起点时下标写错。如果你用右下角坐标 i, j 来枚举,i 要从 s 开始,而不是从 1 开始。否则 i-s 会变成负数,虽然 Java 数组索引负数是运行时异常,但 Python 会默默取到列表末尾的元素,得到完全错误的结果且不报错,这是最阴间的。我之前写 Python 就吃过这个亏,检查了半天才发现是负数索引。
第三个坑:int 溢出恐慌。矩阵元素最大是 10^4,m 和 n 最大是 300,所以整个矩阵的元素和最大是 300 x 300 x 10000 = 9 亿,int 能存下,Java 的 int 最大值约 21 亿。所以这道题用 int 完全够用。但如果你在练习时改过数据范围,或者想更保险,用 long 也无妨,只是一般用不到。这里顺手算一下就能放心,不用看到“和”就无脑上 long。
4.2 如果想写二分,check 函数怎么写
虽然我说这题不需要二分,但如果你正好在练习二分答案,或者想理解单调性,可以这么写。
首先需要证明“如果边长为 s 存在满足条件的正方形,那么边长为 s-1 也一定存在满足条件的正方形”。这个直观上很容易理解:我们不需要在原正方形里找一个 (s-1) x (s-1) 的正方形吗?其实不一定,原正方形是 s x s,直接取它左上角的 (s-1) x (s-1) 子正方形,它的元素和是原来正方形的一部分,必然小于等于原来的元素和,也就小于等于 threshold。所以单调性成立。
那么 check 函数就是固定边长 len,遍历所有 len x len 的正方形,只要有一个元素和小于等于 threshold,就返回 true。
二分框架用“找最大可行值”的模板,注意 mid 和左右边界:
java复制public int maxSideLength(int[][] mat, int threshold) {
int m = mat.length, n = mat[0].length;
int[][] pre = new int[m + 1][n + 1];
for (int i = 1; i <= m; i++) {
for (int j = 1; j <= n; j++) {
pre[i][j] = pre[i - 1][j] + pre[i][j - 1] - pre[i - 1][j - 1] + mat[i - 1][j - 1];
}
}
int left = 0, right = Math.min(m, n);
while (left < right) {
int mid = (left + right + 1) / 2;
if (check(pre, m, n, mid, threshold)) {
left = mid;
} else {
right = mid - 1;
}
}
return left;
}
private boolean check(int[][] pre, int m, int n, int len, int threshold) {
for (int i = len; i <= m; i++) {
for (int j = len; j <= n; j++) {
int sum = pre[i][j] - pre[i - len][j] - pre[i][j - len] + pre[i - len][j - len];
if (sum <= threshold) {
return true;
}
}
}
return false;
}
这种写法的时间复杂度是 O(m x n x log(min(m,n))),在 m,n=300 时大概 72 万次运算,确实更快。但代码量比从大到小枚举大,还要额外理解单调性。说实话,在 LeetCode 这种数据范围下,两者都是秒过,写哪个纯粹看个人习惯。如果追求代码简洁,从大到小枚举更优;如果以后想迁移到大数据量的场景,二分写法值得练一练。
4.3 这类套路还能用在哪些题
二维前缀和的套路远远不止这一道题。我自己的经验是,只要看到“多次查询矩形区域和”“求子矩阵满足某种条件”,第一反应就该是二维前缀和。
经典延伸题包括 304 二维区域和检索,那是一道裸的二维前缀和模板题,用来练手很合适。还有 1074 元素和为目标值的子矩阵数量,那道题在前缀和基础上还要配合哈希表,思路是一维前缀和延伸成二维后,再对每一对行做“压缩成一维”的处理。另外,如果题目要求子矩阵中元素和最大,那就是最大子矩阵和问题,典型的做法是枚举行区间,然后转成一维最大子数组和,配合前缀和也能做。
回到 LeetCode 1292 这道题,它的价值不在于算法有多高深,而在于让你把二维前缀和的推导过程彻底吃透。很多人背得下 pre[i][j] = pre[i-1][j] + pre[i][j-1] - pre[i-1][j-1] + mat[i-1][j-1] 这个公式,但真到了比赛里,下标差一、边界没处理对,一样会卡住。
最后再分享一个我自己的小习惯:凡是涉及前缀和的题,我写完之后一定会用手算一遍示例数据,把 pre 矩阵画出来,再随机挑一个子矩阵验证求和公式。这个习惯帮我省下了无数次 Debug 的时间。你在做这道题的时候,也可以试试先不跑代码,在纸上把 pre 数组手推一遍,推通了再写代码,绝对比直接抄代码要牢靠得多。
