文章目录
1. 题目
给定不同面额的硬币 coins
和一个总金额 amount
。编写一个函数来计算可以凑成总金额所需的最少的硬币个数。如果没有任何一种硬币组合能组成总金额,返回 − 1 -1 −1。
你可以认为每种硬币的数量是无限的。
1.1 示例
- 示例 1 1 1:
- 输入:
coins = [1, 2, 5]
,amount = 11
- 输出: 3 3 3
- 解释: 11 = 5 + 5 + 1 11 = 5 + 5 + 1 11=5+5+1
- 示例 2 2 2:
- 输入:
coins = [2]
,amount = 3
- 输出: − 1 -1 −1
- 示例 3 3 3:
- 输入:
coins = [1]
,amount = 0
- 输出: 0 0 0
- 示例 4 4 4:
- 输入:
coins = [1]
,amount = 1
- 输出: 1 1 1
- 示例 5 5 5:
- 输入:
coins = [1]
,amount = 2
- 输出: 2 2 2
1.2 说明
- 来源: 力扣(LeetCode)
- 链接: https://leetcode-cn.com/problems/coin-change/
1.3 提示
- 1 ≤ c o i n s . l e n g t h ≤ 12 1 \le coins.length \le 12 1≤coins.length≤12
- 1 ≤ c o i n s [ i ] ≤ 2 31 − 1 1 \le coins[i] \le 2^{31} - 1 1≤coins[i]≤231−1
- 0 ≤ a m o u n t ≤ 1 0 4 0 \le amount \le 10^4 0≤amount≤104
1.4 进阶
你可以进一步返回使用最少硬币凑成总金额时都使用了哪些硬币以及其个数么?
2. 解法一(直接递归)
2.1 分析
2.2 解答
from typing import List
class RecursiveSolution:
def _dp(self, n: int, coins: List[int]):
if n == 0:
return 0
if n < 0:
return -1
result = float('INF')
for coin in coins:
if self._dp(n - coin, coins) == -1:
continue
else:
result = min(result, 1 + self._dp(n - coin, coins))
return result
def coin_change(self, coins: List[int], amount: int) -> int:
result = self._dp(amount, coins)
if result != float('INF'):
return result
else:
return -1
def main():
coins = [1, 2, 5]
amount = 15
efficient_sln = RecursiveSolution()
print(efficient_sln.coin_change(coins, amount)) # 3
if __name__ == '__main__':
main()
实际上,上述解答在理论上是可行的,但是随着 amount
的增加,其时间复杂度会急剧快速的上升,原因在于存在重叠子问题。比如 amount = 11
且 coins = [1, 2, 5]
时画出递归树如下:
3 t ( n − 5 ) ≤ t n = t ( n − 1 ) + t ( n − 2 ) + t ( n − 5 ) ≤ t ( n − 1 ) 3t(n - 5) \le tn = t(n - 1) + t(n - 2) + t(n - 5) \le t(n - 1) 3t(n−5)≤tn=t(n−1)+t(n−2)+t(n−5)≤t(n−1)
3. 解法二(带备忘录递归)
3.1 分析
针对上述解法的问题,可以通过下面使用备忘录的方式进行改善。
3.2 解答
from typing import List
class EfficientRecursiveSolution:
def _dp(self, amount: int, coins: List[int], memo: dict):
if amount in memo.keys():
return memo[amount]
if amount == 0:
return 0
if amount < 0:
return -1
result = float('INF')
for coin in coins:
piece = self._dp(amount - coin, coins, memo)
if piece == -1:
continue
else:
result = min(result, 1 + piece)
memo[amount] = result
return result
def coin_change(self, coins: List[int], amount: int) -> int:
memo = dict()
result = self._dp(amount, coins, memo)
print(memo) # {1: 1, 2: 1, 3: 2, 4: 2, 5: 1, 6: 2, 7: 2, 8: 3, 9: 3, 10: 2, 11: 3, 12: 3, 13: 4, 14: 4, 15: 3}
if result != float('INF'):
return result
else:
return -1
def main():
coins = [1, 2, 5]
amount = 15
efficient_sln = EfficientRecursiveSolution()
print(efficient_sln.coin_change(coins, amount)) # 3
if __name__ == '__main__':
main()
- 执行用时: 1596 ms , 在所有 Python3 提交中击败了 15.67% 的用户;
- 内存消耗: 19.8 MB , 在所有 Python3 提交中击败了 10.90% 的用户。
3.3 复杂度
- 时间复杂度: 很显然备忘录大大减小了子问题数目,完全消除了子问题的冗余,所以子问题总数不会超过金额数 n n n,即子问题数目为 O ( n ) O(n) O(n)。处理一个子问题的时间不变,仍是 O ( k ) O(k) O(k),所以总的时间复杂度是 O ( k n ) O(kn) O(kn)。
4. 解法三(自下而上迭代)
4.1 分析
对于上述带备忘录递归的解法,其也有一个致命的问题,即递归是有最大深度限制的,在上述的测试案例中,当 coins = [1, 2, 5]
时,amount
不能超过 994 994 994 。
实际上,对此我们还可以采用自下而上的方式进行思考。定义 dp[i]
为组成金额 i
所需最少的硬币数量,假设在计算 dp[i]
之前,我们已经计算出 dp[0]
至 dp[i - 1]
的答案。 则 dp[i]
对应的状态转移方程应为:
d p [ i ] = min j = 0 ⋅ ⋅ ⋅ n − 1 d p [ i − c j ] + 1 dp[i] = \min \limits_{j=0\cdot\cdot\cdot{n-1}}dp[i-c_j]+1 dp[i]=j=0⋅⋅⋅n−1mindp[i−cj]+1
其中 c j c_j cj 代表的是第 j j j 枚硬币的面值,即我们枚举最后一枚硬币面额是 c j c_j cj ,那么需要从 i − c j i-c_j i−cj 这个金额的状态 d p [ i − c j ] dp[i-c_j] dp[i−cj] 转移过来,再算上枚举的这枚硬币数量 1 1 1 的贡献,由于要硬币数量最少,所以 d p [ i ] dp[i] dp[i] 为前面能转移过来的状态的最小值加上枚举的硬币数量 1 1 1 。
4.2 解答
from typing import List, Union
class BottomUpIterativeSolution:
@classmethod
def coin_change(cls, coins: List[int], amount: int) -> Union[float, int]:
dp = [float('INF')] * (amount + 1)
dp[0] = 0
for i in range(1, amount + 1):
piece = float('INF')
for coin in coins:
if i - coin >= 0 and piece > dp[i - coin] + 1:
piece = dp[i - coin] + 1
dp[i] = piece
print(dp) # [0, inf, 1, inf, 1, 1, 2, 2, 2, 2, 2, 3, 3, 3]
if dp[amount] != float('INF'):
return dp[amount]
else:
return -1
def main():
# coins = [1, 2, 5]
coins = [2, 4, 5]
amount = 13
print(BottomUpIterativeSolution.coin_change(coins, amount)) # 3
if __name__ == '__main__':
main()
- 执行用时: 712 ms , 在所有 Python3 提交中击败了 97.80% 的用户;
- 内存消耗: 14.8 MB , 在所有 Python3 提交中击败了 99.27% 的用户。
易知自下而上迭代法的求解效率要高于自上而下记忆法,因为后者需要维持递归状态所需的额外内存开销。
4.3 复杂度
- 时间复杂度: O ( k n ) O(kn) O(kn),其中 n n n 是金额, k k k 是面额数。我们一共需要计算 O ( n ) O(n) O(n) 个状态, n n n 为题目所给的总金额。对于每个状态,每次需要枚举 k k k 个面额来转移状态,所以一共需要 O ( k n ) O(kn) O(kn) 的时间复杂度。
- 空间复杂度: O ( n ) O(n) O(n)。数组
dp
需要长度为总金额 n n n 的空间。