Leetcode 322. 零钱兑换——DP问题总结————重复书写硬币问题

10 篇文章 0 订阅
4 篇文章 0 订阅

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

原题地址:零钱兑换
或直转到:https://leetcode-cn.com/problems/coin-change/

由于直接上动态规划中等难度的题我有点晕,这直接借鉴一下知乎大佬的文章,并加入自己的理解。

  • 先上代码
int min(int a, int b) {
    return a < b ? a : b;
}

int coinChange(int* coins, int coinsSize, int amount){
    int *value = malloc(sizeof(int) * (amount + 1));
    for (int i = 0; i <= amount; i++) {
        value[i] = amount + 1;
    }
    value[0] = 0;
    for (int i = 1; i <= amount; i++) {
        // 要遍历数组的每个元素,找到所有情况的最优解
        for (int j = 0; j < coinsSize; j++) {
            if (i >= coins[j]) { // i要大于等于数组元素的值
                // 获取value[i]位置的初始值
                int value_i = value[i]; 
                // 获取i减去数组元素的剩余金额
                int remain = i - coins[j]; 
                // 获取之前计算好的剩余金额的最优解
                int value_remain = value[remain];
                // 剩余金额的最优解加一计算出金额i的除了当前面额的最优解
                int count_min_value = value_remain + 1;
                // 比较计算出来的最优解和i金额的初始值,把较小的值赋值给value[i]
                value[i] = min(count_min_value, value_i); 
            }
        }
    }
    if (value[amount] == amount + 1) {
        return -1;
    }
    return value[amount];
}

动态规划(Dunamic Programming)
首先,动态规划问题的一般形式就是求极值,而动态规划的核心问题是穷举,穷举又不是直接进行穷举,而是利用所求解问题的**“重叠子问题”。暴力穷举(我经常使用而且正确率也不高,需要频繁的debug,因为我的思路不是很清晰,自己用脑子很难正确想到所有情况)的效率在一些情况下会很低。
而且,动态规划问题一定具备
“最优子结构”,也正是因为具备最优子结构,我们才可以通过子问题的最值来求解原问题的最值。
另外,尽管动态规划的核心就是穷举求最值,但是问题可以千变万化,穷举真的很困难。但是,我们可以通过列出
[状态转移方程]**来完成正确的穷举。
上面提到的“重叠子问题”、“最优子结构”、“状态转移方程”就是DP问题的三要素。其中,写出状态转移方程是最困难的。

明确 base case -> 明确「状态」-> 明确「选择」 -> 定义 dp 数组/函数的含义。

大体流程如下:

# 初始化 base case
dp[0][0][...] = base
# 进行状态转移
for 状态1 in 状态1的所有取值:
    for 状态2 in 状态2的所有取值:
        for ...
            dp[状态1][状态2][...] = 求最值(选择1,选择2...)
  • Fibonacci

这是C的入门问题,可只有简单的例子才能让我们把精力充分集中在算法背后的通用思想和技巧上,而不会被那些隐晦的细节问题搞的莫名其妙。

1、暴力递归

int fib(int N){
	if(N == 1 || N == 2)return 1;
	return fib(N-1)+fib(N-2);
}

很直接粗暴的递归,代码简洁的过分,但其运行效率却十分低下,整个运算过程很像二叉树向下分支,答案就是顶点,然而必须求解所有分支子问题才能得到最终答案。然而每个问题又是独立的,因此就会徒增计算的复杂度,时间用的更长,内存占的更多。举个例子,其实fib(N-1)中已经包含了子问题fib(N-2),然而在这个递归方法中,fib(N-2)要计算两次,太悲剧了。更低层的计算可能重复的更多。子问题个数为(O(2^n)),解决一个问题的时间就是1,然而就算如此,子问题个数也太多,复杂度爆炸。
兄弟,这就是动态规划问题的第一个性质:重叠子问题

2、嗲备忘录的递归解法
其实明确了问题,问题就已经解决了一半,尽管另一半可能更难。竟然耗费时间的主要原因是重复计算,那我们可以创造一个【备忘录】,每次出现子问题先寻找备忘录,而不是直接进行返回,在return那里递归。如果发现之前已经解决过这个问题了,直接把答案拿来用,不要再耗时间计算了。
这里利用数组充当【备忘录】,也可以使用hash。

int fib(int N) {
    if (N < 1) return 0;
    // 备忘录全初始化为 0
    vector<int> memo(N + 1, 0);//整型容器memo,空间大小为N+1,全为0
    // 进行带备忘录的递归
    return helper(memo, N);
}

