货币找零问题:动态规划的经典应用

货币找零问题:动态规划的经典应用

今天我们要解决一个经典的动态规划问题——货币找零问题。这个问题在日常生活中很常见:当你去商店买东西需要找零时,收银员如何用最少的硬币/纸币给你找零?

问题描述

给定一个正整数数组 arr(表示不同面额的货币)和一个目标金额 aim,每种货币可以使用任意多次。求组成目标金额 aim 所需的最少货币数量

示例:

  • 输入:arr = [1, 3, 5], aim = 11
  • 输出:3
  • 解释:11 = 5 + 5 + 1,共需要 3 张货币

暴力递归解法

核心思想

对于每个面额的货币,我们有两个选择:

  1. 不使用当前面额
  2. 使用当前面额(可以使用多次)

通过递归尝试所有可能的组合,找到最少的货币数量。

代码实现

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解法(最短路径思想)

核心思想

将问题转化为图的最短路径问题:

  • 节点:当前剩余金额
  • :使用一张某种面额的货币
  • 目标:从 aim0 的最短路径

代码实现

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)中等数据量
二维DPO(N×aim)O(N×aim)通用解法
一维DPO(N×aim)O(aim)最优解法
BFSO(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
    }
}

关键要点总结

  1. 问题本质:完全背包问题的变种
  2. 状态定义dp[i] 表示组成金额 i 的最少货币数
  3. 状态转移dp[i] = min(dp[i], dp[i-coin] + 1)
  4. 边界处理:无法组成的金额用特殊值标记
  5. 空间优化:一维DP是最优解法

扩展思考

这个问题还可以扩展为:

  • 求具体的组合方案(而不仅仅是数量)
  • 限制每种货币的使用次数(变成多重背包)
  • 求所有可能的组合数量(而不是最少数量)

掌握这个经典问题的解法,不仅能解决实际的找零问题,还能为解决其他动态规划问题打下坚实基础!

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值