在LeetCode的数组与矩阵类题目中,221. 最大正方形是一道经典的中等难度题目,核心考察对二维矩阵的遍历、边界处理,以及动态规划思想的应用。题目要求在由 '0' 和 '1' 组成的二维矩阵内,找到只包含 '1' 的最大正方形,并返回其面积。今天我们就来拆解这道题,详细分析两种主流解法——暴力枚举法和动态规划法,帮你彻底吃透这道题的解题逻辑。
一、题目核心解读
先明确题目关键信息,避免踩坑:
-
矩阵元素仅为 '0' 或 '1',正方形必须完全由 '1' 组成,不能包含任何 '0';
-
返回值是最大正方形的面积,而非边长(边长的平方即为面积);
-
边界情况:矩阵为空、只有一行/一列、全为 '0' 时,最大正方形面积为 0;全为 '1' 时,面积为矩阵最小边长的平方。
举个简单例子:若矩阵为 [["1","0"],["1","1"]],最大正方形是右下角的 2x2 正方形(边长为2),面积为4。
二、解法一:暴力枚举法(直观易懂,适合入门)
2.1 解题思路
暴力枚举的核心思路是:遍历矩阵中的每一个 '1',将其作为正方形的左上角顶点,然后逐步扩大正方形的边长,判断扩大后的正方形是否全为 '1',记录最大的有效边长,最终计算面积。
具体步骤拆解:
-
先判断矩阵是否为空,若为空直接返回 0;
-
遍历矩阵的每一个元素(i,j),当遇到 '1' 时,说明可以作为正方形的左上角;
-
确定当前顶点能构成的最大可能边长(受限于矩阵的剩余行数和列数,即 currentMaxSide = min(矩阵行数 - i, 矩阵列数 - j));
-
从边长 1 开始,逐步扩大到 currentMaxSide,每次扩大后,判断新增的一行和一列是否全为 '1';
-
若扩大后仍全为 '1',则更新最大边长 maxSide;若出现 '0',则停止当前顶点的扩大(因为更大的边长必然包含 '0');
-
遍历结束后,返回 maxSide 的平方(即最大面积)。
2.2 完整代码(TypeScript)
function maximalSquare_1(matrix: string[][]): number {
let maxSide = 0;
if (matrix === null || matrix.length === 0 || matrix[0].length === 0) {
return maxSide;
}
let rows = matrix.length, columns = matrix[0].length;
for (let i = 0; i < rows; i++) {
for (let j = 0; j < columns; j++) {
if (matrix[i][j] === '1') {
// 遇到一个 1 作为正方形的左上角
maxSide = Math.max(maxSide, 1);
// 计算可能的最大正方形边长
let currentMaxSide = Math.min(rows - i, columns - j);
for (let k = 1; k < currentMaxSide; k++) {
// 判断新增的一行一列是否均为 1
let flag = true;
if (matrix[i + k][j + k] === '0') {
break;
}
for (let m = 0; m < k; m++) {
if (matrix[i + k][j + m] === '0' || matrix[i + m][j + k] === '0') {
flag = false;
break;
}
}
if (flag) {
maxSide = Math.max(maxSide, k + 1);
} else {
break;
}
}
}
}
}
let maxSquare = maxSide * maxSide;
return maxSquare;
};
2.3 优缺点分析
优点:思路直观,无需复杂的动态规划推导,容易理解和实现,适合刚接触矩阵类题目的新手;
缺点:时间复杂度较高,为 O(m*n*min(m,n))(m 为矩阵行数,n 为矩阵列数),因为嵌套了三层循环,当矩阵规模较大时(如 1000x1000),会出现超时问题,仅适合小规模矩阵。
三、解法二:动态规划法(最优解法,高效简洁)
暴力枚举的痛点是重复判断,而动态规划可以通过“记录子问题的解”,避免重复计算,将时间复杂度优化到 O(m*n),是这道题的最优解法。
3.1 动态规划核心思路
首先定义 dp 数组的含义:dp[i][j] 表示以 (i,j) 为右下角顶点的最大正方形的边长。
为什么定义右下角?因为正方形的右下角顶点,能关联到其上方、左侧、左上方三个相邻顶点的 dp 值,进而推导出当前顶点的最大边长。
推导递推公式:
-
当 matrix[i][j] 为 '0' 时,dp[i][j] = 0(因为包含 '0' 无法构成正方形);
-
当 matrix[i][j] 为 '1' 时,分两种情况:
-
若 i=0 或 j=0(矩阵第一行或第一列),dp[i][j] = 1(因为只能构成边长为 1 的正方形);
-
若 i>0 且 j>0,dp[i][j] = min(dp[i-1][j], dp[i][j-1], dp[i-1][j-1]) + 1。
-
递推公式解读:以 (i,j) 为右下角的正方形,其最大边长受限于三个相邻正方形的最大边长——上方(i-1,j)、左侧(i,j-1)、左上方(i-1,j-1),取这三个值的最小值,再加上当前的 '1'(边长加 1),就是当前顶点能构成的最大正方形边长。
举个例子:若 dp[i-1][j] = 2,dp[i][j-1] = 2,dp[i-1][j-1] = 2,那么 dp[i][j] = 3,说明以 (i,j) 为右下角的正方形边长为 3。
3.2 完整代码(TypeScript)
function maximalSquare_2(matrix: string[][]): number {
let maxSide = 0;
if (matrix == null || matrix.length == 0 || matrix[0].length == 0) {
return maxSide;
}
let rows = matrix.length, columns = matrix[0].length;
// 初始化 dp 数组,与矩阵大小一致,初始值为 0
let dp: number[][] = Array.from({ length: rows }, () => new Array(columns).fill(0));
for (let i = 0; i < rows; i++) {
for (let j = 0; j < columns; j++) {
if (matrix[i][j] == '1') {
if (i == 0 || j == 0) {
dp[i][j] = 1;
} else {
// 递推公式:取三个相邻 dp 值的最小值 + 1
dp[i][j] = Math.min(Math.min(dp[i - 1][j], dp[i][j - 1]), dp[i - 1][j - 1]) + 1;
}
// 更新最大边长
maxSide = Math.max(maxSide, dp[i][j]);
}
}
}
// 面积 = 边长的平方
let maxSquare = maxSide * maxSide;
return maxSquare;
};
3.3 优缺点分析
优点:时间复杂度 O(m*n),仅需遍历一次矩阵;空间复杂度 O(m*n)(可优化到 O(n),下文补充),效率极高,适合大规模矩阵;
缺点:需要理解 dp 数组的定义和递推公式,对动态规划思想的要求较高,新手可能需要多推导几次才能理解。
四、两种解法对比与优化技巧
4.1 核心对比
| 解法 | 时间复杂度 | 空间复杂度 | 适用场景 | 核心优势 |
|---|---|---|---|---|
| 暴力枚举法 | O(m*n*min(m,n)) | O(1) | 小规模矩阵、新手入门 | 思路直观、易于实现 |
| 动态规划法 | O(m*n) | O(m*n)(可优化为 O(n)) | 所有规模矩阵、面试最优解 | 高效简洁、无重复计算 |
4.2 动态规划空间优化技巧
观察递推公式可知,dp[i][j] 只依赖于 dp[i-1][j](上一行)、dp[i][j-1](当前行前一个)、dp[i-1][j-1](上一行前一个)三个值,因此无需存储整个 dp 二维数组,只需用一个一维数组即可优化空间。
优化思路:用 dp[j] 表示当前行的 dp 值,用一个临时变量存储 dp[i-1][j-1](上一行前一个的值),避免被覆盖。优化后空间复杂度为 O(n),代码如下(核心片段):
// 空间优化版(核心片段)
let dp: number[] = new Array(columns).fill(0);
let prev = 0; // 存储 dp[i-1][j-1] 的值
for (let i = 0; i < rows; i++) {
prev = 0; // 每一行开始时,prev 重置为 0(对应 i=0,j=0 的情况)
for (let j = 0; j < columns; j++) {
let temp = dp[j]; // 保存当前 dp[j],作为下一次的 prev
if (matrix[i][j] === '1') {
if (i === 0 || j === 0) {
dp[j] = 1;
} else {
dp[j] = Math.min(dp[j], dp[j-1], prev) + 1;
}
maxSide = Math.max(maxSide, dp[j]);
} else {
dp[j] = 0; // 当前位置为 '0',dp 值重置为 0
}
prev = temp; // 更新 prev 为当前行的前一个 dp 值
}
}
五、常见踩坑点总结
-
忽略边界情况:矩阵为空、只有一行/一列时,直接返回 0,否则会出现数组越界;
-
混淆“边长”和“面积”:最终返回的是边长的平方,而非边长,很多新手会忘记平方操作;
-
动态规划递推公式错误:误将 min 写成 max,或忘记加 1,导致结果偏小;
-
暴力枚举时,未及时 break:当新增行/列出现 '0' 时,未停止当前顶点的扩大,导致无效计算。
六、总结
LeetCode 221. 最大正方形的两种解法,分别对应了“暴力枚举”和“动态规划”两种核心思想:
-
暴力枚举适合新手入门,能帮助理解题目本质,但效率较低,不适合大规模数据;
-
动态规划是最优解法,通过定义 dp 数组和递推公式,将时间复杂度优化到 O(m*n),是面试中推荐使用的解法,同时可通过空间优化进一步降低内存消耗。
建议大家先手动推导动态规划的递推过程,再结合代码练习,重点掌握 dp 数组的定义和边界处理,这样既能吃透这道题,也能为后续解决类似的矩阵动态规划题目打下基础。