算法弱鸡也能懂的动态规划刷题(一)

662 阅读7分钟

今天终于搞懂了一点动态规划了,给大家分享一下。 leetcode 题目为 剑指 Offer II 103. 最少的硬币数目

题目描述

给定不同面额的硬币 coins 和一个总金额 amount。编写一个函数来计算可以凑成总金额所需的最少的硬币个数。如果没有任何一种硬币组合能组成总金额,返回 -1。

你可以认为每种硬币的数量是无限的。

题目示例

示例一:

输入: coins = [1, 2, 5], amount = 11
输出: 3 
解释: 11 = 5 + 5 + 1

示例二:

输入: coins = [2], amount = 3
输出: -1

错误示范

有的同学可能第一直觉就是,尽量先使用较大的硬币来付,然后不够的部分再使用小的部分来补。

试试 5 + 5 + 1 = 11,哎,我们的直觉很不错嘛,这题答案就是3个硬币,一下就对了,简直so easy嘛。

哎哎哎,同学,慢点儿~

假如我们换一个示例,再用这种想法试试看,能成吗?

输入: coins = [2, 5, 7], amount = 27

试试看,用上面的直觉的的想法来看,可能是这样:

最大的面额为7元, 最多能使用 3 个面额为7元的硬币,此时的结果为 7 + 7 + 7 = 21,那么离我们的结果 27 还差6元,当我们想要使用 5元 时,还需要一个1元的硬币,但此时我们的口袋里面并没有1元的硬币,所以不能使用5元,退而求次,发现口袋里面有2元的硬币可以使用。我们把做个过程列出来

7 + 7 + 7 = 21 // 首先使用最大面额,使用了 3次
21 + 2 + 2 + 2 // 因为不能使用 5元,最后使用了 3次 2元 的硬币

我们得到的结果是 6 枚,看起来好像是正确的,但我们仔细一想,好像 7 + 5 + 5 + 5 + 5 = 27,仅需要 5枚硬币就可以,这说明直觉好像并不太好使呢,2333333🤣🤣🤣

那么到底该如何使用动态规划来解这道题呢?

第一部分:确定状态

在动态规划中,我们一般就会需要开辟一个数组,然后数组中的每个元素 f(i)f(i) 代表着我们的状态。

确定状态需要有两个意识:

  • 最后一步
  • 子问题

最后一步

虽然我们还不知道最优的策略是什么,但最优策略肯定是K枚硬币 a₁, a₂, ..., a𝚔 面值加起来是27元。

所以一定有一枚最后的硬币:a𝚔。

那么减去这枚硬币,前面的硬币的面额加起来就是 27-a𝚔

我们这里有两个关键点:

  • 我们不关心前面的K-1枚硬币是怎样拼出27-a𝚔的,甚至不知道a𝚔和K是多少,但能确定前面的硬币拼出了27-a𝚔
  • 因为是最优策略,所以拼出27-a𝚔的硬币数量一定要最少,否则就不是最优策略

子问题

因为是最优策略,那么子问题就是:最少用多少枚硬币拼出 27-a𝚔。 我们的原问题是最少用多少枚硬币拼出27。我们将原问题转化成了一个子问题,并且规模更小:27a𝚔27-a𝚔。 那么为了简化定义,我们可以设状态 f(x)=最少用多少枚硬币拼出Xf(x)=最少用多少枚硬币拼出X 因为我们已知能使用的面额分别为 2、5、7,所以ak只能是这其中的某一个,那么就有三种情况:

  • 如果ak是2,f(27)f(27)应该是f(272)+1f(27-2) + 1(加上最后一枚硬币2)
  • 如果ak是5,f(27)f(27)应该是f(275)+1f(27-5) + 1(加上最后一枚硬币5)
  • 如果ak是7,f(27)f(27)应该是f(277)+1f(27-7) + 1(加上最后一枚硬币7)

因为要求结果是最少的硬币数,所以需要在以上三种情况中取最小值:

f(27)=min[f(272)+1,f(275)+1,f(277)+1]f(27) = min[ f(27 - 2) + 1, f(27 - 5) + 1, f(27 - 7) + 1 ]

其中等号左边 f(27)f(27)为所需最少的硬币数。f(272)f(27 - 2)拼出2525所需最少的硬币数,f(275)f(27 - 5)为拼出2222所需最少硬币数,f(27 - 7) 为拼出2020所需最少硬币数,它们都加 11是因为求的是2727,需要加上最后一枚硬币。

递归解法

这个式子一列出来,我第一个想到的就是递归算法

function f(x: number) { // f(x) = 最少用多少枚硬币拼出 x
  if (x === 0) return 0
  let res = Infinity
  if (x >= 2) { // 假设最后一枚硬币为 2
    res = Math.min(f(x - 2) + 1, res)
  }
  if (x >= 5) { // 假设最后一枚硬币为 5
    res = Math.min(f(x - 5) + 1, res)
  }
  if (x >= 7) { // 假设最后一枚硬币为 7
    res = Math.min(f(x - 7) + 1, res)
  }
  return res
}

我们来看一下这个算法的计算过程图 但是递归算法的复杂度实在是太高了,做了很多重复的计算,很明显不可取。

第二部分:转移方程

设状态 f[x] = 最少用多少枚硬币拼出x,那么对于任意 x,有方程

f[x]=min(f[x2]+1,f[x5]+1,f[x7]+1)f[x] = min( f[x - 2] + 1, f[x - 5] + 1, f[x - 7] + 1 )

第三部分:初始条件和边界情况

初始条件:f[0]=0f[0] = 0

对于这题的边界情况有两个问题: x - 2,x-5,x-7 小于0怎么办?何时停下来?

