货币找零问题:动态规划的经典应用
今天我们要解决一个经典的动态规划问题——货币找零问题。这个问题在日常生活中很常见:当你去商店买东西需要找零时,收银员如何用最少的硬币/纸币给你找零?
问题描述
给定一个正整数数组 arr(表示不同面额的货币)和一个目标金额 aim,每种货币可以使用任意多次。求组成目标金额 aim 所需的最少货币数量。
示例:
- 输入:
arr = [1, 3, 5],aim = 11 - 输出:
3 - 解释:
11 = 5 + 5 + 1,共需要 3 张货币
暴力递归解法
核心思想
对于每个面额的货币,我们有两个选择:
- 不使用当前面额
- 使用当前面额(可以使用多次)
通过递归尝试所有可能的组合,找到最少的货币数量。
代码实现
public static int minCoinsRecursive(int[] arr, int aim) {
if (arr == null || arr.length == 0 || aim < 0) {
return -1;
}
return process(arr, 0, aim);
}
/**
* 从index位置开始,组成rest金额的最少货币数
*/
private static int process(int[] arr, int index, int rest) {
// base case: 已经考虑完所有面额
if (index == arr.length) {
return rest == 0 ? 0 : Integer.MAX_VALUE;
}
int result = Integer.MAX_VALUE;
// 尝试使用0张、1张、2张...当前面额的货币
for (int count = 0; count * arr[index] <= rest; count++) {
int next = process(arr, index + 1, rest - count * arr[index]);
if (next != Integer.MAX_VALUE) {
result = Math.min(result, count + next);
}
}
return result;
}
时间复杂度分析
- 时间复杂度:O(aim^N),其中 N 是货币种类数
- 空间复杂度:O(N),递归栈深度
问题:存在大量重复计算,效率极低!
记忆化搜索优化
核心思想
使用缓存(memoization)存储已经计算过的子问题结果,避免重复计算。
代码实现
public static int minCoinsMemo(int[] arr, int aim) {
if (arr == null || arr.length == 0 || aim < 0) {
return -1;
}
int[][] memo = new int[arr.length + 1][aim + 1];
// 初始化为-1,表示未计算
for (int i = 0; i <= arr.length; i++) {
for (int j = 0; j <= aim; j++) {
memo[i][j] = -1;
}
}
int result = processMemo(arr, 0, aim, memo);
return result == Integer.MAX_VALUE ? -1 : result;
}
private static int processMemo(int[] arr, int index, int rest, int[][] memo) {
if (memo[index][rest] != -1) {
return memo[index][rest];
}
if (index == arr.length) {
memo[index][rest] = (rest == 0) ? 0 : Integer.MAX_VALUE;
return memo[index][rest];
}
int result = Integer.MAX_VALUE;
for (int count = 0; count * arr[index] <= rest; count++) {
int next = processMemo(arr, index + 1, rest - count * arr[index], memo);
if (next != Integer.MAX_VALUE) {
result = Math.min(result, count + next);
}
}
memo[index][rest] = result;
return result;
}
时间复杂度分析
- 时间复杂度:O(N × aim²)
- 空间复杂度:O(N × aim)
虽然有所改善,但还有优化空间!
动态规划解法(二维DP)
状态定义
dp[i][j] 表示使用前 i 种货币组成金额 j 所需的最少货币数量。
状态转移方程
对于第 i 种货币(面额为 arr[i-1]),我们可以选择使用 k 张(k ≥ 0):
dp[i][j] = min(dp[i-1][j - k × arr[i-1]] + k)
其中 k 满足 k × arr[i-1] ≤ j
代码实现
public static int minCoinsDP2D(int[] arr, int aim) {
if (arr == null || arr.length == 0 || aim < 0) {
return -1;
}
int n = arr.length;
int[][] dp = new int[n + 1][aim + 1];
// 初始化:无法组成的金额设为最大值
for (int j = 1; j <= aim; j++) {
dp[n][j] = Integer.MAX_VALUE;
}
dp[n][0] = 0;
// 填充DP表
for (int i = n - 1; i >= 0; i--) {
for (int j = 0; j <= aim; j++) {
dp[i][j] = dp[i + 1][j]; // 不使用当前货币
// 尝试使用当前货币
if (j >= arr[i] && dp[i][j - arr[i]] != Integer.MAX_VALUE) {
dp[i][j] = Math.min(dp[i][j], dp[i][j - arr[i]] + 1);
}
}
}
return dp[0][aim] == Integer.MAX_VALUE ? -1 : dp[0][aim];
}
关键优化点
注意状态转移方程的优化:
dp[i][j] = min(dp[i+1][j], dp[i][j-arr[i]] + 1)- 这样避免了内层循环,将时间复杂度从 O(aim²) 降到 O(aim)
动态规划解法(一维DP)
空间优化思路
观察二维DP的状态转移方程,发现 dp[i][j] 只依赖于:
dp[i+1][j](上一行同列)dp[i][j-arr[i]](当前行前面的列)
因此可以用一维数组优化空间。
代码实现
public static int minCoinsDP1D(int[] arr, int aim) {
if (arr == null || arr.length == 0 || aim < 0) {
return -1;
}
int[] dp = new int[aim + 1];
// 初始化:除了dp[0]=0,其他都设为最大值
Arrays.fill(dp, 1, aim + 1, Integer.MAX_VALUE);
// 遍历每种货币
for (int coin : arr) {
// 从小到大遍历金额(完全背包)
for (int j = coin; j <= aim; j++) {
if (dp[j - coin] != Integer.MAX_VALUE) {
dp[j] = Math.min(dp[j], dp[j - coin] + 1);
}
}
}
return dp[aim] == Integer.MAX_VALUE ? -1 : dp[aim];
}
为什么内层循环要从小到大?
这是因为完全背包问题的特点:每种物品可以使用多次。
- 如果从大到小遍历,就是0-1背包(每种物品只能用一次)
- 如果从小到大遍历,就是完全背包(每种物品可以用多次)
BFS解法(最短路径思想)
核心思想
将问题转化为图的最短路径问题:
- 节点:当前剩余金额
- 边:使用一张某种面额的货币
- 目标:从
aim到0的最短路径
代码实现
public static int minCoinsBFS(int[] arr, int aim) {
if (arr == null || arr.length == 0 || aim < 0) {
return -1;
}
if (aim == 0) return 0;
Queue<Integer> queue = new LinkedList<>();
Set<Integer> visited = new HashSet<>();
queue.offer(aim);
visited.add(aim);
int steps = 0;
while (!queue.isEmpty()) {
int size = queue.size();
steps++;
for (int i = 0; i < size; i++) {
int current = queue.poll();
for (int coin : arr) {
int next = current - coin;
if (next == 0) {
return steps;
}
if (next > 0 && !visited.contains(next)) {
visited.add(next);
queue.offer(next);
}
}
}
}
return -1;
}
时间复杂度分析
- 时间复杂度:O(aim × N)
- 空间复杂度:O(aim)
算法对比总结
| 算法 | 时间复杂度 | 空间复杂度 | 适用场景 |
|---|---|---|---|
| 暴力递归 | O(aim^N) | O(N) | 小数据量,理解问题 |
| 记忆化搜索 | O(N×aim²) | O(N×aim) | 中等数据量 |
| 二维DP | O(N×aim) | O(N×aim) | 通用解法 |
| 一维DP | O(N×aim) | O(aim) | 最优解法 |
| BFS | O(N×aim) | O(aim) | 需要具体方案时 |
完整测试代码
import java.util.*;
public class MinCoinsProblem {
// 一维DP解法(推荐)
public static int minCoins(int[] arr, int aim) {
if (arr == null || arr.length == 0 || aim < 0) {
return -1;
}
if (aim == 0) return 0;
int[] dp = new int[aim + 1];
Arrays.fill(dp, 1, aim + 1, Integer.MAX_VALUE);
for (int coin : arr) {
for (int j = coin; j <= aim; j++) {
if (dp[j - coin] != Integer.MAX_VALUE) {
dp[j] = Math.min(dp[j], dp[j - coin] + 1);
}
}
}
return dp[aim] == Integer.MAX_VALUE ? -1 : dp[aim];
}
// 测试方法
public static void main(String[] args) {
int[] arr = {1, 3, 5};
int aim = 11;
System.out.println("最少货币数: " + minCoins(arr, aim)); // 输出: 3
// 边界测试
System.out.println(minCoins(new int[]{2}, 3)); // 输出: -1 (无法组成)
System.out.println(minCoins(new int[]{1}, 0)); // 输出: 0
}
}
关键要点总结
- 问题本质:完全背包问题的变种
- 状态定义:
dp[i]表示组成金额i的最少货币数 - 状态转移:
dp[i] = min(dp[i], dp[i-coin] + 1) - 边界处理:无法组成的金额用特殊值标记
- 空间优化:一维DP是最优解法
扩展思考
这个问题还可以扩展为:
- 求具体的组合方案(而不仅仅是数量)
- 限制每种货币的使用次数(变成多重背包)
- 求所有可能的组合数量(而不是最少数量)
掌握这个经典问题的解法,不仅能解决实际的找零问题,还能为解决其他动态规划问题打下坚实基础!


2010

被折叠的 条评论
为什么被折叠?



