前言
之前在【线性DP】栏目中发表过 从递归入手一维动态规划 的博客中,我们知道:
尝试函数有1个可变参数 可以完全决定返回值,进而可以改出一维动态规划表的实现。
同理,
尝试函数有2个可变参数 可以完全决定返回值,那么就可以二维动态规划的实现。
本篇文章从递归入手,详细分析二维动态规划的具体实现。
[!TIP]
一维、二维、三维甚至多维动态规划问题,大体过程都是:
1) 写出尝试递归
2) 记忆化搜索(从顶到底的动态规划)
3)严格位置依赖的动态规划(从底到顶的动态迭代)
4)考虑空间、时间的更多优化
空间优化有很多种,可以压成常数个变量、可以将二维压成一维、...,具体视情况而定。
我们先来看一个例子,体会一下二维DP。
LeetCode 64. 最小路径和
给定一个包含非负整数的 m x n 网格 grid ,请找出一条从左上角到右下角的路径,使得路径上的数字总和为最小。
**说明:**每次只能向下或者向右移动一步。
示例 1:
输入:grid = [[1,3,1],[1,5,1],[4,2,1]]
输出:7
解释:因为路径 1→3→1→1→1 的总和最小。
示例 2:
输入:grid = [[1,2,3],[4,5,6]]
输出:12
提示:
m == grid.lengthn == grid[i].length1 <= m, n <= 2000 <= grid[i][j] <= 200
问题分析:
递归的思路一般都自顶向下,将最终的结果分解为多个中间结果,这道题最终要返回的结果是到达右下角(n-1,n-1)格子的最小路径和,我们可以记到达右下角(n-1,n-1)格子的最小路径和为f(n-1,n-1)。
现在考虑怎么将f(n-1,n-1)分解为多个中间结果(多个子问题),题目告诉我们,必须从左上角(0,0)出发,每次只能向下或者向右移动一步,所以要想到达右下角(n-1,n-1),只能从(n-2.n-1)或者(n-1,n-2)出发,如果我们能知道(n-2.n-1)、处和(n-1,n-2)处的最小路径和,那么f(n-1,n-1)自然呼之欲出了。
所以我们定义f(i,j)为从左上角(0,0)出发到达(i,j)处的最小路径和。
暴力递归解决代码:
class Solution {
public:
int minPathSum(vector<vector<int>>& grid) {
int rows = grid.size();
int columns = grid[0].size();
return f(rows - 1, columns - 1, grid);
}
int f(int i, int j, const vector<vector<int>>& grid) {
if (i == 0 && j == 0)
return grid[0][0];
else if (i == 0)
return f(i, j - 1, grid) + grid[i][j];
else if (j == 0)
return f(i - 1, j, grid) + grid[i][j];
else
return min(f(i - 1, j, grid), f(i, j - 1,grid)) + grid[i][j];
}
};
暴力递归代码一定要多写,因为暴力递归出来之后,该问题的核心解决思路就出来了,剩下的无非是进行时间和空间上的优化罢了。就像上面这段暴力递归代码,我们的状态转移表达式不就出来了嘛。
挂缓存表的递归解决代码:
class Solution {
public:
int minPathSum(vector<vector<int>>& grid) {
int rows = grid.size();
int columns = grid[0].size();
vector<vector<int>> visited(
rows,
vector<int>(columns, -1)); // grid[i][j]=-1,表示(i,j)格子还没访问过
return f(rows - 1, columns - 1, grid,visited);
}
int f(int i, int j, const vector<vector<int>>& grid,
vector<vector<int>>& visited) {
if (visited[i][j] != -1)
return visited[i][j];
else if (i == 0 && j == 0) {
visited[0][0] = grid[0][0];
return grid[0][0];
} else if (i == 0) {
visited[i][j] = f(i, j - 1, grid, visited) + grid[i][j];
return visited[i][j];
} else if (j == 0) {
visited[i][j] = f(i - 1, j, grid, visited) + grid[i][j];
return visited[i][j];
} else {
visited[i][j] =
min(f(i - 1, j, grid, visited), f(i, j - 1, grid, visited)) +
grid[i][j];
return visited[i][j];
}
}
};
尝试自底向上的动态规划解法(动态迭代)
从递归思路中,我们可以总结出状态转移方程,如下:
dp(i,j) = min(dp(i-1,j),dp(i,j-1)) + grid[i][j]
我们在获取(i,j)时需要提前获取(i-1,j),(i,j-1)格子的信息,所以格式遍历方向应该是从左往右,从上往下。只需要注意处理边界信息即可。
class Solution {
public:
int minPathSum(vector<vector<int>>& grid) {
int rows = grid.size();
int columns = grid[0].size();
vector<vector<int>> dp(rows, vector<int>(columns, 0));
dp[0][0] = grid[0][0];
for (int j = 1; j < columns; j++) {
dp[0][j] = dp[0][j - 1] + grid[0][j];
}
for (int i = 1; i < rows; i++) {
dp[i][0] = dp[i - 1][0] + grid[i][0];
}
for (int i = 1; i < rows; i++) {
for (int j = 1; j < columns; j++) {
dp[i][j] = min(dp[i - 1][j], dp[i][j - 1]) + grid[i][j];
}
}
return dp[rows - 1][columns - 1];
}
};
进行空间压缩的动态规划解法:
class Solution {
public:
int minPathSum(vector<vector<int>>& grid) {
int rows = grid.size();
int columns = grid[0].size();
vector<int> dp(columns);
dp[0] = grid[0][0];
for(int j = 1; j < columns; j++){
dp[j] = dp[j-1] + grid[0][j];
}
for(int i = 1; i < rows; i++){
for(int j = 0; j < columns; j++){
if(j == 0)
dp[j] = dp[j] + grid[i][j];
else
dp[j] = min(dp[j], dp[j-1]) + grid[i][j];
}
}
return dp[columns-1];
}
};
其实二维DP和一维DP并没有太大的区别,只是多了一个变量而已,分析问题、提取出状态转移方程仍然是难点、重点。
小总结:
DP表的大小:每个参数的可能性相乘
DP方法的时间复杂度:DP表的大小*每个格子的枚举代价
二维DP依然需要去整理 DP表的格子之间的关系,找寻依赖关系,往往 通过画图来建立空间感,使其更显而易见,然后依然是 从简单格子填写到复杂格子 的过程,即严格位置依赖的DP(自底到顶)。
寻找依赖关系,确定状态转移方程 是难点,空间压缩的技巧千篇一律。