最近遇到了好几个跟硬币有关的问题,特地总结一下,下次再遇到就不会混淆了。
问题描述: 给你几个不同面额的硬币以及一个总金额 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=0n−1xi×ci=S 的前提下,使得 m i n x = ∑ i = 0 n − 1 x i min_x = \sum^{n-1}_{i=0}x_i minx=∑i=0n−1xi 最小。
其中 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...xn−1],计算他们的和,然后返回其中最小的一个。不难发现, 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} x1j、x2k、x3l,如果 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;
}
}
显然解法一有些暴力,我们可以用动态规划来解决这个问题。首先我们定义 F ( S ) F(S) F(S) 为使用硬币 [ c 0 . . . c n − 1 ] [c_0...c_{n-1}] [c0...cn−1] 组成金额 S 所需的最少的硬币数。我们注意到,就像其他的动态规划问题一样,这个问题也有一个最优子结构。换句话说,这个问题的最优解可以由其子问题的最优解构造出来。
那么接下来的问题就是如何分解出子问题。假设我们现在知道 F(S) 的值,并且组成 S 的最后一个硬币的面额是 C,那么下面这个等式一定是成立的:
F ( S ) = F ( S − C ) + 1 F(S)=F(S-C)+1 F(S)=F(S−C)+1
但是我们不知道最后一个硬币的面值 C 具体是多少,所以对于每一种可能的硬币面额 c 0 , c 1 , c 2 , . . . , c n − 1 c_0,c_1,c_2,...,c_{n-1} c0,c1,c2,...,cn−1 ,我们都需要计算出 F ( S − c i ) F(S-c_i) F(S−ci),然后取其中最小的那个。所以就有了下面的递推关系:
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(S−ci)+1 ∣ 0≤i≤n−1,S−ci≥0}当S=0时,F(S)=0当n=0时,F(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;
}
}
}
相对于解法一,解法二在性能方面有了较大改进,但是在解法二中是用递归实现的,如果 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];
}
}
这个题目可以在 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,...,cn−1},那么
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+...+xn−1cn−1
若 X = { x 0 , x 1 , x 2 , . . . , x n − 1 } X=\{x_0, x_1,x_2,...,x_{n-1}\} X={x0,x1,x2,...,xn−1} ,那么我们的目的就是找到总共有多少个集合 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],0≤i≤n−1,所以上述等式可以拆解成下面几个等式:
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×cn−1S=x0c0+x1c1+x2c2+...+1×cn−1S=x0c0+x1c1+x2c2+...+2×cn−1...S=x0c0+x1c1+x2c2+...+k×cn−1
其中 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(S−0×ci,i−1)+F(S−1×ci,i−1)+F(S−2×ci,i−1)+⋯+F(S−k×ci,i−1)
即
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=0∑kF(S−jci,i−1)
初始情况下,如果 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];
}
}
复杂度分析
不知道 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;
}
}
}
}
复杂度分析
LeetCode 上有一个和这个类似的问题,虽然描述不一样,但是问题的本质是一样的,有兴趣的同学可以做一下,链接在此:39. Combination Sum。
参考链接:【算法27】硬币面值组合问题