点击蓝色“五分钟学算法”关注我哟
加个“星标”,天天中午 12:15,一起学算法
作者 | P.yh
来源 | 五分钟学算法
上一次 解释了动态规划的一些基本特性和解题思路,也说了动态规划其实就是记住之前问题的答案,然后利用之前问题的答案来分析并解决当前问题,这里面有两个非常重要的步骤,就是 拆解问题 和 定义状态。
这次来针对具体的一类动态规划问题,矩阵类动态规划问题,来看看针对这一类问题的思路和注意点。
矩阵类动态规划,也可以叫做坐标类动态规划,一般这类问题都会给你一个矩阵,矩阵里面有着一些信息,然后你需要根据这些信息求解问题。
其实 矩阵可以看作是图的一种,怎么说?你可以把整个矩阵当成一个图,矩阵里面的每个位置上的元素当成是图上的节点,然后每个节点的邻居就是其相邻的上下左右的位置,我们遍历矩阵其实就是遍历图,在遍历的过程中会有一些临时的状态,也就是子问题的答案,我们记录这些答案,从而推得我们最后想要的答案。
一般来说,在思考这类动态规划问题的时候,我们只需要思考当前位置的状态,然后试着去看当前位置和它邻居的递进关系,从而得出我们想要的递推方程,这一类动态规划问题,相对来说比较简单,我们通过几道例题来熟悉一下。
LeetCode 第 62 号问题:不同路径。
题目描述
一个机器人位于一个 m x n 网格的左上角 (起始点在下图中标记为“Start” )。
机器人每次只能向下或者向右移动一步。机器人试图达到网格的右下角(在下图中标记为“Finish”)。
问总共有多少条不同的路径?
例如,上图是一个7 x 3 的网格。有多少可能的路径?
说明: m 和 n 的值均不超过 100。
示例 1:
输入: m = 3, n = 2
输出: 3
解释:
从左上角开始,总共有 3 条路径可以到达右下角。
1. 向右 -> 向右 -> 向下
2. 向右 -> 向下 -> 向右
3. 向下 -> 向右 -> 向右
示例 2:
输入: m = 7, n = 3
输出: 28
题目解析
给定一个矩阵,问有多少种不同的方式从起点(0,0) 到终点 (m-1,n-1),并且每次移动只能向右或者向下,我们还是按之前提到的分析动态规划那四个步骤来思考一下:
-
问题拆解
题目中说了,每次移动只能是向右或者是向下,矩阵类动态规划需要关注当前位置和其相邻位置的关系,对于某一个位置来说,经过它的路径只能从它上面过来,或者从它左边过来,因此,如果需要求到达当前位置的不同路径,我们需要知道到达其上方位置的不同路径,以及到达其左方位置的不同路径
-
状态定义
矩阵类动态规划的状态定义相对来说比较简单,只需要看当前位置即可,问题拆解中,我们分析了当前位置和其邻居的关系,提到每个位置其实都可以算做是终点,状态表示就是 “从起点到达该位置的不同路径数目”
-
递推方程
有了状态,也知道了问题之间的联系,其实递推方程也出来了,就是
dp[i][j] = dp[i - 1][j] + dp[i][j - 1]
-
实现
有了这些,这道题还没完,我们还要考虑状态数组的初始化问题,对于上边界和左边界的点,因为它们只能从一个方向过来,需要单独考虑,比如上边界的点只能从左边这一个方向过来,左边界的点只能从上边这一个方向过来,它们的不同路径个数其实就只有 1,提前处理就好。
参考代码
//www.cxyxiaowu.com
public int uniquePaths(int m, int n) {
int[][] dp = new int[m][n];
for (int i = 0; i < m; ++i) {
dp[i][0] = 1;
}
for (int j = 0; j < n; ++j) {
dp[0][j] = 1;
}
for (int i = 1; i < m; ++i) {
for (int j = 1; j < n; ++j) {
dp[i][j] = dp[i - 1][j] + dp[i][j - 1];
}
}
return dp[m - 1][n - 1];
}
LeetCode 第 63 号问题:不同路径II
题目描述
一个机器人位于一个 m x n 网格的左上角 (起始点在下图中标记为“Start” )。
机器人每次只能向下或者向右移动一步。机器人试图达到网格的右下角(在下图中标记为“Finish”)。
现在考虑网格中有障碍物。那么从左上角到右下角将会有多少条不同的路径?
网格中的障碍物和空位置分别用 1 和 0 来表示。
说明:m 和 n 的值均不超过 100。
示例 1:
输入:
[
[0,0,0],
[0,1,0],
[0,0,0]
]
输出: 2
解释:
3x3 网格的正中间有一个障碍物。
从左上角到右下角一共有 2 条不同的路径:
1. 向右 -> 向右 -> 向下 -> 向下
2. 向下 -> 向下 -> 向右 -> 向右
题目解析
在上面那道题的基础上,矩阵中增加了障碍物,这里只需要针对障碍物进行判断即可,如果当前位置是障碍物的话,状态数组中当前位置记录的答案就是 0,也就是没有任何一条路径可以到达当前位置,除了这一点外,其余的分析方法和解题思路和之前 一样 。
参考代码
public int uniquePathsWithObstacles(int[][] obstacleGrid) {
if (obstacleGrid.length == 0 || obstacleGrid[0].length == 0) {
return 0;
}
if (obstacleGrid[0][0] == 1) {
return 0;
}
int m = obstacleGrid.length, n = obstacleGrid[0].length;
int[][] dp = new int[m][n];
dp[0][0] = 1;
for (int i = 1; i < m; ++i) {
dp[i][0] = obstacleGrid[i][0] == 1 ? 0 : dp[i - 1][0];
}
for (int i = 1; i < n; ++i) {
dp[0][i] = obstacleGrid[0][i] == 1 ? 0 : dp[0][i - 1];
}
for (int i = 1; i < m; ++i) {
for (int j = 1; j < n; ++j) {
dp[i][j] = obstacleGrid[i][j] == 1 ? 0 : dp[i - 1][j] + dp[i][j - 1];
}
}
return dp[m - 1][n - 1];
}
LeetCode 第 64 号问题:最小路径和
题目描述
给定一个包含非负整数的 m x n 网格,请找出一条从左上角到右下角的路径,使得路径上的数字总和为最小。
说明:每次只能向下或者向右移动一步。
示例:
输入:
[
[1,3,1],
[1,5,1],
[4,2,1]
]
输出: 7
解释: 因为路径 1→3→1→1→1 的总和最小。
题目解析
给定一个矩阵,问从起点(0,0) 到终点 (m-1,n-1) 的最小路径和是多少,并且每次移动只能向右或者向下,按之四个步骤来思考一下:
-
问题拆解
拆解问题的方式方法和前两道题目非常类似,这里不同的地方只是记录的答案不同,也就是状态不同,我们还是可以仅仅考虑当前位置,然后可以看到只有上面的位置和左边的位置可以到达当前位置,因此当前问题就可以拆解成两个子问题
-
状态定义
因为是要求路径和,因此状态需要记录的是 “从起始点到当前位置的最小路径和”
-
递推方程
有了状态,以及问题之间的联系,我们知道了,当前的最短路径和可以由其上方和其左方的最短路径和对比得出,递推方程也可以很快写出来:
dp[i][j] = Math.min(dp[i - 1][j] + dp[i][j - 1]) + grid[i][j]
-
实现
实现上面需要重点考虑的还是状态数组的初始化,这一步还是和前面两题类似,这里就不过多赘述
参考代码
public int minPathSum(int[][] grid) {
int m = grid.length, n = grid[0].length;
int[][] dp = new int[m][n];
dp[0][0] = grid[0][0];
for (int i = 1; i < m; ++i) {
dp[i][0] = dp[i - 1][0] + grid[i][0];
}
for (int i = 1; i < n; ++i) {
dp[0][i] = dp[0][i - 1] + grid[0][i];
}
for (int i = 1; i < m; ++i) {
for (int j = 1; j < n; ++j) {
dp[i][j] = Math.min(dp[i - 1][j], dp[i][j - 1]) + grid[i][j];
}
}
return dp[m - 1][n - 1];
}
LeetCode 第 221 号问题:最大正方形。
题目描述
在一个由 0 和 1 组成的二维矩阵内,找到只包含 1 的最大正方形,并返回其面积。
示例:
输入:
1 0 1 0 0
1 0 1 1 1
1 1 1 1 1
1 0 0 1 0
输出: 4
题目解析
题目给定一个字符矩阵,字符矩阵中只有两种字符,分别是 ‘0’ 和 ‘1’,题目要在矩阵中找全为 ‘1’ 的,面积最大的正方形。
刚拿道这道题,如果不说任何解法的话,其实并不是特别好想,我们先来看看切题的思路是怎么样的。
首先一个正方形是由四个顶点构成的,如果说我们在矩阵中随机找四个点,然后判断该四个点组成的是不是正方形,如果是正方形,然后看组成正方形的每个位置的元素是不是都是 ‘1’,这种方式也是可行的,但是比较暴力,这么弄下来,时间复杂度是 O((m*n)^4)
。
那我们就会思考,组成一个正方形是不是必须要四个点都找到?如果我们找出其中的三个点,甚至说两个点,能不能确定这个正方形呢?
你会发现,这里我们只需要考虑 正方形对角线的两个点 即可,这两个点确定了,另外的两个点也就确定了,因此我们可以把时间复杂度降为 O((m*n)^2)
。
但是这里还是会有一些重复计算在里面,我们和之前一样,本质还是在做暴力枚举,只是说枚举的个数变少了,我们能不能记录我们之前得到过的答案,通过牺牲空间换取时间呢,这里正是动态规划所要做的事情!
-
问题拆解
我们可以思考,如果我们从左到右,然后从上到下遍历矩阵,假设我们遍历到的当前位置是正方形的右下方的点,那其实我们可以看之前我们遍历过的点有没有可能和当前点组成符合条件的正方形,除了这个点以外,无非是要找另外三个点,这三个点分别在当前点的上方,左方,以及左上方,也就是从这个点往这三个方向去做延伸,具体延伸的距离是和其相邻的三个点中的状态有关
-
状态定义
因为我们考虑的是正方形的右下方的顶点,因此状态可以定义成 “当前点为正方形的右下方的顶点时,正方形的最大面积”
-
递推方程
有了状态,我们再来看看递推方程如何写,前面说到我们可以从当前点向三个方向延伸,我们看相邻的位置的状态,这里我们需要取三个方向的状态的最小值才能确保我们延伸的是全为 ‘1’ 的正方形,也就是
dp[i][j] = Math.min(dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1]) + 1
-
实现
在实现上,我们需要单独考虑两种情况,就是当前位置是 ‘1’,还有就是当前位置是 ‘0’,如果是 ‘0’ 的话,状态就是 0,表示不能组成正方形,如果是 ‘1’ 的话,我们也需要考虑位置,如果是第一行的元素,以及第一列的元素,表明该位置无法同时向三个方向延伸,状态直接给为 1 即可,其他情况就按我们上面得出的递推方程来计算当前状态。
参考代码
public int maximalSquare(char[][] matrix) {
if (matrix.length == 0 || matrix[0].length == 0) {
return 0;
}
int m = matrix.length, n = matrix[0].length;
int[][] dp = new int[m][n];
int maxLength = 0;
for (int i = 0; i < m; ++i) {
for (int j = 0; j < n; ++j) {
if (matrix[i][j] == '1') {
if (i == 0 || j == 0) {
dp[i][j] = matrix[i][j] == '1' ? 1 : 0;
} else {
dp[i][j] = Math.min(dp[i - 1][j],
Math.min(dp[i][j - 1], dp[i - 1][j - 1])) + 1;
}
maxLength = Math.max(dp[i][j], maxLength);
}
}
}
return maxLength * maxLength;
}
对于矩阵类的动态规划,相对来说比较简单,这一类动态规划也比较好识别,一般输入的参数就是一个矩阵,解题的时候,我们只需要从当前位置出发考虑状态即可,通常来说当前位置的状态的求解仅仅需要借助其相邻位置的状态,通常我们也不需要考虑非常隐蔽的边界条件,一般需要做的初始化操作都可以从矩阵中,以及题目中的信息得出。
戳一下下方的小程序,24 小时一起学算法
原文始发于微信公众号(五分钟学算法):(进阶版)有了四步解题法模板,再也不害怕动态规划!