大家好,最近碰到一道动态币值规划问题,通过几种方法求解,深得其奥妙,也十分有趣,特将我的心路历程分享出来。
题目
币值规划目的
设计一种在给定的币值类型里规划出币值数量最少,币值总和与给定值一致的方案
解法一
改解法为此题已给定的方法,以下仅是我自己的理解。
# bz[i] = min{bz[i - bizhi[j]] + 1}, 且 其中 i >= bizhi[j], 0 <= j < bizhi.length
# 输出可找的币值方案
# path[i] 表示经过本次已给币值后所剩下的面值,即 i - path[i] 可得到本次所需的币值。
def paperNum(bizhi, n):#定义币值以及输入金额n
bz, path = [0] * (n + 1), [0] * (n + 1) # 初始化
for i in range(1, n + 1):
minNum = i # 初始化当前币值最优值
for c in bizhi: # 扫描一遍币值列表,选择一个最优值
if i >= c and minNum > bz[i - c] + 1:
minNum, path[i] = bz[i - c] + 1, i - c
bz[i] = minNum # 更新当前币值最优值
print('bz: ', bz)
print('path: ', path)
print('最少币值数:', bz[-1])#读取数组最后一个值
print('所需的币值', end=': ')
while path[n] != 0:
print(n - path[n], end=' ')
n = path[n]
print(n, end=' ')
if __name__ == '__main__':
bizhi = [100,70,50,20,10,5,2,1]# 输入可换的币值种类,总金额n
while True:
n = input("输入总金额: ")
n = int(n)
if n < 0:
print('输入数值无效,请重新输入')
if n>=0:
break
paperNum(bizhi, n)
输出结果:
这个解法初看有点难以理解,其思路是什么,为什么要这样写,当尝试输出一遍结果后再细看,解法相当之妙,其输出的bz
及path
不仅仅是针对输入的总金额"16",而是把"16"作为该序列的最大值进行遍历求解,即求出了小于等于16的所有金额的币值最优组合。
我们从输出的结果来试看问题,输入的总金额是16,则设置bz和path的列表长度为16+1,但我们现在不推算出16的结果,来推算15试试,bz[15]为2,所以用bizhi中的币值种类,所需最少币值数为2个,可想而知币值为[10, 5],使用path中的元素推算过程为:path[15]为5,第一个值为n-path[n]即10,将path[n]赋值给n,此时path[n]为0,则将n最为结果输出,结果为:[10, 5]
不得感叹下,求解方法上是动态化对1-n上的币值规划最少币值数,但在提取结果上依旧是单个数据提取,当给定数值过于大时,会有较长的等待时间。所以下一种方法是对一个数值进行规划,最后可以套用for循环构造数值映射字典。
解法二
根据已给定的bizhi列表及已有的知识构造bz_dict字典
# 手动构造币值区间字典
bz_dict = {3: {0: []},
2: {9: [70, 20],
8: [70, 10],
7: [70],
6: [50, 10],
5: [50],
4: [20, 20],
3: [20, 10],
2: [20],
1: [10],
0: [],
},
1: {9: [5, 2, 2],
8: [5, 2, 1],
7: [5, 2],
6: [5, 1],
5: [5],
4: [2, 2],
3: [2, 1],
2: [2],
1: [1],
0: []}
}
修改 解法一 中的paperNum
函数:
def paperNum(bizhi, n): # 定义币值以及输入金额n
# 手动构造币值区间字典
bz_dict = {3: {0: []},
2: {9: [70, 20],
8: [70, 10],
7: [70],
6: [50, 10],
5: [50],
4: [20, 20],
3: [20, 10],
2: [20],
1: [10],
0: [],
},
1: {9: [5, 2, 2],
8: [5, 2, 1],
7: [5, 2],
6: [5, 1],
5: [5],
4: [2, 2],
3: [2, 1],
2: [2],
1: [1],
0: []}
}
n_str = '0' * (3 - len(str(n))) + str(n)
bz = []
if len(n_str) > 3 or n_str[-3] in list('123456789'):
bz = [100] * int(n_str[:-2]) # 如果不能使用*号,改用for循环累加
n_str = n_str[-2:][::-1]
bz += bz_dict.get(2).get(int(n_str[1]))
bz += bz_dict.get(1).get(int(n_str[0]))
print('最少币值数: ', len(bz))
print('所需币值: ', bz)
乍一看比上一种容易理解多了,主要原因也是这只是针对一个值进行币值规划,而且bz_dict在已知bizhi = [100,70,50,20,10,5,2,1]的情况下容易构建,而当bizhi中的元素发生了改变,paperNum
中的一切都需要改变,也没有解法一的通用性,所以得把bz_dict用代码动态构造。
经过一顿瞎操作终于搞定,最终代码:
# 单一金额币值规划
def paperNum(bizhi, n):#定义币值以及输入金额n
def get_bizhi_dict(bizhi):
"""
获取各分区币值组合,对于此题而言该步骤是多余的,完全可以用先验知识构造字典,
但这段可以使总体更加普适化,即当币值列表中币值发生了变化不用再次修改字典中的值。
:param bizhi: 币值列表
:return: 币值分区字典
"""
bizhi_dict = dict()
for i in bizhi:
N = len(str(i))
if N not in bizhi_dict.keys():
bizhi_dict[N] = [i]
else:
bizhi_dict[N].append(i)
for key, value in bizhi_dict.items():
bz_dict = dict()
for num in range(1, 10):
j = num * 10 ** (key - 1)
bz_list = []
for i in value:
quotient, j = divmod(j, i)
bz_list += [i] * quotient
bz_dict[num] = bz_list
bizhi_dict[key] = bz_dict
return bizhi_dict
bizhi_max = max(bizhi)
bz_dict = get_bizhi_dict(bizhi) # 获取币值区间字典
bz_keys_max = max(bz_dict.keys()) # bizhi最高位
n_str = '0' * (bz_keys_max - len(str(n))) + str(n) # 填补字符串长度,保证与bizhi最高位一致
bz = []
if len(n_str) > bz_keys_max:
bz = [bizhi_max] * len(range(bizhi_max, n, bizhi_max)) # 如果不能使用*号,改用for循环累加
n_str = str(n - sum(bz))
for i in range(1, bz_keys_max + 1): # 逐位获取币值
bz2_dict = bz_dict.get(i)
if not bz2_dict:
continue
bz += bz2_dict.get(int(n_str[::-1][i - 1]), [])
print('数据验证: ', sum(bz) == n)
print('最少币值数: ', len(bz))
print('所需币值: ', bz)
if __name__ == '__main__':
bizhi = [500,100,70,50,20,10,5,2,1]# 输入可换的币值种类,总金额n
while True:
n = input("输入总金额: ")
n = int(n)
if n < 0:
print('输入数值无效,请重新输入')
#continue
if n>=0:
break
paperNum(bizhi, n)
通过动态构造币值区间字典,再通过字符串化的数值,对逐个位数上的值进行币值规划,而且在bizhi列表发生变化时也不用更改paperNum
中的代码,在实际测试中也是只规划单个数值,所以当数值很大时,耗时也在心理层面上能够接受范围之内。
转念一想,毕竟对单个数值进行规划,与其一通乱搞瞎构造字典,不如直接计算更是妙哉啊。
解法三
使用求商取余的方式规化币值数
# 求商取余对单一金额进行币值规划
def paperNum(bizhi, n):#定义币值以及输入金额n
n_orig = n
bz = []
for i in bizhi:
bz_num, n = divmod(n, i) # 求商取余函数
bz += [i] * bz_num
print('数据验证: ', sum(bz) == n_orig)
print('最少币值数: ', len(bz))
print('所需币值: ', bz)
if __name__ == '__main__':
bizhi = [500,100,70,50,20,10,5,2,1]# 输入可换的币值种类,总金额n
while True:
n = input("输入总金额: ")
n = int(n)
if n < 0:
print('输入数值无效,请重新输入')
if n>=0:
break
paperNum(bizhi, n)
这段代码极大的简化解法二的繁琐步骤,且bizhi发生改变也不用更改paperNum
函数中的代码。
更新部分
后续计算中发现bizhi列表中有个值70,在实际生活中会考虑换算效率大概率不会有70面值的币,但在此题就需要解决,如140,正确输入为:最少币值数:2,所需币值:[70, 70],除解法一外另外两种解法均是[100, 20, 20],不满足题意,正当我着手解决时,转念一想210呢,规划方案有两种,分别为[100, 100, 20]和[70, 70, 70],应当把两种方案均输出,可见所有解法中均不满足,故接下仅更改解法三。
- 解法三_改
# 求商取余对单一金额进行币值规划(多方案归类)
def paperNum(bizhi, n): # 定义币值以及输入金额n
minNum = n_orig = n # 初始化币值数量
bizhi.sort(reverse=True) # 按币值从大至小排序
bizhi_length = len(bizhi)
result = []
for _ in range(bizhi_length):
bz = []
n = n_orig
for i in bizhi:
bz_num, n = divmod(n, i)
bz += [i] * bz_num
if not n: # 除数被整除中断循环
break
if len(bz) > minNum: # 币值数大于最优币值数跳出总循环
break
elif len(bz) == minNum and bz not in result: # 币值数等于最优币值数,且方案不存在增加方案
result.append(bz)
else: # 币值数小于最优币值数,更换方案
result = [bz]
minNum = len(bz)
bizhi = bizhi[1:] # 剔除最大币值
print('最少币值数: ', minNum)
print('所需币值: ', result)
if __name__ == '__main__':
bizhi = [100, 70, 50, 20, 10, 5, 2, 1] # 输入可换的币值种类,总金额n
while True:
n = input("输入总金额: ")
n = int(n)
if n < 0:
print('输入数值无效,请重新输入')
if n >= 0:
break
paperNum(bizhi, n)
思路:对逐个最大不币值不同的bizhi进行求解,并找出币值数最少的方案,虽然代码相较解法三多了不少,但在考虑条件上更加丰满,也消除了一些不必要的币值规划方案。
总结
一道题目求解方式有很多,计算的过程也各有不同,奈何我学识尚浅,今后的学习之路还很漫长。
二零二一年十一月二十五日作