1774. 最接近目标价格的甜点成本

你打算做甜点,现在需要购买配料。目前共有 ​​n​​​ 种冰激凌基料和 ​​m​​ 种配料可供选购。而制作甜点需要遵循以下几条规则:

  • 必须选择 一种 冰激凌基料。
  • 可以添加 一种或多种 配料,也可以不添加任何配料。
  • 每种类型的配料 最多两份 。

给你以下三个输入:

  • ​baseCosts​​​ ,一个长度为 ​​n​​​ 的整数数组,其中每个 ​​baseCosts[i]​​​ 表示第 ​​i​​ 种冰激凌基料的价格。
  • ​toppingCosts​​​,一个长度为 ​​m​​​ 的整数数组,其中每个 ​​toppingCosts[i]​​​ 表示 一份 第 ​​i​​ 种冰激凌配料的价格。
  • ​target​​ ,一个整数,表示你制作甜点的目标价格。

你希望自己做的甜点总成本尽可能接近目标价格 ​​target​​ 。

返回最接近 ​​target​​ 的甜点成本。如果有多种方案,返回 成本相对较低 的一种。

提示

  • ​n == baseCosts.length​
  • ​m == toppingCosts.length​
  • ​1 <= n, m <= 10​
  • ​1 <= baseCosts[i], toppingCosts[i] <= 10^4​
  • ​1 <= target <= 10^4​

示例

输入:baseCosts = [1,7], toppingCosts = [3,4], target = 10
输出:10
解释:考虑下面的方案组合(所有下标均从 0 开始):
- 选择 1 号基料:成本 7
- 选择 1 份 0 号配料:成本 1 x 3 = 3
- 选择 0 份 1 号配料:成本 0 x 4 = 0
总成本:7 + 3 + 0 = 10 。

思路

回放

由于自己还比较菜,所以当时的思路一通乱飞。但是记录下自己思考的过程我觉得挺好

还原一下我当时的脑子:

先从基料中​​n​​​选1,然后每种配料可不选,可选1份,可选2份。又是组合类型的题目。那就先看看能不能暴力做咯。基料​​n​​​种,配料​​m​​​种。​​n​​​和​​m​​​最大为10。基料​​n​​​选1,先乘个​​n​​​,每种配料有3种选择:不选,选1份,选2份。那么总共的方案数就是每日一题 —— LC. 1774 最接近目标价格的甜点成本_动态规划,我超!已经到每日一题 —— LC. 1774 最接近目标价格的甜点成本_算法_02以上了,那暴力肯定超时了!不行,得换思路。

(真是个猪脑子,怎么推出来 每日一题 —— LC. 1774 最接近目标价格的甜点成本_i++_03的?)

我仔细回想了下当时的情景,每日一题 —— LC. 1774 最接近目标价格的甜点成本_i++_03

每种配料有3种选择,那​​m​​​种配料一共就能形成​​3m​​种选项吧,最大也就是30。对于每个选项,我都有不选两种决策吧。那组合起来总共的方案数就是 每日一题 —— LC. 1774 最接近目标价格的甜点成本_i++_05

真是个猪脑子 × 2

其实暴力的时间复杂度应该只能达到 每日一题 —— LC. 1774 最接近目标价格的甜点成本_动态规划_06。是这样 → 一共最多10种配料,每种配料我有3个选项,根据乘法原理,10种配料能组合出的方案数就是 每日一题 —— LC. 1774 最接近目标价格的甜点成本_动态规划_06,大概在 每日一题 —— LC. 1774 最接近目标价格的甜点成本_i++_08 ,算上基料的​​​n​​​,一共也就 每日一题 —— LC. 1774 最接近目标价格的甜点成本_算法_09,这妥妥的不超时啊。

暴力做法这里先按下不表,继续记录当时的思考过程

