动态规划解题套路

379 阅读14分钟

一、动态规划的三大步骤

动态规划,无非就是利用历史记录,来避免我们的重复计算。而这些历史记录,我们得需要一些变量来保存,一般是用一维数组或者二维数组来保存。下面我们先来讲下做动态规划题很重要的三个步骤:

  • 第一步骤:定义数组元素的含义,上面说了,我们会用一个数组,来保存历史数组,假设用一维数组 dp[] 吧。这个时候有一个非常非常重要的点,就是规定你这个数组元素的含义,例如你的 dp[i] 是代表什么意思?
  • 第二步骤:找出数组元素之间的关系式,我觉得动态规划,还是有一点类似于归纳法,当我们要计算 dp[n] 时,是可以利用 dp[n-1],dp[n-2].....dp[1],来推出 dp[n] 的,也就是可以利用历史数据来推出新的元素值,所以我们要找出数组元素之间的关系式,例如 dp[n] = dp[n-1] + dp[n-2],这个就是他们的关系式了。而这一步,也是最难的一步,后面我会讲几种类型的题来说。
  • 第三步骤:找出初始值。学过数学归纳法的都知道,虽然我们知道了数组元素之间的关系式,例如 dp[n] = dp[n-1] + dp[n-2],我们可以通过 dp[n-1] 和 dp[n-2] 来计算 dp[n],但是,我们得知道初始值啊,例如一直推下去的话,会由 dp[3] = dp[2] + dp[1]。而 dp[2] 和 dp[1] 是不能再分解的了,所以我们必须要能够直接获得 dp[2] 和 dp[1] 的值,而这,就是所谓的初始值。 有了初始值,并且有了数组元素之间的关系式,那么我们就可以得到 dp[n] 的值了,而 dp[n] 的含义是由你来定义的,你想求什么,就定义它是什么,这样,这道题也就解出来了。

二、案例详解

案例一、简单的一维 DP

问题描述:一只青蛙一次可以跳上1级台阶,也可以跳上2级。求该青蛙跳上一个n级的台阶总共有多少种跳法。

1、定义数组元素的含义

按我上面的步骤说的,首先我们来定义 dp[i] 的含义,我们的问题是要求青蛙跳上 n 级的台阶总共由多少种跳法,那我们就定义 dp[i] 的含义为:跳上一个 i 级的台阶总共有 dp[i] 种跳法(一般情况下问题是什么,我们定义的dp[i]就是什么,或者定义dp是与问题紧密相关的内容)。这样,如果我们能够算出 dp[n],不就是我们要求的答案吗?所以第一步定义完成。

2、找出数组元素间的关系式

我们的目的是要求 dp[n],动态规划的题,如你们经常听说的那样,就是把一个规模比较大的问题分成几个规模比较小的问题,然后由小的问题推导出大的问题。也就是说,dp[n] 的规模为 n,比它规模小的是 n-1, n-2, n-3.... 也就是说,dp[n] 一定会和 dp[n-1], dp[n-2]....存在某种关系的。我们要找出他们的关系。

那么问题来了,怎么找?

这个怎么找,是最核心最难的一个,我们必须回到问题本身来了,来寻找他们的关系式,dp[n] 究竟会等于什么呢?

对于这道题,由于情况可以选择跳一级,也可以选择跳两级,所以青蛙到达第 n 级的台阶有两种方式:

  • 一种是从第 n-1 级跳上来
  • 一种是从第 n-2 级跳上来

由于我们是要算所有可能的跳法的,所以有 dp[n] = dp[n-1] + dp[n-2]

3、找出初始条件

当 n = 1 时,dp[1] = dp[0] + dp[-1],而我们是数组是不允许下标为负数的,所以对于 dp[1],我们必须要直接给出它的数值,相当于初始值,显然,dp[1] = 1。一样的 dp[0] = 0(0 个台阶,有人说是0种跳法,有人说是1种,我们暂时当作0种处理吧,不过无论哪种,都不影响问题都思路哈)。于是得出初始值:

