题目描述:给你一个整数数组 coins ,表示不同面额的硬币;以及一个整数 amount ,表示总金额。
计算并返回可以凑成总金额所需的 最少的硬币个数 。如果没有任何一种硬币组合能组成总金额,返回 -1 。
你可以认为每种硬币的数量是无限的。
示例:
输入:coins = [1, 2, 5], amount = 11
输出:3
解释:11 = 5 + 5 + 1
题解1:回溯+贪心(用回溯的模板,贪心的思想)
- 卡的点是不知道怎么在递归中返回count的结果,解决方法就是不用返回,直接用一个全局变量存取count就行
- 递归中变量的命名一定要注意!因为在处在同一层递归时,for循环还会在这层递归中循环多次。下面的代码里一定要注意在for循环里要用new_count和new_amount,在递归中的变量命名太容易犯错了,要根据需求确保在递归每一层中的命名是否需要改变。如果用count和amount代替new_count和new_amount的话,在递归返回到上一层后,然后在走下一个for循环时,因为for循环的条件的变量,所以变量发生改变的话会导致for循环的条件发生改变。然后就是在递归遍历for循环的这一层时count作为一个变量应该也是不能改变的!
class Solution:
def coinChange(self, coins: List[int], amount: int) -> int:
#采用贪心的思想,回溯的模板
if amount == 0:
return 0
coins.sort(reverse=True)
#声明了一个全局变量
global min_count
min_count = float("inf")
self.backtracking(coins,amount,0,0)
#考虑到没有面值能使得amount_last=0的情况
ans = min_count if min_count != float("inf") else -1
return ans
def backtracking(self,coins,amount,idx,count):
global min_count
if idx >= len(coins):
return
#这个循环的设置就表示了每一步先选最大面值的硬币
# 还表示了如果剩余的硬币面值大于总数也不行
for i in range(amount//coins[idx],-1,-1):
#如果用当前最大硬币的最大次数不行的话,在返回后,继续走当前for循环,for循环的amount变量在当前层应该不能被改变
new_amount = amount - i*coins[idx]
new_count = count + i
if new_amount == 0:
#因为每一步遍历都有一个新的count,所以不能直接将count赋值给min_count
min_count = min(min_count,new_count)
break
#因为这里amount没有等于0,所以后面还至少都还需要一个硬币,
#那么就直接可以不尝试了
if count >= min_count -1:
break
self.backtracking(coins,new_amount,idx+1,new_count)
时间复杂度:O(n2)
空间复杂度:O(n)
refer:参考题解
题解2: 暴力递归
递归暴力求解,当我们面试过程中遇到这种问题时,如果一时没有思路,也要想到一种万能算法–暴力破解。这种方法为什么能找到最优值呢?因为暴力求解就是指遍历出所有的可能性,就是穷举所有的情况,然后每次都和最优值进行比较,最后取到最优值。
大家发现了没有,我们的问题提可以不断被分解为「我们用现有的币值凑出 n 最少需要多少个币」,比如我们用 f(n) 函数代表 「凑出 n 最少需要多少个币]。
原问题:凑齐amount金额,最少需要多少枚硬币
子问题:凑齐amount−coin金额,最少需要多少枚硬币
class Solution:
def coinChange(self, coins: List[int], amount: int) -> int:
if amount == 0:
return 0
if amount<0:
return -1
res = float("inf")
for i in coins:
#subproblem为子问题,表示面值为amount时所需的最小硬币数
subproblem = self.coinChange(coins,amount-i)
if subproblem == -1:
continue
#???res和subproblem不会被上层覆盖
#不会 因为每一层都把当前的res返回给上一层了 这是因为代码最后有个return
#也就是说上一层保存的subproblem变量值就是这一层最后的res
#然后返到上一层后,res又被马上更新成subproblem+1的值
res = min(res,subproblem+1)
if res == float("inf"):
return -1
else:
return res
时间复杂度:对于一些比较正常的数据,这个代码都能正常运行,但是对于LeetCode中的[186,419,83,408] 6249,标准答案是20(最少用20个硬币),也就是递归至少有20层,因此计算量至少是3^20(每次节点数为3的n-1次方,然后知道层数后,将每层的节点数用等比数列公式求和就是总节点数),但是递归深度可以达到75层(全取最小面值83时),所以时间复杂度应该是3的75次方,计算量就是一个天文数字了。
refer:
动态规划引入篇: 逐步探索找零问题
动态规划问题:零钱兑换的递归、备忘录、动态规划解法(Python)
题解3:在暴力递归的基础上加入备忘录(记忆化搜索),把存在重复的节点全部剔除,只保留一个节点即可,这样在遍历时,碰到直接见过的就直接拿来用的就行了,节省了时间复杂度。剔除完最后只会留下线性的节点形式,这个带备忘录的递归算法时间复杂度只有O(n),所以就是由暴力解的指数级的时间复杂度减少到线性时间复杂度了,已经跟动态规划的时间复杂度相差不大了。那么这不就可以了吗?为什么还要搞动态规划?因为递归仍然存在一个问题,就是爆栈!:
class Solution:
def coinChange(self, coins: List[int], amount: int) -> int:
#声明了个全局变量,所有函数都能看到,这里如果把memo作为参数传进recursion函数也是可以的,因为是引用传递
global memo
memo = [-100 for i in range(amount+1)]
return self.recursion(coins,amount)
def recursion(self,coins,amount):
if amount == 0:
return 0
if amount<0:
return -1
if memo[amount] != -100:
return memo[amount]
res = float("inf")
for i in coins:
#subproblem为子问题,表示面值为amount时所需的最小硬币数
subproblem = self.recursion(coins,amount-i)
if subproblem == -1:
continue
#???res和subproblem不会被上层覆盖
res = min(res,subproblem+1)
if res == float("inf"):
memo[amount] = -1
else:
memo[amount] = res
return memo[amount]
题解4:用动态规划算法,虽然带备忘录的递归算法时间复杂度只有O(n),但是由于递归深度的原因需要 O(n) 的空间复杂度,容易爆栈(每一个函数调用,都要在栈区创建一个空间,能用动态规划或者迭代,就不用递归,因为递归太耗堆栈了,效率不高)。而换成迭代之后我们根本不需要如此多的的储存空间,这个技巧就是所谓的「状态压缩」,来缩小 DP table 的大小,只记录必要的数据,空间复杂度缩小到常数级别。但我们这里的题解并没有用这个方法,而是用一个 DP table 存储了所有的状态。
过大数组导致爆栈的解决方法记录(堆栈)
class Solution:
def coinChange(self, coins: List[int], amount: int) -> int:
#初始化备忘录,用amount+1填满备忘录,amount+1 表示该值不可以用硬币凑出来
dp = [amount+1 for _ in range(amount+1)]
#设置初始条件为 0
dp[0] = 0
#去遍历这个数组
for i in range(len(dp)):
for coin in coins:
#根据动态转移方程求出最小值
if i-coin<0:
continue
dp[i] = min(dp[i],1+dp[i-coin])
#如果 `dp[amount] === amount+1`说明没有最优解返回-1,否则返回最优解
if dp[amount] == amount+1:
return -1
else:
return dp[amount]