力扣解题-322. 零钱兑换

2 阅读8分钟

力扣解题-322. 零钱兑换

给你一个整数数组 coins ,表示不同面额的硬币;以及一个整数 amount ,表示总金额。

计算并返回可以凑成总金额所需的 最少的硬币个数 。如果没有任何一种硬币组合能组成总金额,返回 -1 。

你可以认为每种硬币的数量是无限的。

示例 1:

输入:coins = [1, 2, 5], amount = 11

输出:3

解释:11 = 5 + 5 + 1

示例 2:

输入:coins = [2], amount = 3

输出:-1

示例 3:

输入:coins = [1], amount = 0

输出:0

提示:

1 <= coins.length <= 12

1 <= coins[i] <= 2³¹ - 1

0 <= amount <= 10⁴

Related Topics

广度优先搜索、数组、动态规划


第一次解答

解题思路

核心方法:完全背包型动态规划(DP)基础版,通过定义dp数组记录每个金额的最少硬币数,利用“无限硬币”的特性从小到大递推,时间复杂度O(amount×k)(k为硬币种类数)、空间复杂度O(amount),是本题最经典、易实现的解法。

核心逻辑拆解

零钱兑换的核心是“完全背包问题”(物品可重复选),核心思路是:

  1. DP数组定义dp[i]表示凑成金额i所需的最少硬币个数
  2. 初始条件
    • dp[0] = 0:凑成金额0不需要任何硬币(递推的基础);
    • 其他dp[i]初始化为amount+1(表示“无穷大”,即初始状态下该金额不可凑出,选择amount+1是因为最多需要amount个1元硬币,超过这个数即不可达);
  3. 状态转移逻辑
    • 对于每个金额i(从1到amount),遍历所有硬币面额coin
    • i >= coin(硬币面额不超过当前金额),则dp[i] = min(dp[i], dp[i-coin] + 1)(凑i元的最少硬币数 = 不选当前硬币的解 vs 选当前硬币(凑i-coin元的解+1枚当前硬币)的最小值);
  4. 结果判断:若最终dp[amount] > amount,说明金额amount不可凑出,返回-1;否则返回dp[amount]
具体执行逻辑
  1. 特殊情况处理:若amount=0,直接返回0(无需硬币);
  2. DP数组初始化
    • 数组长度为amount+1(覆盖0到amount的所有金额);
    • Arrays.fill(dp, amount+1)将所有元素初始化为“无穷大”;
    • 单独设置dp[0] = 0(金额0的基准条件);
  3. 双层循环递推
    • 外层循环i:遍历所有金额(从1到amount);
    • 内层循环coin:遍历所有硬币面额;
    • i >= coin,则更新dp[i] = Math.min(dp[i], dp[i-coin]+1)
  4. 结果返回:若dp[amount] > amount(仍为初始的“无穷大”),返回-1;否则返回dp[amount]
执行流程可视化(以示例1 coins=[1,2,5]、amount=11为例)
金额i遍历硬币条件(i>=coin)dp[i-coin]+1dp[i]更新后说明
11dp[0]+1=111元需1枚1元硬币
21dp[1]+1=22先暂存2枚1元
22dp[0]+1=11更新为1枚2元硬币
55dp[0]+1=115元需1枚5元硬币
115dp[6]+1=2+1=3311元=5+5+1(3枚)
关键细节说明
  • 初始值选择:用amount+1而非Integer.MAX_VALUE,避免dp[i-coin]+1时出现整数溢出;
  • 循环顺序:先遍历金额再遍历硬币,符合“完全背包”(硬币可重复选)的递推逻辑;
  • 边界处理i >= coin的判断避免了负数金额的无效计算;
  • 结果判断dp[amount] > amount等价于“该金额不可凑出”,因为最多需要amount枚1元硬币。
性能说明
  • 时间复杂度:O(amount×k)(amount为目标金额,k为硬币种类数,本题amount≤1e4、k≤12,总计算量约1.2e5,效率极高);
  • 空间复杂度:O(amount)(仅需存储amount+1个元素的dp数组);
  • 优势:
    1. 逻辑直观,完全贴合“完全背包”动态规划的经典范式;
    2. 代码简洁,无复杂数据结构,易实现和调试;
    3. 时间效率满足题目限制,工程实践中首选。
public int coinChange(int[] coins, int amount) {
    // 特殊情况:金额为0,不需要硬币
    if(amount == 0){
        return 0;
    }
    // 创建 dp 数组,大小 amount+1
    int [] dp=new int[amount+1];
    // 初始化:用 amount+1 表示“无穷大”(不可达)
    Arrays.fill(dp,amount+1);
    dp[0]=0;
    // 从小到大计算每个金额的最少硬币数
    for(int i=1;i<=amount;i++){
        for(int coin:coins){
            if(i>=coin){// 硬币不能比金额大
                // 尝试用这枚硬币:前面 i-coin 的最优解 + 1
                dp[i]=Math.min(dp[i],dp[i-coin]+1);
            }
        }
    }
    // 如果 dp[amount] 仍是初始大值,说明凑不出
    return dp[amount]>amount?-1:dp[amount];
}

示例解答

解题思路

解法1:广度优先搜索(BFS)解法(最优解,O(amount×k)时间,更高效)

