题目描述
给你一个整数数组 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 <= 121 <= coins[i] <= 2^31 - 10 <= amount <= 10^4
记忆化搜索(自顶向下)
记忆化搜索(递归 + 记忆)使用递归来分解问题,并通过一个记忆数组存储已计算的子问题结果,避免重复计算。
核心思路
- 基准情况:
- 如果金额
amount为0,返回0。 - 如果金额为负,返回
-1表示无解。
- 如果金额
- 记忆化:
- 使用数组
count存储子问题的解,其中count[i]表示金额i+1的最少硬币数(数组下标从0开始)。 - 如果当前金额已经计算过,直接返回记忆的结果。
- 使用数组
- 递归计算:
- 遍历每个硬币,递归计算剩余金额
rem - coin的解。 - 取所有有效解中的最小值加
1(当前硬币占一个数量)。
- 遍历每个硬币,递归计算剩余金额
- 存储结果:
- 将当前金额的解存入记忆数组,并返回。
代码实现
1 | public int coinChange(int[] coins, int amount) { |
动态运行过程(以 coins = [1,2,5], amount = 11 为例):
- 初始调用
coinChange(coins, 11, new int[11]),其中count数组大小为11,初始化为0。 rem = 11,检查count[10](对应金额11,但尚未计算),进入循环。- 遍历硬币:
coin = 1:递归计算rem = 10。rem = 10,检查count[9](对应金额10,未计算),进入循环。- 类似地,递归计算
rem = 9, 8, ..., 0(深度优先)。 - 当
rem = 0时,返回0,然后回溯计算各金额的最小值。 - 例如,
rem = 1:计算rem = 0返回0,所以min = 1,count[0] = 1。 rem = 2:尝试硬币1(rem = 1返回1),硬币2(rem = 0返回0),所以min = min(1+1, 1+0) = 1,count[1] = 1。rem = 5:尝试硬币1、2、5,最小值为1(直接使用硬币5),count[4] = 1。rem = 10:尝试硬币1、2、5。当硬币5时,rem = 5返回1,所以min = 1 + 1 = 2。因此count[9] = 2(金额10的解)。
coin = 2:递归计算rem = 9,但可能已被计算过,直接使用count[8]。coin = 5:递归计算rem = 6,等。
- 对于
rem = 11,当硬币1时,rem = 10返回count[9] = 2,所以min = 2 + 1 = 3。 - 最终,
count[10](对应金额11)被设置为3。 - 过程中,每个金额最多计算一次,记忆数组避免了重复递归。
复杂度分析
- 时间复杂度:
O(amount * n),其中n是硬币种类数。每个金额计算一次,每次遍历所有硬币。 - 空间复杂度:
O(amount),用于记忆数组和递归调用栈。
动态规划(自底向上)
动态规划使用一个 dp 数组来存储子问题的解,从最小金额开始逐步构建到目标金额。
核心思路
- 初始化:
- 创建
dp数组,dp[i]表示金额i的最少硬币数。 - 设置
dp[0] = 0,其他初始化为Integer.MAX_VALUE(表示尚未解决)。
- 创建
- 遍历硬币:
- 对于每个硬币,从硬币面值开始遍历到总金额。
- 如果金额
j减去硬币面值有解(即dp[j - coin] != MAX_VALUE),则更新dp[j] = min(dp[j], dp[j - coin] + 1)。
- 返回结果:
- 如果
dp[amount]仍为MAX_VALUE,返回-1,否则返回dp[amount]。
- 如果
代码实现
1 | public int coinChange(int[] coins, int amount) { |
动态运行过程(以 coins = [1,2,5], amount = 11 为例):
- 初始化:
dp = [0, MAX, MAX, ..., MAX](长度12)。 - 处理硬币
1:j从1到11:dp[1] = min(MAX, dp[0] + 1) = 1,dp[2] = min(MAX, dp[1] + 1) = 2,…,dp[11] = 11。
- 处理硬币
2:j从2到11:j=2:dp[2] = min(2, dp[0] + 1) = 1。j=3:dp[3] = min(3, dp[1] + 1) = 2。j=4:dp[4] = min(4, dp[2] + 1) = 2。j=5:dp[5] = min(5, dp[3] + 1) = 3。- … 继续更新直到
j=11。
- 处理硬币
5:j从5到11:j=5:dp[5] = min(3, dp[0] + 1) = 1。j=6:dp[6] = min(3, dp[1] + 1) = 2。j=7:dp[7] = min(3, dp[2] + 1) = 2。j=8:dp[8] = min(4, dp[3] + 1) = 3。j=9:dp[9] = min(4, dp[4] + 1) = 3。j=10:dp[10] = min(5, dp[5] + 1) = 2。j=11:dp[11] = min(6, dp[6] + 1) = 3。
- 最终
dp[11] = 3,返回3。
复杂度分析
- 时间复杂度:
O(amount * n),因为外层循环遍历硬币,内层循环遍历金额。 - 空间复杂度:
O(amount),用于dp数组。
如何思考这道题
- 识别问题类型:这是一个优化问题(最小硬币数),且具有重叠子问题(同一金额可能多次计算)和最优子结构(当前金额的解依赖于子问题的解),适合用动态规划解决。
- 定义状态:通常定义
dp[i]为金额i所需的最少硬币数。 - 状态转移方程:对于每个硬币
c,如果dp[i - c]计算过,则dp[i] = min(dp[i], dp[i - c] + 1)。 - 初始化:
dp[0] = 0,因为金额0不需要硬币;其他初始化为一个大值,表示未知。 - 选择方法:
- 记忆化搜索:适合递归思维,避免重复计算,但可能有递归开销。
- 动态规划:直接迭代填充数组,通常更高效。
- 考虑边界:金额为
0时返回0;硬币面值可能大于金额,需跳过。 - 测试用例:用示例验证代码,例如
coins = [2], amount =3应返回-1。
通过以上步骤,可以系统地解决此类动态规划问题。实际应用中,自底向上的动态规划往往更受欢迎,因为它避免了递归开销且代码简洁。