动态规划(Dynamic Programming,DP)是运筹学的一个分支,是求解决策过程最优化的过程。

动态规划的核心思想:下一个状态可以由上一个状态推导而来,因此可以利用数组保存之前计算的结果,避免重复计算

我们来看一个经典案例:斐波那契数列

function fibonacci(n) {
    if(n == 1 || n == 2) return 1;
    return fibonacci(n - 1) + fibonacci(n - 2)
}

上面的代码看似简单,但其实存在一个严重问题。通过下图我们可以看出,在递归过程中,很多节点被重复计算了,这些无疑导致了性能损失,而且随着问题规模的变大,重复计算的节点呈几何级数增长。

image-20210317225340670.png

那么怎么样优化这个流程呢?由斐波那契数列的特性可以看出,第 n 个值可以由第 n-1 和第 n-2 个值推导出来,例如我们要求第 10 个值,可以确定第 10 个值必然也是一个数,而且这个数可以由第 9 和第 8 个值相加得出,这样我们就确定了 最后一步子问题

最后一步:f(10) 一定也是一个数
子问题:f(10) = f(9) + f(8)

由于初始状态已知,即 f(1) = 1 以及 f(2) = 1 ,因此我们可以从最初的状态不断向后递推,即 f(n) = f(n-1) + f(n-1) ,然后用一个数组保存之前的状态,避免重复计算。

function fibonacci(n) {
    if (n == 1 || n == 2) return 1; // 边界值处理
    let dp = new Array(n);
    dp[0] = 1; // 初始条件
    dp[1] = 1;
    for (let i=2; i<n; i++) {
        dp[i] = dp[i-1] + dp[i-2]; // 状态转移方程
    }
    return dp[n-1]
}

上面的代码中,我们用了一个长度为 n 数组保存之前的状态,时间复杂度降低到了 O(n) ,极大提高了效率。从上面的代码中,我们可以总结出动态规划的两个核心组成部分:初始条件和边界情况 以及 状态转移方程。所谓状态转移方程,也就是由上一状态推导到下一状态的递推公式。在上面的例子中,状态转移方程可以抽象为:

f(i) = f(i-1) + f(i-2)

一般来说,解动态规划的题目都需要开一个数组,求第 n 个元素,就需要开一个长度为 n 的数组,空间复杂度为 O(n) 。但数组不是必须的,有些情况下是可以简化的。例如在斐波那契数列的例子中,我们只关心当前状态的前两个状态就行,不需要把之前所有的状态都给保存下来。因此,上面的代码还可以再优化,只创建一个长度为 2 的数组就满足需要了,空间复杂度降为常数阶。

function fibonacci(n) {
    if (n == 1 || n == 2) return 1;
    let dp = [1, 1];
    for (let i=2; i<n; i++) {
        let res = dp[0] + dp[1];
        dp[0] = dp[1];
        dp[1] = res % 1000000007;
    }
    return dp[1];
}
上面的代码中使用了长度为 2 的数组,其实还可以再简单点,不用数组,用两个变量就行

总结一下,解动态规划的题目有以下几个步骤:

  1. 先要确定状态(最后一步 + 子问题)
  2. 然后确定状态转移方程
  3. 确定初始条件和边界情况

下面一起分析几道经典例题进一步理解动态规划思想。

leetcode 322. 零钱兑换

你有三种硬币,2元、5元、7元,每种硬币足够多,买一本书需要27元,用最少的硬币组合

看到这个题,最直观的想法就是暴力枚举,想要硬币最少,那就用面值最大的去凑:

7+7+7+7 > 27 (排除)

7+5+5+5+5=27, 5枚硬币

7 +7 +7+2+2+2 6枚

很明显暴力枚举太慢了,而且会涉及到很多重复的计算。既然计算机可以通过内存保存之前的内容,又快,很明显,我们开一个数组来保存之前的状态。

确定状态

最后一步
对于这道题,虽然我们不知道最优策略是什么,但是最优策略肯定是K枚硬币,a1, a2, ....ak 面值加起来是27。所以一定有一枚最后的硬币: ak ,除掉这么硬币,前面硬币的面值加起来是 27-ak

我们不关心前面的 k-1 枚硬币是怎么拼出 27-ak 的(可能有一种拼法,也可能有 100 种拼法),但是我们确定前面的硬币拼出了 27-ak 。
因为是最优策略, 所以拼出 27-ak 的硬币数一定要最少,否则这就不是最优策略了。

子问题
在确定了最后一步之后,我们得到了子问题:最少用多少枚硬币可以拼出 27-ak 。很明显,最后的那枚硬币只可能是 2 ,5 或者 7 中的某一个。因此我们可以列出下面几种情况:

如果 ak=2 , f(27) = f(27-2)+1 (1代表最后一枚硬币2)
如果 ak=5 , f(27) = f(27-5)+1 (1代表最后一枚硬币5)
如果 ak=7 , f(27) = f(27-7)+1 (1代表最后一枚硬币7)

