最优硬币组合问题 | 豆包MarsCode AI刷题

146 阅读4分钟

学习方法与心得

这道题是经典的最小硬币问题,属于动态规划的范畴。问题核心是如何通过最优子结构状态转移方程求解出凑出目标金额的最少硬币数量,并同时返回硬币组合。这类问题在动态规划入门阶段非常重要。

我的心得如下:

  1. 学习动态规划问题时,明确状态定义转移方程是解决的关键。
  2. 对于需要回溯路径的问题,额外维护辅助数组记录选择的状态。
  3. 重视动态规划的边界条件和初始状态,避免逻辑错误。

1. 题目解析

题目要求

  1. 给定硬币面值数组 coins 和目标金额 amount,求最少数量的硬币组合。
  2. 若无法凑出目标金额,返回空列表。
  3. 返回的硬币组合顺序可以任意,但必须是最优解。

核心概念

  • 动态规划的思想是利用之前的最优解构造当前的最优解。
  • 需要额外维护一个数组记录如何凑出目标金额。

示例分析

  • 示例 1:

    coins = [1, 2, 5], amount = 18
    
    • 动态规划过程中,我们可以逐步凑出目标金额,最终解为 [5, 5, 5, 2, 1]
  • 示例 2:

    coins = [5], amount = 10
    
    • 硬币只有面值为 5,目标金额为 10,可以直接用两个 5 凑出结果。

2. 思路分析

  1. 动态规划状态定义

    • dp[i] 表示凑出金额 i 所需的最少硬币数量。
    • 初始化 dp[0] = 0,表示凑出金额 0 所需硬币数量为 0。
  2. 状态转移方程

    • 对于每个硬币 coin,当 j >= coin 时:

    • dp[j]=min(dp[j],dp[jcoin]+1)dp[j]=min⁡(dp[j],dp[j−coin]+1)
    • 即当前金额 j 的最优解,可以由金额 j - coin 的最优解加上当前硬币的数量 1 得到。

  3. 辅助数组记录路径

    • 使用数组 coinsUsed[j],记录凑出金额 j 时使用的硬币面值。
  4. 回溯路径

    • 从目标金额 amount 开始,根据 coinsUsed 数组逐步回溯,构造硬币组合。
  5. 时间复杂度

    • 时间复杂度为

    • O(n×m)O(n×m)
    • 其中 n是硬币数量,m是目标金额。


3. 代码详解

import java.util.ArrayList;
import java.util.List;
import java.util.Arrays;
​
public class Main {
    public static List<Integer> solution(int[] coins, int amount) {
        // 动态规划数组,dp[i] 表示凑出金额 i 的最少硬币数
        int[] dp = new int[amount + 1];
        Arrays.fill(dp, Integer.MAX_VALUE); // 初始化为最大值
        dp[0] = 0; // 凑出金额 0 的硬币数为 0
​
        // 辅助数组,用于记录选择的硬币面值
        int[] coinsUsed = new int[amount + 1];
​
        // 动态规划求解
        for (int coin : coins) { // 遍历每种硬币
            for (int j = coin; j <= amount; j++) { // 更新所有金额状态
                if (dp[j - coin] != Integer.MAX_VALUE && dp[j] > dp[j - coin] + 1) {
                    dp[j] = dp[j - coin] + 1; // 更新最优解
                    coinsUsed[j] = coin; // 记录当前使用的硬币
                }
            }
        }
​
        // 若无法凑出目标金额,返回空列表
        if (dp[amount] == Integer.MAX_VALUE) {
            return new ArrayList<>();
        }
​
        // 回溯路径,构造硬币组合
        List<Integer> result = new ArrayList<>();
        while (amount > 0) {
            result.add(coinsUsed[amount]); // 添加当前硬币
            amount -= coinsUsed[amount];  // 减去硬币面值
        }
​
        return result;
    }
​
    public static void main(String[] args) {
        // 测试样例
        System.out.println(solution(new int[]{1, 2, 5}, 18).equals(List.of(5, 5, 5, 2, 1))); // 输出:[5, 5, 5, 2, 1]
        System.out.println(solution(new int[]{1, 3, 4}, 6).equals(List.of(3, 3))); // 输出:[3, 3]
        System.out.println(solution(new int[]{5}, 10).equals(List.of(5, 5))); // 输出:[5, 5]
        System.out.println(solution(new int[]{2}, 3).isEmpty()); // 输出:[]
    }
}

4. 学习总结与经验

  1. 动态规划问题的思路

    • 先确定状态表示(dp[i] 的含义)。
    • 找到状态转移方程(由较小子问题递推到更大问题)。
    • 注意边界条件的初始化。
  2. 路径回溯

    • 在动态规划基础上,通过辅助数组记录选择的状态,最终实现路径回溯。
  3. 优化实践

    • 将重复逻辑封装到函数内,避免代码冗余。
    • 使用辅助数组避免多余计算,从而提升代码效率。
  4. 调试技巧

    • 使用小规模数据测试 dp 数组的每一步变化,验证状态转移是否正确。
    • 对边界情况(如无法凑出金额的情况)进行专项测试。

5. 学习方法与建议

  1. 扎实基础

    • 掌握动态规划问题的基本框架:状态定义、转移方程、初始条件。
    • 多练习类似问题(如背包问题、路径问题)。
  2. 分步实现

    • 先完成基本的动态规划问题求解,再逐步加入路径回溯等功能。
  3. 测试与验证

    • 对动态规划数组的中间状态进行打印,确保逻辑正确。
    • 测试边界值、特殊值(如金额为 0、无解等)以验证代码的健壮性。
  4. 参考经典题目

    • 动态规划是算法学习的核心,多刷经典题型(如 LeetCode 中的硬币问题、路径问题)可以加深理解。