322:零钱兑换(三种方法)

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

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

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

示例 1:

ini 复制代码
输入: coins = [1, 2, 5], amount = 11
输出: 3 
解释: 11 = 5 + 5 + 1

示例 2:

ini 复制代码
输入: coins = [2], amount = 3
输出: -1

示例 3:

ini 复制代码
输入: coins = [1], amount = 0
输出: 0

提示:

  • 1 <= coins.length <= 12
  • 1 <= coins[i] <= 231 - 1
  • 0 <= amount <= 104

🧩 解法一:记忆化搜索(DFS + 递归 + 备忘录)

思路

  • 自顶向下,递归定义函数 dfs(amount):凑出金额 amount 需要的最少硬币数。
  • 枚举每一枚硬币 coin,递归计算 dfs(amount - coin),取最小值。
  • 使用 备忘录 memo 避免重复计算,减少指数级爆炸。

代码

ini 复制代码
import java.util.Arrays;

class Solution {
    // memo[a] 保存凑出金额 a 所需的最少硬币数:
    //   -2 表示还没计算过(unknown)
    //   -1 表示无法凑出(impossible)
    //   >=0 表示最少硬币数(可行解)
    private int[] memo;

    public int coinChange(int[] coins, int amount) {
        if (amount == 0) return 0;

        memo = new int[amount + 1];
        Arrays.fill(memo, -2); // -2 表示尚未计算

        return dfs(coins, amount);
    }

    private int dfs(int[] coins, int amount) {
        if (amount == 0) return 0;   // base case: 0 元需要 0 枚硬币
        if (amount < 0) return -1;   // base case: 无效金额

        if (memo[amount] != -2) return memo[amount];

        int res = Integer.MAX_VALUE;
        for (int coin : coins) {
            int sub = dfs(coins, amount - coin);
            if (sub >= 0 && sub < res) {
                res = sub + 1;
            }
        }

        memo[amount] = (res == Integer.MAX_VALUE) ? -1 : res;
        return memo[amount];
    }
}

特点

  • 优点:递归直观,写法简洁,带备忘录性能大幅提升。
  • 缺点 :递归深度可能很大(当 amount 较大且硬币最小为 1 时),可能栈溢出。

🧩 解法二:广度优先搜索(BFS)

思路

  • amount 看作图中的目标节点,0 为起点,每个硬币 coin 相当于一条边。
  • BFS 一层一层扩展,每扩展一层意味着多用一枚硬币。
  • 第一次到达目标金额时,一定是最少硬币数。

代码

ini 复制代码
import java.util.*;

class Solution {
    public int coinChange(int[] coins, int amount) {
        if (amount == 0) return 0;

        boolean[] visited = new boolean[amount + 1];
        Queue<Integer> q = new ArrayDeque<>();
        q.add(0);
        visited[0] = true;

        int steps = 0;
        while (!q.isEmpty()) {
            steps++;
            int size = q.size();
            for (int i = 0; i < size; i++) {
                int cur = q.poll();
                for (int coin : coins) {
                    int nxt = cur + coin;
                    if (nxt == amount) return steps;
                    if (nxt < amount && !visited[nxt]) {
                        visited[nxt] = true;
                        q.add(nxt);
                    }
                }
            }
        }
        return -1;
    }
}

特点

  • 优点:天然保证第一次找到解就是最优解;避免深递归问题。
  • 缺点:需要维护队列和 visited 数组,可能会占用较多空间。

🧩 解法三:动态规划(自底向上 DP)

思路

  • 定义 dp[i] = 凑出金额 i 所需的最少硬币数。

  • 初始值:dp[0] = 0,其他设为一个大数。

  • 转移方程:

    dp[i]=min⁡coin∈coins(dp[i−coin]+1)dp[i] = \min_{coin \in coins}(dp[i - coin] + 1)

  • 最终返回 dp[amount]

代码

ini 复制代码
import java.util.*;

