LeetCode 1292 这道题,我觉得是二维前缀和的教科书级例题。题面很短:给你一个 m x n 的矩阵,找一个正方形区域,让元素和小于等于给定的阈值 threshold,返回能满足条件的最大边长。乍一看,很多人第一反应是二分答案,然后写一个 check 函数去验证某个边长是否可行。但仔细看数据范围,m、n 最大只有 300,压根不用二分,直接用二维前缀和预处理,再暴力枚举边长就能稳稳过掉,而且代码写起来反而更直白、更不容易错。这篇就用最通俗的方式,把二维前缀和怎么推、代码怎么写、坑在哪里讲清楚。
1. 题目拆解:它到底在求什么
1.1 数据规模和暴力解法的瓶颈
先看数据范围:矩阵的长和宽都在 300 以内,元素值是非负整数,阈值 threshold 最大到 1e9。如果完全按暴力思路,枚举每个可能的正方形左上角,再枚举边长,然后重新累加这个正方形内部的所有元素,那复杂度会是多少?
假设矩阵是 300 x 300,正方形边长从 1 到 300。每次累加一个 k x k 的正方形,需要 k^2 次加法。把所有边长、所有左上角的情况都枚举一遍,总操作量大概是:
S = Σ_{k=1}^{300} (301 - k)^2 * k^2
这个数非常大,粗算一下:当 k 在中间段(比如 k=150)时,(301-k)^2 约等于 151^2 ≈ 22801,k^2 ≈ 22500,相乘再乘上左上角数量,单层循环就接近 5 亿次加法。整体加起来,C++ 在 LeetCode 上大概率超时,Python 更是想都别想。
所以核心矛盾是:我们需要一种方法,能在 O(1) 时间内算出任意一个矩形区域的和,而不是每次现加。这正是二维前缀和存在的意义。
1.2 从一维前缀和到二维的思维迁移
一维前缀和大家都熟:给一个数组 nums,预处理一个 prefix 数组,prefix[i] 表示 nums[0] 到 nums[i-1] 的和,这样任意区间 [l, r] 的和就是 prefix[r+1] - prefix[l]。它把「区间求和」从 O(n) 降到了 O(1)。
二维前缀和的思路完全一样,只是把「区间」换成「矩形」。我们需要一个二维数组 p,让 p[i][j] 表示从矩阵左上角 (0,0) 到 (i-1,j-1) 这个矩形区域内所有元素的和。注意这里故意用 1-based 的含义来描述,代码里通常也把 p 开成 (m+1) x (n+1),就是为了让边界处理更干净,这个细节后面专门讲。
一旦有了 p,任何以 (x1,y1) 为左上角、(x2,y2) 为右下角的矩形和,都可以通过 p 数组的四项组合算出来。整个问题的难点就全集中在这两步:如何快速构造 p,以及如何准确写出矩形和的查询公式。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 二维前缀和的核心细节:预处理、查询与公式推导
2.1 预处理:容斥公式怎么来的
p[i][j] 的定义是「从 (0,0) 到 (i-1,j-1) 的矩形和」。怎么递推?我们盯住右下角这个格子,也就是矩阵中的第 i-1 行、第 j-1 列,它的值记为 mat[i-1][j-1]。
p[i][j] 其实可以拆成三块:
- 上面部分:p[i-1][j],它覆盖了从 (0,0) 到 (i-2,j-1) 的矩形;
- 左边部分:p[i][j-1],它覆盖了从 (0,0) 到 (i-1,j-2) 的矩形;
- 当前格子:mat[i-1][j-1]。
但把 p[i-1][j] 和 p[i][j-1] 直接相加,左上角那块区域 p[i-1][j-1] 被重复加了一次。因为 p[i-1][j] 覆盖了 (0,0) 到 (i-2,j-1),p[i][j-1] 覆盖了 (0,0) 到 (i-1,j-2),它们共同重叠的部分正好是 (0,0) 到 (i-2,j-2),也就是 p[i-1][j-1]。所以要减去一次。
于是预处理公式就是:
p[i][j] = p[i-1][j] + p[i][j-1] - p[i-1][j-1] + mat[i-1][j-1]
这个「加两块、减一块、再加当前格子」的容斥过程,如果你第一次接触会觉得有点绕。我的建议是不要死记,找一张 3x3 的纸,自己手写一遍 p 表,推两遍就彻底记住了。网格的容斥,本质上就是集合的容斥,只是从一维的「前缀差」变成了二维的「面积叠加」。
2.2 查询:四段边界怎么组合
有了 p 表,怎么求任意矩形的和?假设要求左上角 (r,c) 到右下角 (x2,y2) 的和,其中 r、c、x2、y2 都是 1-based 意义下的坐标(也就是第几行、第几列)。
矩形的和 = p[x2][y2] - p[r-1][y2] - p[x2][c-1] + p[r-1][c-1]
拆开看:p[x2][y2] 是整个大矩形的和;减去 p[r-1][y2] 是去掉上方多余的部分;减去 p[x2][c-1] 是去掉左侧多余的部分;但左上角那一块被减了两次,所以要加回来 p[r-1][c-1]。
如果你把 p 表想象成一张「累计销售报表」,每个格子存的是「从起点到当前格子的总销量」,那么查任意子区域的销量,就是拿大总数剪掉上边、剪掉左边,再把左上角被剪两次的部分补回来。这个类比几乎一次就能让人记住公式。
2.3 为什么 dp 要开 (m+1) x (n+1)
这是很多人写代码时犹豫的点:p 直接开成 m x n 不行吗?行,但你要在 i=0 或 j=0 的时候做一堆 if 特判,非常烦,而且容易漏。更优雅的做法是统一把坐标往后挪一位,让 p 的第 0 行和第 0 列全是 0。
这样一来:
- p[i][0] = 0,p[0][j] = 0,天然处理了边界;
- 矩阵元素 mat[i-1][j-1] 被映射到 p[i][j];
- 查询的时候,左上角如果是 (r,c),那么 r-1 最小是 0,永远不会越界;
- 预处理和查询的公式不需要任何特殊情况判断,代码直接照着公式写就行。
多开一行一列,本质上是拿一点空间换掉所有边界分支,这对竞赛或者刷题来说是非常划算的。很多二维动态规划、二维差分也都有类似的操作。
3. 完整实现:不用二分的直接枚举法
3.1 从大往小枚举的写法逻辑
既然有了 O(1) 的矩形求和,剩下的就是枚举边长。这里有个关键点:题目矩阵元素是非负的,所以正方形会越大、元素和越可能超阈值。换句话说,「能不能找到边长为 k 的合法正方形」这个性质,对于 k 来说是单调的:如果边长为 k 都不满足,那更小的边长不一定不满足;但如果边长为 k 满足,那更小的边长一定也满足。
这个单调性给了我们两个选择:
- 从 1 到 min(m,n) 从小到大枚举,记录最后一个满足条件的 k;
- 从 min(m,n) 到 1 从大到小枚举,找到第一个满足条件的 k 直接返回。
第二种写起来更短,因为一旦找到就一定是最大边长。为什么?因为如果边长为 k 的正方形满足阈值限制,取这个正方形的左上角子正方形(边长 k-1、k-2、...)元素和不会更大,所以也一定满足;也就是说合法边长在 [0, ans] 这个范围内是连续存在的。从大往小扫,遇到的第一个合法边长就是 ans。
这就是「无需二分」的底气。二分的本质也依赖这个单调性,只是这题数据范围小,线性枚举完全够用,代码更简单。
3.2 复杂度证明:为什么 300x300 秒过
直接把所有边长从大到小枚举一遍,总操作量是多少?
对每个边长 k,左上角的合法位置有 (m-k+1) * (n-k+1) 种。最坏情况 m = n = 300,总枚举次数就是:
Σ_{k=1}^{300} (301-k)^2 = 1^2 + 2^2 + ... + 300^2 = 300 * 301 * 601 / 6 ≈ 9,045,050
约 900 万次查询,每次查询是常数时间的几行算术运算。这个量级在 C++ 里跑起来连 0.1 秒都用不到,在 Python 里也能轻松通过。所以真的不需要二分,二分反而会把 check 函数包一层,代码更长,还不直观。
3.3 一个 3x3 示例手算全过程
拿一个简单例子验证全过程。假设矩阵是:
text复制1 1 1
1 1 1
1 1 1
threshold = 4。
先预处理前缀和,为了清楚我们直接列出 p 表:
text复制0 0 0 0
0 1 2 3
0 2 4 6
0 3 6 9
p[1][1] = 1,p[1][2] = 2,p[2][2] = 1+1+1+1 = 4,p[3][3] = 9,完全正确。
然后从 k = 3 开始枚举。整个矩阵和是 p[3][3] = 9,9 > 4,不满足。
k = 2,查看左上角 (1,1)。矩形和的公式是:
p[2][2] - p[0][2] - p[2][0] + p[0][0] = 4 - 0 - 0 + 0 = 4
4 正好小于等于 threshold,于是直接返回 2。这个例子里最大边长就是 2。
如果你手算或调试时发现结果不对,就用这种小矩阵去对,几乎立刻能定位是公式符号的问题还是下标边界的问题。
3.4 参考代码:C++ 与 Python 双版本
C++ 写法:
cpp复制class Solution {
public:
int maxSideLength(vector<vector<int>>& mat, int threshold) {
int m = mat.size(), n = mat[0].size();
vector<vector<int>> p(m + 1, vector<int>(n + 1, 0));
for (int i = 1; i <= m; ++i) {
for (int j = 1; j <= n; ++j) {
p[i][j] = p[i-1][j] + p[i][j-1] - p[i-1][j-1] + mat[i-1][j-1];
}
}
auto getSum = [&](int r, int c, int k) {
int x2 = r + k - 1, y2 = c + k - 1;
return p[x2][y2] - p[r-1][y2] - p[x2][c-1] + p[r-1][c-1];
};
for (int k = min(m, n); k >= 1; --k) {
for (int i = 1; i + k - 1 <= m; ++i) {
for (int j = 1; j + k - 1 <= n; ++j) {
if (getSum(i, j, k) <= threshold) return k;
}
}
}
return 0;
}
};
Python 写法:
python复制class Solution:
def maxSideLength(self, mat: List[List[int]], threshold: int) -> int:
m, n = len(mat), len(mat[0])
p = [[0] * (n + 1) for _ in range(m + 1)]
for i in range(1, m + 1):
for j in range(1, n + 1):
p[i][j] = p[i-1][j] + p[i][j-1] - p[i-1][j-1] + mat[i-1][j-1]
for k in range(min(m, n), 0, -1):
for i in range(1, m - k + 2):
for j in range(1, n - k + 2):
s = p[i+k-1][j+k-1] - p[i-1][j+k-1] - p[i+k-1][j-1] + p[i-1][j-1]
if s <= threshold:
return k
return 0
注意 Python 的 for i in range(1, m - k + 2) 保证了 i 最大取到 m - k + 1,刚好是最后一个合法的左上角行号,别把 +1 漏了,漏了会跳过最后一行的情况。
4. 常见踩坑与排查实录
4.1 下标边界:最容易错的三个地方
我见过太多人在这个题上翻车,基本都集中在坐标转换。
第一个坑:把 mat 的下标和 p 的下标搞混。p[i][j] 对应的是 mat[i-1][j-1],不是 mat[i][j]。预处理时写 p[i][j] = ... + mat[i][j] 是错的,数组会越界或者结果完全不对。
第二个坑:查询公式的符号写错、漏项。正确的是 p[x2][y2] - p[r-1][y2] - p[x2][c-1] + p[r-1][c-1],这四个项的坐标要仔细对。建议把 getSum 封装成一个 lambda 或者函数,而不是在循环里手写三遍公式,这样即使写错也只需要改一处。
第三个坑:枚举左上角的循环边界。i + k - 1 <= m 和 j + k - 1 <= n 这个条件一定不能丢。如果用 for (int i = 1; i <= m - k + 1; ++i) 也可以,但要保证 m - k + 1 是最后一个合法行号。很多人在一重循环里用 i <= m,一旦 k 比较大就越界或者访问到 p 表外。
4.2 数据类型与溢出问题
这题 mat[i][j] <= 10^4,m、n <= 300,整个矩阵和最大是 300 * 300 * 10^4 = 9 * 10^8,正好在 int 范围内(int 上限约 2.1 * 10^9)。所以 C++ 用 int 存储 p 表是安全的,threshold 最大 1e9 也不会超。但这里有个隐藏风险:如果题目数据稍微改大一点,比如矩阵是 1000 x 1000,那总和就会到 10^10,int 就爆了。所以要么写的时候就习惯用 long long,要么至少在注释里说明为什么 int 够用。我个人的习惯是比赛或者刷题时优先用 long long 保平安,哪怕这题用 int 也不会错。
Python 没有这个烦恼,整数可以无限大,但要注意性能:Python 的二维列表访问、多层循环会比 C++ 慢很多,不过这道题 900 万次量级仍然稳过。
4.3 「无需二分」的真实边界:什么时候必须上二分
标题里写「无需二分」,并不是说二分没用,而是针对这题的数据规模,暴力枚举边长性价比最高。
如果你遇到下面几种情况,就要认真考虑二分了:
- m、n 变成 1000,甚至 2000。此时 Σ_{k=1}^{min(m,n)} (m-k+1)(n-k+1) 会达到 10^9 甚至更高,暴力枚举边长就超时了;
- 查询从正方形变成「尽量大的矩形」,且阈值约束方式更复杂;
- 需要在一个大型数据集上做多次不同阈值的查询,每次二分能显著减少枚举边长次数。
这时候二分的 check 函数思路是:固定边长 mid,遍历所有左上角查是否存在 sum <= threshold。check 本身 O(mn),二分外层 O(log min(m,n)),总体 O(mn log min(m,n))。m=n=1000 时大概也就 10^6 * 10 = 10^7 量级,完全能接受。
所以「无需二分」的准确表述应该是:这题的数据规模决定了线性枚举已经足够,写二分属于过度设计,但二分的单调性思想必须理解,因为它是这类题的底层逻辑。
5. 复盘:从这题提炼出的通用套路
5.1 怎么判断一道题该用「暴力 + 前缀和」还是「二分 + check」
我在刷题时总结了一个比较快的判断方法:先看数据范围,再看是否涉及「子矩阵求和」或「子区间求和」。
- 如果矩阵长宽都在 500 以内,并且是正方形枚举、固定边长这种,往往二维前缀和 + 直接枚举就能过;
- 如果矩阵边长到了 1000 以上,并且答案有明显的单调性,那么大概率需要二分答案 + check,check 内部继续用前缀和来加速;
- 如果题目里要求「最大」「最小」「最长」这类词,并且合法的解构成连续区间,先想想单调性,大部分情况下二分是可用的。
前缀和本身不解决「搜索最优解」的问题,它只解决「快速验证」的问题。把这层关系想清楚,你看到这类题就不会被「要不要二分」困住。
5.2 扩展场景:最大矩形、三维前缀和、树状数组上二分
二维前缀和可以平滑地扩展到很多方向:
- 如果题目把「正方形」改成「矩形」,并且要求面积最大,那就不能简单地枚举边长,而是要考虑双指针、单调栈或者更复杂的技巧,但求矩形和仍然用二维前缀和;
- 如果题目变成三维空间,求一个正方体内部元素和,可以构造三维前缀和,容斥公式会从 4 项变成 8 项;
- 热搜里经常看到「树状数组上二分」「带权二分」,那是另一种二分场景:树状数组上二分多用于动态修改 + 查询前缀和的场景,和这题不是一个套路,但核心依然是「利用单调性把查值问题转成判定问题」。
所以我一直觉得,LeetCode 1292 的价值不在题目本身有多难,而在于它把二维前缀和这个基础工具嵌进了一个非常自然的场景里,顺便让你理解「无脑暴力」和「合理优化」之间的平衡点。把这个题吃透,后面遇到类似的矩阵求和问题,你会很快反应过来要用前缀和。
最后再分享一个我实际操作中的小习惯:拿到这种题,我会先写一个最朴素的暴力版本(直接三重循环累加),然后用随机生成的小矩阵去对拍,确认前缀和版本的答案和暴力版本一致。这个习惯帮我拦截了无数次下标写错、公式漏项的 bug。刷题不是比谁一次 AC,而是比谁能稳定地把思路写对,多花两分钟对拍,节省的可能是一下午的调试时间。