dp[0] = 0; dp[1] = 1; 即 n <= 1时,dp[n] = n

三个步骤都做出来了,那么我们就来写代码吧,代码会详细注释滴。

int f( int n ){
    if(n <= 1)
    return n;
    // 先创建一个数组来保存历史数据
    int[] dp = new int[n+1];
    // 给出初始值
    dp[0] = 0;
    dp[1] = 1;
    // 通过关系式来计算出 dp[n]
    for(int i = 2; i <= n; i++){
        dp[i] = dp[i-1] + dp[i-2];
    }
    // 把最终结果返回
    return dp[n];
}

4、再说初始化

大家先想以下,你觉得,上面的代码有没有问题?

答是有问题的,还是错的,错在对初始值的寻找不够严谨,这也是我故意这样弄的,意在告诉你们,关于初始值的严谨性(一步错,步步错)。例如对于上面的题,当 n = 2 时,dp[2] = dp[1] + dp[0] = 1。这显然是错误的,你可以模拟一下,应该是 dp[2] = 2。

也就是说,在寻找初始值的时候,一定要注意不要找漏了,dp[2] 也算是一个初始值,不能通过公式计算得出。有人可能会说,我想不到怎么办?这个很好办,多做几道题就可以了。

下面我再列举三道不同的例题,下面这几道例题,不会讲的特性详细哈。实际上 ,上面的一维数组是可以把空间优化成更小的,不过我们现在先不讲优化的事,下面的题也是,不讲优化版本。

案例二:二维数组的 DP

问题描述

一个机器人位于一个 m x n 网格的左上角 (起始点在下图中标记为“Start” )

机器人每次只能向下或者向右移动一步。机器人试图达到网格的右下角(在下图中标记为“Finish”)。

问总共有多少条不同的路径?

不同路径,这是 Leetcode第62题

还是老样子,三个步骤来解决。

步骤一、定义数组元素的含义

由于我们的目的是从左上角到右下角一共有多少种路径,那我们就定义 dp[i] [j]的含义为:当机器人从左上角走到(i, j) 这个位置时,一共有 dp[i] [j] 种路径。那么,dp[m-1] [n-1] 就是我们要的答案了。

这个网格相当于一个二维数组,数组是从下标为 0 开始算起的,所以 右下角的位置是 (m-1, n - 1),所以 dp[m-1] [n-1] 就是我们要找的答案。

步骤二:找出关系数组元素间的关系式

想象以下,机器人要怎么样才能到达 (i, j) 这个位置?由于机器人可以向下走或者向右走,所以有两种方式到达

  • 一种是从 (i-1, j) 这个位置走一步到达
  • 一种是从 (i, j-1) 这个位置走一步到达

因为是计算所有可能的步骤,所以是把所有可能走的路径都加起来,所以关系式是 dp[i] [j] = dp[i-1] [j] + dp[i] [j-1]

步骤三、找出初始值

显然,当 dp[i] [j] 中,如果 i 或者 j 有一个为 0,那么还能使用关系式吗?答是不能的,因为这个时候把 i - 1 或者 j - 1,就变成负数了,数组就会出问题了,所以我们的初始值是计算出所有的 dp[0] [0….n-1] 和所有的 dp[0….m-1] [0]。这个还是非常容易计算的,相当于计算机图中的最上面一行和左边一列。因此初始值如下:

  • dp[0] [0….n-1] = 1; // 相当于最上面一行,机器人只能一直往右走
  • dp[0….m-1] [0] = 1; // 相当于最左面一列,机器人只能一直往下走

三个步骤都写出来了,直接看代码

