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

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种。nm最大为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% 的用户

image-20221206171841342

上面的时间复杂度的分析是正确的,但已经快到 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;
    }
};

提交发现跑的很快

image-20221207143817491

动态规划

再看一种动态规划的做法。假设有某种方案,能够凑出成本j,那么此时,对于某种还未添加过的配料,我们可以选择不添加,添加1份,添加2份,假设该配料成本为x,那么我们能够凑出成本j + xj + 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的距离一定比mintarget的距离更远,是一定不会作为答案输出的。并且如果距离最近的方案取到了minupper,一定是选择成本更小的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;
    }
};

image-20221207160450993

由于每一行的全部状态只依赖于上一行,所以可以用滚动数组思想,把第一维去掉(去掉行,只保留列),变成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;
    }
};

image-20221207160717852

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值