如不能拼出值 y,就定义 f[y]=正无穷f[y] = 正无穷,例如 f[1]=f[2]=f[x]=正无穷f[-1] = f[-2] = f[-x] = 正无穷,使用正无穷就能表示拼不出来需要计算的值

第四部分:计算顺序

有了初始条件以及确定了边界情况,我们在解题之前,还需要确定一下计算顺序,我们这题按照从小到大的顺序去计算,也就是我们要

  1. 先从初始条件f[0]=0f[0] = 0开始,
  2. 然后计算f[1],f[2],...,f[27]f[1], f[2], ..., f[27]就是分别计算出最少用多少枚硬币拼出1—27块钱,将其放到数组中
  3. 那么当我们计算到f[x]f[x]时,f[x2],f[x5],f[x7]f[x - 2], f[x - 5], f[x - 7]都已经得到结果了,不需要再次计算了

图解过程

忘记题目的同学建议再看一遍题目🤣🤣🤣

我们将这个解题的过程通过画图的方式来呈现出来,我们开辟一个数组,数组空间大小为amount + 1(因为从0开始要装到amount)下面的图我会把不存在的负数下标使用虚线表示。

0.png

当 x = 1 时 f[1]=min(f[12]+1,f[15]+1,f[17]+1)f[1] = min(f[1 - 2] + 1, f[1 - 5] + 1, f[1 - 7] + 1),因为 1 - 2, 1 - 5, 1 - 7 全部都为负数,在数组中是找不到负数下标的,对应的f[1]f[4]f[6]f[-1],f[-4],f[-6]全部为,那么根据我们的表达式可知f[1]=f[1] = ∞,即我们手中的硬币无法拼出 1。

1.png

当 x = 2 时 f[2]=min(f[22]+1,f[25]+1,f[27]+1)f[2] = min(f[2 - 2] + 1, f[2 - 5] + 1, f[2 - 7] + 1),其中 2 - 5, 2 - 7 也是在数组中找不到下标的,所以对应f[3]f[-3]f[5]f[-5]都为∞,而 f[22]+1=f[0]+1f[2 - 2] + 1 = f[0] + 1 = 1,在三个值中取得的最小值为1,也就是f[2]=1f[2] = 1,即我们想要拼出 2 元最少只需要 1 枚硬币即可

2.png

后续的求解就按照上面的路数来就可以了

......我是省略号🥳 🥳 🥳

最后,我们根据这个运算方式,得出f[27]=5f[27] = 5

27.png

我们可以看到,与递归算法相比,没有任何的重复计算,复杂度就降下来了,对于 27,复杂度为 27 * 3

解题

我们根据上面都思路来编写代码

function coinChange(coins: number[], amount: number): number {
  // 开辟长度为 amount + 1 的数组用于存储状态,默认状态为正无穷(之所以是 amount + 1 是因为从 0 - amount)
  const dp = new Array(amount + 1).fill(Infinity)
  // 初始化 f[0] = 0
  dp[0] = 0

  for (let x = 1; x <= amount; x++) { // x 为不同面额,从 f[1] 开始计算到 f[amount]
    
    for (let j = 0; j < coins.length; j++) { // 依次从钱包里拿不同面额的硬币出来
      
      // 条件一:x 的面额得大于从钱包里拿出来的硬币面额,才能使用从钱包里面里面拿出来的硬币,比如你不能使用 5块 去付 4块
      // 条件二:f[x - 2] 可以通过钱包内的硬币通过组合的方式付清(这个条件判断能付清,下一个条件判断是否还有更优方式)
      // 条件三:f[x - 5] 比 f[x] 得到的值更加的小(比如此时的 f[x] 由 f[x - 2] + 1 得到)
      if (
        x >= coins[j]
        && dp[x - coins[j]] !== Infinity 
        && dp[x - coins[j]] + 1 < dp[x]
      ) {
        // 如果能付清,并且当前 f[x - 5] 的方案比 f[x - 2] 的方案更优(使用硬币数更少),则更新 f[x] 的使用最少硬币方案
         dp[x] = dp[x - coins[j]] + 1 // f[x - 5] 是子方案, + 1 是加上本次使用的硬币
      }
      
    }
    
  }
  // 根据约定 dp[x] 为 ∞ 时返回 -1,否则返回使用硬币数量
  return dp[amount] === Infinity ? -1 : dp[amount]
};
  1. 开辟一个长度为amount + 1的数组,并且设为默认状态为正无穷,初始化 dp[0]=0dp[0] = 0
  2. 第一层循环。因为需要从 f[1]f[1] 计算到 f[amount]f[amount],所以循环需要从 1 计算到amount
  3. 第二曾循环。从钱包coins中依次拿出硬币去为拼目标值做准备
  4. if 判断条件符合一下三个条件则更新 dp[x] 中的值
    a) 目标面额大于等于钱包内拿出来的面额
    b) f[x目标面额]f[x - 目标面额] 可以通过钱包内的面额组合起来付清
    c) f[x目标面额]+1f[x - 目标面额] + 1 得到的硬币数比 f[x]f[x] 得到的硬币数更少,说明 f[x目标面额]+1f[x - 目标面额] + 1 是更优策略
  5. 第4点的所有条件符合了,就可以更新 dp[x]=f[x目标面额]+1dp[x] = f[x - 目标面额] + 1
  6. 最后返回 dp[amount]dp[amount] 时判断其是否为 Infinity,如果是,则按题意返回-1,否则返回dp[amount] 的值即可。

到这里,我们算是解决了这个动态规划的题目,也懂得了一点点动态规划的解题思路了,OK,本次就到这里了,动态规划后续还会继续更新,争取啃下动态规划问题! 看到这里的大帅比、大漂亮们还请点个关注,点个赞👍呗~