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份。那么总共的方案数就是 10 × 2 30 10 × 2^{30} 10×230,我超!已经到 1 0 9 10^9 109以上了,那暴力肯定超时了!不行,得换思路。
(真是个猪脑子,怎么推出来 2 30 2^{30} 230的?)
我仔细回想了下当时的情景, 2 30 2^{30} 230 大概是这么来的 →
每种配料有3种选择,那
m
种配料一共就能形成3m
种选项吧,最大也就是30。对于每个选项,我都有选或不选两种决策吧。那组合起来总共的方案数就是 2 30 2^{30} 230
真是个猪脑子 × 2
其实暴力的时间复杂度应该只能达到
3
10
3^{10}
310。是这样 → 一共最多10种配料,每种配料我有3个选项,根据乘法原理,10种配料能组合出的方案数就是
3
10
3^{10}
310,大概在
6
×
1
0
4
6 × 10^4
6×104 ,算上基料的n
,一共也就
6
×
1
0
5
6 × 10^5
6×105,这妥妥的不超时啊。
暴力做法这里先按下不表,继续记录当时的思考过程
在添加配料的过程中,成本是不断变大的,也就是成本较大的方案一定是由成本较小的方案,添加某种配料转移过来的。诶!有点动态规划那意思了。但怎么来表示状态呢?前面分析过了,配料的选择方案一共有 2 30 2^{30} 230 ,这如果直接作为状态表示肯定不行,已经到 1 0 9 10^9 109 个状态了,每个状态的计算就算是 O ( 1 ) O(1) O(1) 也会超时。诶!一共有 2 30 2^{30} 230 种情况,恰好在
int
的范围内,是不是在暗示我用状态压缩呢?用一个int
的二进制表示来表达每种配料的选择情况,每个二进制位是0或1,0表示选,1表示不选,30个二进制位就足够表达 2 30 2^{30} 230 个状态。用状态压缩的话,状态表示好像没问题了,但怎么考虑状态的转移呢? ?&^*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
组当前被选中的元素的成本)来算下时间复杂度,能形成的最大成本,算了下是 1 0 4 + 10 × 2 × 1 0 4 = 1.3 × 1 0 5 10^4 + 10 × 2 × 10^4 = 1.3 × 10^5 104+10×2×104=1.3×105,总共的状态数是 1 0 6 10^6 106级别,每个状态的转移,需要枚举该分组的元素个数,一共最多有11个分组(1个基料组+10个配料组),全部分组的元素个数是 10 + 3 × 10 = 40,平均一个组的元素个数是 40 / 11,大概是4个,那么总的时间复杂度大概在 4 × 1 0 6 4 × 10^6 4×106 ,应该是不会超时…的吧…
于是噼里啪啦敲键盘…得到了如下这份代码
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%
的用户
上面的时间复杂度的分析是正确的,但已经快到 1 0 7 10^7 107 了,已经到超时的边缘了。
总结下自己的思考过程:
→ 暴力?→ 错误的高估了时间复杂度,舍弃 → 动态规划 ? → 状压? → 状压想不通 → 背包问题?→ 分组背包?→ 好像可以
正解
暴搜
其实这道题的数据范围,暴力是可以求解的,基料最多为10,配料最多为10,每种配料有3种选项,暴力的时间复杂度为 10 × 3 10 10 × 3^{10} 10×310,大概在 6 × 1 0 5 6 × 10^5 6×105。
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;
}
};
提交发现跑的很快
动态规划
再看一种动态规划的做法。假设有某种方案,能够凑出成本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;
}
};
由于每一行的全部状态只依赖于上一行,所以可以用滚动数组思想,把第一维去掉(去掉行,只保留列),变成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;
}
};