凑零钱问题


经典动态规划凑零钱问题,给定面值的硬币如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)]

可见耗时是最低的.

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值