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