在添加配料的过程中,成本是不断变大的,也就是成本较大的方案一定是由成本较小的方案,添加某种配料转移过来的。诶!有点动态规划那意思了。但怎么来表示状态呢?前面分析过了,配料的选择方案一共有 每日一题 —— LC. 1774 最接近目标价格的甜点成本_i++_05 ,这如果直接作为状态表示肯定不行,已经到 每日一题 —— LC. 1774 最接近目标价格的甜点成本_算法_02 个状态了,每个状态的计算就算是 每日一题 —— LC. 1774 最接近目标价格的甜点成本_算法_12 也会超时。诶!一共有 每日一题 —— LC. 1774 最接近目标价格的甜点成本_i++_05 种情况,恰好在​​​int​​​的范围内,是不是在暗示我用状态压缩呢?用一个​​int​​​的二进制表示来表达每种配料的选择情况,每个二进制位是0或1,0表示选,1表示不选,30个二进制位就足够表达 每日一题 —— LC. 1774 最接近目标价格的甜点成本_i++_05

用状态压缩的话,状态表示好像没问题了,但怎么考虑状态的转移呢? ?&^*9#$

状态压缩好像走不下去了。

(大脑运转中…)

诶!如果将基料和配料的成本看成体积,每次选基料或配料时,就是往背包里扔进一个物品。是不是有点像背包问题呢?好像是的哦!

如果用​​dp[i][j]​​​来表示状态,那它应该表示的是,在前​​i​​​种配料中做选择,成本总和恰好为​​j​​的方案,具体表示啥呢?那就表示这种方案的总成本吧?

那状态数组第二个维度要开多大呢?算下能形成的最大总成本就行了,那就是选成本最大的基料,并且每种配料全都加2份。那最后的答案就是,遍历下所有能构成的总成本,取其中距离​​target​​最近的就行了!

由于每种配料可以不选,选1份,选2份。那这其实就是一个分组背包

一种配料就是一个分组,这个分组里有3个物品,分别是:不选该配料,选1份该配料,选2份该配料。

基料是特殊的一个分组,这个分组里的物品数量就是基料的种类数,需要从中选择一种基料。

在每个分组里,我们只能从该分组中选1个物品。

分析到这里就搞定啦!下面直接套用分组背包的模板就好啦!

在计算​​dp[i][j]​​​时,我们考虑第​​i​​​组的情况,我们枚举第​​i​​组的每个元素的情况,依次计算状态转移即可。

​dp[i][j] = min(dp[i][j], dp[i - 1][j - x] + x)​​​ (​​x​​​是第​​i​​组当前被选中的元素的成本)

来算下时间复杂度,能形成的最大成本,算了下是 每日一题 —— LC. 1774 最接近目标价格的甜点成本_leetcode_15,总共的状态数是 每日一题 —— LC. 1774 最接近目标价格的甜点成本_dfs_16级别,每个状态的转移,需要枚举该分组的元素个数,一共最多有11个分组(1个基料组+10个配料组),全部分组的元素个数是 10 + 3 × 10 = 40,平均一个组的元素个数是 40 / 11,大概是4个,那么总的时间复杂度大概在 每日一题 —— LC. 1774 最接近目标价格的甜点成本_leetcode_17

于是噼里啪啦敲键盘…得到了如下这份代码

