文章目录
最近遇到了好几个跟硬币有关的问题,特地总结一下,下次再遇到就不会混淆了。
问题一:换零钱需要最少几个硬币
问题描述: 给你几个不同面额的硬币以及一个总金额 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;
}
}
复杂度分析
- 时间复杂度: 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×⋅⋅⋅×cn−1S=c0×c1×c2×⋅⋅⋅×cn−1Sn 个,故时间复杂度就是 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...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;
}
}
}
复杂度分析
- 时间复杂度: 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,...,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];
}
}
复杂度分析
- 时间复杂度: 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】硬币面值组合问题