class Solution {
    public int coinChange(int[] coins, int amount) {
        if (amount < 0) return -1;
        if (amount == 0) return 0;

        int[] dp = new int[amount + 1];
        Arrays.fill(dp, amount + 1);
        dp[0] = 0;

        for (int i = 1; i <= amount; i++) {
            for (int coin : coins) {
                if (i - coin >= 0) {
                    dp[i] = Math.min(dp[i], dp[i - coin] + 1);
                }
            }
        }

        return dp[amount] > amount ? -1 : dp[amount];
    }
}

特点

  • 优点:迭代过程无递归,稳定可靠;最常用,面试友好。
  • 缺点 :需要 O(amount * n) 时间,当 amount 很大时可能较慢。

🔎 三种方法总结对比

方法 思路 时间复杂度 空间复杂度 优缺点
记忆化 DFS 自顶向下递归 + 备忘录 O(n * amount) O(amount)(+递归栈深度) 写法直观,但可能栈溢出
BFS 图的层次遍历 O(n * amount) O(amount)(队列+visited) 找到解即最优,不会递归爆栈
DP(自底向上) 状态转移,迭代计算 O(n * amount) O(amount) 稳定高效,面试/比赛最推荐

一、统一的建模视角(出发点)

把题目统一抽象成两种常见模型,会帮助理解三种算法之间的关系:

  1. 最短路径 / 最短步数问题(无权图)

    • 把每个金额 x(0..amount)看作图中的一个节点;从节点 x 可以通过一条边到达 x + coin(对于所有 coin),边权为 1(使用一枚硬币)。
    • 问题变为:从起点 0 到终点 amount 的最短路径长度(最少用多少条边)------这是 BFS 的自然模型。
  2. 动态规划(重叠子问题 + 最优子结构)

    • 定义 dp[i] = 凑出金额 i 的最少硬币数,则有递推:dp[i] = min(dp[i - coin] + 1)(遍历所有 coin)。
    • 这是典型的「自底向上」DP 模型,对应自顶向下的记忆化递归。

理解了这两个视角,三种方法就是同一问题的三种等价/相关求解策略:BFS 是最短路、DP/记忆化是动态规划(状态压缩与缓存)。


二、方法一 --- 记忆化递归(自顶向下 DP / DFS + memo)

1) 思路(非常细)

  • 设函数 f(x)凑出金额 x 最少需要的硬币数(若凑不出则为 -1)。
  • 枚举任意一枚硬币 c:如果 f(x - c) 可行(≥0),则 f(x) 可通过 f(x - c) + 1 得到。对所有 c 取最小。
  • 直接递归会产生大量重复子问题(例如 f(6) 可能被多条路径重复计算),因此用 memof(k) 的结果缓存起来:第一次计算 f(k) 时写入 memo[k],后续直接返回缓存值。

2) 递归方程(数学式)

scss 复制代码
f(0) = 0
f(x) = -1,  x < 0
f(x) = min_{c in coins} { f(x - c) + 1 }  (若所有 f(x - c) 都为 -1 则 f(x) = -1)

3) 正确性与不变式

  • 不变式 :在计算 f(x) 时,memo[y](y < x)保存正确的最少硬币数或不可达状态。通过数学归纳法可证明:当递归返回时 f(x) 是正确的最优值(因为它是基于所有更小子问题的最小值构成的)。
  • 记忆化只是把重复子问题的计算缓存,不改变递归分解本身,因此正确性不受影响。

4) 复杂度推导

  • 每个 amount(0..amount)最多 会被实际计算一次并写入 memo
  • 对于每次计算 f(x),需要枚举 n = coins.length 个硬币来比较,所以总体时间复杂度约为 O(amount * n)
  • 空间复杂度:O(amount) 用于 memo;另外递归栈深度在最坏情况下可能达 O(amount / min_coin)(当最小面额为 1 时接近 O(amount)),会有栈溢出风险。

5) 实现细节 & 常见坑(Java)

  • memo 的初始化与哨兵值 :不要用 Integer.MAX_VALUE 做未计算标记然后在比较时做 step+1(会溢出)。常用 -2 表示未计算,-1 表示不可达,>=0 表示可达最优值。
  • 递归深度 :当 amount 很大、coins 包含 1 时递归深度可能接近 amount,Java 会抛 StackOverflowError。若平台限制,考虑改成自底向上或 BFS。
  • 排序 coins 降序:在递归里先尝试大面额能更快找到较小解,从而更早剪枝(启发式)。

