#每日n题#背包和点外卖

141 阅读3分钟

背景

最近看到这样一道笔试题:

你点开了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],背包容量为jdp[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]]是什么意思呢?

背包问题的本质:在【满足某个条件的所有组合】组成的集合中,寻找最值。(cr: 闫式dp法) 不包含 i 就是在 i - 1 这几个数的组合里面选最优;
包含 i 就是在 i 这个数固定( 即 i 这个元素必须有),在 j - W[i] 的限制下、在 i - 1这几个数的组合里面选最优。 image.png

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]
    求出最大补集之后,我们就能得到大于某个价格的集合中找最小值