跟硬币杠上了!!!

最近遇到了好几个跟硬币有关的问题,特地总结一下,下次再遇到就不会混淆了。

问题一:换零钱需要最少几个硬币

问题描述: 给你几个不同面额的硬币以及一个总金额 amount,求组成 amount 所需的最少硬币数,如果无法组成 amount,则输出 -1。

样例 1:

输入: coins = [1, 2, 5], amount = 11
输出: 3 
解释: 组成 11 最少需要 3 个硬币——两个面额为 5 的硬币,一个面额为 1 的硬币。

样例 2:

输入: coins = [2], amount = 3
输出: -1
解释:面额为 2 的硬币无法组成 3,所以输出 -1。

解法一:回溯法

这个问题可以抽象成下面这个数学模型:

在满足 ∑ i = 0 n − 1 x i × c i = S \sum^{n-1}_{i=0}x_i\times c_i=S i=0n1xi×ci=S 的前提下,使得 m i n x = ∑ i = 0 n − 1 x i min_x = \sum^{n-1}_{i=0}x_i minx=i=0n1xi 最小。

其中 S 就是金额,n 是硬币的个数, c i c_i ci 是第 i 个硬币的面额, x i x_i xi 是组成 S 所需的 c i c_i ci 的个数。

一个简单的思路就是枚举所有满足上述约束的 [ x 0 . . . x n − 1 ] [x_0...x_{n-1}] [x0...xn1],计算他们的和,然后返回其中最小的一个。不难发现, x i x_i xi 的取值范围是 [ 0 , S c i ] [0, \frac{S}{c_i}] [0,ciS],我们可以把 x i x_i xi 的每个可能的取值都列出来,形成一个二维表,以样例 1 为例,这个二维表如下:
在这里插入图片描述
最左边的一列是硬币的面额,绿色区域的数字表示的是组成金额 11 可能需要的该硬币的个数。我们每次从每一行中取一个数,分别记为 x 1 j 、 x 2 k 、 x 3 l x_{1j}、x_{2k}、x_{3l} x1jx2kx3l,如果 1 × x 1 j + 2 × x 2 k + 5 × x 3 l = 11 1\times x_{1j}+2\times x_{2k}+5 \times x_{3l}=11 1×x1j+2×x2k+5×x3l=11,就把 x 1 j + x 2 k + x 3 l x_{1j}+x_{2k}+x_{3l} x1j+x2k+x3l 记录下来,最后,从所有记录下的数中找到最小的那个,就是我们要找的最优解了。

上述思路可以利用回溯法来实现,代码如下:

public class Solution {
    public int coinChange_backTrack(int[] coins, int amount){
        if (coins == null || coins.length == 0) {
            return -1;
        }
        return coinChange(0, coins, amount);
    }

    public int coinChange(int i, int[] coins, int amount){
        if (i <= coins.length && amount == 0){
            return 0;
        }
        if (i < coins.length && amount > 0){
            int maxVal = amount / coins[i];
            int minCost = Integer.MAX_VALUE;
            for (int j = 0; j <= maxVal; j++) {
                int res = coinChange(i + 1, coins, amount - j * coins[i]);
                if (res != -1){
                    minCost = Math.min(minCost, res + j);
                }
            }
            return minCost == Integer.MAX_VALUE ? -1 : minCost;
        }
        return -1;
    }
}

复杂度分析

  • 时间复杂度: O ( S n ) O(S^n) O(Sn)。最坏情况下,时间复杂度与硬币数量成指数级关系。因为每一种面额的金币 c i c_i ci 最多需要 S c i \frac{S}{c_i} ciS 个,所以所有可能的组合数就有 S c 0 × S c 1 × S c 2 × ⋅ ⋅ ⋅ × S c n − 1 = S n c 0 × c 1 × c 2 × ⋅ ⋅ ⋅ × c n − 1 \frac{S}{c_0}\times\frac{S}{c_1}\times\frac{S}{c_2}\times···\times\frac{S}{c_{n-1}}=\frac{S^n}{c_0\times c_1\times c_2\times ···\times c_{n-1}} c0S×c1S×c2S××cn1S=c0×c1×c2××cn1Sn 个,故时间复杂度就是 O ( S n ) O(S^n) O(Sn)
  • 空间复杂度: O ( n ) O(n) O(n),最坏情况下最大的递归深度是 n,因此需要 O ( n ) O(n) O(n) 的空间用于系统递归栈。