核心方法:将“金额”视为图的节点,“使用一枚硬币”视为边,通过BFS寻找从0到amount的最短路径(最少硬币数),时间复杂度与DP相当,但实际运行中可能更快(找到解后可立即终止)。

代码实现
public int coinChange(int[] coins, int amount) {
    if (amount == 0) {
        return 0;
    }
    // 记录每个金额是否已访问,避免重复计算
    boolean[] visited = new boolean[amount + 1];
    Queue<Integer> queue = new LinkedList<>();
    queue.offer(0); // 初始节点:金额0
    visited[0] = true;
    int step = 0; // 硬币个数(路径长度)
    
    while (!queue.isEmpty()) {
        int size = queue.size();
        step++;
        // 遍历当前层的所有节点
        for (int i = 0; i < size; i++) {
            int curr = queue.poll();
            // 尝试添加每一种硬币
            for (int coin : coins) {
                int next = curr + coin;
                // 找到目标金额,返回当前步数
                if (next == amount) {
                    return step;
                }
                // 金额未超且未访问过,加入队列
                if (next < amount && !visited[next]) {
                    visited[next] = true;
                    queue.offer(next);
                }
            }
        }
    }
    // 遍历完所有可能仍未找到,返回-1
    return -1;
}
核心逻辑说明
  1. BFS核心思想
    • 每一层代表“使用k枚硬币能凑出的金额”,第一层(step=1)是所有硬币面额,第二层(step=2)是两枚硬币的组合,以此类推;
    • 找到amount时的step即为最少硬币数(BFS的特性是“最短路径优先”);
  2. 去重优化visited数组标记已处理的金额,避免重复入队(如1+2和2+1都得到3,只需处理一次);
  3. 终止条件
    • 找到next == amount,立即返回step(保证最少硬币数);
    • 队列为空仍未找到,返回-1。
性能说明
  • 时间复杂度:O(amount×k)(最坏情况遍历所有金额和硬币);
  • 空间复杂度:O(amount)(队列和visited数组);
  • 优势:
    1. 实际运行效率更高,找到解后可立即终止,无需计算所有金额;
    2. 无需处理“无穷大”初始化等DP细节,逻辑更贴合“找最短路径”的直观思路;
  • 劣势:空间开销略高于DP(需维护队列和visited数组)。
解法2:记忆化递归(Top-Down,O(amount×k)时间)

核心方法:通过递归+记忆化缓存,自顶向下计算每个金额的最少硬币数,避免重复递归计算,逻辑更贴合“拆分问题”的直观思路。

代码实现
public int coinChange(int[] coins, int amount) {
    // 记忆化缓存:存储每个金额的最少硬币数,-2表示未计算,-1表示不可凑出
    int[] memo = new int[amount + 1];
    Arrays.fill(memo, -2);
    memo[0] = 0; // 金额0需要0枚硬币
    return dfs(coins, amount, memo);
}

private int dfs(int[] coins, int amount, int[] memo) {
    // 金额小于0,不可凑出
    if (amount < 0) {
        return -1;
    }
    // 已计算过,直接返回缓存值
    if (memo[amount] != -2) {
        return memo[amount];
    }
    
    int min = Integer.MAX_VALUE;
    // 遍历所有硬币,递归计算子问题
    for (int coin : coins) {
        int sub = dfs(coins, amount - coin, memo);
        // 子问题可凑出,更新最小值
        if (sub != -1) {
            min = Math.min(min, sub + 1);
        }
    }
    
    // 缓存结果:可凑出则存min,否则存-1
    memo[amount] = (min == Integer.MAX_VALUE) ? -1 : min;
    return memo[amount];
}
核心逻辑说明
  1. 记忆化缓存memo[amount]记录金额amount的最少硬币数,避免重复递归(如计算11元时重复计算6元、1元等子问题);
  2. 递归终止条件
    • amount < 0:返回-1(不可凑出);
    • amount == 0:返回0(基准条件);
  3. 递归递推:遍历所有硬币,计算amount - coin的最少硬币数,若子问题可凑出,则更新当前金额的最小值;
  4. 缓存结果:将计算结果存入memo,后续直接使用。
性能说明
  • 时间复杂度:O(amount×k)(每个金额仅计算一次);
  • 空间复杂度:O(amount)(递归栈深度+memo数组);
  • 优势:
    1. 自顶向下的递归逻辑更贴合“拆分问题”的直观思路;
    2. 无需计算所有金额,仅计算需要的子问题;
  • 劣势:递归栈有额外开销,amount=1e4时可能栈溢出(本题amount≤1e4,实际可通过限制递归深度避免)。

总结

  1. 完全背包DP法(第一次解答):O(amount×k)时间+O(amount)空间,逻辑经典,易实现,工程实践首选;
  2. BFS解法:O(amount×k)时间+O(amount)空间,实际运行效率更高,找到解后可立即终止;
  3. 记忆化递归:O(amount×k)时间+O(amount)空间,自顶向下解题,易于理解拆分逻辑;
  4. 关键技巧
    • 核心思想:零钱兑换是典型的完全背包问题,核心是“无限选硬币”的递推/递归逻辑;
    • 效率选择:追求代码简洁选DP,追求实际运行速度选BFS,学习阶段可选记忆化递归;
    • 边界处理:金额0返回0、不可凑出返回-1是核心边界条件,需重点关注。