问题描述
给你一个整数数组 coins
,表示不同面额的硬币;以及一个整数 amount
,表示总金额。
计算并返回可以凑成总金额所需的 最少的硬币个数 。如果没有任何一种硬币组合能组成总金额,返回 -1
。
你可以认为每种硬币的数量是无限的。
示例 1:
1 | 输入:coins = [1, 2, 5], amount = 11 |
示例 2:
1 | 输入:coins = [2], amount = 3 |
示例 3:
1 | 输入:coins = [1], amount = 0 |
约束条件:
- 1 <= coins.length <= 12
- 1 <= coins[i] <= 2^31 - 1
- 0 <= amount <= 10^4
错误解法与分析
我的初始解法是尝试使用动态规划,但实现方式有误:
1 | func coinChange(coins []int, amount int) int { |
错误原因分析
这个解法存在几个问题:
-
状态转移方程错误:在使用 k 个当前面值的硬币时,应该是
dp[j-k*coin]+k
,而不是dp[j-k*coin]+1
。这是因为我们使用了 k 个硬币,所以应该加上 k。 -
不必要的三重循环:即使修正了状态转移方程,这种实现方式也有严重的效率问题。三重循环导致时间复杂度高达 O(amount^2 * n),其中 n 是硬币数量。最内层循环(枚举使用硬币的个数)是不必要的。
-
理解完全背包问题的本质:这是一个典型的完全背包问题,每种物品(硬币)可以重复选择,目标是使用最少的物品达到特定容量(金额)。对于完全背包问题,有更高效的解法。
正确解法一:标准动态规划(自底向上)
1 | func coinChange(coins []int, amount int) int { |
解法一分析
这是完全背包问题的标准解法,时间复杂度为 O(amount * n),其中 n 是硬币数量。
状态定义:
dp[i]
表示凑成金额 i 所需的最少硬币数
状态转移方程:
dp[i] = min(dp[i], dp[i-coin]+1)
,其中 i ≥ coin
解题步骤:
- 初始化 dp 数组,除了 dp[0] = 0 外,其他位置设为一个很大的数(表示暂时无法达到)
- 对每种硬币,遍历从该面值到 amount 的所有金额
- 对于金额 j,考虑使用当前硬币后的最少硬币数:当前状态 dp[j] 与 dp[j-coin]+1 取较小值
- 最后,如果 dp[amount] 仍为初始大值,返回 -1;否则返回 dp[amount]
正确解法二:记忆化搜索(自顶向下)
1 | func coinChange(coins []int, amount int) int { |
解法二分析
这种方法使用记忆化搜索(自顶向下的动态规划)来解决问题,时间复杂度同样为 O(amount * n)。
递归函数定义:
dp(remaining)
返回凑成金额 remaining 所需的最少硬币数
递归转移关系:
dp(remaining) = min(dp(remaining - coin) + 1) for coin in coins
,前提是 remaining ≥ coin
解题步骤:
- 创建记忆化数组,初始值为 -1,表示状态尚未计算
- 定义递归函数,对于每个金额,尝试使用每种硬币
- 对于每种硬币,计算使用后剩余金额所需的最少硬币数
- 取所有可能中的最小值
- 使用记忆化数组避免重复计算
两种方法的比较与思考
方法对比表
对比维度 | 解法一:自底向上 DP | 解法二:自顶向下记忆化搜索 |
---|---|---|
实现方式 | 迭代 | 递归 |
思考顺序 | 从小问题构建大问题 | 从大问题分解为小问题 |
时间复杂度 | O(amount * n) | O(amount * n) |
空间复杂度 | O(amount) | O(amount) + 递归栈空间 |
实际效率 | 通常更快(无递归开销) | 可能稍慢(有递归调用开销) |
代码简洁度 | 较为简洁 | 较为直观,符合思考过程 |
适用场景 | 状态转移清晰的问题 | 递归思路更容易理解的问题 |
易理解性 | 对初学者可能较难理解 | 更符合直觉的思考方式 |
调试难度 | 相对容易追踪状态 | 递归调用栈可能较难追踪 |
详细比较
-
自底向上 vs 自顶向下:
- 自底向上的方法(解法一)从小问题开始解决,逐步构建大问题的解
- 自顶向下的方法(解法二)从大问题开始,分解成小问题,并使用记忆化避免重复计算
- 两种方法的时间复杂度相同,但实际运行效率可能因具体问题而异
-
空间复杂度:
- 两种方法的空间复杂度均为 O(amount),用于存储 dp 数组或记忆化数组
- 自顶向下方法有额外的递归调用栈开销,最坏情况为 O(amount)
-
错误原因深入思考:
- 我的错误代码试图通过三重循环来枚举所有可能性,但这种方法效率低下且更容易出错
- 标准的完全背包问题解法通过巧妙的动态规划转移关系,避免了显式枚举的需要
- 在动态规划中,状态转移方程的正确设计是关键,需要深入理解问题的本质
学习总结
通过这个错题,我学到了:
- 对于完全背包问题,标准的解法是两层循环,外层遍历物品,内层正序遍历金额
- 动态规划问题中,状态定义和转移方程的正确性至关重要
- 优化算法时,不仅要考虑正确性,还要考虑时间和空间复杂度
- 记忆化搜索是解决动态规划问题的另一种思路,有时更直观
- 在解决问题时,理解问题本质比死记硬背算法模板更重要
这个题目是动态规划中的经典问题,理解了它的解法,可以帮助解决许多类似的完全背包问题。