解法二:动态规划(自顶向下)

显然解法一有些暴力,我们可以用动态规划来解决这个问题。首先我们定义 F ( S ) F(S) F(S) 为使用硬币 [ c 0 . . . c n − 1 ] [c_0...c_{n-1}] [c0...cn1] 组成金额 S 所需的最少的硬币数。我们注意到,就像其他的动态规划问题一样,这个问题也有一个最优子结构。换句话说,这个问题的最优解可以由其子问题的最优解构造出来。

那么接下来的问题就是如何分解出子问题。假设我们现在知道 F(S) 的值,并且组成 S 的最后一个硬币的面额是 C,那么下面这个等式一定是成立的:
F ( S ) = F ( S − C ) + 1 F(S)=F(S-C)+1 F(S)=F(SC)+1
但是我们不知道最后一个硬币的面值 C 具体是多少,所以对于每一种可能的硬币面额 c 0 , c 1 , c 2 , . . . , c n − 1 c_0,c_1,c_2,...,c_{n-1} c0,c1,c2,...,cn1 ,我们都需要计算出 F ( S − c i ) F(S-c_i) F(Sci),然后取其中最小的那个。所以就有了下面的递推关系:
F ( S ) = m i n { F ( S − c i ) + 1   ∣   0 ≤ i ≤ n − 1 , S − c i ≥ 0 } 当 S = 0 时 , F ( S ) = 0 当 n = 0 时 , F ( S ) = − 1 F(S)=min\{F(S−ci)+1\ |\ 0\le i \le n−1,S−ci≥0\} \\当S=0时,F(S) = 0\\当n=0时,F(S) = -1 F(S)=min{F(Sci)+1  0in1Sci0}S=0F(S)=0n=0F(S)=1
有了递推关系式就可以写代码了吗?还不行,因为这其中包含了大量的重复计算,为了说明这个问题,我们假设有三种硬币,每种硬币的面额分别是 1、2、3,要组成金额 5,则递推树如下:
在这里插入图片描述
从中可以看到,F(1) 被计算了 5 次。为了解决这个问题,我们可以维护一个哈希表,里面记录着我们已经计算出来的 F(S)。

代码如下:

public class Solution {
    public int coinChange_recursive(int[] coins, int amount) {
        HashMap<Integer, Integer> memo = new HashMap<>();
        return helper(coins, amount, memo);
    }

    public int helper(int[] coins, int amount, HashMap<Integer, Integer> memo){
        if (amount == 0){
            return 0;
        }else if (amount < 0){
            return -1;
        }else{
            Integer cur = memo.get(amount);
            if (cur != null){
                return cur;
            }
            int minCost = Integer.MAX_VALUE;
            for (int j = 0; j < coins.length; j++) {
                if (amount > coins[j]){
                    int res = helper(coins, amount - coins[j], memo);
                    if (res != -1) {
                        minCost = Math.min(res + 1, minCost);
                    }
                }else if (amount == coins[j]){
                    minCost = 1;
                    break;
                }
            }
            if (minCost == Integer.MAX_VALUE){
                minCost = -1;
            }
            memo.put(amount, minCost);
            return minCost;
        }
    }
}

复杂度分析

  • 时间复杂度: O ( S × n ) O(S\times n) O(S×n),其中 S 是金额,n 是硬币面额种类的个数。最坏情况下,递归树的高度是 S,因为我们会缓存之前计算过的子问题的解,所以最多计算 S 个子问题就可以了,计算每一个子问题时会有 n 次迭代,因此时间复杂度就是 O ( S × n ) O(S\times n) O(S×n)
  • 空间复杂度: O ( S ) O(S) O(S)。因为最多递归 S 层,所以空间复杂度就是 S。

解法三:动态规划(自底向上)

