01、关于满减优惠券可叠加使用场景下的动态规划算法
01、关于满减优惠券可叠加使用场景下的动态规划算法之前在一家公司做停车业务,做优惠券相关的内容。有...
2023-03-30之前在一家公司做停车业务,做优惠券相关的内容。有一期需求是关于满减优惠券可叠加使用场景下,为用户推荐最优的优惠券使用方案,特意在网上找了些资料学习,这里做个记录,方便学习。
后面在网上找到了类似的需求,放在了文章的最后,特别感谢原作者。
需求描述为:支付停车费时,有多张满减优惠券可用(可叠加使用),求最优策略(减免最多)。
(相关资料图)
准确描述为:
设共有n张优惠券C: [(V1, D1), (V2, D2), (V3, D3), ..., (Vn, Dn)],其中Vn为面值,Dn为减免值(对于一张优惠券Cx,满Vx减Dx),优惠券为单张,可叠加使用(使用过一张后,如果满足面值还可以使用其他优惠券)。求停车费用为M时,使用优惠券的最优策略:1.减免值最多,2.优惠券剩余最优(比如对于 C1 (2, 0.1) 、C2 (1, 0.1) 只能选择一张的最优取舍就是用C1留C2 )。
示例:
输入:
C = [(2, 1.9), (1, 1), (1, 0.1), (2, 0.1)] , M = 3
期望输出:
使用优惠券:[(2, 0.1), (2,1.9), (1,1)]
总减免:3
满减优惠券可叠加使用的场景下的解决方案,可背包算法相似,通过 动态算法规划算法背包问题 学习了下背包的思想。顺便了解一下动态规划能解决什么问题:
适用动态规划方法求解的最优化问题应该具备的两个要素:最优子结构和子问题重叠。——《算法导论》动态规划原理
优惠券问题看起来和背包问题很像,但是有一点不同。
图中,背包问题里面的数据为:在负重已知的前提下能装物品的最优总价值;优惠券问题里面的数据为总金额能使用优惠券的最优总减免值。
对于背包问题,如果负重为4,策略只能是拿B号物品,因为拿取B号之后负重还剩(4-3=1),再拿不了A号物品了(最终价值为1.5);
对于优惠券问题,如果金额为4,使用完B号优惠券之后,金额还剩(4-1.5=2.5),还可以再用A号优惠券的(最终减免值为2.5)。
总结这个不同就是:
而且因为这个不同,优惠券问题的数据对优惠券顺序是有要求的,不像背包问题中,总是负重减物品重量,剩余的重量直接去找上次最优再计算就好了。顺序问题分两种:
优惠券面额顺序对结果的影响
图中,将物品和券的顺序颠倒,对于背包问题,最后一行数据完全相同,对结果无影响;
对于优惠券问题,顺序变了结果会不一样。(因为需要满足优惠券(v,d), 中的v才能减去第二项,所以对顺序有要求)。
所以,不同面额 (V不同) 的优惠券,应该升序排列。
优惠券面额相同,不同减免值的顺序对结果的影响
因为背包思想是通过上一次的结果来铺垫下一次的值,所以从上往下需要先生成同额度的最优值。
所以,同面额不同减免值 (V同D不同) 的优惠券,应该降序排列。
排序示例为:
[ (2, 1.9), (1, 1), (1, 0.1), (2, 0.1)]
需排列为:
[ (1, 1), (1, 0.1), (2, 1.9), (2, 0.1),]
综以上 一点不同两种顺序的情况所述,使用背包之前需要排序(V升D降),按V升序,如果V相同,再按D降序排。再使用背包算法(大于V减去D)。
对于上面的排序要求,我们知道,需要使用具有稳定性的排序算法,下面使用归并排序来进行计算。注意:排序算法一定要具有稳定性,否则无效。
def merge(nums: list[int], left: int, mid: int, right: int) -> None: """ 合并左子数组和右子数组 """ # 左子数组区间 [left, mid] # 右子数组区间 [mid + 1, right] # 初始化辅助数组 tmp: list[int] = list(nums[left:right + 1]) # 左子数组的起始索引和结束索引 left_start: int = 0 left_end: int = mid - left # 右子数组的起始索引和结束索引 right_start: int = mid + 1 - left right_end: int = right - left # i, j 分别指向左子数组、右子数组的首元素 i: int = left_start j: int = right_start # 通过覆盖原数组 nums 来合并左子数组和右子数组 for k in range(left, right + 1): # 若“左子数组已全部合并完”,则选取右子数组元素,并且 j++ if i > left_end: nums[k] = tmp[j] j += 1 # 否则,若“右子数组已全部合并完”或“左子数组元素 <= 右子数组元素”,则选取左子数组元素,并且 i++ elif j > right_end or tmp[i] <= tmp[j]: nums[k] = tmp[i] i += 1 # 否则,若“左右子数组都未全部合并完”且“左子数组元素 > 右子数组元素”,则选取右子数组元素,并且 j++ else: nums[k] = tmp[j] j += 1def merge_sort(nums: list[int], left: int, right: int) -> None: """ 归并排序 """ # 终止条件 if left >= right: return # 当子数组长度为 1 时终止递归 # 划分阶段 mid: int = (left + right) // 2 # 计算中点 merge_sort(nums, left, mid) # 递归左子数组 merge_sort(nums, mid + 1, right) # 递归右子数组 # 合并阶段 merge(nums, left, mid, right)""" Driver Code """if __name__ == "__main__": nums: list[int] = [ 7, 3, 2, 6, 0, 1, 5, 4 ] merge_sort(nums, 0, len(nums) - 1) print("归并排序完成后 nums =", nums)
# coding:utf-8# 背包算法,解决满减优惠券叠加使用问题def coupon_bags(coupon, amount): """ 优惠券背包算法 param: coupon 优惠券数组 param: amount 金额 """ # 转换金额跨度(间隔): 元->角 span = 10 amount = int(amount*span) for i, v in enumerate(coupon): for j in range(len(v)): coupon[i][j] = int(coupon[i][j]*span) # 初始化结果数组,dps 存储满减值(背包算法结果) # dps_coupons 存储 dps 对应满减值下使用的 优惠券方案 dps = [] dps_coupons = [] # len(coupon)+1,这里为什么要加 1, # 记住 动态算法规划算法背包问题一文中的: dp[i][0] = dq[0][j]=0;原因就是这个 for i in range(len(coupon)+1): # 这里为啥是 amount+1,因为 dps 中的索引是从0开始的, # 索引0的位置处存的是金额0的满减值,所以这里需要 amount+1 dps.append(list((0,)*(amount+1))) # list 直接 * 生成的是同一list,用循环生成 dps_coupons.append([]) for j in range(amount+1): dps_coupons[i].append([]) for i in range(1, len(coupon)+1): # i 代表第 i 张优惠券,从 1 开始的 for j in range(1, amount+1): # j 代表的是 总金额M为:j # coupon[i-1][0] 代表的是优惠券的中的 V,即满 coupon[i-1][0] 减 coupon[i-1][1] if j < coupon[i-1][0]: # 金额j 达不到 满减的条件:V,取上一层对应位置的值 # 获取上个策略值 dps[i][j] = dps[i-1][j] dps_coupons[i][j] = dps_coupons[i-1][j] else: # 金额j 达到了 满减的条件:V # dps中的i代表的是第几张优惠券,j代表的是总金额为J时的最优满减值 # coupon[i - 1][1],代表的是:当前优惠券的减免值 # dps[i - 1][j - coupon[i - 1][1]],代表的是剩余金额对应的最优满减值 if dps[i - 1][j] > coupon[i - 1][1] + dps[i - 1][j - coupon[i - 1][1]]: # 上一行同列数据 优于 当前优惠券+剩余的金额对应的上次数据,取之前数据 dps[i][j] = dps[i-1][j] dps_coupons[i][j] = dps_coupons[i-1][j] else: # 选取当前+剩余 优于 上一行数据 dps[i][j] = dps[i-1][j-coupon[i-1][1]]+coupon[i-1][1] # dps_coupons[i-1][j-coupon[i-1][1]],值是list,需要拷贝,才不会影响之前的结果 dps_coupons[i][j] = dps_coupons[i-1][j-coupon[i-1][1]].copy() # 表示使用了第 i 张优惠券 dps_coupons[i][j].insert(0, list(coupon[i-1])) # 结果需返回数据原单位(元) result_coupons = dps_coupons[-1][-1].copy() for i, v in enumerate(result_coupons): for j in range(len(v)): result_coupons[i][j] = result_coupons[i][j]/span print(f"使用优惠券:{result_coupons} 总减免:{dps[-1][-1]/span}")# 优惠券coupon_items = [ [1, 1], [1, 0.1], [2, 1.9], [2, 0.1],]# 举例中的优惠券是最终顺序。确保优惠券已经排序过,多维升序(V升D降),此处省略coupon_bags(coupon_items, 3)"""coupon_items = [ [1, 0.6], [2, 0.7], [2, 1.3], [3, 2.3],]coupon_bags(coupon_items, 5)"""
dps,dps_coupons数据存储示意图
上面就是将上面两种算法结合在一起进行使用。
class CouponSort(object): """ 具有稳定性的归并排序 coupon_list: [[5, 1, 189], [6, 1, 200]] [5, 1, 189] --> 5: 总金额为5,1:减免值,189:此张优惠券对应数据库中的记录ID """ def __init__(self, coupon_list): tmp = coupon_list.copy() self.coupon_list = tmp self.length = len(coupon_list) def sort(self): left = 0 right = self.length self._merge_sort(left, right - 1, True) # 下面是当总金额M相同时,按照减免值降序排序 start, end, index = 0, 0, 0 while index != self.length: if index != self.length - 1: if self.coupon_list[index][0] != self.coupon_list[index + 1][0]: self._merge_sort(start, end, False) start = index + 1 end = start else: end += 1 else: self._merge_sort(start, end, False) index += 1 return self.coupon_list def _merge_sort(self, left, right, l2m): """ 归并排序 """ # 终止条件 if left >= right: return # 当子数组长度为 1 时终止递归 # 划分阶段 mid = (left + right) // 2 # 计算中点 self._merge_sort(left, mid, l2m) # 递归左子数组 self._merge_sort(mid + 1, right, l2m) # 递归右子数组 # 合并阶段 self._merge(left, mid, right, l2m) def _merge(self, left, mid, right, l2m): """ 合并左子数组和右子数组 """ # 左子数组区间 [left, mid] # 右子数组区间 [mid + 1, right] # 初始化辅助数组 tmp = list(self.coupon_list[left:right + 1]) # 左子数组的起始索引和结束索引 left_start = 0 left_end = mid - left # 右子数组的起始索引和结束索引 right_start = mid + 1 - left right_end = right - left # i, j 分别指向左子数组、右子数组的首元素 i = left_start j = right_start # 通过覆盖原数组 nums 来合并左子数组和右子数组 for k in range(left, right + 1): if l2m: i, j = self._little2max(i, j, k, left_end, right_end, tmp) else: i, j = self._max2little(i, j, k, left_end, right_end, tmp) def _little2max(self, i, j, k, left_end, right_end, tmp): # 若“左子数组已全部合并完”,则选取右子数组元素,并且 j++ if i > left_end: self.coupon_list[k] = tmp[j] j += 1 # 否则,若“右子数组已全部合并完”或“左子数组元素 <= 右子数组元素”,则选取左子数组元素,并且 i++ elif j > right_end or tmp[i][0] <= tmp[j][0]: self.coupon_list[k] = tmp[i] i += 1 # 否则,若“左右子数组都未全部合并完”且“左子数组元素 > 右子数组元素”,则选取右子数组元素,并且 j++ else: self.coupon_list[k] = tmp[j] j += 1 return i, j def _max2little(self, i, j, k, left_end, right_end, tmp): # 若“左子数组已全部合并完”,则选取右子数组元素,并且 j++ if i > left_end: self.coupon_list[k] = tmp[j] j += 1 # 否则,若“右子数组已全部合并完”或“左子数组元素 <= 右子数组元素”,则选取左子数组元素,并且 i++ elif j > right_end or tmp[i][1] >= tmp[j][1]: self.coupon_list[k] = tmp[i] i += 1 # 否则,若“左右子数组都未全部合并完”且“左子数组元素 > 右子数组元素”,则选取右子数组元素,并且 j++ else: self.coupon_list[k] = tmp[j] j += 1 return i, jdef _coupon_bags(coupon, amount): """ 优惠券背包算法 param: coupon 优惠券数组 param: amount 金额 """ # 转换金额跨度(间隔): 元->角 span = 10 amount = int(amount*span) for i, v in enumerate(coupon): for j in range(len(v)): coupon[i][j] = int(coupon[i][j]*span) # 初始化结果数组,dps 存储满减值(背包算法结果) # dps_coupons 存储 dps 对应满减值下使用的 优惠券方案 dps = [] dps_coupons = [] # len(coupon)+1,这里为什么要加 1, # 记住 动态算法规划算法背包问题一文中的: dp[i][0] = dq[0][j]=0;原因就是这个 for i in range(len(coupon)+1): # 这里为啥是 amount+1,因为 dps 中的索引是从0开始的, # 索引0的位置处存的是金额0的满减值,所以这里需要 amount+1 dps.append(list((0,)*(amount+1))) # list 直接 * 生成的是同一list,用循环生成 dps_coupons.append([]) for j in range(amount+1): dps_coupons[i].append([]) for i in range(1, len(coupon)+1): # i 代表第 i 张优惠券,从 1 开始的 for j in range(1, amount+1): # j 代表的是 总金额M为:j # coupon[i-1][0] 代表的是优惠券的中的 V,即满 coupon[i-1][0] 减 coupon[i-1][1] if j < coupon[i-1][0]: # 金额j 达不到 满减的条件:V,取上一层对应位置的值 # 获取上个策略值 dps[i][j] = dps[i-1][j] dps_coupons[i][j] = dps_coupons[i-1][j] else: # 金额j 达到了 满减的条件:V # dps中的i代表的是第几张优惠券,j代表的是总金额为J时的最优满减值 # coupon[i - 1][1],代表的是:当前优惠券的减免值 # dps[i - 1][j - coupon[i - 1][1]],代表的是剩余金额对应的最优满减值 if dps[i - 1][j] > coupon[i - 1][1] + dps[i - 1][j - coupon[i - 1][1]]: # 上一行同列数据 优于 当前优惠券+剩余的金额对应的上次数据,取之前数据 dps[i][j] = dps[i-1][j] dps_coupons[i][j] = dps_coupons[i-1][j] else: # 选取当前+剩余 优于 上一行数据 dps[i][j] = dps[i-1][j-coupon[i-1][1]]+coupon[i-1][1] # dps_coupons[i-1][j-coupon[i-1][1]],值是list,需要拷贝,才不会影响之前的结果 dps_coupons[i][j] = dps_coupons[i-1][j-coupon[i-1][1]].copy() # 表示使用了第 i 张优惠券 dps_coupons[i][j].insert(0, list(coupon[i-1])) # 结果需返回数据原单位(元) result_coupons = dps_coupons[-1][-1].copy() for i, v in enumerate(result_coupons): for j in range(len(v)): result_coupons[i][j] = result_coupons[i][j]/span print(f"使用优惠券:{result_coupons} 总减免:{dps[-1][-1]/span}") return result_coupons, dps[-1][-1]/spandef find_max_discount(coupon_list, amount): # 这里应该对 coupon_list 中的元素有要求,即 coupon_list[i][0] >= coupon_list[i][1] # 否则在计算 最优使用方案时会报错,即:dps[i - 1][j - coupon[i - 1][1]], # 要求:j - coupon[i - 1][1] >= 0 cs = CouponSort(coupon_list) sort_coupon = cs.sort() print(sort_coupon) return _coupon_bags(sort_coupon, amount)t = [[10, 1, 12], [20, 10, 43], [20, 15, 12], [24, 14, 1], [5, 4, 4], [66, 40, 15]]find_max_discount(t, 100)
1、使用一维数组存储结果值。
2、dps 间隔优化(如果优惠券有分,span为100,那数组就很大了)。
对于上面的优化点有机会再去思考
以上就是关于停车场优惠券可叠加使用的解决方案,特此记录下,文中有错误的地方,恳请指出,谢谢。特别感谢下面两位大佬!!!
关于满减优惠券叠加的背包算法
归并排序