跳到主要内容

20、数据结构与算法 - 实战:动态规划

摘要

动态规划是求解最优化的一种常用策略,它依然需要拆分成若干个子问题,但是每个子问题只有求解一次,并保存它的解。可以用动态规划解决的问题需要有最优子结构无后效性两个特定。

动态规划简称 DP,是求解最优化问题的一种常用策略。动态规划通常使用的套路大致分为这三种:

1、 暴力递归,自顶向下,会出现重叠子问题;
2、 记忆搜索,自顶向下;
3、 递归,自底向上;

假设动态规划的函数为 dp(),其中的“动态”可以理解是“状态是会变化的”,动态规划的处理通常有以下三个步骤:

1、 定义状态,就是原问题和子问题的解,也就是定义dp(i)的含义;
2、 设置初始状态,也就是边界,作为DP的结束条件,即设置dp(0)的值;
3、 确定状态转移方程,比如确定dp(i)dp(i-1)的关系;

动态规划的英文释义:
Dynamic Programming is a method for solving a complex problem by breaking it down into a collection of simpler subproblems, solving each of those subproblems just once, and storing their solutions.

根据上面的释义可以梳理出动态规划的执行步骤:

1、 将复杂的原问题拆解成若干个简单的子问题;
2、 每个子问题仅仅解决1次,并保存这些子问题的解;
3、 最后推导出原问题的解;

所以使用动态规划解决的问题,需要具有以下两个特点:

1、 最优子结构,就是通过求解子问题的最优解,可以获得原问题的最优解;
2、 无后效性,某个阶段的状态一旦确定,则此后过程的演变不再受到此前各状态或者决策的影响,也就是未来与过去无关;在推导后面阶段的状态时,只需要关心前面阶段的具体状态值,不用关心这个状态是怎么一步步推导出来的;

无后效性

比如在一个 5x5 的格子中,从起点(0,0)走到终点(4,4) 总共有多少种走法?前提是只能向右、向下走。

假设dp(i, j) 是从 (0,0) 走到 (i, j) 的走法,那么当 dp(i,0) = dp(0,j) = 1,dp(i, j) = dp(i, j-1) + dp(i-1, j)。

当推导dp(i, j) 时,只需要用到 dp(i, j-1) 和 dp(i-1, j) 的值,不需要关心 dp(i, j-1) 和 dp(i-1, j) 的值是怎么求出来的,这就是无后效性

如果更改一下前提,就是可以向下、向上、向左、向右走,并且同一个格子不可以走2次。那么求 dp(i, j) 时,需要关心 dp(i, j-1) 和 dp(i-1, j) 是如何获得的,这就是有后效性

找零钱——案例

假设有25分、20分、5分、1分的硬币,现在要找给客户41分零钱,如何办到找的硬币个数最少?

这个案例在贪心策略([[220527 数据结构与算法 - 实战(十八)贪心]])中被求解过,但是贪心策略求得的结果不是最优解。

运用动态规划求解这个问题时,假设 dp(n) 是找 n 分需要的最少硬币个数,有以下几种可能:

  • 如果第一次选择了25分硬币,那么 dp(n) = dp(n-25) + 1;
  • 如果第一次选择了20分硬币,那么 dp(n) = dp(n-20) + 1;
  • 如果第一次选择了5分硬币,那么 dp(n) = dp(n-5) + 1;
  • 如果第一次选择了1分硬币,那么 dp(n) = dp(n-1) + 1;

综上所有可能,dp(n) = min { dp(n-25), dp(n-20), dp(n-5), dp(n-1)} + 1。

首先使用暴力递归的思想实现代码:

static int coins1(int n) {
   
     
    if (n < 1) return Integer.MAX_VALUE;
    if (n == 1 || n == 5 || n == 20 || n == 25) return 1;
    int min1 = Math.min(coins1(n - 1), coins1(n-5));
    int min2 = Math.min(coins1(n-20), coins1(n-25));

    return Math.min(min1, min2) + 1;
}

因为使用了递归的思想,所以会有大量的重复计算,时间复杂度也是比较高的。那么就可以使用数组记录计算出的值,避免重复的计算,即记忆搜索的思想,实现代码如下:

static int coins2(int n) {
   
     
    if (n < 1) return -1;
    int[] dp = new int[n+1];
    int[] faces = {
   
     1, 5, 20, 25};
    for (int face : faces) {
   
     
        if (n < face) break;
        dp[face] = 1;
    }

    return coins2(n, dp);
}

static int coins2(int n, int[] dp) {
   
     
    if (n < 1) return Integer.MAX_VALUE;
    if (dp[n] == 0) {
   
     

        int min1 = Math.min(coins1(n - 1), coins1(n-5));
        int min2 = Math.min(coins1(n-20), coins1(n-25));
        dp[n] = Math.min(min1, min2) + 1;
    }
    return dp[n];
}

代码中可以看到,创建了一个长度为 n+1 的数组 dp 存放找零硬币的个数,为什么要创建 n+1 的长度呢?

因为找的 n-1 有可能是任意小于 n 的值,并且索引和要找的 n 保持一致,不用时刻操心 index = n -1 的事情,所以创建的数组长度为 n+1

梳理上面的代码,可以看到递归的规律,就是先拆解原问题为子问题,然后从子问题的解推导出原问题的解,即自顶向下。那么可不可以直接从子问题的解去推导呢?

答案是肯定的,也就是自底向上,在代码部分,要多做一些边界判断处理,代码如下:

static int coins3(int n) {
   
     
    if (n < 1) return -1;
    int[] dp = new int[n+1];
    for (int i = 1; i <= n; i++) {
   
     
        int min = dp[i-1];
        if (i >=5) min = Integer.min(dp[i-5], min);
        if (i >=20) min = Integer.min(dp[i-20], min);
        if (i >=25) min = Integer.min(dp[i-25], min);
        dp[i] = min + 1;
    }
    return dp[n];
}

总结

  • 动态规划是求解最优化的一种常用策略,实现过程时要用到递归思想;
  • 动态规划常用的套路是暴力递归,记忆搜索和自底向上的递归;
  • 动态规划可以弥补贪心策略”局部最优,推导到全局,并不是全局最优“的弊端。