class Solution {
public:
int closestCost(vector<int>& baseCosts, vector<int>& toppingCosts, int target) {
int n = baseCosts.size(), m = toppingCosts.size(), INF = 1e9;
// 分组背包, 一共有 u = 1 + m 组
int u = 1 + m, v = 0;
// 重新计算每个分组中每件物品的各自代价
vector<vector<int>> costs(u + 1);
// 基料组
for (int i = 0; i < n; i++) {
costs[1].push_back(baseCosts[i]);
v = max(v, baseCosts[i]);
}
// 配料组
for (int i = 0; i < m; i++) {
for (int j = 0; j <= 2; j++) {
costs[i + 2].push_back(j * toppingCosts[i]);
}
v += 2 * toppingCosts[i];
}

// 计算结束, v是能组合出来的最大的成本
vector<vector<int>> dp(u + 1, vector<int>(v + 1, INF));

dp[0][0] = 0;
for (int i = 1; i <= u; i++) {
for (int j = 1; j <= v; j++) {
for (int k = 0; k < costs[i].size(); k++) {
if (j >= costs[i][k] && dp[i - 1][j - costs[i][k]] != INF) {
dp[i][j] = min(dp[i][j], dp[i - 1][j - costs[i][k]] + costs[i][k]);
// printf("dp[%d][%d] = %d\n", i, j, dp[i][j]);
}
}
}
}

// 遍历所有能组合出来的成本, 计算答案
int minGap = INF, ans = INF;
for (int i = 1; i <= v; i++) {
if (dp[u][i] == INF) continue;
int gap = abs(target - i);
if (gap < minGap) {
minGap = gap;
ans = dp[u][i];
} else if (gap == minGap && dp[u][i] < ans) {
ans = dp[u][i];
}
}
return ans;
}
};

然后提交,发现竟然通过了!虽然耗时 ​​1200ms​​​,击败了 ​​5%​​ 的用户

每日一题 —— LC. 1774 最接近目标价格的甜点成本_dfs_18

上面的时间复杂度的分析是正确的,但已经快到 每日一题 —— LC. 1774 最接近目标价格的甜点成本_i++_19

总结下自己的思考过程:

→ 暴力?→ 错误的高估了时间复杂度,舍弃 → 动态规划 ? → 状压? → 状压想不通 → 背包问题?→ 分组背包?→ 好像可以

正解

暴搜

其实这道题的数据范围,暴力是可以求解的,基料最多为10,配料最多为10,每种配料有3种选项,暴力的时间复杂度为 每日一题 —— LC. 1774 最接近目标价格的甜点成本_leetcode_20,大概在 每日一题 —— LC. 1774 最接近目标价格的甜点成本_算法_09

class Solution {
public:
int ans = 1e9;

void dfs(int i, vector<int>& costs, int sum, int target) {
// 剪枝, 当sum超过target,并且当前的距离大于最小的距离, 则可以提前结束
// 因为后续只可能增加配料, 成本只会更大, 距离也就只会更远
if (sum > target && sum - target >= abs(ans - target)) return ;

if (i == costs.size()) {
// 到达末尾
if (abs(sum - target) < abs(ans - target)) {
ans = sum;
} else if (abs(sum - target) == abs(ans - target)) {
ans = min(ans, sum);
}
return ;
}
dfs(i + 1, costs, sum, target); // 不加该种配料
dfs(i + 1, costs, sum + costs[i], target); // 加1份该种配料
dfs(i + 1, costs, sum + 2 * costs[i], target); // 加2份该种配料
}

int closestCost(vector<int>& baseCosts, vector<int>& toppingCosts, int target) {
for (int& base : baseCosts) {
dfs(0, toppingCosts, base, target);
}
return ans;
}
};

提交发现跑的很快

每日一题 —— LC. 1774 最接近目标价格的甜点成本_leetcode_22

动态规划

再看一种动态规划的做法。假设有某种方案,能够凑出成本​​j​​,那么此时,对于某种还未添加过的配料,我们可以选择不添加,添加1份,添加2份,假设该配料成本为​​x​​​,那么我们能够凑出成本​​j + x​​​,​​j + 2x​​。

我们设​​dp[i][j]​​​表示,只考虑前​​i​​​个配料,是否能够凑出成本​​j​​​,若能凑出,则​​dp[i][j] = true​​​,否则​​dp[i][j] = false​​。

状态转移的逻辑为:只考虑前​​i - 1​​​个配料时,如果有某个​​j​​​满足​​dp[i - 1][j] = true​​​,那么可以更新​​dp[i][j] = dp[i][j + x] = dp[i][j + 2x] = true​​。

