通过排列组合获取最大报销金额

前言

  工作福利,月末可以报销一些吃饭开的发票,每个月我拿着金额不等的多张发票算来算去都觉得挺麻烦,想着写段代码执行下直接算出使用哪几张发票报销,金额也可最接近可报销额度。回顾了一下学的算法(好久不看很多都忘了- -!,顺便搜索了网上的解决思路),(1)最暴力的就是排列组合,找出所有可能,发票总和和报销额度差值最小即为解 (2)0-1背包也适合解决这个问题 (3)基于搜索的思路去组合发票


一、排列组合

  借鉴(照抄= =)了一老哥的思路,在数组中找出n个数相加,使得最接近数num。枚举所有数组中数字组合,加和与num比较,取绝对值最小组合,文末附了原文链接。

代码如下:

@RunWith(SpringRunner.class)
@SpringBootTest(classes = TradeControlApplication.class, webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@EnableAutoConfiguration
@Slf4j
public class MyTest {

    //组合算法全局区间  用于存储相同绝对值的多个数字组合
    static List<Integer> list = new ArrayList<>();
    //组合枚举数记录
    static int cnt = 0;

    @Test
    public void excute() {

        // 比较值  报销总额度
        int num = 400;

        //获取数组-生成数组 这里就是发票金额
        int[] array = {25,168,323,27,88};

        //找出数组中 几个数之和最靠近 num 的
        List<String> result = arrayCalculation(array, num);
        //结果打印
        log.info("找出数组中几个数之和最接近num,数组{},比较值{}", array, num);
        result.parallelStream().forEach(log::info);
    }

    /**
     * 找出数组中 几个数之和最靠近 num 的
     * 1、组合枚举记录 Map<Integer, List<String>>
     * Integer作为Key,重写了hashCode方法,返回int值
     * 2、map的key排序,获取最小
     * 3、找出最小的n个记录获取返回
     */
    public List<String> arrayCalculation(int[] array, int num) {

        //二项式定理计算组合枚举个数
        int count = (int) Math.pow(2, array.length) - 1;
        log.info("非空子集数{}", count);

        //2、非空子集,组合加和并记录
        Map<Integer, List<String>> integerListMap = appendList(array, num);
        log.info("组合枚举数{}", cnt);

        //3、map的key排序,获取最小,map取第一个key
        Integer smallKey = getSmallKey(integerListMap);
        log.info("最小差值{}", smallKey);
        //返回结果
        return integerListMap.get(smallKey);
    }

    /**
     * 遍历数组求取结果
     */
    private Map<Integer, List<String>> appendList(int[] array, Integer num) {
        //保存枚举结果,K为相加和,value为排列组合
        Map<Integer, List<String>> paMap = new HashMap<>();
        //组合逻辑,1到n的组合
        for (int i = 0; i < array.length; i++) {
            recursion3(array, 0, i + 1, 0, paMap, num);
        }
        return paMap;
    }

    /**
     * 对map的key排序获取最小
     *
     * @param integerListMap map Map<Integer, List<String>>
     * @return Integer map中最小的key
     */
    private Integer getSmallKey(Map<Integer, List<String>> integerListMap) {
        List<Integer> keyList = new ArrayList<>(integerListMap.keySet());
        Collections.sort(keyList);
        return keyList.get(0);
    }


    /**
     * 组合并枚举-递归
     * 核心算法
     * @param array    数组
     * @param curnum   栈中当前个数
     * @param maxnum   最大位数
     * @param indexnum 当前下标
     * @param paMap    结果map  -枚举记录用到
     * @param num      比较值  -枚举记录用到
     */
    public void recursion3(int[] array, int curnum, int maxnum, int indexnum, Map<Integer, List<String>> paMap, Integer num) {
        if (curnum == maxnum) {
            cnt++;
            AtomicReference<String> str = new AtomicReference<>("");
            //拼接数字组合
            list.forEach(item -> str.set(str + "+" + item));
            //放入结果map
            addResultMap(paMap, num, str.get().substring(1));
            return;
        }
        for (int i = indexnum; i < array.length; i++) {
            if (!list.contains(array[i])) {
            //每个数组元素在枚举的排列组合中只出现一次
                list.add(array[i]);
                //maxnum递增,从1-n,最终的排列组合为数组中所有元素
                recursion3(array, curnum + 1, maxnum, i, paMap, num);
                list.remove(new Integer(array[i]));
            }
        }
    }


    /**
     * 组合记录保存结果集
     *
     * @param paMap 结果集
     * @param num   比较值
     * @param str   组合枚举记录
     */
    private void addResultMap(Map<Integer, List<String>> paMap, Integer num, String str) {
        String[] idsArry = str.split("\\+");
        List<Integer> listSec = Arrays.stream(idsArry)
                .map(s -> Integer.parseInt(s.trim()))
                .collect(Collectors.toList());
        //求和
        Integer sum2 = listSec.stream().reduce(Integer::sum).orElse(0);
        //计算绝对值
        Integer strKey2 = Math.abs(num - sum2);
        pushMap(paMap, str, strKey2, sum2);
    }

    /**
     * 填充map
     *
     * @param paMap  map结果集
     * @param str    组合枚举记录
     * @param strKey key绝对值
     * @param sum    记录和
     */
    private void pushMap(Map<Integer, List<String>> paMap, String str, Integer strKey, Integer sum) {
        String record = sum + "=" + str;
        log.info("组合记录枚举|{}", record);
        //无相同加和组合,放入
        if (paMap.get(strKey) == null) {
            List<String> strList = new ArrayList<>();
            strList.add(record);
            paMap.put(strKey, strList);
        } else {
            //有相同加和组合,放于对应value中
            List<String> strings = paMap.get(strKey);
            strings.add(record);
            paMap.put(strKey, strings);
        }
    }
}

二、使用0-1背包

  报销问题可以转化为0-1背包问题寻找最优解,将报销上限额度当作背包总重,发票金额当作物品重量,物品放入背包就是某张发票占用报销额度,尽可能报销多的发票。

    @Test
    public void execute2() {
        // 背包总重 看作是报销总额度
        int num = 400;
        //获取数组--物品重量,看作是每张发票金额
        int[] array = {25, 21, 37, 49, 168, 188, 88, 923, 200};
        //物品价值,都置为1
        int[] array3 = new int[array.length];
        Arrays.fill(array3, 1);
        Map<Integer,List<String>> result = knapsack(array.length,num,array,array3);
        
        for(Map.Entry entry:result.entrySet()){
            List<String> list = (List) entry.getValue();
            String str= String.join(",", list);
            System.out.println("发票:"+ str);
        }
    }

    /**
     * @param N 发票数目
     * @param C 报销额度
     * @param v 发票金额
     * @param w 发票张数
     * @return 结果
     */
    public Map<Integer,List<String>> knapsack(int N, int C, int[] v, int[] w) {

        int[][] dp = new int[N][C+1];
        //初始化一个物品,不超过背包容量可直接放入
        for (int j = 0; j <= C; j++) {
            dp[0][j] = j >= v[0] ? w[0] : 0;
        }

        //处理剩余元素
        for (int i = 1; i < N; i++) {
            for (int j = 0; j <= C; j++) {
                //不放入
                int x = dp[i-1][j];
                //放入
                int y = j >= v[i] ? dp[i-1][j-v[i]] + w[i] : 0;
                //取两者中的最大值
                dp[i][j] = Math.max(x, y);
            }
        }

        Map<Integer,List<String>> result = new HashMap<>();

        System.out.println(Arrays.deepToString(dp));
        //此时放入发票最多,可以理解为发票总金额最接近额度
        int key = dp[N-1][C];

        if (dp[0][C] > 0) {
            List<String> temp = new ArrayList<>();
            temp.add(0 + "");
            result.put(key, temp);
        }

        for (int i = N-1; i > 0; i--) {
            //物品放入背包后金额会变大,依此判断放入需记录下结果
            if (dp[i][C] > dp[i - 1][C]) {
                if (result.get(key) == null) {
                    List<String> temp = new ArrayList<>();
                    temp.add(i + "");
                    result.put(key, temp);
                } else {
                    List<String> temp = result.get(key);
                    temp.add(i + "");
                    result.put(key, temp);
                }
            }
        }
        return result;
    }

三、进化算法

  通过0-1背包找出最接近报销额度的发票组合让我想到了可以利用进化优化算法找出最优解,基于搜索找出发票组合,比如利用遗传算法等进化算法。
利用进化算法搜索最优解一般需要三个步骤:
  1、定义解结构、初始化解空间
  2、定义操作算子、构造适应度函数
  3、迭代执行算子搜素最优解
以遗传算法为例
  1、定义最后的解是一个数组 int[] result = new int[n];数组长度即为发票张数n,result[i]=1表示发票在解中,需要报销,result[i]=0则不需要该发票,这样我们最后获取的结果可能就是 [0,1,0,1],取第2、4张发票去优雅的报销啦。
定义n*n二维数组invoice作为解空间(遗传算法应该叫种群)invoice对角线可以先赋值1,这样初始化一下保证至少有一张发票;
  2、定义完需要搜索的解,然后定义一些操作算子,针对解中的某一位随机进行操作变为1或0,增加搜索最优解可能性;
如何去评价解的优劣呢,这就需要构造适应度函数对得出的解进行评价;
设计 f=min(abs(total-sum(result[i] *value))),value为发票金额,total为报销总金额,解的金额与total更接近时该解更优,将其保存。
  3、针对整个解空间,多次迭代进行变异进化等操作,利用适应度函数评价解的优劣,f越小则保存该结果直至找出最优解,即可知道使用哪几张发票报销
简单梳理了下使用进化优化算法搜索最优报销发票组合的思路,具体使用哪种搜索算法有待研究吧,我接触过的遗传算法,粒子群、化学反应算法等等只是算法原理不同,实现及搜索解的方式也有类似。

参考

java实现排列组合:
[1] https://blog.csdn.net/xinpz/article/details/109801335
[2] https://blog.csdn.net/xinpz/article/details/109728624
0-1背包
[3] https://www.bilibili.com/read/cv12924751
[4] https://blog.csdn.net/weixin_42385782/article/details/121582836
[5] https://zhuanlan.zhihu.com/p/345364527
[6] https://blog.csdn.net/weixin_52605156/article/details/123313223
遗传算法
[7] https://www.renrendoc.com/paper/99195836.html

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值