动态规划步骤详解

动态规划步骤详解

本文参考

链接:https://leetcode-cn.com/problems/coin-change/solution/dong-tai-gui-hua-tao-lu-xiang-jie-by-wei-lai-bu-ke/
来源:力扣(LeetCode)

动态规划本质上就是一个求最优解的问题,为了能求最优解,直观的方法是把所有的解都穷举出来,然后寻找最优解。看似简单粗暴,但穷举的方法是应对动态规划的最有效方法。

  1. 对于动态规划问题进行穷举时有一个特殊的特性,那就是动态规划问题存在重叠子问题,因此,在穷举的过程中,维护一个备忘录dp对于减少问题的计算复杂度是很有效。因此动态规划通常利用备忘录或DP table来优化算法。
  2. 动态规划问题一定存在最优子结构
  3. 挨个穷举所有解计算开销很大,动态规划问题一般会存在状态转移式,一般穷举时需要找到所有解之间的状态转移式

综上所述,求解动态规划问题的三要素为:重叠子问题最优子结构状态转移式

动态规划求解步骤

找出状态 ——> 定义dp数组/函数的含义 ——> 明确 解 ——> 明确 基础解
先看下题目:给你 k 种面值的硬币,面值分别为 c1, c2 … ck,每种硬币的数量无限,再给一个总金额 amount,问你最少需要几枚硬币凑出这个金额,如果不可能凑出,算法返回 -1 。算法的函数签名如下:

// coins 中是可选硬币面值,amount 是目标金额
int coinChange(int[] coins, int amount);
比如说 k = 3,面值分别为 1,2,5,总金额 amount = 11。那么最少需要 3 枚硬币凑出,即 11 = 5 + 5 + 1。

你认为计算机应该如何解决这个问题?显然,就是把所有肯能的凑硬币方法都穷举出来,然后找找看最少需要多少枚硬币。

1、暴力递归

首先,这个问题是动态规划问题,因为它具有「最优子结构」的。要符合「最优子结构」,子问题间必须互相独立。啥叫相互独立?你肯定不想看数学证明,我用一个直观的例子来讲解。

比如说,你的原问题是考出最高的总成绩,那么你的子问题就是要把语文考到最高,数学考到最高…… 为了每门课考到最高,你要把每门课相应的选择题分数拿到最高,填空题分数拿到最高…… 当然,最终就是你每门课都是满分,这就是最高的总成绩。

得到了正确的结果:最高的总成绩就是总分。因为这个过程符合最优子结构,“每门科目考到最高”这些子问题是互相独立,互不干扰的。

但是,如果加一个条件:你的语文成绩和数学成绩会互相制约,此消彼长。这样的话,显然你能考到的最高总成绩就达不到总分了,按刚才那个思路就会得到错误的结果。因为子问题并不独立,语文数学成绩无法同时最优,所以最优子结构被破坏。

回到凑零钱问题,为什么说它符合最优子结构呢?比如你想求 amount = 11 时的最少硬币数(原问题),如果你知道凑出 amount = 10 的最少硬币数(子问题),你只需要把子问题的答案加一(再选一枚面值为 1 的硬币)就是原问题的答案,因为硬币的数量是没有限制的,子问题之间没有相互制,是互相独立的。

那么,既然知道了这是个动态规划问题,就要思考如何列出正确的状态转移方程?

先确定「状态」,也就是原问题和子问题中变化的变量。由于硬币数量无限,所以唯一的状态就是目标金额 amount。

然后确定 dp 函数的定义:当前的目标金额是 n,至少需要 dp(n) 个硬币凑出该金额。

然后确定「选择」并择优,也就是对于每个状态,可以做出什么选择改变当前状态。具体到这个问题,无论当的目标金额是多少,选择就是从面额列表 coins 中选择一个硬币,然后目标金额就会减少:

伪码框架

def coinChange(coins: List[int], amount: int):
# 定义:要凑出金额 n,至少要 dp(n) 个硬币
def dp(n):
# 做选择,选择需要硬币最少的那个结果
for coin in coins:
res = min(res, 1 + dp(n - coin))
return res
# 我们要求的问题是 dp(amount)
return dp(amount)
最后明确 base case,显然目标金额为 0 时,所需硬币数量为 0;当目标金额小于 0 时,无解,返回 -1:

def coinChange(coins: List[int], amount: int):

def dp(n):
    # base case
    if n == 0: return 0
    if n < 0: return -1
    # 求最小值,所以初始化为正无穷
    res = float('INF')
    for coin in coins:
        subproblem = dp(n - coin)
        # 子问题无解,跳过
        if subproblem == -1: continue
        res = min(res, 1 + subproblem)

    return res if res != float('INF') else -1

return dp(amount)

至此,状态转移方程其实已经完成了,以上算法已经是暴力解法了,以上代码的数学形式就是状态转移方程:在这里插入图片描述

至此,这个问题其实就解决了,只不过需要消除一下重叠子问题,比如 amount = 11, coins = {1,2,5} 时画出递归树看看:
在这里插入图片描述

时间复杂度分析:子问题总数 x 每个子问题的时间。

子问题总数为递归树节点个数,这个比较难看出来,是 O(n^k),总之是指数级别的。每个子问题中含有一个 for 循环,复杂度为 O(k)。所以总时间复杂度为 O(k * n^k),指数级别。

2、带备忘录的递归

只需要稍加修改,就可以通过备忘录消除子问题:

def coinChange(coins: List[int], amount: int):
# 备忘录
memo = dict()
def dp(n):
# 查备忘录,避免重复计算
if n in memo: return memo[n]

    if n == 0: return 0
    if n < 0: return -1
    res = float('INF')
    for coin in coins:
        subproblem = dp(n - coin)
        if subproblem == -1: continue
        res = min(res, 1 + subproblem)
    
    # 记入备忘录
    memo[n] = res if res != float('INF') else -1
    return memo[n]

return dp(amount)

不画图了,很显然「备忘录」大大减小了子问题数目,完全消除了子问题的冗余,所以子问题总数不会超过金额数 n,即子问题数目为 O(n)。处理一个子问题的时间不变,仍是 O(k),所以总的时间复杂度是 O(kn)。

3、dp 数组的迭代解法

当然,我们也可以自底向上使用 dp table 来消除重叠子问题,dp 数组的定义和刚才 dp 函数类似,定义也是一样的:

dp[i] = x 表示,当目标金额为 i 时,至少需要 x 枚硬币。

int coinChange(vector& coins, int amount) {
// 数组大小为 amount + 1,初始值也为 amount + 1
vector dp(
amount + 1, amount + 1);
// base case
dp[0] = 0;
for (int i = 0; i < dp.size(); i++) {
// 内层 for 在求所有子问题 + 1 的最小值
for (int coin : coins) {
// 子问题无解,跳过
if (i - coin < 0) continue;
dp[i] = min(dp[i], 1 + dp[i - coin]);
}
}
return (dp[amount] == amount + 1) ? -1 : dp[amount];
}
在这里插入图片描述

PS:为啥 dp 数组初始化为 amount + 1 呢,因为凑成 amount 金额的硬币数最多只可能等于 amount(全用 1 元面值的硬币),所以初始化为 amount + 1 就相当于初始化为正无穷,便于后续取最小值。

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值