所以使用最少的硬币数 f(27) = min{f(27-2)+1, f(27-5)+1, f(27-7)+1}

状态转移方程

设状态 f(x) = 最少用多少枚硬币拼出 x

对于任意的 x : f(X) = min{f(X-2)+1, f(X-5)+1, f(X-7)+1}

初始条件和边界情况

提出问题:
x-2, x-5, x-7 小于0怎么办?
什么时候停下来?

如果不能拼出Y, 就定义f[Y] = 正无穷。例如:拼不出f[1]=min{f(-1)+1, f(-3)+1, f(-6)+1}

初始条件:f[0] = 0

个人觉得前两步其实不难,倒是这一步有点麻烦且易错

参考代码

class Solution {
    public int coinChange(int[] coins, int amount) {
        int[] dp = new int[amount+1];
        dp[0] = 0; // 初始值

        for (int i=1; i<=amount; i++) {
            dp[i] = Integer.MAX_VALUE;
            for (int j=0; j<coins.length; j++) {
                if (i >= coins[j] && dp[i - coins[j]] != Integer.MAX_VALUE) {
                    dp[i] = Math.min(dp[i - coins[j]] + 1, dp[i]);
                }
            }
        }
        if (dp[amount] == Integer.MAX_VALUE) {
            dp[amount] = -1;
        }
        
        return dp[amount];
    }
}

leetcode 62. 不同路径

零钱兑换其实有点难的,不是很好理解。这道比较简单,很容易就能理解。

一个机器人位于一个 m x n 网格的左上角 (起始点在下图中标记为 “Start” )。
机器人每次只能向下或者向右移动一步。机器人试图达到网格的右下角。
问总共有多少条不同的路径?

image.png

确定状态

最后一步
无论机器人用何种方式到达右下角,总有最后挪动的一步:向右或者向下

如果设右下角的坐标为 (m-1, n-1) ,那么最后一步的上一步机器人的位置只可能在 (m-2, n-1) 或者 (m-1, n-2) 。

子问题
如果机器人从左上角走到 (m-2, n-1) 有 X 种方式,从左上角走到 (m-1, n-2) 有 Y 种方式,那么机器人从左上角走到 (m-1, n-1) 就有 X+Y 种方式。

问题转化为,机器人有多少种 方式从左上角走到 (m-2, n-1) 或者 (m-1, m-2) 。

如下图所示,如果是走到 (m-2, n-1) ,我们可以直接把最后一列去掉;同理如果走到 (m-1, n-2) ,可以直接把最后一行去掉。

image.png

状态转移方程

对于任意一个格子都有:f[i][j] = f[i-1][j] + f[i][j-1]

f[i][j] 代表机器人有多少种方式走到 [i][j]
f[i-1][j] 代表机器人有多少种方式走到 [i-1][j]
f[i][j-1] 代表机器人有多少种方式走到 [i][j-1]

初始条件和边界情况

初始条件:f[0][0]=1 ,因为机器人只有一个方式到左上角

边界情况:在第 0 行或者第 0 列,即 i=0j=0 的情况,都只有一种走法。在其他位置都满足状态转移方程。

参考代码

class Solution {
    public int uniquePaths(int m, int n) {
        int[][] dp = new int[m][n]; // 使用二维数组来记录某一位置有几种走法
        
        for (int i=0; i<m; i++) {
            for (int j=0; j<n; j++) {
                if (i == 0 || j == 0) {
                    dp[i][j] = 1; // 边界情况
                } else {
                    dp[i][j] = dp[i-1][j] + dp[i][j-1]; // 其他情况都满足状态方程
                }
            }
        }

        return dp[m-1][n-1];
    }
}

剑指 Offer 42 连续子数组的最大和

class Solution {
    public int maxSubArray(int[] nums) {
        int len = nums.length;
        int[] dp = new int[len];
        dp[0] = nums[0];
        int max = nums[0];

        for (int i=1; i<len; i++) {
            dp[i] = Math.max((dp[i-1] + nums[i]), nums[i]);
            max = Math.max(dp[i], max);
        }

        return max;
    }
}

leetcode 64 最小路径和

class Solution {
    public int minPathSum(int[][] grid) {
        int rows = grid.length, columns = grid[0].length;
        int[][] dp = new int[rows][columns];
        dp[0][0] = grid[0][0];
        
        for (int i=1; i<rows; i++) {
            dp[i][0] = dp[i-1][0] + grid[i][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++) {
            for (int j=1; j<columns; j++) {
                dp[i][j] = Math.min(dp[i-1][j], dp[i][j-1]) + grid[i][j];
            }
        }

        return dp[rows-1][columns-1];
    }
}

leetcode 300 最长递增子序列

参考:

肝了好多天-动态规划十连-超细腻解析|刷题打卡

动态规划 - 小彭的博客


一杯绿茶
199 声望17 粉丝

人在一起就是过节,心在一起就是团圆