LeetCode 221. 最大正方形:两种解法吃透动态规划与暴力枚举

27 阅读7分钟

在LeetCode的数组与矩阵类题目中,221. 最大正方形是一道经典的中等难度题目,核心考察对二维矩阵的遍历、边界处理,以及动态规划思想的应用。题目要求在由 '0' 和 '1' 组成的二维矩阵内,找到只包含 '1' 的最大正方形,并返回其面积。今天我们就来拆解这道题,详细分析两种主流解法——暴力枚举法和动态规划法,帮你彻底吃透这道题的解题逻辑。

一、题目核心解读

先明确题目关键信息,避免踩坑:

  • 矩阵元素仅为 '0' 或 '1',正方形必须完全由 '1' 组成,不能包含任何 '0';

  • 返回值是最大正方形的面积,而非边长(边长的平方即为面积);

  • 边界情况:矩阵为空、只有一行/一列、全为 '0' 时,最大正方形面积为 0;全为 '1' 时,面积为矩阵最小边长的平方。

举个简单例子:若矩阵为 [["1","0"],["1","1"]],最大正方形是右下角的 2x2 正方形(边长为2),面积为4。

二、解法一:暴力枚举法(直观易懂,适合入门)

2.1 解题思路

暴力枚举的核心思路是:遍历矩阵中的每一个 '1',将其作为正方形的左上角顶点,然后逐步扩大正方形的边长,判断扩大后的正方形是否全为 '1',记录最大的有效边长,最终计算面积。

具体步骤拆解:

  1. 先判断矩阵是否为空,若为空直接返回 0;

  2. 遍历矩阵的每一个元素(i,j),当遇到 '1' 时,说明可以作为正方形的左上角;

  3. 确定当前顶点能构成的最大可能边长(受限于矩阵的剩余行数和列数,即 currentMaxSide = min(矩阵行数 - i, 矩阵列数 - j));

  4. 从边长 1 开始,逐步扩大到 currentMaxSide,每次扩大后,判断新增的一行和一列是否全为 '1';

  5. 若扩大后仍全为 '1',则更新最大边长 maxSide;若出现 '0',则停止当前顶点的扩大(因为更大的边长必然包含 '0');

  6. 遍历结束后,返回 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. 最大正方形的两种解法,分别对应了“暴力枚举”和“动态规划”两种核心思想:

  1. 暴力枚举适合新手入门,能帮助理解题目本质,但效率较低,不适合大规模数据;

  2. 动态规划是最优解法,通过定义 dp 数组和递推公式,将时间复杂度优化到 O(m*n),是面试中推荐使用的解法,同时可通过空间优化进一步降低内存消耗。

建议大家先手动推导动态规划的递推过程,再结合代码练习,重点掌握 dp 数组的定义和边界处理,这样既能吃透这道题,也能为后续解决类似的矩阵动态规划题目打下基础。