最近刷LeetCode刷到第1895题“最大的幻方”,光看题目名字差点被“幻方”两个字唬住,以为是什么数学竞赛题。实际拆开之后发现,核心考的就是两件事:一是能不能看懂“幻方”的定义,二是懂不懂用前缀和去加速矩阵子区域的求和判断。题目本身是中等难度,但背后的思维链非常典型,从暴力枚举到前缀和优化,再到枚举顺序的剪枝,一套组合拳下来,很多类似矩阵题都能触类旁通。
这道题适合什么人刷?如果你在准备面试,或者刚把一维前缀和弄明白、想看看怎么平移到二维矩阵里,这题是很好的过渡练习。它不会像二维差分那么绕,但也足够让你体会“把O(k^2)的求和变成O(1)查表”到底是怎么一回事。文章里我会把暴力思路、前缀和预处理的完整过程、check函数怎么写、复杂度到底该怎么算,以及我实际提交时踩过的几个坑全部摊开讲,文末附完整可运行的Python和C++代码。
1. 题目到底在问什么:先把“幻方”翻译成代码条件
1.1 “幻方”的数学条件拆解
题目给了你一个m行n列的整数矩阵grid,让你在这个矩阵里找一个边长最大的正方形子矩阵,要求这个子矩阵满足:每一行的和、每一列的和、两条对角线的和,全部相等。
这里有个关键点要明确:不是要求整个矩阵满足幻方性质,而是要求从中截取出来的某个正方形满足。也就是说,我们要做的事情是“在矩阵里搜索一个符合条件的正方形”。
我把这个条件用白话再翻译一遍:假设你找到了一个左上角在(i, j)、边长是k的正方形,它的行和、列和、主对角线和副对角线都必须等于同一个数target。这个target不是预先给定的,而是由这个正方形本身的第一行元素和决定的。
判断条件四件套拆开就是:
- k行,每行的和都是target
- k列,每列的和都是target
- 主对角线(从左上到右下)的和是target
- 副对角线(从右上到左下)的和是target
任何一个不满足,这个正方形就不算幻方。
1.2 边界情况:k = 1 一定是答案兜底
这里有一个很容易被忽略的边界:当k = 1时,正方形只有一个元素grid[i][j]。这一行(就是它自己)、这一列(就是它自己)、两条对角线(也都是它自己)的和当然全都相等。所以任何1×1的子矩阵天然就是幻方。
这意味着答案至少是1,永远不会出现“找不到幻方”的情况。这个边界在后面写代码的时候特别重要,你甚至可以在主循环里直接return 1兜底,保证函数一定有返回值。
1.3 先想清楚“最大”这两个字怎么处理
“最大”意味着我们要找最大的边长k。最直观的办法当然是枚举所有可能的k,然后对每个k检查是否存在至少一个边长k的幻方子矩阵。但是枚举顺序有讲究:从大到小枚举,一旦发现某个k可行,直接返回,不用继续看更小的k。
这个剪枝看起来不起眼,实际上非常关键。如果从小到大枚举,你必须找到最后一个可行的k才能返回;而从大到小枚举,你找到的第一个可行k就是答案,后面的所有计算都可以省掉。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 暴力解法与复杂度分析:为什么不能直接硬算
2.1 暴力方案:三重循环套四层求和
最简单的思路,就是枚举所有可能的左上角(i, j)和边长k,然后老老实实把子矩阵里所有元素求和、比较。
伪代码大概是:
- 外层循环k,从min(m, n)往下递减
- 中层循环枚举左上角i
- 内层循环枚举左上角j
- 对每个正方形,用四层循环分别计算行和、列和、两条对角线和,一旦发现不相等就跳过
这种做法的正确性毋庸置疑,但问题在于慢。对每个候选正方形,行求和要算k行,每行k个元素;列求和要算k列,每列k个元素;两条对角线加起来还要算2k个元素。光一个check函数,时间复杂度就至少是O(k^2),因为光是“取出所有元素看一眼”就要k^2次操作。
2.2 复杂度到底有多高:用N = 50算给你看
LeetCode这题的约束是m和n都不超过50。别看50不大,我们把复杂度算清楚。
假设m = n = N,枚举一个边长k时,左上角可选的坐标有(N - k + 1)^2个。对每个坐标做一次check,check的复杂度是O(k^2)。所以总操作量大约是:
sum_{k=1}^{N} (N - k + 1)^2 * k^2
当N = 50时,这个值大约是百万量级,暴力其实也能勉强通过。但问题在于,暴力写法里每算一个行和就要重新做k次加法,代码跑起来常数很大,而且如果题目数据范围稍微放大到N = 200,这个式子就会变成亿级甚至十亿级,直接超时。
所以这道题真正的目的不是让你靠暴力过用例,而是逼你想明白:能不能把check函数里的“求和”从每次重新加优化成“查表一次得到”?这就是前缀和的用武之地。
2.3 从暴力到优化的思维转折
我们要优化的核心是:在check一个k×k正方形时,要判断k行和k列的和是否等于target。如果能在O(1)时间内算出任意一行从第y列到第y+k-1列的和,以及任意一列从第x行到第x+k-1行的和,那么整个check的复杂度就可以从O(k^2)降到O(k)。
为什么能降?因为行和列分别只需要k次查询,加上两条对角线合计2k次累加,总共O(k)量级。比起原来把k行k列全部重新加一遍,少了整整一个数量级(从k^2变成k)。
3. 前缀和预处理:把“区间和”变成“查询表”
3.1 一维前缀和先热个身
如果你对一维前缀和还不熟,我这里用一句话带过:给定数组a,前缀和数组pre[i]表示a[0]到a[i-1]的和。想求区间[l, r)的和,直接拿pre[r] - pre[l]就行。它的本质是把“多次累加”变成“一次减法”,用预处理空间换查询时间。
生活化一点的类比:你在银行按月存款,前缀和就是每个月的“累计结余”数。想知道某个月到某个月总共存了多少,只需要用月末的累计结余减去前一个月末的累计结余,不需要把中间每个月的存入额重新加一遍。
3.2 行前缀和:对每一行单独做一维前缀和
本题的矩阵不是一维数组,我们没法直接套一维前缀和,但可以对每一行分别做一维前缀和,得到一个m行、n+1列的表row_pre。
row_pre[i][j]表示第i行前j个元素的和。注意列数比原矩阵多一列,这是为了把“前0个元素的和是0”这个边界存进去,这样区间查询天然满足左边是开区间、右边是闭区间,不容易越界。
构建代码是两重循环:
python复制for i in range(m):
for j in range(n):
row_pre[i][j + 1] = row_pre[i][j] + grid[i][j]
以后想查第i行从第y列到第y+k-1列的和(左闭右开区间[y, y+k)),只需要写:
python复制row_pre[i][y + k] - row_pre[i][y]
这一步就是从“k次加法”变成“1次减法”。
3.3 列前缀和:转个方向再做一次
光有行前缀和还不够,因为幻方还要求每一列的和相等。列方向上的求和同样要优化。这次我们对每一列分别做一维前缀和,得到一个m+1行、n列的表col_pre。
col_pre[i][j]表示第j列前i个元素的和。注意这里是行数比原矩阵多一行,因为边界是“前0个元素的和为0”。
构建代码:
python复制for i in range(m):
for j in range(n):
col_pre[i + 1][j] = col_pre[i][j] + grid[i][j]
以后想查第j列从第x行到第x+k-1行的和,只需要写:
python复制col_pre[x + k][j] - col_pre[x][j]
3.4 为什么两条对角线不额外做前缀和
行和列分别有k个需要判断的对象,所以非优化不可。但一个正方形只有两条对角线,每次check只需要累加两条对角线上的元素,每条长度是k,加起来是2k次加法。这个次数本身已经够小了,再做对角线前缀和反而会把代码复杂度拉高,收益却几乎为零。
一句话总结:什么求和次数多,就给它做前缀和;什么求和次数少,就老老实实当场累加。这是一个很实用的工程权衡。
4. 主循环与check函数:从大到小枚举是关键
4.1 主循环为什么从min(m, n)开始往下减
子矩阵是正方形,所以边长k最多只能是min(m, n),再大就超出矩阵边界了。我们让k从min(m, n)开始,递减到1。
在这个循环里,对每一个k,还要枚举所有可能的左上角(i, j)。左上角的范围也有讲究:i可以从0取到m-k,j可以从0取到n-k,一旦超过这个范围,正方形就会越界。
伪代码的结构大概是:
python复制for k in range(min(m, n), 0, -1):
for i in range(m - k + 1):
for j in range(n - k + 1):
if check(i, j, k):
return k
return 1
当k减少时,可用左上角的数量反而变多(边长远小于矩阵尺寸时,能放的位置更多),这也正是为什么要从大到小枚举:先在“少而大”的候选里找答案,找到就退出,而不是在“多而小”的集合里拼命试探。
4.2 check函数的四步判断
现在到了最核心的部分:给定左上角(x, y)和边长k,怎么判断它是不是幻方。
第一步,算基准值target。我习惯用第一行的和作为基准。注意这里不能用前缀和偷懒,因为只需要算这一次,直接累加k个元素就行。
第二步,检查每一行。对i从x到x+k-1,计算row_pre[i][y+k] - row_pre[i][y],只要有一个不等于target就直接返回False。这里O(1)查询的好处就体现出来了,k行只需要k次减法。
第三步,检查每一列。对j从y到y+k-1,计算col_pre[x+k][j] - col_pre[x][j],同样只要有一个不等于target就返回False。
第四步,检查两条对角线。主对角线是grid[x+i][y+i](i从0到k-1)的累加,副对角线是grid[x+i][y+k-1-i]的累加。两条都算出来和target比较。
这里有个小细节:行和列检查的顺序可以交换,但把行和列放在对角线前面通常更高效。因为行和列用的是O(1)前缀和查询,而对角线是O(k)累加。如果行或列已经不等,可以最快速返回,避免做后面更贵的对角线累加。
4.3 完整Python实现与注释
花点时间把这个实现写完整,你可以直接跑LeetCode用例验证。
python复制from typing import List
class Solution:
def largestMagicSquare(self, grid: List[List[int]]) -> int:
m, n = len(grid), len(grid[0])
# 行前缀和:row_pre[i][j] 表示第 i 行前 j 个元素之和
row_pre = [[0] * (n + 1) for _ in range(m)]
for i in range(m):
for j in range(n):
row_pre[i][j + 1] = row_pre[i][j] + grid[i][j]
# 列前缀和:col_pre[i][j] 表示第 j 列前 i 个元素之和
col_pre = [[0] * n for _ in range(m + 1)]
for i in range(m):
for j in range(n):
col_pre[i + 1][j] = col_pre[i][j] + grid[i][j]
def check(x: int, y: int, k: int) -> bool:
# 基准值取第一行的和
target = sum(grid[x][y + i] for i in range(k))
# 逐行检查
for i in range(x, x + k):
if row_pre[i][y + k] - row_pre[i][y] != target:
return False
# 逐列检查
for j in range(y, y + k):
if col_pre[x + k][j] - col_pre[x][j] != target:
return False
# 主对角线
diag1 = 0
for i in range(k):
diag1 += grid[x + i][y + i]
if diag1 != target:
return False
# 副对角线
diag2 = 0
for i in range(k):
diag2 += grid[x + i][y + k - 1 - i]
return diag2 == target
for k in range(min(m, n), 0, -1):
for i in range(m - k + 1):
for j in range(n - k + 1):
if check(i, j, k):
return k
return 1
4.4 C++实现:注意维度和下标
C++写法和Python逻辑完全一致,但有几个容易踩的类型和下标细节单独说明。首先是vector的维度:rowPre是m行、n+1列;colPre是m+1行、n列。构建时注意内层循环的顺序不要写反,否则colPre会越界。
cpp复制class Solution {
public:
int largestMagicSquare(vector<vector<int>>& grid) {
int m = grid.size(), n = grid[0].size();
vector<vector<int>> rowPre(m, vector<int>(n + 1, 0));
vector<vector<int>> colPre(m + 1, vector<int>(n, 0));
for (int i = 0; i < m; ++i) {
for (int j = 0; j < n; ++j) {
rowPre[i][j + 1] = rowPre[i][j] + grid[i][j];
}
}
for (int j = 0; j < n; ++j) {
for (int i = 0; i < m; ++i) {
colPre[i + 1][j] = colPre[i][j] + grid[i][j];
}
}
auto check = [&](int x, int y, int k) -> bool {
int target = 0;
for (int j = 0; j < k; ++j) {
target += grid[x][y + j];
}
for (int i = 0; i < k; ++i) {
if (rowPre[x + i][y + k] - rowPre[x + i][y] != target) {
return false;
}
}
for (int j = 0; j < k; ++j) {
if (colPre[x + k][y + j] - colPre[x][y + j] != target) {
return false;
}
}
int diag1 = 0, diag2 = 0;
for (int i = 0; i < k; ++i) {
diag1 += grid[x + i][y + i];
diag2 += grid[x + i][y + k - 1 - i];
}
return diag1 == target && diag2 == target;
};
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) {
if (check(i, j, k)) {
return k;
}
}
}
}
return 1;
}
};
4.5 复杂度分析:很多人会误算,这里说清楚
很多题解会笼统写“O(mnmin(m,n))”,这其实是个方便的估算,但并不严格。严格一点说,外层k从1枚举到min(m,n),对每个k有(m-k+1)(n-k+1)个左上角,每个check的开销是O(k),所以总复杂度是:
sum_{k=1}^{min(m,n)} k * (m-k+1) * (n-k+1)
当m=n=N时,这个式子大约是O(N^4)。但别被N^4吓到,因为这题的N最大只有50,算下来实际有效操作量只有几十万次,加上常数也就是一两百万次循环,Python完全能跑过。
空间复杂度是O(mn),因为row_pre是(m×(n+1)),col_pre是((m+1)×n),加起来是O(mn)。
5. 常见问题与排查技巧:我提交时踩过的那些坑
5.1 最容易错的地方:把行前缀和和列前缀和搞混
行前缀和的维度是m×(n+1),索引方式是row_pre[行号][列前缀长度];列前缀和的维度是(m+1)×n,索引方式是col_pre[行前缀长度][列号]。我第一遍写的时候把两个表的下标都写成了row_pre[i][j],结果不是越界就是答案错误。
这里分享一个我自己的检查方法:写完之后,找一个小矩阵手动模拟一次。比如grid = [[5, 1, 3, 1], [9, 3, 8, 1], [1, 3, 7, 1]],你手动算一遍row_pre和col_pre的值,再对着代码跑一遍,两个表一对比就知道哪个下标写错了。
5.2 target千万别设成左上角元素
我见过有人把基准值设成grid[x][y](左上角的元素),然后要求所有行、列、对角线和都等于这个元素。这显然不对,幻方要求的是“行和、列和、对角线和相等”,不是“等于矩阵中某个位置的值”。target应该是第一行所有元素的和,不是一个元素的值。
5.3 check里别用切片或者sum重算整行
Python里写sum(grid[i][y:y+k])在功能上没错,但这是O(k)的操作,k行加起来就是O(k^2),直接把前缀和的优势抵消掉了。遇到这种写法,面试官大概率会追问“你的复杂度到底是多少”。正确做法是用row_pre表:row_pre[i][y+k] - row_pre[i][y],这才是O(1)。
5.4 为什么k=1的兜底return 1不能省
如果主循环写成for k in range(min(m, n), 1, -1),那k=1的情况根本不会被检查。虽然k=1永远是幻方,但函数必须有返回值。我建议主循环保留k=1的迭代,或者最后加一句return 1兜底,两种方式都可以,但绝不能既不让循环到1又没有兜底,否则编译器会提示“没有返回值”或者行为未定义。
5.5 关于从左到右还是从右到左遍历左上角
从代码逻辑上讲,遍历左上角的顺序完全不影响正确性,因为check函数是纯函数,不依赖上次结果。但从缓存性能角度,我习惯让内层循环按行方向连续移动,也就是j作为最内层循环,这样访问grid时地址更连续,实际跑起来会快一点点。这是微优化,但养成习惯没坏处。
5.6 想提速还可以加个小剪枝
因为k是从大到小枚举的,一旦找到第一个可行解就返回,这本身已经是最重要的剪枝。此外,还可以在check之前先快速判断第一行和是否等于最后一行和、第一列和是否等于最后一列和,如果不相等直接跳过。这个剪枝在数据集较大的时候能省掉一部分对角线累加,但对50的规模影响不大,可加可不加。
6. 从这题往外看:一类“枚举+验证”的矩阵题套路
6.1 这题到底在练什么核心能力
说到底,LeetCode 1895考的是两件事:一是能不能把“幻方”这个数学概念准确转译成程序里的条件判断,二是知不知道用前缀和把频繁查询的区间和变成O(1)查表。
把这两点吃透,类似题目“二维区域和检索 - 矩阵不可变”(304)、“矩阵区域和”(1314)都会有豁然开朗的感觉。它们的底层都是同一套前缀和思想,区别只是查询的条件和频率不同。
6.2 与“最大正方形”系题目的对比
LeetCode上还有一类的“最大正方形”(221),那个题是在0/1矩阵里找全为1的最大正方形,用的是动态规划。和本题相比,同样是“找最大正方形”,但一个是根据动态规划状态转移,一个是暴力枚举加验证。
这说明一个很重要的点:看到“最大”两个字,不要条件反射就觉得是DP。如果验证一个答案的成本不高,从大到小枚举答案并快速验证,往往比推导状态转移方程更直接。本题就是“二分/枚举答案 + 验证”这一思路的典型案例。
6.3 热榜里其他题的横向联想
最近刷题热榜上经常和1895一起出现的还有“目标和”(LeetCode 494)和“5. 最长回文子串”。这几题放在一起看很有意思:“目标和”用的是DFS+备忘录,“最长回文子串”用的是中心扩展或DP,而本题用的是前缀和+枚举验证。看起来解法完全不同,但本质上都是同一件事:明确状态(哪个位置、多长、什么条件),然后用某种方式快速判断可行性。
碰到这类题,我个人的习惯是先不要急着套模板,先把题目要验证的条件写清楚,再想怎么优化验证的速度。这道题如果你能自己推导出行前缀和+列前缀和的组合方案,那说明对“区间和查询”的理解已经过关了。
7. 实操总结:这道题的最好打开方式
我刷这类“中等题”的建议是:不要只看题解就觉得自己会了。第一遍可以先用暴力写法提交一次,看看数据规模下能不能过;然后再用前缀和优化重写一遍,对比两次代码的check函数差别。这个过程比直接看三遍题解都有用。
一个小技巧是,每次写完前缀和题目,都手动构造一个极小的例子,比如2×2或3×3的矩阵,把row_pre、col_pre、check的每一步都手算一遍,和程序输出对照。这能帮你把“前缀和的下标偏移”这种细节彻底钉死在脑子里。
最后再分享一个心得:这道题我第一次做的时候,把复杂度误算成O(n^3),后来仔细想了才发现严格是O(n^4),只是因为N只有50所以没有影响。以后在面试里讲复杂度,建议先给出精确的求和式,再说“本题数据范围下可接受”,这样比直接甩一个大O更有说服力,面试官也更喜欢听到这种有量化意识的答案。