public static int uniquePaths(int m, int n) {
    if (m <= 0 || n <= 0) {
        return 0;
    }
    int[][] dp = new int[m][n];
    // 初始化
    for(int i = 0; i < m; i++){
      dp[i][0] = 1;
    }
    for(int i = 0; i < n; i++){
      dp[0][i] = 1;
    }
    // 推导出 dp[m-1][n-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];
}

O(n*m) 的空间复杂度可以优化成 O(min(n, m)) 的空间复杂度的,不过这里先不讲

案例三、二维数组的DP

问题描述

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

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

示例:

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

和上面的差不多,不过是算最优路径和,这是Leetcode第64题

继续三个步骤来解决。

步骤一、定义数组元素的含义

由于我们的目的是从左上角到右下角,最小路径和是多少,那我们就定义 dp[i] [j]的含义为:从左上角走到(i, j) 这个位置时,最小的路径和是 dp[i] [j]。那么,dp[m-1] [n-1] 就是我们要的答案了。

注意,这个网格相当于一个二维数组,数组是从下标为 0 开始算起的,所以 由下角的位置是 (m-1, n - 1),所以 dp[m-1] [n-1] 就是我们要走的答案。 步骤二:找出关系数组元素间的关系式

步骤二:找出关系数组元素间的关系式

想象以下,要怎么样才能到达 (i, j) 这个位置?由于可以向下走或者向右走,所以有两种方式到达

  • 一种是从 (i-1, j) 这个位置走一步到达
  • 一种是从 (i, j-1) 这个位置走一步到达

不过这次不是计算所有可能路径,而是计算哪一个路径和是最小的,那么我们要从这两种方式中,选择一种,使得dp[i] [j] 的值是最小的,显然有

dp[i] [j] = min(dp[i-1][j],dp[i][j-1]) + grid[i][j];   // gird[i][j] 表示网格种的值

步骤三、找出初始值

显然,当 dp[i] [j] 中,如果 i 或者 j 有一个为 0,那么还能使用关系式吗?答是不能的,因为这个时候把 i - 1 或者 j - 1,就变成负数了,数组就会出问题了,所以我们的初始值是计算出所有的 dp[0] [0….n-1] 和所有的 dp[0….m-1] [0]。这个还是非常容易计算的,相当于计算机图中的最上面一行和左边一列。因此初始值如下:

  • dp[0] [j] = grid[0] [j] + dp[0] [j-1]; // 相当于最上面一行,只能一直往右走
  • dp[i] [0] = grid[i] [0] + dp[i-1] [0]; // 相当于最左面一列,只能一直往下走

代码如下

public int minPathSum(int[][] grid) {
    if (grid.length == 0 || grid[0].length == 0 ) return 0;
    int m = grid.length;
    int 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 j=1; j<n; j++) {
        dp[0][j] = dp[0][j-1] + grid[0][j];
    }
    // 推导出 dp[m-1][n-1]
    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];
}

案例四、最长回文子串

问题描述

给定一个字符串 s,找到 s 中最长的回文子串。你可以假设 s 的最大长度为 1000。

示例 1:

输入: "babad"
输出: "bab"
注意: "aba" 也是一个有效答案。

示例 1:

输入: "cbbd"
输出: "bb"

这是 Leetcode第5题

首先确保你理解什么是回文。回文是一个正读和反读都相同的字符串,例如,“aba”、“abccba” 是回文,而 “abc” 不是。

这道题需要先分析清楚回文性质,一个回文去掉两头以后,剩下的部分依然是回文(这里暂不讨论边界)

从回文串的定义展开讨论:

  • 如果一个字符串的头尾两个字符都不相等,那么这个字符串一定不是回文串;
  • 如果一个字符串的头尾两个字符相等,才有必要继续判断下去。
    • 如果里面的子串是回文,整体就是回文串;
    • 如果里面的子串不是回文串,整体就不是回文串。

最关键的步骤是想清楚“状态如何转移” 即关系数组元素间的关系式

即在头尾字符相等的情况下,里面子串的回文性质据定了整个子串的回文性质,这就是状态转移。因此可以把“状态”定义为原字符串的一个子串是否为回文子串。

