背景
有这么一个需求,后台会给客户发优惠券。用这些券可以抵扣买基金时的手续费。
客户有多张优惠券可使用,手续费是确定的。
系统自动给算出来,要使用哪几张券,客户最划算。
规则有以下三条:
- 半月内过期的券优先使用
- 优先使用大额的优惠券
- 优惠券充足时,选出的组要不低于手续费,尽可能接近。
需求拆分
这样的要求很合理,对客户来讲也很人性化。
不太好实现,比较复杂,可对于合情合理的需要,理应努力实现其需求。
于是乎,开始构想应对方法,这个过程很有意思。
我提出了初步方案。
- 第一步分组,将快过期的券单独拿出来,优先选择
- 第二步,问题转化为从一堆券中,挑出组合,使其接近目标值。
解释下:
比如手续费是100元,如果快过期的券总面值120元,那就从快过期的券中,挑选近似100元的组合即可。
如果快过期券总面值是70元。那就是快过期券全用,在正常券中,挑选近似30元的组合。
总之,问题都转化为从一堆券中,挑出组合,使其接近某个目标值。
- 第三步,目标值加入后排序,问题转化为三种可能
将优惠券从大到小排序,那手续费与之相比,有三种可能,排在最后,排在最前,排在中间
。
若排在最后,那不用证明,取面值最小的优惠券即可,将其定义为方案A
。
若排在最前,那定义这方案B
,待会再讨论。
若排在中间,那就是两种方案取其一, 一种方案是排在它前面的,方案A解决,另一种是排在它后面的方案B解决。优先其中一种方案
最后一句是什么意思?
比如有10张券,手续费排在第3张与第4张券之前,
那相当于,前3张券看做一个整体,手续费排最后。
第4张券到第10张券看做一个整体,手续费用排在最前。
最终都会转化是排在最前,或排在最后的问题。
- 第四步,讨论方案B怎么实现。
方案B 对应的场景是,一堆优惠券,每张面值都比手续费小。
解决分为两种情况
所有优惠券面值总额小于等于手续费用,不用想,优惠券全部使用。
优惠券面值总额大于手续费用,那挑选合适的组合。
至此,将一个现实中需要一步一步抽象,问题逐步转化,变成一个单纯的算法问题。
有一组数中,每个都小于目标值,总和大于目标值。
挑选合适的组合,使其总和大于或等于目标值。
在所有符合条件的组合中,选择总和最小的那个组合。
算法选择
我首先想到的是,类似于贪心算法:
将所有的数,从大到小排列,逐个累加。
首次出现大于目标值时,停止 。
如上图,到第4个数停止,
取第1到第3的总和,计为SUM,(一定小于目标值)。
从第5个数开始与SUB相加,其和与目标值比较,
大于目标值,舍弃,试下一个。
直到首次出现,小于目标值时停止。
比如上图中,假设 SUM加上第8个数,
首次小于目标值,那取第7个数即可。
所选出的组合就是 1 2 3 7
智力所及,我能想到最好的方案,到此就结束了。
可回头想想,这是一种方案,有可能不是最优的。举个反例,暂时也没想出来。
比如上面那个例子中,首次出现大于目标值,舍弃第4个,那舍弃第3个,有时也是可行的,甚至能找出更优解。
我无法从数学上证明,给出的算法是最优解,但反例确实一时也没想出来
这就算是初步的方案,先完成需求,以后再优化。
没有完美的方案,本身这个需要条件就很多,
而且这三个条件有时还互斥。
想了好久,我还真的写出了个反例来。
优惠券金额 70, 40, 30, 21, 5, 手续费 120
按我给出的方案会选出 70, 40, 21 , 总计131
但如果选 70 , 30, 21, 总计 121,显然这个更合理
贪心算法失效了……
我曾想到用动态规划,可动态转移方程没想好。
更不好想的是,整个过程要记录路径,最终要输出,选了哪几张券。
晚上下班,继续想,看相关文章,最后,终于找到了更合理的方案。
用动态转移表法,更容易实现。直接上代码。
如果看代码吃力,请参考我之前写的这篇文章:初识动态规划
public class DpTest {
public static void main(String[] args) {
int expectValue = 90; // 目标值,即手续费是多少
int n = 20; // 优惠券数量
List<Node> list = getCouponList(n);
dp(expectValue,list,n);
}
/**
* 随机获取一组优惠券
*
* @param n
* @return
*/
private static List<Node> getCouponList(int n) {
List<Node> list = new ArrayList<>(n)