Leetcode【动态规划】| 面试题 08.11. 硬币

Leetcode | 面试题 08.11. 硬币

  • 题目
  • 解题
    • 动态规划
      • 思路
      • java实现
      • 优化
    • 数学思路

题目

给定数量不限的硬币,币值为25分、10分、5分和1分,编写代码计算n分有几种表示法。(结果可能会很大,你需要将结果模上1000000007)
示例1:
 输入: n = 5
 输出:2
 解释: 有两种方式可以凑成总金额:
    5=5
    5=1+1+1+1+1
示例2:
 输入: n = 10
 输出:4
 解释: 有四种方式可以凑成总金额:
    10=10
    10=5+5
    10=5+1+1+1+1+1
    10=1+1+1+1+1+1+1+1+1+1
说明:注意,你可以假设 0 <= n (总金额) <= 1000000
来源:力扣(LeetCode)
链接:https://leetcode-cn.com/problems/coin-lcci

解题

动态规划

 这个问题实际上是一个完全背包问题,典型的动态规划解法。

动态规划的直接思路就是当前 i 分的组合数dp[i]i-1 分的组合数dp[i-1] 以某种方式递推而来。这样所求的n分就可以从0分一步步递推求来。

常规思路(错误):每次递推时判断选择4种硬币的情况
 以这个题目的具体情况来说,因为coin有四种分别为1,5,10,25四种,我们把问题想象为总金额为n的组合都是对这四种硬币的不同选择(每个硬币都可以选择多次),这个动态规划可以设计为 i 分的不同组合有dp[i]次,当最后一步分别选择1,5,10,25时,表示可以从 dp[i-1],dp[i-5],dp[i-10],dp[i-25] 四种情况递推而来。dp[i] = dp[i-1]+dp[i-5]+dp[i-10]+dp[i-25]
 然而这会导致乱序重复问题:上述常规思路实现是把最外层循环设置为动态规划递推的循环0到n,然后里层对硬币的四种情况循环判断,其实是每次递推都考虑四种硬币的情况,这样造成结果就是四种不同分值硬币的乱序重复问题。
 比如,当每一次递归分别选择1,5,10,25时,如果是乱序的组合,前面dp[i-1]&1,dp[i-5]&5,dp[i-10]&10,dp[i-25]&25这四种情况简单相加可能会出现硬币组合的顺序不一样都是实质是一样的重复情况,比如dp[7]可以由dp[6]&1和dp[2]&5两种组合,但是dp[6]的组合可以是{1,5},加上1之后dp{7}为{1,5,1};dp[2]是{1,1},加上5后dp[7]是{1,1,5},这样dp[6]&1与dp[2]&5就重复了不能简单的相加。
 这个问题如何解决?
 
正确思路:每次递推时只选择1种硬币的情况,将选择一种硬币的情况递推完进行选择下一个硬币的递推,总共递推c_n*n次 (c_n为硬币种类数,n为总金额) **
 我们组合时依次选择四种硬币,使
存储的硬币组合都是以递增/递减的顺序来排列**如{1,1,5,5,10,10,25},这样就避免了如上例{1,5,1}和{1,1,5}这种的顺序不同实质一样的重复情况。
 如何实现?很简单,我们将常规思路的两层循环反过来,将对选择四种硬币的情况的循环放在最外层,递推的循环放在里层,就变成了每次先把0到n分的组合中选择1的情况递推完,再选择5、10、25,这样我们所有情况的组合最终都是以 {1,1,…,1,5,5,…,5,10,10,…,10,25,25,…,25} 这样的顺序展现,避免了乱序重复问题。

思路

dp设置:dp[i][j] 表示选择到第i个硬币的时候(表明当前有i种硬币组成),组成总金额为j的方式数。(i有四种)
例:dp[3][10]:表示选择到第3个硬币(1,5,10三种硬币组合而成的)组成总金额为10分的组合方式数。
状态转移方程:dp[i][j] = dp[i-1][j] + dp[i][j-coins[i]]
表示遍历到第i个硬币的时候有两种选择,组合数为两种选择组合数之和:一种是不选择这个硬币,组合数为dp[i-1][j];一种是选择这个硬币,组合数为dp[i][j-coins[i]],前提是当前总金额 j 要大于该硬币的值coins[i]。
边界条件: dp[0][j] = 0,0种硬币组合金额j,不可能有方案,因此是0
      dp[i][0] = 1,i种硬币组合称金额0,只有一种组合方式,即哪个硬币也不选。