6) 可扩展的优化(工程化)

  • 使用一个**上界(upper bound)**来强化剪枝。比如先用贪心(尽量用大面额)得到一个可行解 best,再在递归中如果当前步数 >= best 就剪掉该分支。

    • 说明:贪心得到的 best 不是最优的通用证明,但作为上界用于剪枝是安全的(只会加速,不会破坏正确性)。
  • HashMap<Integer,Integer> 替代数组 memo:当 amount 很大但实际可达金额稀疏时节省内存。

7) 手工运行示例(coins = [1,2,5], amount = 11)

  • 调用 f(11)

    • 访问 f(10) (coin=1)、
    • f(9)(coin=2)、
    • f(6)(coin=5)
  • 假设先计算 f(6),其下继续递归到 f(5), f(4), f(1)f(5) 可能直接得 1(因为 coin=5),memo[5]=1,之后 f(6) 可以得到 f(6)=f(5)+1=2,写入 memo[6]

  • 当之后 f(11) 再次需要 f(6) 时直接读 memo[6],避免重复展开。最终 memo 会将 0..11 的必要子问题值都填好,每个 f(k) 实际计算一次。

8) 何时使用

  • 喜欢递归写法或需结合启发式剪枝时。适合 amount 不太大或能通过排序/贪心快速得到较小上界的情况。

三、方法二 --- 广度优先搜索(BFS)

1) 思路(非常细)

  • 把金额看成图的节点,边为 +coin(权重均为 1)。
  • 从起点 0 做层序遍历(BFS):第一层代表用 1 枚硬币能达到的金额集合,第二层代表用 2 枚硬币能达到的金额集合,依次类推。
  • 第一次遇到 amount 的层数即为最少硬币数,因为所有边权相同且 BFS 逐层扩展保证最短路径顺序。

2) 正确性(形式化说明)

  • 在无向/有向、且边权相同为 1 的图中,BFS 求得的层次数就是最短路径长度(经典图论结论)。这里每一步把金额加上某个 coin,对应一条边(有向,但无权),因此 BFS 可以直接得到最短步数。

3) 复杂度

  • 时间:最坏 O(amount * n) ------ 每个金额(0..amount)最多入队一次,每次处理枚举 n 个 coin。
  • 空间:O(amount) ------ visited 数组 + 队列(极端情况下队列收集大量节点)。

4) 实现细节 & 常见坑(Java)

  • visited 数组大小为 amount + 1,确保 next <= amount 才访问并入队。
  • 队列建议用 ArrayDeque<Integer>(比 LinkedList 性能好)。
  • 小心 nxt == amount 的检查要放在 nxt <= amount 之前,或正确处理 nxt==amount 的返回逻辑以避免越界。
  • amount 很大且内存受限时,visited 带来的内存压力可能成为瓶颈;可改用 BitSet(Java 的 BitSet 更紧凑)或 HashSet<Integer>(稀疏时更节省)。

5) 强化技巧与扩展

  • 双向 BFS(bidirectional BFS) :从 0 向上扩展,同时从 amountamount - coin 向下扩展,两边交汇时终止。通常能大幅降低搜索空间(约平方根级别的缩小),但实现复杂度稍高(需要两个 visited、两层交换扩展方向以保证扩展更小的一端)。

    • 注意:从 amount 向下扩展时,边为 -coin,但要保证 amount - coin >= 0
  • 排序 coins(降序) :先尝试大硬币可以使 BFS 在早期层快速覆盖大步长,可能在很多实例上先遇到 amount(启发式,不改变最坏复杂度)。

6) 还原方案路径(如果需要)

  • 如果需要知道最少硬币的具体组成,可以在 BFS 中维护 parent 映射(child -> parentamount -> (prev_amount, coin_used)),当到达 amount 时就可以回溯出具体的硬币序列。

