力扣解题-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),是本题最经典、易实现的解法。
核心逻辑拆解
零钱兑换的核心是“完全背包问题”(物品可重复选),核心思路是:
- DP数组定义:
dp[i]表示凑成金额i所需的最少硬币个数; - 初始条件:
dp[0] = 0:凑成金额0不需要任何硬币(递推的基础);- 其他
dp[i]初始化为amount+1(表示“无穷大”,即初始状态下该金额不可凑出,选择amount+1是因为最多需要amount个1元硬币,超过这个数即不可达);
- 状态转移逻辑:
- 对于每个金额
i(从1到amount),遍历所有硬币面额coin; - 若
i >= coin(硬币面额不超过当前金额),则dp[i] = min(dp[i], dp[i-coin] + 1)(凑i元的最少硬币数 = 不选当前硬币的解 vs 选当前硬币(凑i-coin元的解+1枚当前硬币)的最小值);
- 对于每个金额
- 结果判断:若最终
dp[amount] > amount,说明金额amount不可凑出,返回-1;否则返回dp[amount]。
具体执行逻辑
- 特殊情况处理:若
amount=0,直接返回0(无需硬币); - DP数组初始化:
- 数组长度为
amount+1(覆盖0到amount的所有金额); - 用
Arrays.fill(dp, amount+1)将所有元素初始化为“无穷大”; - 单独设置
dp[0] = 0(金额0的基准条件);
- 数组长度为
- 双层循环递推:
- 外层循环
i:遍历所有金额(从1到amount); - 内层循环
coin:遍历所有硬币面额; - 若
i >= coin,则更新dp[i] = Math.min(dp[i], dp[i-coin]+1);
- 外层循环
- 结果返回:若
dp[amount] > amount(仍为初始的“无穷大”),返回-1;否则返回dp[amount]。
执行流程可视化(以示例1 coins=[1,2,5]、amount=11为例)
| 金额i | 遍历硬币 | 条件(i>=coin) | dp[i-coin]+1 | dp[i]更新后 | 说明 |
|---|---|---|---|---|---|
| 1 | 1 | 是 | dp[0]+1=1 | 1 | 1元需1枚1元硬币 |
| 2 | 1 | 是 | dp[1]+1=2 | 2 | 先暂存2枚1元 |
| 2 | 2 | 是 | dp[0]+1=1 | 1 | 更新为1枚2元硬币 |
| 5 | 5 | 是 | dp[0]+1=1 | 1 | 5元需1枚5元硬币 |
| 11 | 5 | 是 | dp[6]+1=2+1=3 | 3 | 11元=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数组);
- 优势:
- 逻辑直观,完全贴合“完全背包”动态规划的经典范式;
- 代码简洁,无复杂数据结构,易实现和调试;
- 时间效率满足题目限制,工程实践中首选。
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;
}
核心逻辑说明
- BFS核心思想:
- 每一层代表“使用k枚硬币能凑出的金额”,第一层(step=1)是所有硬币面额,第二层(step=2)是两枚硬币的组合,以此类推;
- 找到amount时的step即为最少硬币数(BFS的特性是“最短路径优先”);
- 去重优化:
visited数组标记已处理的金额,避免重复入队(如1+2和2+1都得到3,只需处理一次); - 终止条件:
- 找到
next == amount,立即返回step(保证最少硬币数); - 队列为空仍未找到,返回-1。
- 找到
性能说明
- 时间复杂度:O(amount×k)(最坏情况遍历所有金额和硬币);
- 空间复杂度:O(amount)(队列和visited数组);
- 优势:
- 实际运行效率更高,找到解后可立即终止,无需计算所有金额;
- 无需处理“无穷大”初始化等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];
}
核心逻辑说明
- 记忆化缓存:
memo[amount]记录金额amount的最少硬币数,避免重复递归(如计算11元时重复计算6元、1元等子问题); - 递归终止条件:
amount < 0:返回-1(不可凑出);amount == 0:返回0(基准条件);
- 递归递推:遍历所有硬币,计算
amount - coin的最少硬币数,若子问题可凑出,则更新当前金额的最小值; - 缓存结果:将计算结果存入memo,后续直接使用。
性能说明
- 时间复杂度:O(amount×k)(每个金额仅计算一次);
- 空间复杂度:O(amount)(递归栈深度+memo数组);
- 优势:
- 自顶向下的递归逻辑更贴合“拆分问题”的直观思路;
- 无需计算所有金额,仅计算需要的子问题;
- 劣势:递归栈有额外开销,amount=1e4时可能栈溢出(本题amount≤1e4,实际可通过限制递归深度避免)。
总结
- 完全背包DP法(第一次解答):O(amount×k)时间+O(amount)空间,逻辑经典,易实现,工程实践首选;
- BFS解法:O(amount×k)时间+O(amount)空间,实际运行效率更高,找到解后可立即终止;
- 记忆化递归:O(amount×k)时间+O(amount)空间,自顶向下解题,易于理解拆分逻辑;
- 关键技巧:
- 核心思想:零钱兑换是典型的完全背包问题,核心是“无限选硬币”的递推/递归逻辑;
- 效率选择:追求代码简洁选DP,追求实际运行速度选BFS,学习阶段可选记忆化递归;
- 边界处理:金额0返回0、不可凑出返回-1是核心边界条件,需重点关注。