int helper(vector<int>& memo, int n) {//将容器的地址放入,话说容器的使用方法好像指针啊,就像把指针打包了。
    // base case 
    if (n == 1 || n == 2) return 1;//这个是最基础的,也是递归的基本,即第一项和第二项都是1.
    // 已经计算过
    if (memo[n] != 0) return memo[n];//查询备忘录
    memo[n] = helper(memo, n - 1) + helper(memo, n - 2);
    return memo[n];
}

1、fib主函数中分了三步走,第一步判断不符合题意的,即"<0"。第二步建立备忘录(数组)存储递归过程中的点,避免重复计算。第三步进行return,这里直接在return处调用helper函数。
2、helper肯定就是得到我们要的答案的,具体也是三步走。第一步判断是否是第一第二次,都是1,这是定义。第二步看备忘录中指定位置是否是0,不是0直接调用后返回,也就是说在之前的递归中已经计算过了,不需要重复计算。第三步对应的肯定是备忘录中n位置是0的,也就是之前的递归中没有计算过的,因此继续进行递归,每次递归都要先查找备忘录,直到返回memo[n]。
3、这个状态转移方程就很灵性,将当前问题分为两个子问题解之和,子问题可以继续在当前函数中寻找子问题的子问题,并优先检索备忘录,降低了复杂度,且递归方式没变。
实际上,使用备忘录的递归算法,把一颗存在巨量冗余的递归树[剪枝]了,改造成了一个不存在冗余的递归图,减少了递归结点的个数。
本题中用到的方法可以称为“自顶向下”,而动态规划问题都是“自底向上”,从问题规模最小的f(1)和f(2)开始往上推,直到推到我们想要的答案,这就是动态规划的思路。这也是动态规划可以一定程度上脱离递归的关系。

备忘录的制作:

int fib(int N) {
    vector<int> dp(N + 1, 0);//容器的创建
    // base case
    dp[1] = dp[2] = 1;
    for (int i = 3; i <= N; i++)
        dp[i] = dp[i - 1] + dp[i - 2];//一个循环搞定,只要向后递推,所需的子问题解都已经得到了
    return dp[N];
}

下面引出**[状态转移方程]一词。想象一下f(n)就是由f(n-1)f(n-2)相加转移而来,这就是状态转移,仅此而已。
上面的所有代码中,不管是return f(n-1)+f(n-2)还是dp[i]=dp[i-1]+dp[n-2]以及备忘录对DP table的初始化操作,都是围绕这个方程式的不同表现形式。可见列出
[状态转移方程]**的重要性。状态转移方程也直接代表着暴力解法。
这里知乎博主提到了一个很有意思的问题:

千万不要看不起暴力解,动态规划问题最困难的就是写出这个暴力解,即状态转移方程。
只要写出了暴力解,任何优化方法,也再无奥妙可言。

难道就这样了么?利用了DP table就无法继续优化了么?根据Fabonacci状态转移方程,当前状态只和之前的两个状态有关,也就是说并不需要那么长的DP table来存储所有状态,只要想办法存储之前两个状态就行了。所以可以进一步优化,将空间复杂度降低为O(1):

int fib(int n) {
    if (n == 2 || n == 1) 
        return 1;
    int prev = 1, curr = 1;
    for (int i = 3; i <= n; i++) {
        int sum = prev + curr;
        prev = curr;
        curr = sum;
    }
    return curr;
}

这么想来,DP真的是一个很奇妙的方法,刚开始代码是多么简洁,直接对自身进行递归。可代码的简洁,也带来了极高的复杂度O(2^n)。随着对DP的认识,我们写出了状态转移方程f(n)=f(n-1)+f(n-2),每一次递归我们都将指定结点的解放在一个备忘录中,极大的降低了计算的复杂度,消除了一个结点重复进行计算的情况。为了提高计算效率,在求解次数较高的情况下,我们直接创建一个DP table,当需要求Fibonacci数列的时候直接从里面调用就可以了。若想单次求解最快,而不是利用DP table。我们可以只存储其前两次的解,利用一个循环就可求解,这就是自底向上,而不像自顶向下的递归,不得不对一些子问题进行重复求解。

知乎博主将这一写法成为**[状态压缩]**

如果我们发现每次状态转移只需要 DP table 中的一部分,那么可以尝试用状态压缩来缩小 DP table 的大小,只记录必要的数据,上述例子就相当于把DP table 的大小从 n 缩小到 2。后续的动态规划章节中我们还会看到这样的例子,一般来说是把一个二维的 DP table 压缩成一维,即把空间复杂度从 O(n^2) 压缩到 O(n)。
有人会问,动态规划的另一个重要特性「最优子结构」,怎么没有涉及?下面会涉及。斐波那契数列的例子严格来说不算动态规划,因为没有涉及求最值,以上旨在说明重叠子问题的消除方法,演示得到最优解法逐步求精的过程。下面,看第二个例子,凑零钱问题。

  • 零钱问题~(无限的硬币有谁不想要呢?)

