从加速道具到动态规划:求解最小溢出时间组合问题

20 阅读3分钟

在日常生活中,我们经常会遇到一些“凑单”或“组合优化”的场景:比如在电商大促时,如何选择手中的优惠券使得刚好达到满减门槛且溢出最少?再比如在游戏里,如何使用手中的道具最快达成目标?这些问题看似简单,但若想得到“最优解”,背后的算法逻辑却值得深究。最近,我在开发一个宠物养成类项目时,就遇到了一个典型的此类问题,并最终通过动态规划(DP)得以解决。

背景

最近,博主在做一个宠物养成的项目,在项目中有这么一个场景:宠物在上课期间可以使用“加速道具”来缩短课程的学习时间,其中,用户可能拥有多种不同的道具,每种道具有不同的加速时长(例如:道具A加速10秒,道具B加速15秒)。当用户想要通过道具来加速时,系统需要自动给出最优的道具组合。

这里的“最优”,定义为:在保证累计加速时间大于或等于课程剩余时间的前提下,使得“溢出时间”(即累计加速时间减去课程剩余时间)最小化。

在对问题进行分析与抽象后,博主发现这种问题在生活中随处可见:

  • 电商凑单:订单金额95元,手里有满100减10、满50减5的券,如何组合最划算?
  • 零钱兑换:要支付25美分,手里有1、5、10、25美分的硬币,如何用最少的硬币凑出刚好或略多于25美分的金额?

问题抽象

这个问题抽象出来,就是一类经典的算法问题。给定一个目标值T(课程剩余时间),以及一个包含n个正整数的数组props(每个道具i的加速时长props[i]),要求从数组props中选出若干个道具,使其和S满足S>=T,并且S−T的值最小(即溢出最少)。

例如,当前课程剩余 25 秒,给定以下道具:

  • 道具A:加速 10 秒,数量 3 个
  • 道具B:加速 15 秒,数量 2 个

需要在其中找到若干道具,使得选中的道具加速时长 大于等于 课程剩余时长25秒,并且差值最小 (即溢出最少)。

问题分析

上述问题是 一个典型的 背包问题变种,可以用动态规划解决。

状态定义

  • dp[t]: 表示能否通过道具组合达到时间t,如果可达则记录最后一个使用的道具索引。

状态转移

dp[t] = dp[t - props[i]]

代码实现

/**
 * 时间复杂度: O(n*total)
 * 空间复杂度: O(total)
 * 
 * n为道具数量, total为道具可加速总时长
 * 
 * @params props 道具列表(存在重复道具)
 * @params time 目标加速时间
 * @return 选中的道具
 */
public List<Integer> find(int[] props, int time) {
    int total = 0;
    for (int i = 0; i < props.length; i++) {
        total += props[i];
    }

    int[] dp = new int[total + 1];
    Arrays.fill(dp, -1);
    dp[0] = -2; //标记初始状态

    for (int i = 0; i < props.length; i++) {
        for (int j = total; j - props[i] >= 0; j--) {
            if (dp[j] == -1 && dp[j - props[i]] != -1) {
                dp[j] = i;
            }
        }
    }

    int bestTime = -1;
    for (int t = time; t <= total; t++) {
        if (dp[t] != -1) {
            bestTime = t;
            break;
        }
    }
    
    List<Integer> res = new ArrayList<>();
    for (int t = bestTime; t > 0; ) {
        int prop = props[dp[t]];
        res.add(prop);
        t -= prop;
    }
    return res;
}

LeetCode相关题目