数据结构与算法-进阶(二十)动态规划

摘要

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

动态规划简称 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];
}

总结

  • 动态规划是求解最优化的一种常用策略,实现过程时要用到递归思想;

  • 动态规划常用的套路是暴力递归,记忆搜索和自底向上的递归;

  • 动态规划可以弥补贪心策略”局部最优,推导到全局,并不是全局最优“的弊端。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

我为双鱼狂

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值