最后只需要遍历一下​​j​​​,找到最接近​​target​​的成本即可。

我们需要看一下第二维的​​j​​​,需要开多大。主要是需要考虑超过​​target​​的情况。

由于基料必选,配料可选,我们能组合出的最小的成本,就是选择成本最小的基料,并且配料一个也不选。此时的方案就是成本最低的,设为​​min​​。

假设​​min < target​​​,我们再看一下超过​​target​​​的,距离同等的成本,容易算得是​​2 * target - min = upper​​​。当超过​​upper​​​后,再增加成本,其与​​target​​​的距离一定比​​min​​​与​​target​​​的距离更远,是一定不会作为答案输出的。并且如果距离最近的方案取到了​​min​​​和​​upper​​​,一定是选择成本更小的​​min​​。

所以,第二维的​​j​​​只需要枚举到​​upper - 1​​即可。

class Solution {
public:
int closestCost(vector<int>& baseCosts, vector<int>& toppingCosts, int target) {
// 最小成本的基料
int minBase = *min_element(baseCosts.begin(), baseCosts.end());
// 特判, 越加只会越大, 直接返回
if (minBase >= target) return minBase;
int n = toppingCosts.size();
int upper = 2 * target - minBase;
vector<vector<bool>> dp(n + 1, vector<bool>(upper));
// 下标从1开始
// 初始化状态数组
for (int& base : baseCosts) {
// 超过upper的base不要加了, 越界会产生奇奇怪怪的问题
if (base < upper) dp[0][base] = true;
}

for (int i = 1; i <= n; i++) {
int x = toppingCosts[i - 1];
for (int j = 1; j < upper; j++) {
if (dp[i - 1][j]) {
for (int k = 0; k <= 2; k++) {
if (j + k * x < upper) dp[i][j + k * x] = true;
}
}
}
}

int ans = 1e9;
for (int i = 1; i < upper; i++) {
if (!dp[n][i]) continue;
if (abs(i - target) < abs(ans - target)) ans = i;
}
return ans;
}
};

每日一题 —— LC. 1774 最接近目标价格的甜点成本_i++_23

由于每一行的全部状态只依赖于上一行,所以可以用滚动数组思想,把第一维去掉(去掉行,只保留列),变成​​dp[j]​​,其表示,(状态转移过程中)某一行的所有列的状态。但是需要从右往左更新。

如果​​dp[i - 1][j] = true​​​,设第​​i​​​种配料的代价为​​x​​​,则可以更新 ​​dp[i][j] = dp[i][j + x] = dp[i][j + 2x] = true​​。根据转移方程,某一个状态,依赖于其上一行,更左侧的列的状态(依赖于左上方的状态)。从右往左更新,这样在走到某个位置时,计算所需要用到的更左侧的状态的值,仍然是更新前的值(上一行的状态),这样才能保证状态转移的正确性。

class Solution {
public:
int closestCost(vector<int>& baseCosts, vector<int>& toppingCosts, int target) {
int minBase = *min_element(baseCosts.begin(), baseCosts.end());
// 特判
if (minBase >= target) return minBase;
int n = toppingCosts.size();
int upper = 2 * target - minBase;
vector<bool> dp(upper);
// 下标从1开始
// 初始化
for (int& base : baseCosts) {
// 超过upper的base不要加了
if (base < upper) dp[base] = true;
}

for (int i = 1; i <= n; i++) {
int x = toppingCosts[i - 1];
for (int j = upper - 1; j >= 1; j--) {
if (dp[j]) {
for (int k = 0; k <= 2; k++) {
if (j + k * x < upper) dp[j + k * x] = true;
}
}
}
}

int ans = 1e9;
for (int i = 1; i < upper; i++) {
if (!dp[i]) continue;
if (abs(i - target) < abs(ans - target)) ans = i;
}
return ans;
}
};

每日一题 —— LC. 1774 最接近目标价格的甜点成本_i++_24