在这篇文章里,通过一道经典题目 Maximal Square(最大正方形),系统梳理这道题的题意、思路演化、核心 DP 转移公式,以及如何直观理解那句"看左、看上、看左上,取最小再加一"。algo+1
题目描述
给定一个 m x n 的二维二进制矩阵 matrix,其中每个元素要么是字符 '0',要么是字符 '1':leetcode
要求在这个矩阵中找到只包含 '1' 的 最大正方形子矩阵
返回这个正方形的 面积(即边长的平方)
示例: sparkcodehub+1
matrix = [["1","0","1","0","0"],["1","0","1","1","1"],["1","1","1","1","1"],["1","0","0","1","0"]]
最大正方形边长为 2,面积为 4
matrix = [["0","1"],["1","0"]]
最大正方形边长为 1,面积为 1
matrix = [["0"]]
没有全为 1 的正方形,面积为 0
**约束:**1 <= m, n <= 300,matrix[i][j] 只能是 '0' 或 '1'。leetcode
直观想法与暴力做法
最直接的想法是"穷举所有可能的正方形":vultr+1
- 枚举左上角 (i, j)
- 枚举边长 len,检查以 (i, j) 为左上角、边长为 len 的正方形是不是都为 '1'
- 更新最大面积
问题在于:
- 每个起点要尝试很多边长
- 每个正方形又要检查一堆格子
- 时间复杂度最坏会接近 O(m²n²)O(m^2 n^2)O(m2n2),在 300 x 300 的数据范围内非常吃力。jointaro+1
于是需要一种方法,把"重复的检查"变成"可复用的状态"------这就是动态规划登场的动机。finalroundai+1
动态规划建模:状态与含义
核心问题:怎么定义一个状态,能帮我们逐步放大正方形? geeksforgeeks+1
非常常见、也是这题最经典的定义是:
令 dp[i][j] 表示:
以 matrix[i][j] 为 右下角 的、全是 '1' 的最大正方形的 边长 。algo+1
注意几点:
- 只考虑以 (i, j) 为 右下角 的正方形,而不是任意包含 (i, j) 的正方形
- dp[i][j] 记录的是 边长(如 0、1、2、3...),不是面积
- 最终答案是所有 dp[i][j] 中的最大值 maxSide 的平方 maxSide * maxSide。sparkcodehub+1
对于某个位置 (i, j):
- 如果 matrix[i][j] == '0',那它不可能是任何全 1 正方形的右下角,dp[i][j] = 0。algo+1
- 如果 matrix[i][j] == '1',才有机会构成更大的正方形。sparkcodehub+1
为什么要看"上、左、左上"三个方向?
这是很多人第一次接触这题时最疑惑的地方:
为啥转移公式是
dp[i][j] = min(dp[i-1][j], dp[i][j-1], dp[i-1][j-1]) + 1
要看上、左、左上这三个?为什么取最小?finalroundai+1
从 2x2 开始:四个格子全为 1
先从一个最小的非平凡情况------边长为 2 的正方形------想起。finalroundai+1
假设我们想让 (i, j) 成为一个 2x2 正方形 的右下角,这个正方形的四个格子是:
- (i-1, j-1) 左上
- (i-1, j) 上
- (i, j-1) 左
- (i, j) 自己
要这四个格子全为 '1',(i, j) 本身必须是 1,并且上、左、左上三个也得是 1。algo+1
这里其实已经在暗示:
- 想要在 (i, j) 形成一个更大的正方形,离不开它周围那三个格子
- 这三个方向里,只要有一个"不够长",整个正方形就被"卡住"了
放大到任意边长 k:积木叠起来
现在把这个想法推广到边长为 k 的情况。geeksforgeeks+1
想象一下:
- 以 (i, j) 为右下角的边长为 k 的正方形
- 它的"上一行"其实是一个以 (i-1, j) 为右下角的、边长至少为 k-1 的正方形"再往下扩一行"
- 它的"左一列"其实是一个以 (i, j-1) 为右下角的、边长至少为 k-1 的正方形"再往右扩一列"
- 它的"左上内部"其实是一个以 (i-1, j-1) 为右下角的、边长至少为 k-1 的正方形finalroundai+1
如果把正方形想象成一块块小积木垒出来的,那么:
- dp[i-1][j] 告诉你:正上方那块区域,最多能形成多大的"正方形塔"
- dp[i][j-1] 告诉你:正左方那块区域,最多能形成多大的"正方形塔"
- dp[i-1][j-1] 告诉你:左上那块区域,最多能形成多大的"正方形塔"algo+1
要在 (i, j) 再往外扩一圈,三边都得"跟得上",不然长出来的形状要么缺角,要么变矩形。finalroundai+1
所以:
- 这三者中"最短"的那一条,决定了你最多还能往外扩多少
- 因此取
min(dp[i-1][j], dp[i][j-1], dp[i-1][j-1])作为"瓶颈值",再在 (i, j) 这个 1 的基础上 +1。algo+1
于是得到了经典转移方程:
若 matrix[i][j] == '1':
dp[i][j] = min(dp[i-1][j], dp[i][j-1], dp[i-1][j-1]) + 1
若 matrix[i][j] == '0':
dp[i][j] = 0
这一点在很多题解中都有类似表述:"当前格子的最大正方形边长,受限于上方、左方和左上方三个位置的最小值"。finalroundai+1
边界处理:第一行、第一列怎么搞?
转移公式里用了 i-1 和 j-1,要小心下标越界问题。leetcode+1
常见做法有两种:
-
直接用大小为 m x n 的 dp 数组
第一行、第一列单独处理:
- 如果 matrix[i][j] == '1',则 dp[i][j] = 1(因为只能形成边长 1 的小正方形)
- 从第二行、第二列开始再用通用转移公式。zhenyu0519.github+1
-
用大小为 (m+1) x (n+1) 的 dp 数组
令 dp[i+1][j+1] 对应 matrix[i][j]
这样 dp[0][] 和 dp[][0] 都是 0,天然当作"越界之外",写转移时就可以统一写成:
dp[i+1][j+1] = min(dp[i][j+1], dp[i+1][j], dp[i][j]) + 1不需要任何特别的 if 判断,代码更简洁。sparkcodehub+1
无论哪种方式,本质上都是为了解决"如何从边界开始递推"的工程问题,与 DP 思想本身无关。leetcode+1
算法步骤小结
整体流程可以概括为:sparkcodehub+1
- 如果矩阵为空,直接返回 0
- 初始化 dp 表(m x n 或 (m+1) x (n+1) 二选一)
- 用一个变量 maxSide 记录遍历过程中遇到的最大边长
- 遍历每个 matrix[i][j]:
- 若 matrix[i][j] == '0', dp[i][j] = 0
- 若 matrix[i][j] == '1',按转移式计算:
dp[i][j] = min(上,左,左上) + 1 - 同时用 maxSide = max(maxSide, dp[i][j])
- 遍历结束后返回 maxSide * maxSide 作为最终答案
时间复杂度为 O(mn),空间复杂度为 O(mn),也可以通过滚动数组或一维 DP 优化到 O(n) 空间。zhenyu0519.github+1
小例子手推,加深"min + 1"理解
以前面的示例矩阵为例:upe-profdev.gitlab+1
1 0 1 0 0
1 0 1 1 1
1 1 1 1 1
1 0 0 1 0
构造同大小的 dp 表(这里省略中间所有步骤,只看关键位置):designgurus+1
在 (2, 2) 这个格子(值为 '1' 的那个位置)计算 dp[2][2] 时:
- 它的上、左、左上三个 dp 值分别是 1, 1, 1
- 说明在这三个方向上,当前都至少能有边长 1 的小正方形
- 所以可以在这个基础上向外扩一圈,形成边长为 1 + 1 = 2 的正方形
- 得到 dp[2][2] = 2。designgurus+1
再往右扩,某些位置的三个邻居可能是 2, 2, 1,那最小值是 1,只能扩到 1 + 1 = 2;如果某个方向已经是 0,则最小值是 0,只能扩到 1,甚至更小。upe-profdev.gitlab+1
这正是"被最短的那一条边限制住"的直观体现。upe-profdev.gitlab+1
写在最后:如何从这题迁移到别的 DP 题?
这道题的价值不只是写出一个通过的答案,更重要的是:习惯用"以谁为结尾/为右下角"来定义状态。hellointerview+1
这里有几个可以继续思考和练习的方向:
-
能否自己实现一维滚动数组版本,把空间压到 O(n)?sparkcodehub+1
-
能否类比这题,去看"最大矩形(Maximal Rectangle)"、"最大全 1 子矩阵"等题?sparkcodehub+1
-
能否把"上、左、左上取 min + 1"这个模式,总结成一种"从相邻子问题扩张目标形状"的通用套路?hellointerview+1