相对于解法一,解法二在性能方面有了较大改进,但是在解法二中是用递归实现的,如果 S 过大,就会导致递归的层数太多,有内存溢出的风险,所以最好是改成递归实现。因为思想是一样的,所以改起来也比较简单。代码如下:

public class Solution {
    public int coinChange_dp(int[] coins, int amount) {
        if (coins == null || coins.length == 0){
            return -1;
        }
        if (amount == 0){
            return 0;
        }
        int[] res = new int[amount + 1];
        for (int i = 1; i <= amount; i++) {
            res[i] = -1;
            for (int j = 0; j < coins.length; j++) {
                if (i >= coins[j] && res[i - coins[j]] != -1){
                    if (res[i] == -1) {
                        res[i] = res[i - coins[j]] + 1;
                    }else{
                        res[i] = Math.min(res[i], res[i - coins[j]] + 1);
                    }
                }
            }
        }
        return res[amount];
    }
}

复杂度分析

  • 时间复杂度依然是 O ( S × n ) O(S\times n) O(S×n),和解法二是一样的。
  • 空间复杂度也和解法二一样,是 O ( S ) O(S) O(S),虽然看起来是同一个数量级,但是假如 S=100000,解法二需要递归 100000 层,而解法三只需要申请一个大小是 100000 的数组即可,这两者的区别想必不用我多说了吧。

这个题目可以在 LeetCode 上找到,链接如下:322. Coin Change。经过测试,也是解法二需要的时间最短。

问题二:求总共有几种换零钱的姿势

问题描述: 给你几个不同面额的硬币以及一个总金额 amount,求总共有几种方式能够组成 amount 。比如有面额为 1、2、5 的硬币,要组成金额 5,可以有 {1,1,1,1,1}、{1,1,1,2}、{1,2,2}、{5} 这 4 种组合方式。

思路

假设金额为 S,硬币集合为 C = { c 0 , c 1 , c 2 , . . . , c n − 1 } C=\{c_0,c_1,c_2,...,c_{n-1}\} C={c0,c1,c2,...,cn1},那么
S = x 0 c 0 + x 1 c 1 + x 2 c 2 + . . . + x n − 1 c n − 1 S=x_0c_0+x_1c_1+x_2c_2+...+x_{n-1}c_{n-1} S=x0c0+x1c1+x2c2+...+xn1cn1
X = { x 0 , x 1 , x 2 , . . . , x n − 1 } X=\{x_0, x_1,x_2,...,x_{n-1}\} X={x0,x1,x2,...,xn1} ,那么我们的目的就是找到总共有多少个集合 X 能够使上述等式成立。由于 x i ∈ [ 0 , S c i ] , 0 ≤ i ≤ n − 1 x_i \in [0, \frac{S}{c_i}],0\leq i\le n-1 xi[0,ciS],0in1,所以上述等式可以拆解成下面几个等式:
S = x 0 c 0 + x 1 c 1 + x 2 c 2 + . . . + 0 × c n − 1 S = x 0 c 0 + x 1 c 1 + x 2 c 2 + . . . + 1 × c n − 1 S = x 0 c 0 + x 1 c 1 + x 2 c 2 + . . . + 2 × c n − 1 . . . S = x 0 c 0 + x 1 c 1 + x 2 c 2 + . . . + k × c n − 1 S=x_0c_0+x_1c_1+x_2c_2+...+0\times c_{n-1} \\S=x_0c_0+x_1c_1+x_2c_2+...+1\times c_{n-1} \\S=x_0c_0+x_1c_1+x_2c_2+...+2\times c_{n-1} \\... \\S=x_0c_0+x_1c_1+x_2c_2+...+k\times c_{n-1} \\ S=x0c0+x1c1+x2c2+...+0×cn1S=x0c0+x1c1+x2c2+...+1×cn1S=x0c0+x1c1+x2c2+...+2×cn1...S=x0c0+x1c1+x2c2+...+k×cn1
其中 k = ⌊ S c i ⌋ k=\lfloor\frac{S}{c_i}\rfloor k=ciS。如果我们定义 F(S,i) 为前 i 个硬币组成金额 S 的所有组合数,那么根据上面的等式,
F ( S , i ) = F ( S − 0 × c i , i − 1 ) + F ( S − 1 × c i , i − 1 ) + F ( S − 2 × c i , i − 1 ) + ⋯ + F ( S − k × c i , i − 1 ) F(S,i)=F(S-0\times c_i,i-1)+F(S-1\times c_i,i-1)+F(S-2\times c_i,i-1)+\cdots+F(S-k\times c_i,i-1) F(S,i)=F(S0×ci,i1)+F(S1×ci,i1)+F(S2×ci,i1)++F(Sk×ci,i1)

