一、 最优硬币组合问题
问题描述
小C有多种不同面值的硬币,每种硬币的数量是无限的。他希望知道,如何使用最少数量的硬币,凑出给定的总金额N。小C对硬币的组合方式很感兴趣,但他更希望在满足总金额的同时,使用的硬币数量尽可能少。
例如:小C有三种硬币,面值分别为 1, 2, 5。他需要凑出总金额为 18。一种最优的方案是使用三个 5 面值的硬币,一个 2 面值的硬币和一个 1 面值的硬币,总共五个硬币。
测试样例
样例1:
输入:
coins = [1, 2, 5], amount = 18
输出:[5, 5, 5, 2, 1]
样例2:
输入:
coins = [1, 3, 4], amount = 6
输出:[3, 3]
样例3:
输入:
coins = [5], amount = 10
输出:[5, 5]
二、解题思路
-
动态规划思想:
- 这类问题可以使用动态规划来解决,因为我们可以通过小金额的解来逐步求解大金额的解。我们通过逐步建立一个状态
dp[i],表示凑出金额i所需的最少硬币数。最后,dp[amount]就会给出凑出目标金额所需的最少硬币数。
- 这类问题可以使用动态规划来解决,因为我们可以通过小金额的解来逐步求解大金额的解。我们通过逐步建立一个状态
-
状态定义:
- 用
dp[i]来表示凑出金额i的最少硬币数。 - 另外,使用一个
usedCoins[i]数组来记录凑出金额i时,最后使用的是哪种硬币。这样可以在最后一步回溯得到硬币的具体组合。
- 用
-
初始化:
- 初始化
dp[0] = 0,因为凑出金额为 0 不需要任何硬币。 - 其他
dp[i]初始化为Integer.MAX_VALUE,表示初始状态下无法凑出该金额。
- 初始化
-
动态转移:
- 对于每个金额
i,我们遍历所有硬币面值coin,如果i >= coin并且dp[i - coin] != Integer.MAX_VALUE(即凑出i - coin需要的硬币数是有限的),我们就考虑用一个coin来凑出金额i。此时,dp[i]可以更新为dp[i - coin] + 1,表示在凑出i - coin的基础上再加上一个硬币coin。 - 同时,我们记录
usedCoins[i] = coin,即凑出金额i时,最后使用了硬币coin。
- 对于每个金额
-
回溯求解硬币组合:
- 在动态规划表构建完成后,我们通过
usedCoins数组回溯,找出所有使用过的硬币面值,直到金额为 0。
- 在动态规划表构建完成后,我们通过
-
结果返回:
- 返回通过回溯得到的硬币组合。
三、solution函数
import java.util.ArrayList;
import java.util.List;
public class Main {
public static List<Integer> solution(int[] coins, int amount) {
// 初始化 dp 数组,dp[i] 表示凑出金额 i 所需的最少硬币数
int[] dp = new int[amount + 1];
// 初始化 usedCoins 数组,usedCoins[i] 表示凑出金额 i 时使用的最后一个硬币的面值
int[] usedCoins = new int[amount + 1];
// 将 dp 数组初始化为一个较大的值,表示初始状态下无法凑出该金额
for (int i = 1; i <= amount; i++) {
dp[i] = Integer.MAX_VALUE;
}
// 动态规划求解
for (int i = 1; i <= amount; i++) {
for (int coin : coins) {
if (i >= coin && dp[i - coin] != Integer.MAX_VALUE) {
if (dp[i - coin] + 1 < dp[i]) {
dp[i] = dp[i - coin] + 1;
usedCoins[i] = coin; // 记录使用的硬币
}
}
}
}
// 如果 dp[amount] 仍然是 Integer.MAX_VALUE,说明无法凑出该金额
if (dp[amount] == Integer.MAX_VALUE) {
return new ArrayList<>();
}
// 回溯路径,记录使用的硬币组合
List<Integer> result = new ArrayList<>();
int currentAmount = amount;
while (currentAmount > 0) {
int coin = usedCoins[currentAmount];
result.add(coin);
currentAmount -= coin;
}
System.out.println(result);
//翻转数组,返回结果
for (int i = 0; i < result.size() / 2; i++) {
Integer temp = result.get(i);
result.set(i, result.get(result.size() - 1 - i));
result.set(result.size() - 1 - i, temp);
}
System.out.println(result);
return result;
}
public static void main(String[] args) {
// Add your test cases here
System.out.println(solution(new int[]{1, 2, 5}, 18).equals(List.of(5, 5, 5, 2, 1)));
}
}
四、时间和空间复杂度分析
时间复杂度分析
-
动态规划填表部分:
- 外层循环遍历
amount(从 1 到amount),总共进行amount次。 - 内层循环遍历硬币数组
coins,假设硬币的数量为n,则内层循环执行n次。
因此,动态规划填表的时间复杂度是 O(amount × n) ,其中
amount是目标金额,n是硬币种类的数量。 - 外层循环遍历
-
回溯求解硬币组合部分:
- 回溯的过程中,每次减去一个硬币,直到金额为 0。最多需要遍历
amount个硬币(在极端情况下每次选择的都是面值为 1 的硬币),因此回溯的时间复杂度是 O(amount) 。
- 回溯的过程中,每次减去一个硬币,直到金额为 0。最多需要遍历
总时间复杂度:O(amount × n) + O(amount),其中 amount 是目标金额,n 是硬币种类数量。对于大部分情况下,主导的时间复杂度是O(amount × n) 。
空间复杂度分析
-
dp 数组:用来存储每个金额所需的最少硬币数,长度为
amount + 1,因此空间复杂度是O(amount)。 -
usedCoins 数组:用来记录每个金额所用的最后一个硬币面值,长度也是
amount + 1,因此空间复杂度是O(amount)。 -
结果列表:用于存储硬币的组合,最坏情况下,结果列表的长度为
amount(例如当每次都选择面值为 1 的硬币时),因此空间复杂度是O(amount)。
总空间复杂度:O(amount) + O(amount) + O(amount) = O(amount) 。