7) 手工运行示例(coins=[1,2,5], amount=11)

  • step=1(用 1 个硬币能到达): {1,2,5}
  • step=2: 扩展 {1,2,5} 得到 {2,3,6,7,10}(去重后)
  • step=3: 扩展这些会产生 11 ------ 第一次到达 11 时 step=3,返回 3。

8) 何时使用

  • 当你希望尽早得到最短步数且不想用递归(避免栈溢出)时用 BFS。特别适合最优步数通常较小的场景。

四、方法三 --- 自底向上动态规划(迭代 DP)

1) 思路(非常细)

  • 建立数组 dp[0..amount]dp[i] 表示凑成金额 i 的最少硬币数。
  • 初始:dp[0] = 0,其余 dp[i] = INF(例如 amount + 1,因为最坏也不会超过 amount 枚硬币)。
  • 递推:对 i 从 1 到 amountdp[i] = min(dp[i], dp[i - coin] + 1)(若 i - coin >= 0)。
  • 最后若 dp[amount] == INF 则返回 -1,否则返回 dp[amount]

2) 归纳证明(正确性)

  • 基础:dp[0] = 0 正确。
  • 归纳:假设对于所有 k < idp[k] 为最优解。求 dp[i] 时,任何最优方案对于 i 的最后一步必然是使用某枚 coin,即存在一个 coin 使得该方案在金额 i - coin 时也为最优。因此 dp[i] = min(dp[i - coin] + 1) 覆盖所有可能的最后一步,取最小即为最优。由归纳可得正确性。

3) 复杂度

  • 时间:O(amount * n)(外层 amount,内层 n)。
  • 空间:O(amount)(dp 数组)。
  • 常数因子相对较小(紧凑的数组操作,函数/队列开销小)。

4) 实现细节 & 常见坑(Java)

  • 选用 INF = amount + 1 作为哨兵(比 Integer.MAX_VALUE 更安全,便于做 +1 操作而不溢出)。
  • dp 数组初始化为 amount + 1,最后 dp[amount] > amount 判为不可达。
  • 注意 amount 可能为 0 的边界直接返回 0。

5) 路径重建(如果需要)

  • 若想输出组成硬币序列,可在计算 dp[i] 时记录 choice[i](记录使 dp[i] 达到最优的 coin),最终从 amount 回溯得到硬币序列(每次 i -= choice[i])。

6) 空间优化

  • 这里已经用的是一维 dp(最小空间),无法进一步压缩(因为本来就是 1D 解)。对不同问题(比如计数组合数)可能需要二维,但本题一维足够。

7) 手工演示(coins=[1,2,5], amount=11)

建立 dp[0..11]

less 复制代码
dp[0]=0
i=1: dp[1]=min(dp[1], dp[0]+1)=1
i=2: dp[2]=min(dp[2], dp[1]+1, dp[0]+1)=1
i=3: dp[3]=min(dp[3], dp[2]+1, dp[1]+1)=2
i=4: dp[4]=2
i=5: dp[5]=1   (直接 coin=5)
i=6: dp[6]=dp[1]+1=2
...
i=11: dp[11]=3

最终 dp[11]=3

相关推荐
NAGNIP19 小时前
大模型框架性能优化策略:延迟、吞吐量与成本权衡
算法
美团技术团队20 小时前
LongCat-Flash:如何使用 SGLang 部署美团 Agentic 模型
人工智能·算法
Fanxt_Ja1 天前
【LeetCode】算法详解#15 ---环形链表II
数据结构·算法·leetcode·链表
侃侃_天下1 天前
最终的信号类
开发语言·c++·算法
茉莉玫瑰花茶1 天前
算法 --- 字符串
算法
博笙困了1 天前
AcWing学习——差分
c++·算法
NAGNIP1 天前
认识 Unsloth 框架:大模型高效微调的利器
算法
NAGNIP1 天前
大模型微调框架之LLaMA Factory
算法
echoarts1 天前
Rayon Rust中的数据并行库入门教程
开发语言·其他·算法·rust