F ( S , i ) = ∑ j = 0 k F ( S − j c i , i − 1 ) F(S,i)=\sum^k_{j=0}F(S-jc_i,i-1) F(S,i)=j=0kF(Sjci,i1)
初始情况下,如果 S=0,那么不论 i 等于几,只有一种组合情况,那就是所有硬币都不取,所以 F(S,i)=1。

这不就是动态规划里的状态转移方程吗?我们可以用一个二维数组 state 来表示 F(S,i),state[i][S]=F(S,i),而这个数组第 i 行的值全部依赖于第 i-1 行的值,所以我们可以逐行求解该数组。如果前 0 种硬币要组成 S,我们规定为 state[0][sum] = 0.

代码

public class CoinProblem {
    public int countOfCombine(int[] coins, int amount){
        int coinKinds = coins.length;
        int[][] dp = new int[coinKinds + 1][amount + 1];

        for (int i = 0; i <= coinKinds; ++i) {
            dp[i][0] = 1;
        }

        for (int i = 1; i <= coinKinds; ++i) {
		    for (int j = 1; j <= amount; ++j) {
		        for (int k = 0; k <= j / coins[i-1]; ++k) {
		            dp[i][j] += dp[i-1][j - k * coins[i-1]];
		        }
		    }
		}
        return dp[coinKinds][amount];
    }
}

复杂度分析

  • 时间复杂度: O ( S × n ) O(S\times n) O(S×n)
  • 空间复杂度: O ( S × n ) O(S\times n) O(S×n),因为要申请大小为 S × n S\times n S×n 的数组。

不知道 LeetCode 上有没有相同的题目,如果有知道的读者欢迎在评论区留言。

问题三:求每种换零钱的姿势分别是啥

问题描述: 给你几个不同面额的硬币以及一个总金额 amount,求能够组成 amount 的所有硬币组合。说明:解集不能包含重复的组合。

样例:

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

这个问题的解题思路和问题一中的解法二有点像,都是采用递归树来做。
在这里插入图片描述
上图中,每一个到叶子节点的路径上的权重组合起来都是一个解,但是里面有重复的。
在这里插入图片描述
上图中红色的路径就是重复的路径,也就是我们不需要递归的部分。那么应该如何避免重复呢?首先,在递归前需要给硬币的面额排个序,对应到代码中就是给 coins 数组排序。当递归到第 i 个硬币时,下一层递归继续从第 i 个硬币开始,而不是从第 0 个硬币开始。

代码

import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;

public class CombinationSum {
    public List<List<Integer>> combinationSum(int[] candidates, int target) {
        List<List<Integer>> res = new ArrayList<>();
        Arrays.sort(candidates);
        backtrace(res, new ArrayList<>(), candidates, target, 0);
        return res;
    }

    public void backtrace(List<List<Integer>> res, List<Integer> tempList, int[] candidates, int target, int begin){
        for (int i = begin; i < candidates.length; i++) {
            if (candidates[i] <= target){
                List<Integer> list = new ArrayList<>(tempList);
                list.add(candidates[i]);
                if (target == candidates[i]){
                    res.add(list);
                    return;
                }
                backtrace(res, list, candidates, target - candidates[i], i);
            }else {
                break;
            }
        }
    }
}

复杂度分析

  • 时间复杂度: O ( S × n ) O(S\times n) O(S×n)
  • 空间复杂度: O ( S ) O(S) O(S)

LeetCode 上有一个和这个类似的问题,虽然描述不一样,但是问题的本质是一样的,有兴趣的同学可以做一下,链接在此:39. Combination Sum

参考链接:【算法27】硬币面值组合问题

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值