1、暴力递归
首先,这个问题是动态规划问题,因为它具有[最优子结构]。要符合最优子结构,子问题之间必须相互独立
那么回到凑零钱问题,为什么说它最符合最优子结构呢?比如我们要求amount = 11时的最少硬币数量(原问题),如果你知道凑出 amount = 10的最少硬币数(子问题),你只需要把子问题的答案加一(再选一枚面值为1的硬币)就是原问题的答案。因为硬币的数量是没有限制的,所有例子问题之间没有相互制约,是相互独立的。
如何列出正确的状态转移方程呢?
1、确定base case,这个很简单,显然目标金额为0时算法返回0,因为不需要任何硬币就银镜凑出目标金额了。
2、确定[状态],也就是原问题和子问题中会变化的变量。由于硬币数量无限,硬币的面额也是题目给定的,只有秒爆金额会不断向base case靠近,所以唯一的[状态]就是目标金额 amount。
3、确定[选择],也就是导致[状态]产生变化的行为。目标金额为什么变化呢?因为你在选择硬币,你每选择一枚硬币,就相当于减少了目标金额。所以说所有硬币的面值,就是你的[选择]
4、明确 dp 函数/数组的定义。 我们这里说的是自顶向下的解法,所以会有一个递归的 dp 函数,一般来说函数的参数就是状态转移中会变化的量,也就是说上面提到的[状态];函数的返回值就是题目要求我们计算的量。就本题来说,状态只有一个,即[目标金额],题目要求我们计算凑出目标金额所需的最少硬币数量。我们可以这样定义 dp 函数。

dp(n) 的定义:输入一个目标金额 n ,返回凑出目标金额 n 的最少硬币数量。

伪代码:

# 伪码框架
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)

2、带备忘录的递归

def coinChange(coins: List[int], amount: int):
    # 备忘录
    memo = dict()
    def dp(n):
        # 查备忘录,避免重复计算
        if n in memo: return memo[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)

        # 记入备忘录
        memo[n] = res if res != float('INF') else -1
        return memo[n]

    return dp(amount)

3、dp 数组的迭代解法
当然,我们也可以自底向上使用DP table来消除重叠子问题,关于**[状态][选择]和base case 与之前没有区别,dp数组的定义和刚才dp函数类似,也就是把[状态]**,也就是目标金额作为变量。不过dp函数体现在 函数参数,而ap数组体现在 数组索引。(即寻找转移方程位置)

dp 数组的定义:当目标金额为 i 时,至少需要 dp[i] 枚硬币凑出:

int coinChange(vector<int>& coins, int amount) {
    // 数组大小为 amount + 1,初始值也为 amount + 1
    vector<int> dp(amount + 1, amount + 1);
    // base case
    dp[0] = 0;
    // 外层 for 循环在遍历所有状态的所有取值
    for (int i = 0; i < dp.size(); i++) {
        // 内层 for 循环在求所有选择的最小值
        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];
}
  • 最后总结
    第一个斐波那契数列的问题,解释了如何通过 [备忘录] 或者 [dp table] 的方法来优化递归树,并且明确了这两种方法本质上是一样的,只是自顶向下和自底向上的不同而已。
    第二个凑零钱问题,战术了如何流程化确定 [状态转移方程] ,只要通过状态转移方程写出暴力递归解,剩下的也就是优化递归树,消除重叠子问题而已。

计算机解决问题其实没有任何奇技淫巧,它唯一的解决办法就是穷举


我在编写Fibonacci的时候遇到了一些问题,因为我对C++容器运用的不熟练,在传入备忘录参数的时候没有进行取地址,即:


int myhelp(int n,vector<int>/* & */ index){
    if(n == 1 || n == 2) return 1;
       // myreturn = 1;
    if(index[n]!=-1)     return index[n];
       // myreturn=index[n];
    index[n]=myhelp(n-1,index)+myhelp(n-2,index);
//  myreturn=index[n];
                         return index[n];
}

我把编写的代码简化又简化,结果到最后逻辑上是没有错误的 ,但在每次递归的过程中都新创建了空间,大大增加了时间复杂度。如果直接传入地址,就没有必要在每次传入容器的时候创建新的容器接收,这真的恶心死我了。这里竟然和数组与指针那里这么相似,又有点不一样,数组允许直接传入数组名字,而这个要是直接传入容器名字就会创建新的容器形参来接收,直接传入地址就可以调用实参地址指向的容器!

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值