刷 LeetCode 1895 之前,我对“幻方”的印象还停留在小学奥数的九宫格填数,以为又是什么构造题,结果读完题目发现完全不是一回事。这题要求在一个 m x n 的矩阵里,找出边长最大的正方形子矩阵,满足这个子矩阵的每一行、每一列、两条对角线的元素和全部相等。说白了,考的是前缀和、枚举顺序、还有边界处理,难度定在“中等”也算合理,但如果不提前想清楚,写起来很容易被各种下标绕晕。
我刷完这道题最大的感受是:它本身并不难,难的是把“检查一个子矩阵是否满足条件”的操作从 O(k) 降到 O(1)。只要想通这一点,整道题的代码量其实非常小。这篇文章我会把从读题到 AC 的完整思考过程写出来,包括四张前缀表的搭建思路、判断幻方时的边界陷阱,还有我实际调试时踩过的坑。如果你正在刷矩阵类题目,或者准备面试想巩固一下前缀和的用法,这篇应该对你有帮助。
1. 题目到底在说什么:最大幻方的问题拆解
1.1 幻方不是玄学,定义一句话就能讲清楚
题目里的“幻方”不是让你去构造 3x3 的九宫格,它的定义很简单:一个 k x k 的正方形子矩阵,如果所有行的和、所有列的和、两条对角线的和全部相等,那这个子矩阵就是幻方,k 就是这个幻方的边长。
举个例子,一个 2x2 的矩阵:
text复制2 7
3 6
第一行和是 9,第二行和是 9,第一列和是 5,第二列和是 13,主对角线 2+6=8,副对角线 7+3=10。这些和并不全相等,所以它不是幻方。但如果矩阵是:
text复制2 7
7 2
第一行 9,第二行 9,第一列 9,第二列 9,两个对角线也都是 9,这就是一个合法幻方。
还有一个容易被忽略的点:边长 k=1 的单个格子一定是幻方。因为一个元素单独形成的矩阵,行和、列和、对角线全都是它自己,天然相等。所以这道题的答案至少是 1,不存在“找不到幻方”返回 0 或 -1 的情况。这个性质看似废话,但它决定了我们枚举边长时可以从大到小直接返回,也决定了最坏情况下一定有个兜底值。
1.2 数据范围藏着解题线索
看数据范围永远是刷题的第一步。这道题里 m 和 n 都小于等于 50,矩阵元素值不超过 10^5。
50 这个规模意味着什么?如果完全暴力,枚举所有可能的正方形子矩阵:边长 k 有 50 种可能,每种边长下左上角起点有大约 (m-k+1)*(n-k+1) 个,检查一个 k x k 矩阵是否满足幻方需要算 k 行、k 列、2 条对角线,也就是 O(k) 次求和。最坏情况下总操作量大约在千万级别,理论上暴力加一点优化也不是完全不能过。但 LeetCode 考这种题,重点不是让你卡着时间过,而是考察你有没有意识到“重复计算”的问题。
同一个格子会在不同行、不同列、不同对角线里被反复累加很多次。假设我们暴力检查一个 50x50 的矩阵,左上角在 (0,0)、边长为 50 时,第一行会被算一次,第一列会被算一次,主对角线会被算一次,这些和之间没有任何复用。可如果我们提前把“某一行从第几列到第几列的和”“某一列从第几行到第几行的和”“从某个点出发沿对角线到边界的和”都存下来,那么任何一个 k x k 子矩阵的行、列、对角线求和都可以变成一次减法,判断时间从 O(k) 变成 O(1)。这才是这题真正想教的东西:用空间换时间,用前缀和把重复计算缓存起来。
1.3 枚举方向决定代码简洁度
确定用前缀和之后,下一步就是枚举策略。要找的是“最大的幻方”,也就是边长 k 最大的子矩阵。最直观的想法是从小到大枚举 k,把所有满足条件的结果记录下来,最后取最大值。这样当然可以,但你会发现代码里要多维护一个 ans 变量,而且即使找到了一个很大的合法幻方,只要后面还有更大的可能,你就不能提前退出,必须把所有情况都遍历完。
更好的做法是从大到小枚举 k,从 min(m, n) 开始往下递减,一旦发现某个 k 下存在合法幻方,直接返回这个 k。因为 k 是从大到小枚举的,第一个命中的一定是最大的。这样既省去了 ans 变量,又能在大多数情况下提前结束循环,代码逻辑也更贴近人的直觉。我自己写的时候一开始用的从小到大,后面发现从大到小明显清爽得多,算是这道题第一个值得记住的小技巧。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 四张前缀表:把重复计算压成 O(1)
2.1 行前缀和:一维想法的横向延伸
先来说最基础的行前缀和。如果你刷过“区域和检索”或者“一维前缀和”这类题,对下面的写法肯定不陌生:用 rowSum[i][j] 表示第 i 行从第 0 列到第 j-1 列的元素和。注意这里我用的是“第 j-1 列”,也就是说 rowSum[i][j] 的下标 j 比实际列数多偏移了一位,这种写法是为了让区间 [left, right] 的和可以直接用 rowSum[right+1] - rowSum[left] 表示,避免处理减一的问题。
具体到这道题,我们想要求第 r 行从 l 列到 r 列(左闭右闭)的和,只需:
text复制rowSum[r][r+1] - rowSum[r][l]
构建的代码也很简单:
cpp复制vector<vector<int>> rowSum(m, vector<int>(n + 1, 0));
for (int i = 0; i < m; i++) {
for (int j = 0; j < n; j++) {
rowSum[i][j + 1] = rowSum[i][j] + grid[i][j];
}
}
这个数组的大小是 m 行、n+1 列,多出来的一列就是为了存“空前缀”的 0。实际检查幻方时,第一行的和就是 target,后面每一行的和都用 O(1) 时间比较,不用再写内层循环累加。
2.2 列前缀和:只需要多开一维
列前缀和和行前缀和的思路完全一样,区别只是累加的方向从横向变成纵向。用 colSum[i][j] 表示第 j 列从第 0 行到第 i-1 行的元素和。如果你想求第 c 列从第 u 行到第 d 行的和,就是:
text复制colSum[d+1][c] - colSum[u][c]
构建代码:
cpp复制vector<vector<int>> colSum(m + 1, vector<int>(n, 0));
for (int i = 0; i < m; i++) {
for (int j = 0; j < n; j++) {
colSum[i + 1][j] = colSum[i][j] + grid[i][j];
}
}
注意行前缀和是每个 i 对应一行数组,列前缀和是每个 j 对应一列数组,但实现上我们仍然用一个二维数组存,i 是行方向累计,j 是列索引。这一步容易搞混,我的记忆方式是:rowSum 第一个下标是“第几行”,第二个下标是“列区间前缀”;colSum 第一个下标是“行区间前缀”,第二个下标是“第几列”。写代码时盯着下标维度多确认一遍,能省下不少调试时间。
2.3 对角线后缀和:这里和行列不一样
行和列都是一维区间求和,用标准前缀和很自然。但两条对角线的情况稍微特殊一点,如果你用标准前缀和去存“主对角线方向从左上到右下的累计和”,你会发现求任意一段对角线区间反而别扭,因为你还需要知道这段对角线的起点和终点。这里我推荐一个更直观的思路:从某个格子出发,沿着对角线方向一直走到矩阵边界,把这一整段的和提前存好。
以主对角线为例,定义 diag[i][j] 表示从 (i, j) 出发,沿着右下方向走到边界,所有经过格子元素的和。那么从 (i, j) 开始连续走 k 步,也就是 (i,j), (i+1,j+1), ..., (i+k-1,j+k-1) 这一段的和,就等于:
text复制diag[i][j] - diag[i+k][j+k]
前提是 i+k 和 j+k 没有越界。如果越界,说明后面那段不存在,直接减 0 就行。
构建 diag 数组要从右下角往左上角方向遍历,因为当前格子的值要依赖右下方格子的结果:
cpp复制vector<vector<int>> diag(m, vector<int>(n, 0));
for (int i = m - 1; i >= 0; i--) {
for (int j = n - 1; j >= 0; j--) {
diag[i][j] = grid[i][j] + (i + 1 < m && j + 1 < n ? diag[i + 1][j + 1] : 0);
}
}
同理,副对角线的方向是左下,也就是行增加、列减少。定义 anti[i][j] 表示从 (i, j) 出发,沿着左下方向走到边界,所有经过格子元素的和。构建时从右下往左上不适用了,要改成“行从下往上,列从左往右”的顺序遍历,因为当前格子依赖的是左下方格子。这里其实每个方向都要根据依赖关系确定遍历顺序,写代码前先在草稿纸上把方向画出来,比硬记代码要靠谱得多。
构建副对角线后缀和:
cpp复制vector<vector<int>> anti(m, vector<int>(n, 0));
for (int i = m - 1; i >= 0; i--) {
for (int j = 0; j < n; j++) {
anti[i][j] = grid[i][j] + (i + 1 < m && j - 1 >= 0 ? anti[i + 1][j - 1] : 0);
}
}
这里我把行下标从下往上遍历,列下标从左往右遍历,这样访问 anti[i+1][j-1] 时它已经被算好了。
2.4 为什么不用二维前缀和
可能有朋友会问,标准的二维前缀和不是也能算矩形区域和吗,能不能直接用它判断幻方?
二维前缀和确实能快速求任意矩形内所有元素的总和,但幻方要求的是每一行分别相等、每一列分别相等,不是整个矩形总和相等。你没法用二维前缀和快速得到“某一行中间一段的和”或者“某一条对角线的和”,除非再额外开数据结构。所以这题正确的做法是分别针对行、列、对角线建四张表,每一张表解决一种求和需求。这也是这类矩阵题的常见套路:遇到什么性质要查,就单独为它建一个辅助数组。
3. 判断幻方:从枚举到验证的完整实现
3.1 确定基准值:第一行和作为 target
现在四张表都齐了,接下来就是枚举所有可能的 k 和所有可能的左上角 (i, j),判断对应的 k x k 子矩阵是不是幻方。
判断的时候先要确定一个基准值 target,所有行、列、对角线的和都要和它相等。我习惯用第一行的和作为 target,因为行前缀和已经存好了,O(1) 就能算出来:
cpp复制int target = rowSum[i][j + k] - rowSum[i][j];
这里 i 是左上角的行下标,j 是左上角的列下标,k 是当前枚举的边长。这个 target 计算好之后,后面的每一行、每一列、两条对角线都要和它比较。
为什么选第一行不选第一列或者对角线?其实都可以,选第一行纯粹是比较顺手,因为 rowSum 是第一个建好的数组,而且第一行一定存在,不需要额外的边界判断。你选第一列也一样能过,只是代码风格不同罢了。
3.2 行、列、双对角线的验证细节
验证过程分四步:先检查所有行,再检查所有列,最后检查两条对角线。每一步都可以提前 break,只要有一处不满足,这个子矩阵就可以直接判定为不合法,不用继续算下去。
检查行时,遍历 r 从 i 到 i+k-1:
cpp复制bool ok = true;
for (int r = i; r < i + k; r++) {
if (rowSum[r][j + k] - rowSum[r][j] != target) {
ok = false;
break;
}
}
检查列时,遍历 c 从 j 到 j+k-1:
cpp复制if (ok) {
for (int c = j; c < j + k; c++) {
if (colSum[i + k][c] - colSum[i][c] != target) {
ok = false;
break;
}
}
}
这里 colSum 的用法和行相反,行是固定 r 变列区间,列是固定 c 变行区间。
最后是对角线。主对角线从左上角 (i, j) 出发,走 k 步到 (i+k-1, j+k-1),利用 diag 后缀和做差:
cpp复制int diagSum = diag[i][j];
if (i + k < m && j + k < n) {
diagSum -= diag[i + k][j + k];
}
副对角线稍麻烦一点,它从右上角 (i, j+k-1) 出发,走 k 步到左下角 (i+k-1, j),利用 anti 后缀和做差:
cpp复制int antiSum = anti[i][j + k - 1];
if (i + k < m && j - 1 >= 0) {
antiSum -= anti[i + k][j - 1];
}
这里第二个 if 的 j-1 是容易写错的地方。副对角线往下走时,列是在减少的,所以走出 k 步之后对应的格子是 (i+k, j-1),不是 (i+k, j+1)。我第一遍写的时候想当然用了 j+1,结果样例就一直错,后来画了个 3x3 的图才反应过来。
上面这两个对角线的边界判断逻辑可以用一张表总结:
| 对角线类型 | 当前方块内起点 | 走出方块后的下一个点 | 做差需要的条件 |
|---|---|---|---|
| 主对角线 | (i, j) | (i+k, j+k) | i+k < m 且 j+k < n |
| 副对角线 | (i, j+k-1) | (i+k, j-1) | i+k < m 且 j-1 >= 0 |
只要走出方块后的下一个点越界,说明后缀和里不包含额外元素,直接不减就行。这个表格建议收藏,实际写的时候对着抄都不会错。
3.3 完整代码参考
把前面的思路拼起来,完整 C++ 代码如下:
cpp复制class Solution {
public:
int largestMagicSquare(vector<vector<int>>& grid) {
int m = grid.size(), n = grid[0].size();
vector<vector<int>> rowSum(m, vector<int>(n + 1, 0));
vector<vector<int>> colSum(m + 1, vector<int>(n, 0));
vector<vector<int>> diag(m, vector<int>(n, 0));
vector<vector<int>> anti(m, vector<int>(n, 0));
// 行前缀和
for (int i = 0; i < m; i++) {
for (int j = 0; j < n; j++) {
rowSum[i][j + 1] = rowSum[i][j] + grid[i][j];
}
}
// 列前缀和
for (int i = 0; i < m; i++) {
for (int j = 0; j < n; j++) {
colSum[i + 1][j] = colSum[i][j] + grid[i][j];
}
}
// 主对角线后缀和(从右下往左上构建)
for (int i = m - 1; i >= 0; i--) {
for (int j = n - 1; j >= 0; j--) {
diag[i][j] = grid[i][j];
if (i + 1 < m && j + 1 < n) {
diag[i][j] += diag[i + 1][j + 1];
}
}
}
// 副对角线后缀和(从左下往右上方向构建)
for (int i = m - 1; i >= 0; i--) {
for (int j = 0; j < n; j++) {
anti[i][j] = grid[i][j];
if (i + 1 < m && j - 1 >= 0) {
anti[i][j] += anti[i + 1][j - 1];
}
}
}
for (int k = min(m, n); k >= 1; k--) {
for (int i = 0; i + k <= m; i++) {
for (int j = 0; j + k <= n; j++) {
int target = rowSum[i][j + k] - rowSum[i][j];
bool ok = true;
// 检查所有行
for (int r = i; r < i + k; r++) {
if (rowSum[r][j + k] - rowSum[r][j] != target) {
ok = false;
break;
}
}
if (!ok) continue;
// 检查所有列
for (int c = j; c < j + k; c++) {
if (colSum[i + k][c] - colSum[i][c] != target) {
ok = false;
break;
}
}
if (!ok) continue;
// 检查主对角线
int diagSum = diag[i][j];
if (i + k < m && j + k < n) {
diagSum -= diag[i + k][j + k];
}
if (diagSum != target) continue;
// 检查副对角线
int antiSum = anti[i][j + k - 1];
if (i + k < m && j - 1 >= 0) {
antiSum -= anti[i + k][j - 1];
}
if (antiSum != target) continue;
return k;
}
}
}
return 1;
}
};
这段代码在时间和空间上都没有浪费。外层枚举 k,从大到小;中层枚举左上角;内层验证。命中一个直接返回,后面更大的 k 也不用管了。
4. 复杂度分析:为什么这样写稳过
4.1 时间复杂度逐层拆解
构建四张表的时间复杂度是 O(mn),因为行、列、两条对角线都只需要遍历一次矩阵。
枚举部分稍微复杂一点。外层 k 最多 min(m, n) 种取值,中层左上角位置最多 m*n 种,最内层验证需要检查 k 行和 k 列,所以最坏情况下复杂度是:
text复制O(mn * (1^2 + 2^2 + ... + K^2)) = O(mn * K^3) ?
这里需要严谨一点。其实外层每个 k 对应的左上角数量是 (m-k+1)(n-k+1),不是一个固定的 mn,所以精确一点的总量是:
text复制sum_{k=1}^{K} (m-k+1)*(n-k+1)*O(k)
当 m=n=K=50 时,这个值大约在几百万量级,完全在 LeetCode 的承受范围内。很多题解直接粗暴地写 O(mnK^2),也能说得通,因为如果按最坏情况 m*n 个起点、每个起点验证 O(k) 来估,再对 k 求和,确实不会超过 10^7。总之,这个复杂度对 50 的数据规模来说非常安全,运行时间一般在 10ms 以内。
4.2 空间复杂度:四张表换来的是时间
空间上,rowSum 是 m*(n+1),colSum 是 (m+1)n,diag 和 anti 各是 mn,全部加起来大概是 4mn 级别,也就是 O(mn)。对于 50x50 的矩阵,总共才 10000 个元素,四张表加一起也就几万个 int,几十 KB 内存,完全不用心疼。
这是我反复强调的“空间换时间”策略。你每多建一张辅助表,就把一类查询的耗时从 O(k) 降到 O(1)。四张表换来的是代码主逻辑从三重循环退化成了“枚举 + 查表”,时间上能压一个数量级。这道题的 50 数据范围其实暴力也可能过,但当你遇到 m、n 更大、比如 200 甚至 1000 的变种时,有没有建立前缀表的思路,就是能不能 AC 的区别。
4.3 实际可以再快一点的剪枝
虽然当前复杂度已经够用,但有几个小优化可以让代码更稳:
第一,先检查行、再检查列,任何一个不满足就 continue,这个顺序能提前过滤掉大部分非法矩阵。第二,如果当前 k 已经很小,比如快到 1 了,前面还没找到,可以尽早返回 1,不过代码里的 return 1 兜底已经覆盖这种情况了。第三,如果矩阵中最大值和最小值相差很大,理论上可以加一个和值范围的快速判断,但实战中没必要,反而增加代码复杂度。
对这道题来说,最重要的优化其实只有一个:从大到小枚举 k,找到就返回。这个优化不仅省时间,还让代码逻辑更清晰,所以一定要养成这个习惯。
5. 刷这道题我踩过的坑和总结的经验
5.1 三个容易翻车的地方
第一个坑是副对角线的做差坐标。我在前面已经提过,anti[i][j + k - 1] 要走 k 步到 anti[i + k][j - 1],一定要减的是 j-1,不是 j+1。这个错误特别隐蔽,因为编译器不会报错,逻辑上也不会越界,结果就是答案不对。我排查了很久,最后用一个小矩阵手动模拟才发现。
第二个坑是主对角线的越界条件。如果 i+k 和 j+k 刚好等于 m 和 n,说明走出方块后的下一格正好在矩阵外,这时不能访问 diag[i+k][j+k],代码里要用 if 判断。我一开始图省事直接减,结果数组越界读到了脏数据,输出完全不可控。
第三个坑是枚举起始点时的边界。循环条件要写成 i + k <= m,而不是 i < m - k。前者在 k=m 时也能正确遍历到起点 i=0,后者在 k 比较大的时候会漏掉一些行。这个错误通常不会让你编译失败,但会让你漏解,差之毫厘谬以千里。
我整理了一个常见错误速查表,刷题的时候可以直接对照:
| 易错点 | 错误写法 | 正确写法 |
|---|---|---|
| 行循环边界 | for (int i = 0; i < m - k; i++) | for (int i = 0; i + k <= m; i++) |
| 副对角线做差 | anti[i+k][j+1] | anti[i+k][j-1] |
| 主对角线越界 | 无条件减 diag[i+k][j+k] | 先判断 i+k < m && j+k < n |
5.2 这类“找最大合法块”问题的通用套路
做完这道题你会发现,所谓的“最大的幻方”本质上是“找最大的满足某种条件的正方形子矩阵”。这类题在 LeetCode 上非常多,比如最大正方形、最大黑方阵、最大加号标志等等。它们的通用套路都是三步:先确定验证条件,再选择合适的数据结构把验证操作变快,最后从大到小枚举边长并返回第一个满足条件的值。
前缀和不是这种题的唯一解,有时候可以用动态规划,有时候可以用滑动窗口,但核心思想是一样的:不要在验证的时候临时去遍历整个子块,而是提前把可能用到的统计信息准备好。对于矩阵类问题来说,行前缀和、列前缀和、对角线和是最常用的三个工具,这道题恰好一次性全用上了,所以刷完它,你对这三类前缀和的理解会扎实很多。
5.3 一点题外话
说实话,这道题的难度主要不在算法思路上,而在实现细节上。前缀和的模板谁都会写,但能不能在 20 分钟内把四个辅助数组全部写对、边界全部处理对,才是区分水平的地方。我自己第一次写的时候花了大概四十分钟,中间还翻了两次草稿纸,主要时间都耗在对角线的构建方向上。如果让我总结一句心得,那就是:凡是涉及二维数组方向性遍历的题,先画图、再写代码,永远比直接硬写省时间。
刷完这道题之后,你还可以试着把它改成“最大幻方”的变种:如果要求不一定是正方形,而是任意矩形呢?如果要求行列和相等,对角线不要求呢?这些变种都能用类似的思路解决。矩阵题的乐趣就在于,一个前缀和的小工具,换个场景又能玩出花来。
