问题分析
题目给定硬币面值列表,每种面值的硬币数量都是无限的,要求使用最少的硬币数量凑够给定的总金额 amount
对于这道题,可以转化为完全背包问题,即某种物品的数量无限,要求使用最少的物品恰好装满背包
算法描述
状态定义:dp[i][j] 表示考虑前 i 个硬币,恰好凑出总金额 j 的最小硬币数量
鉴于硬币的供应量是无限的,对于总金额 j,我们假设已经找到了使用前 i - 1 种硬币面值的最优解。现在,当我们引入第 i 种硬币面值时,我们需要考虑所有可能的该面值硬币的数量,因此可以推导出以下递推关系:
dp[i][j] = min(dp[i-1][j], dp[i-1][j-coins[i]] + 1, dp[i-1][j - 2 * coins[i]] + 2, ...)
对于总金额 j - coins[i] > 0,可以得到如下的推导公式:
dp[i][j - coins[i]] = max(dp[i-1][j - coins[i]], dp[i-1][j - 2 * coins[i]] + 2, dp[i-1][j - 3 * coins[i]] + 2, ...)
合并两项可得:
dp[i][j] = min(dp[i-1][j], dp[i][j-coins[i]] + 1)
这样,我们就把状态转移方程从无限多项转化为只有 2 项,且符合最优子结构,因此我们可以使用动态规划进行求解
算法需要两层循环,整体的时间复杂度是
如果只存储最优的硬币数量,空间复杂度是 ,其中 amount 是要凑够的总金额
状态压缩
观察状态转移方程,可以发现第 i 层的结果只需要通过第 i 层和第 i-1 层计算而来,因此我们可以进一步将空间复杂度压缩到
如果将 dp 数组从二维压缩到一维,重新考虑状态转移方程:
- 第 1 项
dp[i-1][j]:直接由上一状态转移过来,直接去掉[i-1]即可 - 第 2 项
dp[i][j-coins[i]] + 1:从第 i 层的角度,后面状态是由前面状态转移过来的。因此,我们需要第 i 层前面的状态是第 i 层的,后面还未更新的状态是第 i - 1 层的。因此我们去掉[i]的同时,确保内层循环是从前往后遍历。
最终的状态转移方程:dp[j] = min(dp[j], dp[j-coins[i]] + 1)
接下来,我们可以从直觉的角度,重新理解这个状态转移方程。
外层循环遍历硬币的面值列表,内层循环从 coins[i] 开始遍历,直到 j == amount,内层循环其实就是在模拟一直增加 coins[i] 的数量,直到超过总金额 amount 为止
方案存储
这道题的不同之处是,要求我们给出最终方案,而不是最终最少的硬币数。因此,在求解的过程红,我们还需要一个临时变量存储最优方案
完整代码
import java.util.*;
public class Main {
public static List<Integer> solution(int[] coins, int amount) {
// Edit your code here
int[] dp = new int[amount + 1];
for(int i = 1; i <= amount; i++) {
dp[i] = Integer.MAX_VALUE / 2;
}
List<List<Integer>> res = new ArrayList<>();
for(int i = 0; i <= amount; i++) {
res.add(new ArrayList<>());
}
for(int coin : coins) {
for(int j = coin; j <= amount; j++) {
if( dp[j - coin] + 1 < dp[j] ) {
dp[j] = dp[j - coin] + 1;
List<Integer> list = new ArrayList<>(res.get(j - coin));
list.add(coin);
res.set(j, list);
}
}
}
List<Integer> ans = res.get(amount);
Collections.sort(ans, Collections.reverseOrder());
return ans;
}
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)));
}
}