背景
最近看到这样一道笔试题:
你点开了xx外卖,选择了一家店,此时你手里有一张满х元减10元的券。店里共有n种菜,第
i种菜一份需要Ai元。因为你不想吃太多份同一种菜,所以每种菜你最多只能点一份。现在问你最少需要选择多少元的商品才能使用这张券。 【输入描述】
第一行两个正解数 n 和 x,分别表示菜品数量和券的最低使用价格(1<=n<=199,1<=x<=1000)。
第二行是整数数组,第 i 个整数表示第 i 种菜品的价格(i<= Ai<= 100)。
【输出描述】
一个数,表示最少需要选择多少元的菜才能使用这张满x元减10元的券,保证有解。
[e.g.]
input 5 20
output 18 19 17 6 7
分析
首先,这张券减多少元都跟我们没关系,重点在于要怎样才能凑够满减(太真实了
在一个数组里凑数……怎么听起来这么熟悉?啊!是背包问题
背包问题怎么解
背包问题的经典解法是动态规划:设第i个物品重量为W[i],价值为C[i],背包容量为j,dp[i][j]表示选到第i个东西,且背包容量j时的最大值。
【转移方程关键点】想象自己装东西的时刻,在放第 i 个东西时:
- 如果
W[i] > j,这个东西我们背不了,丢掉 - 如果
W[i] < j,我们可以试着背一背这个东西 (^o^)/
- if 不背,then
dp[i][j] = dp[i - 1][j](因为距离上一个没有变化) - if 背,then
dp[i][j] = dp[i - 1][j - W[i]] + C[i]
dp[i - 1][j - W[i]]是什么意思呢?
- if 不背,then
背包问题的本质:在【满足某个条件的所有组合】组成的集合中,寻找最值。(cr: 闫式dp法) 不包含
i就是在i - 1这几个数的组合里面选最优;
包含i就是在i这个数固定( 即i这个元素必须有),在j - W[i]的限制下、在i - 1这几个数的组合里面选最优。
const weights = [1, 3, 5, 7];
const values = [2, 5, 2, 5];
const volume = 8;
let dp = [];
dp[-1] = new Array(volume + 1).fill(0); // 初始一个数组,用于获取dp[-1][...]
for (let i = 0; i < weights.length; i++) {
dp[i] = []; // 新建一列
for (let j = 0; j <= volume; j++) {
dp[i][j] = dp[i - 1][j];
if (weights[i] < volume) {
// 不超过总容量
// 由于已经更新过dp[i][j],这里直接用dp[i][j]
dp[i][j] = Math.max(dp[i][j], dp[i - 1][j - weights[i]] + values[i]);
}
}
}
return dp[weights.length - 1][volume];
优化
DP问题的优化一般从空间入手,关键点在于观察【依赖】。回想斐波那契的DP解法,由于当前值只依赖于前两个数,所以只需要用滑动窗口的方式记录前两个数即可。
对于二维数组,重点是思考二维数组填表的过程。我们在更新的时候是一行一行/一列一列填表,不会覆盖之前的值。而降维打击之后,每一轮更新都会覆盖上轮更新。回到背包问题,当前值的 i 依赖于 i - 1,我们可以利用上一轮更新时存好的值,来对本轮值更新。
for (let i = 0; i < weights.length; i++) {
dp[i] = [];
for (let j = 0; j <= volume; j++) {
dp[j] = dp[j];
// 等效于dp[i][j] = dp[i - 1][j];
// 因为这个时候dp[j]还是上一轮 i 的(即i - 1)j值
if (weights[i] <= volume) {
dp[j] = Math.max(dp[j], dp[j - weights[i]] + values[i]);
// 是否等效 (dp[i][j], dp[i - 1][j - weights[i]] + values[i])?
// dp[j]用的是上面刚更新好的值,不用管
// j - weights[i] 用的是j之前的值,在【本轮,本 i 值】已经被更新过了
// 所以现在整个表达式等效于
// (dp[i][j], dp[i][j - weights[i]] + values[i])
}
}
}
如果倒序更新 j,用的 j - weights[i] 就是 i - 1这轮的了。
同时,还可以将 if (weights[i] <= volume) 移到循环条件。
// 正确版本
for (let i = 0; i < weights.length; i++) {
dp[i] = [];
for (let j = volume; j >= weights[i]; j++) {
dp[j] = Math.max(dp[j], dp[j - weights[i]] + values[i]);
// (dp[i - 1][j], dp[i - 1][j - weights[i]] + values[i])
}
}
}
回到外卖
点外卖的时候,我们是在大于某个价格的集合中找最小值。
转换成背包问题,求已选菜品的补集的【最大值】,这个补集最大不能超过【菜品总价值 - 满减门槛】。设第i个菜价格为P[i],dp[i][j]表示选到第i个东西,补集大小限制 j。
在选第 i 个菜时:
- if 不要,then
dp[i][j] = dp[i - 1][j](因为距离上一个没有变化) - if 要,then
dp[i][j] = dp[i - 1][j - P[i]] + P[i]
求出最大补集之后,我们就能得到大于某个价格的集合中找最小值。