步骤一、定义数组元素的含义

dp[i][j] 表示子串 s[i, j] 是否为回文子串

步骤二:找出关系数组元素间的关系式

根据回文性质我们可以分析得到:

dp[i][j] = (s[i] == s[j]) and dp[i + 1][j - 1]

很明显当子串首尾相等,并且排除首尾后,仍为回文,则dp[i][j]就是回文。

看到 dp[i + 1][j - 1] 就得考虑边界情况

边界条件是:表达式 [i + 1, j - 1] 不构成区间,即长度严格小于 2,即 j - 1 - (i + 1) + 1 < 2 ,整理得 j - i < 3。

这个结论很显然:当子串 s[i, j] 的长度等于 2 或者等于 3 的时候,我其实只需要判断一下头尾两个字符是否相等就可以直接下结论了。

  • 如果子串 s[i + 1, j - 1] 只有 1 个字符,即去掉两头,剩下中间部分只有 1 个字符,当然是回文;
  • 如果子串 s[i + 1, j - 1] 为空串,那么子串 s[i, j] 一定是回文子串。

因此,在 s[i] == s[j] 成立和 j - i < 3 的前提下,直接可以下结论,dp[i][j] = true,否则才执行状态转移。

步骤三、找出初始值

初始化的时候,单个字符一定是回文串,因此把对角线先初始化为 1,即 dp[i][i] = 1 。

事实上,初始化的部分都可以省去。因为只有一个字符的时候一定是回文,dp[i][i] 根本不会被其它状态值所参考

步骤四、考虑输出

这道题因为是计算最长的回文,所以只要一得到 dp[i][j] = true,就记录子串的长度和起始位置,没有必要截取,因为截取字符串也要消耗性能,记录此时的回文子串的“起始位置”和“回文长度”即可。通过比较子串长度得到最长的回文。

编码的时候要注意的事项:总是先得到小子串的回文判定,然后大子串才能参考小子串的判断结果

public String longestPalindrome(String s) {
    int len = s.length();
    if (len < 2) {
        return s;
    }

    boolean[][] dp = new boolean[len][len];

    // 初始化
    for (int i = 0; i < len; i++) {
        dp[i][i] = true;
    }
    
    // 记录最长回文长度和开始位置
    int maxLen = 1, start = 0;

    for (int j = 1; j < len; j++) {
        for (int i = 0; i < j; i++) {
            if (s.charAt(i) == s.charAt(j)) {
                if (j - i < 3) {
                    // 表达式 [i + 1, j - 1] 不构成区间,即长度严格小于 2,即 j - 1 - (i + 1) + 1 < 2 ,整理得 j - i < 3
                    // 此时只有两种情况,[i + 1, j - 1] 里面为空或只有一个字符,这两种情况,都一定是回文
                    dp[i][j] = true;
                } else {
                    dp[i][j] = dp[i + 1][j - 1];
                }
            } else {
                dp[i][j] = false;
            }

            // 只要 dp[i][j] == true 成立,就表示子串 s[i, j] 是回文,此时记录回文长度和起始位置
            if (dp[i][j]) {
                int curLen = j - i + 1;
                if (curLen > maxLen) {
                    maxLen = curLen;
                    start = i;
                }
            }
        }
    }
    return s.substring(start, start + maxLen);
}

上面获取dp[i][j]的值可以简化为一行代码

dp[i][j] = s.charAt(i) == s.charAt(j) && (j - i < 3 || dp[i + 1][j - 1]);

虽然看起来短了一些,但是丢失了一定可读性,逻辑运算符混用,虽然加上了括号表示优先级,但如果没有前文铺垫,很难读懂是什么意思。(不过我在提交code的时候,发现用一行代码提交运算时间快了很多)


参考:什么是动态规划(Dynamic Programming)?动态规划的意义是什么?