322. 零钱兑换#
还记得小时候去超市找零钱的场景吗?收银员总能巧妙地用最少的硬币给我们找零。今天,让我们通过 LeetCode 322 题「零钱兑换」,一起探索这个生活中的动态规划问题。
🎯 问题的本质:生活中的场景#
想象你是一个收银员,手里有不同面值的硬币,需要找给顾客一定金额的零钱。你想用最少的硬币数完成找零。比如:
输入:coins = [1, 2, 5], amount = 11
输出:3
解释:11 = 5 + 5 + 1,用了3枚硬币plaintext这不就是我们日常生活中经常遇到的问题吗?看似简单,但要用程序解决,还真需要一番思考。
问题描述#
题目目标#
给定不同面额的硬币数组 coins 和一个总金额 amount ,计算可以凑成总金额所需的最少硬币个数。如果没有任何一种硬币组合能组成总金额,返回 -1。
示例 1#
输入: coins = [1,2,5], amount = 11
输出: 3
说明: 11 = 5 + 5 + 1
💡 从贪心到动态规划:思维的升华#
让我们像解开一个谜题一样,逐步深入这个问题:
第一步:贪心的陷阱#
初学者可能会想:每次都选最大的硬币不就好了吗? 比如要找 11 元,先用 5 元,然后再用 5 元,最后用 1 元。
但如果硬币面值是 [1, 3, 4],要找 6 元呢?
- 贪心:6 = 4 + 1 + 1(3枚)
- 最优:6 = 3 + 3(2枚)
这告诉我们:贪心策略在这个问题上并不可靠。
第二步:递归的思路#
站在终点思考:要凑出金额 n,我们可以:
- 使用第一种硬币,然后解决剩余金额
- 使用第二种硬币,然后解决剩余金额
- …以此类推
这就形成了递归的思路!
// 方法一:朴素递归(会超时)
class Solution {
public int coinChange(int[] coins, int amount) {
// 边界情况:目标金额为 0 时,不需要任何硬币
if (amount == 0) return 0;
// 边界情况:金额变成负数,说明当前选择路径无效
if (amount < 0) return -1;
// min 记录当前金额的最小硬币数,先初始化为极大值表示“尚未找到方案”
int min = Integer.MAX_VALUE;
// 枚举每一种硬币,尝试把它作为当前要用的一枚硬币
for (int coin : coins) {
// 递归求解“剩余金额 amount - coin”的最优解
int subResult = coinChange(coins, amount - coin);
// 只有子问题可达(>=0)时,当前路径才有效
if (subResult >= 0) {
// 当前方案硬币数 = 子问题硬币数 + 1(加上当前这枚)
min = Math.min(min, subResult + 1);
}
}
// 若 min 仍是初始极大值,表示无解,返回 -1;否则返回最小硬币数
return min == Integer.MAX_VALUE ? -1 : min;
}
}
// 方法二:记忆化递归
class Solution {
// memo[a] 记录凑出金额 a 的最优解:-2 表示未计算,-1 表示无解,其他非负数表示最少硬币数
private int[] memo;
public int coinChange(int[] coins, int amount) {
// 申请缓存数组,下标 0..amount
memo = new int[amount + 1];
// 初始化为 -2,表示“该金额状态尚未被递归计算过”
Arrays.fill(memo, -2); // 用-2表示未计算过
// 从目标金额开始递归搜索
return helper(coins, amount);
}
private int helper(int[] coins, int amount) {
// 边界情况:刚好凑到 0,说明当前路径合法且不需再加硬币
if (amount == 0) return 0;
// 边界情况:金额小于 0,说明超出了目标,路径无效
if (amount < 0) return -1;
// 若该状态已经算过,直接返回缓存结果,避免重复计算
if (memo[amount] != -2) return memo[amount];
// min 保存当前金额 amount 的最小硬币数
int min = Integer.MAX_VALUE;
// 逐个尝试使用每种硬币
for (int coin : coins) {
// 递归求解剩余金额
int subResult = helper(coins, amount - coin);
// 仅当剩余金额可凑出时才更新答案
if (subResult >= 0) {
// 当前候选解 = 子问题解 + 当前硬币 1 枚
min = Math.min(min, subResult + 1);
}
}
// 将当前金额的结果写入缓存:无解存 -1,有解存最少硬币数
memo[amount] = min == Integer.MAX_VALUE ? -1 : min;
// 返回缓存值
return memo[amount];
}
}
// 方法三:动态规划(最终优化版本)
class Solution {
public int coinChange(int[] coins, int amount) {
// dp[i] 表示凑出金额 i 所需的最少硬币数
int[] dp = new int[amount + 1];
// 初始化为 amount+1(不可能达到的上界),便于后续取最小值
Arrays.fill(dp, amount + 1); // 初始化为一个不可能的大数
// 基础状态:凑出金额 0 需要 0 枚硬币
dp[0] = 0;
// 从小到大计算每个金额 i 的最优解
for (int i = 1; i <= amount; i++) {
// 枚举可用硬币,尝试作为“最后一枚硬币”
for (int coin : coins) {
// 只有当硬币面值不超过当前金额时才可使用
if (i >= coin) {
// 状态转移:dp[i] = min(dp[i], dp[i-coin] + 1)
dp[i] = Math.min(dp[i], dp[i - coin] + 1);
}
}
}
// 若 dp[amount] 仍大于 amount,说明无解;否则返回最优值
return dp[amount] > amount ? -1 : dp[amount];
}
}java第三步:认识状态转移#
动态规划的精髓在于找到状态转移方程: dp[i] = min(dp[i-coin] + 1) for coin in coins
这个方程告诉我们:凑出金额i的最少硬币数,等于凑出金额(i-coin)的最少硬币数加1,其中coin遍历所有可用的硬币。
🔍 动态规划的关键要素#
通过零钱兑换这个问题,我们可以总结出动态规划的要点:
- 状态定义:dp[i]表示凑出金额i所需的最少硬币数
- 状态转移:dp[i] = min(dp[i-coin] + 1) for coin in coins
- 初始状态:dp[0] = 0
- 遍历顺序:从小到大,因为大的金额依赖小的金额
💡 举一反三#
理解了零钱兑换,你会发现很多问题都是类似的:
- 完全背包问题
- 最小路径和
- 单词拆分
🎯 思考题#
如果硬币的数量有限制(每种硬币最多只能用k次),该如何修改代码?
🎓 面试建议#
遇到类似的动态规划问题,建议这样思考:
- 先想暴力解法,找到问题的递归本质
- 分析重叠子问题,考虑是否需要记忆化
- 寻找状态转移方程,优化为动态规划
- 考虑空间优化的可能性
记住,动态规划的威力不在于它的复杂,而在于它帮我们把复杂的问题分解成简单的子问题。就像零钱兑换,看似复杂,其实就是不断选择硬币,累积最优解的过程。
每个算法问题都是一个小故事,而动态规划就是将这些故事用最优雅的方式讲述出来的艺术。