从递归入手二维DP(上)

108 阅读5分钟

前言

之前在【线性DP】栏目中发表过 从递归入手一维动态规划 的博客中,我们知道:

​ 尝试函数有1个可变参数 可以完全决定返回值,进而可以改出一维动态规划表的实现。

同理,

​ 尝试函数有2个可变参数 可以完全决定返回值,那么就可以二维动态规划的实现。

本篇文章从递归入手,详细分析二维动态规划的具体实现。

[!TIP]

一维、二维、三维甚至多维动态规划问题,大体过程都是:

1) 写出尝试递归

2) 记忆化搜索(从顶到底的动态规划)

3)严格位置依赖的动态规划(从底到顶的动态迭代)

4)考虑空间、时间的更多优化

空间优化有很多种,可以压成常数个变量、可以将二维压成一维、...,具体视情况而定。

我们先来看一个例子,体会一下二维DP。

LeetCode 64. 最小路径和

给定一个包含非负整数的 m x n 网格 grid ,请找出一条从左上角到右下角的路径,使得路径上的数字总和为最小。

**说明:**每次只能向下或者向右移动一步。

示例 1:

img

输入:grid = [[1,3,1],[1,5,1],[4,2,1]]
输出:7
解释:因为路径 13111 的总和最小。

示例 2:

输入:grid = [[1,2,3],[4,5,6]]
输出:12

提示:

  • m == grid.length
  • n == grid[i].length
  • 1 <= m, n <= 200
  • 0 <= 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(自底到顶)。

寻找依赖关系,确定状态转移方程 是难点,空间压缩的技巧千篇一律。