经典动态规划凑零钱问题,给定面值的硬币如10块,5块,2块等,每一种面值的币数量上不设限制,要求在这些可选的面值用最少数量的硬币去凑齐指定金额的零钱,如果能凑齐则返回对应总数和每一种面值的硬币具体数量,如果不能凑齐则返-1.
工具函数
计算算法运行时间与消耗内存
def time_memory(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
tracemalloc.start()
start = time.perf_counter()
res = func(*args, **kwargs)
duration = (time.perf_counter() - start)*1000
current, peak = tracemalloc.get_traced_memory()
tracemalloc.stop()
print(f"{func.__name__} excution duration: {duration:.3f} ms")
print(f"{func.__name__} memory usage: current = {current / 1024:.3f} KB, peak = {peak / 1024:.3f} KB")
return res
return wrapper
穷举法
如果解决这个问题的时候想不到用动态规划的状态转移方程,那么穷举也可以实现,具体算法如下:
import math
from utils import time_memory
from collections import defaultdict
def coin_encode(decimal_num, coins_count: list[int], dim: int) -> list[int]:
"""
计算每一种方案对应每一种面值硬币的数量列表
相当于10进制数到不定进制数的转换
"""
res = []
while decimal_num:
curr = coins_count.pop()
res.append(decimal_num % curr)
decimal_num = decimal_num // curr
res = res[::-1]
if len(res) < dim:
res = [0] * (dim-len(res)) + res
return res
@time_memory
def change_base(coins: list[int], amount: int):
res = None
if coins is None or len(coins) == 0:
return res
sorted_coins = sorted(coins, reverse=True)
# 计算每一种面值的可取值上限
limit = defaultdict(int)
for i in sorted_coins:
tmp = math.floor(amount/i)
if tmp != 0:
limit[i] = tmp
if len(limit) == 0:
return res
# 穷举所有可能结果.
# 首先计算方案池,每一币种从0到max_value穷举所有肯能取值
total_count = 1
for v in limit.values():
total_count *= (v+1)
coin_dict = {}
for k, v in limit.items():
coin_dict[k] = v+1
plans = []
res_arr = []
for i in range(total_count):
tmp = coin_encode(i, list(coin_dict.values()), len(coin_dict.keys()))
plans.append(tmp)
for plan in plans:
tmp = list(zip(coin_dict.keys(), plan))
sum_res = 0
tmp_coin_count = 0
for item in tmp:
sum_res += item[0] * item[1]
tmp_coin_count += item[1]
if sum_res == amount:
total_coin_count = tmp_coin_count
res_arr.append(tmp + [total_coin_count])
if len(res_arr) == 0:
return -1
sorted_res = sorted(res_arr, key=lambda x: x[-1])
final = sorted_res[0][:-1]
final_count = 0
for item in final:
final_count += item[1]
return (final_count, final)
if __name__ == '__main__':
coins = [5,10,2,1]
amount = 15
count, coin_values = change_base(coins, amount)
if count != -1:
print(f'total count: {count} and coins: {coin_values}')
上面例子提供的面值是5块,10块,2块,1块,要求凑齐15块,运行结果如下:
change_base excution duration: 5.733 ms
change_base memory usage: current = 4.488 KB, peak = 105.595 KB
total count: 2 and coins: [(10, 1), (5, 1), (2, 0), (1, 0)]
10块1张,5块1张一共2张,结果和预期一致.
普通递归解法
此类动态规划问题的状态转移公式分析.这里先考虑总数最小这个问题,假设硬币面值有v1,v2,v3这几种,当要求解change(amount) 即总额为amount的对应的最少硬币币数量时,可以分别求解change(n-v1)+1, change(n-v2)+1,以及change(n-v3)+1三种情况的最小值则是change(n) 最小值,这就是这类问题的最根本的状态转移方程.在考虑边界情况如无法凑齐当前面值返回-1,这样计算总硬币数量最小值的算法代码如下.
import math
from utils import time_memory
from collections import defaultdict
@time_memory
def change_v1(coins: list[int], amount: int):
def inner(amount):
if amount == 0:
return 0
elif amount < 0:
return -1
else:
res = math.inf
for coin in coins:
tmp = inner(amount - coin)
# -1 表示无法凑齐
if tmp == -1:
continue
res = min(res, tmp + 1)
return res if res != math.inf else -1
return inner(amount)
if __name__ == '__main__':
coins = [5,10,2,1]
amount = 15
res = change_v1(coins, amount)
print(res)
在这个算法基础之上要找寻最少钱币数量对应的每种面值硬币取值就简单了.初始化一个字典,字典的key为每一种硬币面值,value为此种硬币的数量,初始化为0.每一次递归计算的时候,可以把当前最小值对应的面额的硬币加1即可.因此稍加改良上面的代码即可实现总数最小时每一种硬币的具体取值:
import math
from utils import time_memory
from collections import defaultdict
@time_memory
def change_v1_value(coins: list[int], amount: int):
def inner(amount):
if amount == 0:
return (0, defaultdict(int))
elif amount < 0:
return (-1, None)
else:
res = math.inf
curr_coin = None
coin_values = None
for coin in coins:
tmp, tmp_coin_values = inner(amount - coin)
if tmp == -1:
continue
if tmp + 1 <= res:
curr_coin = coin
coin_values = tmp_coin_values
res = min(res, tmp + 1)
if res != math.inf:
coin_values[curr_coin] += 1
return (res, coin_values)
else:
return (-1, None)
count, coin_values = inner(amount)
if count != -1:
res = []
for k, v in coin_values.items():
res.append((k, v))
return (count, res)
else:
return (count, coin_values)
if __name__ == '__main__':
coins = [5,10,2,1]
amount = 15
count, coin_values = change_v1_value(coins, amount)
if count != -1:
print(f'total count: {count} and coins: {coin_values}')
运行结果:
change_v1_value excution duration: 11.386 ms
change_v1_value memory usage: current = 1.119 KB, peak = 9.932 KB
total count: 2 and coins: [(5, 1), (10, 1)]
10块1张,5块1张一共2张.且这里看出来普通递归解法耗时还比穷举长.普通递归的算法时间复杂度依然是指数型.
带缓存的递归
同样分析上面的递归解法,依然存在重复计算的子问题,所以可以将计算结果缓存在一个字典中,这样可以让时间复杂度从指数变成线性,利用空间换时间的技巧,具体算法如下:
import math
from utils import time_memory
from collections import defaultdict
@time_memory
def change_v2_value(coins: list[int], amount: int):
cache = {}
def inner(amount):
if amount == 0:
return (0, defaultdict(int))
elif amount < 0:
return (-1, None)
else:
if amount in cache:
return cache[amount]
else:
res = math.inf
curr_coin = None
coin_values = None
for coin in coins:
tmp, tmp_coin_values = inner(amount - coin)
if tmp == -1:
continue
if tmp + 1 <= res:
curr_coin = coin
coin_values = tmp_coin_values
res = min(res, tmp + 1)
if res != math.inf:
coin_values[curr_coin] += 1
cache[amount] = (res, coin_values)
return (res, coin_values)
else:
cache[amount] = (-1, None)
return (-1, None)
count, coin_values = inner(amount)
if count != -1:
res = []
for k, v in coin_values.items():
res.append((k, v))
return (count, res)
else:
return (count, coin_values)
if __name__ == '__main__':
coins = [5,10,2,1]
amount = 15
count, coin_values = change_v2_value(coins, amount)
if count != -1:
print(f'total count: {count} and coins: {coin_values}')
运行输出结果:
change_v2_value excution duration: 0.120 ms
change_v2_value memory usage: current = 2.736 KB, peak = 4.119 KB
total count: 2 and coins: [(5, 1), (1, 2), (2, 2), (10, 1)]
动态规划实现
自底向上使用迭代代替递归且结合dp table的最终算法实现如下:
import math
from utils import time_memory
from collections import defaultdict
@time_memory
def change_v3_value(coins: list[int], amount: int):
# 初始化数组 数组长度为amount+1
count_init = [math.inf]*(amount + 1)
# dp[0][0]表示amount为0时的总硬币数量
dp = {}
for i in range(len(count_init)):
if i == 0:
# tuple第一位表示硬币数量最小值,第二个元素表示取最小值的时候对应的每一种面值的具体数量
dp[0] = [0, defaultdict(int)]
else:
dp[i] = [math.inf, None]
for i in range(len(dp)):
curr_coin = None
curr_coin_values = None
for coin in coins:
if i - coin < 0:
continue
if dp[i-coin][0] != math.inf and dp[i-coin][0] + 1 < dp[i][0]:
dp[i][0] = 1 + dp[i-coin][0]
curr_coin = coin
curr_coin_values = dp[i-coin][1]
if curr_coin:
# 浅拷贝
dp[i][1] = curr_coin_values.copy()
dp[i][1][curr_coin] += 1
if dp[amount][0] != math.inf:
tmp = dp[amount][1]
coin_values = []
for k, v in tmp.items():
coin_values.append((k, v))
return (dp[amount][0], coin_values)
else:
return (-1, None)
if __name__ == '__main__':
coins = [5,10,2,1]
amount = 15
count, coin_values = change_v3_value(coins, amount)
if count != -1:
print(f'total count: {count} and coins: {coin_values}')
运行结果:
change_v3_value excution duration: 0.076 ms
change_v3_value memory usage: current = 1.080 KB, peak = 5.893 KB
total count: 2 and coins: [(10, 1), (5, 1)]
可见耗时是最低的.
1334

被折叠的 条评论
为什么被折叠?