java实现

class Solution {
    /**
     * 方法 1 : 二维 dp 比较直观的解法
     */
    public int waysToChange(int n) {
        int[] coins = new int[]{1, 5, 10, 25};
        int[][] dp = new int[5][n + 1];  // 一般多开一个位置,0 空着不用
        // base case
        for (int i = 1; i <= 4; i++) {
            dp[i][0] = 1;
        }
        for (int i = 1; i <= 4; i++) {
            for (int j = 1; j <= n; j++) {
                // 下面这部分代码是可以进一步改写的,因为从状态转移方程里面可以看到都有 dp[i-1][j],
                // 因此可以直接不用判断就赋值给 dp[i][j],判断后再加上『 选择当前硬币时 』的补偿值就可以了

                if (j - coins[i-1] < 0){                   // 要组成的面值比当前硬币金额小,该硬币不可以选择
                    dp[i][j] = dp[i - 1][j] % 1000000007;  // 只能由 i - 1 中硬币来组成面值 j
                } else {
                    // 当前硬币可以不选,也可以选择
                    dp[i][j] = (dp[i - 1][j] + dp[i][j - coins[i-1]]) % 1000000007;
                }
            }
        }
        return dp[4][n];
    }

优化

 将二维dp优化为一维dp,因为我们发现上述二维dp的dp[i][j] 与dp[i-1][j]的第一维i和i-1只有状态的差别(选择/不选择这个硬币),而与其值不相关,也与总组合数不相关,不会影响到我们组合的计算。所以可以省略压缩为一维数组dp[j],表示面值为j分的总组合数。
 状态转移方程变为:dp[j] = dp[j]+dp[j-coins[i]]
 ① 这里的第一个dp[j] 为i种硬币组合成总金额j时的组合数dp[i][j]
 ② 第二个dp[j] 为i-1种硬币组合成总金额j时的组合数dp[i-1][j]
 ③ dp[j-coins[i]] 表示i种金币组合成总金额为j-coins[i]时的组合数dp[i][j-coins[i]]

本人胡言乱语:为什么dp[]要设置为n+1项,要从0开始计算起把0金额的情况存进去作为第一个而且设置dp[0] = 1呢,而不能dp[0]表示1分的组合数从1分的开始递推?除了可以从二维dp理解来:dp[0]表示金额为0的组合方式,只有一种就是哪个硬币也不选。更具体点的理解就是金额为0是四种硬币选择的统一边界。举个例子,根据我们递推的规则,比如选择5分硬币的时候,dp[5]可以由全部5个1组成,或者一个5组成,而选择1是dp[4]个组合,而最后选择5的话是dp[j-5]个组合数就变成了dp[0]&5,这时的边界就是dp[0]必须=1。同理dp[1],dp[10],dp[25]都可以用递推公式dp[j-coins[i]]从dp[0]递推来,如果我们初始化dp[1]从这里开始的话,我们就还要初始化dp[5],dp[10],dp[25],循环实现也更复杂。

class Solution {
    public int waysToChange(int n) {
        int[] dp = new int[n+1];
        int[] coins = new int[]{1,5,10,25};
        //边界条件
        dp[0] = 1;
        for(int i = 0; i < 4;i++){//实现
            for(int j = coins[i]; j <= n; j++){
            //for(int j = 0; j <= n; j++){
            	//if(j>=coins[i])
                dp[j] = (dp[j-coins[i]]+dp[j])%1000000007;
            }
        }
        return dp[n];
    }
}

数学思路

待续

参考:Kelly: Java 完全背包,详细题解与一步步优化

你可能感兴趣的:(Leetcode)