每日一题 —— LC. 1687 从仓库到码头运输箱子(难度很大,但值得好好消化的一道题)
创始人
2024-03-28 21:12:35
0

1687. 从仓库到码头运输箱子

你有一辆货运卡车,你需要用这一辆车把一些箱子从仓库运送到码头。这辆卡车每次运输有 箱子数目的限制总重量的限制

给你一个箱子数组 boxes和三个整数 portsCount, maxBoxesmaxWeight ,其中 boxes[i]=[portsi,weighti]boxes[i] = [ports_i, weight_i]boxes[i]=[portsi​,weighti​] 。

portsiports_iportsi​ 表示第 i 个箱子需要送达的码头, weightsiweights_iweightsi​ 是第 i 个箱子的重量。
portsCount 是码头的数目。
maxBoxesmaxWeight 分别是卡车每趟运输箱子数目和重量的限制。
箱子需要按照 数组顺序 运输,同时每次运输需要遵循以下步骤:

卡车从 boxes 队列中按顺序取出若干个箱子,但不能违反 maxBoxesmaxWeight 限制。
对于在卡车上的箱子,我们需要 按顺序 处理它们,卡车会通过 一趟行程 将最前面的箱子送到目的地码头并卸货。如果卡车已经在对应的码头,那么不需要 额外行程 ,箱子也会立马被卸货。
卡车上所有箱子都被卸货后,卡车需要 一趟行程 回到仓库,从箱子队列里再取出一些箱子。
卡车在将所有箱子运输并卸货后,最后必须回到仓库。

请你返回将所有箱子送到相应码头的 最少行程 次数。

提示:

  • 1<=boxes.length<=1051 <= boxes.length <= 10^51<=boxes.length<=105
  • 1<=portsCount,maxBoxes,maxWeight<=1051 <= portsCount, maxBoxes, maxWeight <= 10^51<=portsCount,maxBoxes,maxWeight<=105
  • 1<=portsi<=portsCount1 <= ports_i <= portsCount1<=portsi​<=portsCount
  • 1<=weightsi<=maxWeight1 <= weights_i <= maxWeight1<=weightsi​<=maxWeight

示例

输入:boxes = [[1,2],[3,3],[3,1],[3,1],[2,4]], portsCount = 3, maxBoxes = 3, maxWeight = 6
输出:6
解释:最优策略如下:
- 卡车首先运输第一个箱子,到达码头 1 ,然后回到仓库,总共 2 趟行程。
- 卡车运输第二、第三、第四个箱子,到达码头 3 ,然后回到仓库,总共 2 趟行程。
- 卡车运输第五个箱子,到达码头 2 ,回到仓库,总共 2 趟行程。
总行程数为 2 + 2 + 2 = 6 。

思路

这是本周一(2022/12/5)的每日一题。今天才抽出时间来做。结果一整天就做了这么一道题。脑壳疼。

好在最后看了题解后,把思路想明白了。

这道题,题解中出现的较多的思路是:动态规划+前缀和+单调队列,貌似也可以用动态规划+贪心来做,但自己没走通贪心这条思路,也没找到对应题解。所以下面只记录第一种做法。(2022/12/9,今天再做时,已经把自己的贪心思路完善并提交AC啦!开心)

动态规划+贪心

在记录正确做法之前,还是先记录一下自己的思考过程。

首先这道题,读题+理解题意,都要花比较长的时间。

由于卡车必须按照顺序依次将箱子送到对应的码头,那我先假设一下,如果卡车没有箱子数量和重量的限制,那很明显的,只需要一趟就能完成任务,并且行程次数是固定的。首先把全部箱子拉出去记行程次数+1,然后依次将每个箱子拉到对应码头并卸货,最后返回仓库,记行程次数+1。总的行程次数,就是2(出发和返回),再加上 整个箱子数组,相邻两个箱子为不同的码头的次数。

为了方便叙述,我们设cnt[i, j]表示下标位于[i, j]范围内的所有箱子,相邻两个箱子为不同码头的次数。

现在,由于卡车有数量和重量的限制,一趟出车只能拉有限个箱子。假设某一次出车,拉的箱子的下标范围是[a, b],那么这趟出车的行程次数,容易算得是 2 + cnt[a, b],就是出发和返回各算1次,[a, b]范围内,每一组 相邻,但目标码头不同的箱子,都要算1次行程。

所以,卡车每一趟出车,消耗的行程数就是 2 + cnt[a, b]

假设箱子总共有n个,下标从0开始。若卡车一共出了m趟车,才把所有箱子送到对应的码头,那么消耗的总的行程数,就是

2m + cnt[0, n - 1] ,而cnt[0, n - 1]是一个固定的值,所以很直观的,卡车出车的总趟数,要尽可能的少,才能使得总的行程次数最少。这样来看就有点贪心的味道了。

我们可以把这道题目进行一下抽象,转换为下面这样一个问题:

把整个箱子数组,看成一条线段,我们需要将这条线段,切若干刀,分割成若干个子线段,使得每个子线段都满足某种约束(箱子的数量和重量限制),求解在每个子线段都满足约束的情况下,切分的最少的子线段的数量。(一个子线段就是卡车的一趟出车)

这样来看,对于某个子线段[i, j],我们需要判断其是否满足约束,第一个约束是箱子个数,这可以直接用j - i + 1算得,至于箱子重量,则需要累加整个区间内的箱子重量,容易看出这需要用前缀和来处理。

由于最后计算时,需要计算某个区间内的cnt[i, j]cnt[i, j]也能通过预处理,然后用前缀和来计算。

回到上面说的贪心。我们想要子线段的个数最少,很直观的想法是,要在满足约束的条件下,使得每个子线段的长度尽可能的大。

那么如何使得子线段的长度尽可能的大呢?我们可以枚举子线段的右端点,然后看一下这个右端点,往左最远能够走到什么位置,而不违反限制条件。

我们设i为右端点,设leftBorder[i]为以i作为右端点,往左最远能走到的位置。

由于越往左走,箱子个数越多,箱子总重量越大。所以,从i往左走,如果走到某个位置j,第一次违反了约束条件,那么以i为右端点,往左走最远就只能走到j - 1

并且,对于> i的右端点,其往左走最远不能超过j - 1。可以看到ij的移动,具有单调的性质(向同一个方向移动),所以,求解leftBorder数组,我们可以用一次滑动窗口来完成。

随后,设dp[i]表示,卡车将[0, i]范围内的箱子全部送到目的码头,所需要的最少行程次数。

那么根据上面的贪心思路,我们看最后一趟的情况,让最后一趟运的箱子尽可能的多,设leftBorder[i] = j,则最后一趟运输的箱子的起始位置为j,那么dp[i] = dp[j - 1] + 2 + cnt[j, i]

最后的答案便是dp[n]

根据这个思路写出如下代码

typedef long long LL;
class Solution {
public:int boxDelivering(vector>& boxes, int portsCount, int maxBoxes, int maxWeight) {int n = boxes.size();// vector preSum(n + 1); // 重量的前缀和// for (int i = 1; i <= n; i++) preSum[i] = preSum[i - 1] + boxes[i - 1][1];// 这个前缀和好像没什么用? 因为下面已经计算了leftBorder了// 需要每个区间内的行程数, 包括区间2个端点, 加上区间内相邻不同数字的和vector preSum(n + 1);preSum[1] = 0; // n = 1, 2 for (int i = 1; i < n; i++) {preSum[i + 1] = preSum[i];if (boxes[i][0] != boxes[i - 1][0]) preSum[i + 1]++;}// preSum[i] - preSum[j] 就是[i, j]相邻的但目的码头不同的箱子 的组数 (中间要多加多少次行程)vector leftBorder(n + 1); // 某个位置左侧最远能到达的位置LL sum = 0;for (int i = 1, j = 1; i <= n; i++) {sum += boxes[i - 1][1];while (sum > maxWeight || i - j + 1 > maxBoxes) {sum -= boxes[j - 1][1];j++;}leftBorder[i] = j;}// 动态规划vector dp(n + 1);for (int i = 1; i <= n; i++) {int l = leftBorder[i]; // 其左侧最远的位置dp[i] = dp[l - 1] + 2 + preSum[i] - preSum[l];}return dp[n];}
};

提交后发现只通过了26个样例。失败的样例数据很长,非常不好debug。随后自己找了一组数据,发现上面的贪心做法考虑的不够全面。

比如箱子为:{1,2} {2,3} {3,3} {3,3} {3,3} {4,2} {5,4}maxBoxes = 4maxWeight = 9portsCount = 5

按照我上面贪心的思路,每次转移都是尽可能把卡车装满,那么划分出的子数组情况如下(用|表示切割点):

{1, 2} | {2, 3} {3, 3} {3, 3} | {3, 3} {4, 2} {5, 4}

计算一下此时的行程数:

  • 第一趟出车,{1, 2},只有1个箱子,来回共2次行程
  • 第二趟出车,{2, 3} {3, 3} {3, 3},共3个箱子,来回需要2次行程,中间需要1次行程,共3次
  • 第三趟出车,{3, 3} {4, 2} {5, 4},共3个箱子,来回需要2次行程,中间需要2次行程,共4次

总的行程数为:2 + 3 + 4 = 9

而实际存在一种更优的方案如下:

{1, 2} {2, 3} | {3, 3} {3, 3} {3, 3} | {4, 2} {5, 4}
  • 第一趟出车,{1, 2} {2, 3},共2个箱子,来回行程为2,中间行程1,共3
  • 第二趟出车,{3, 3} {3, 3} {3, 3},共3个箱子,来回2,中间0,共2
  • 第三趟出车,{4, 2} {5, 4},共2个箱子,来回2,中间1,共3

总行程数为:3 + 2 + 3 = 8

为什么会出现这种情况呢?前后两种划分方式,出车的趟数都是三趟,为什么下面的方案总行程数会更少呢?

由于我们需要把整个数组切分成若干段,不妨考虑下切割点的位置。

首先,切割点一定是在数组中间的某两个箱子之间的间隙。我们考虑一下,执行这次切割的前后,对行程数的影响。

我们考虑这个切割点前后两个箱子的情况。假设切割点之前的箱子为x,之后的箱子为y

首先,切割后,一定会比不切割,多出2个行程,即切割点左侧部分的回程,以及切割点右侧部分的去程

其次,我们需要考虑切割点左右两个箱子,xy的目的码头是否相同。

  • xy的目的码头相同,则不切割时,从x走到y消耗的行程为0
  • xy的目的码头不同,则不切割时,从x走到y消耗的行程为1

我们假设,从切割点左侧部分,走到箱子x处,消耗的总行程为dx;从切割点右侧的y,走到末尾,消耗的总行程为dy

  • xy的目的码头相同时
    • 不切割时,总的行程为dx + dy
    • 切割时,总的行程为dx + dy + 2,切割后比切割前,多出的行程数为2
  • xy的目的码头不同时
    • 不切割时,总的行程为dx + dy + 1
    • 切割时,总的行程为dx + dy + 2,切割后比切割前,多出的行程数为1

所以,如果切割的次数相同,则我们需要尽可能的在左右两个箱子的目的码头不等的地方,进行切割,才能使切割导致增加的行程数,尽可能的少。

换句话说,就是如果有某一段连续的箱子,它们目的码头相同,则尽可能的不要从中间切开。

所以我一开始的贪心策略(尽可能把卡车装满)是错误的,或者说是不够完善的。

我们不仅需要考虑尽可能的把卡车装满,还要考虑分割点的位置,将连续某一段目的码头相同的箱子,尽可能放在同一趟去运输。

所以贪心策略应该是二选一。先看尽可能把卡车装满的情况,再考虑分割点的位置,对于连续相同目的码头的箱子,不要切割。二者中取最优。

// C++ 二选一贪心
typedef long long LL;
class Solution {
public:int boxDelivering(vector>& boxes, int portsCount, int maxBoxes, int maxWeight) {int n = boxes.size();// 需要每个区间内的行程数, 包括区间2个端点, 加上区间内相邻不同数字的和vector preSum(n + 1);preSum[1] = 0; // n = 1, 2 for (int i = 1; i < n; i++) {preSum[i + 1] = preSum[i];if (boxes[i][0] != boxes[i - 1][0]) preSum[i + 1]++;}// preSum[i] - preSum[j] 就是[i, j]相邻的但目的码头不同的箱子 的组数 (中间要多加多少次行程)vector leftBorder(n + 1); // 某个位置左侧最远能到达的位置LL sum = 0;for (int i = 1, j = 1; i <= n; i++) {sum += boxes[i - 1][1];while (sum > maxWeight || i - j + 1 > maxBoxes) {sum -= boxes[j - 1][1];j++;}leftBorder[i] = j;}// left[i]表示, 坐标i的箱子, 左侧的连续相同码头的最左侧的箱子的坐标, right[i]同理vector left(n + 1);vector right(n + 1);int j = 1;while (j <= n) {int k = j;while (k + 1 <= n && boxes[k - 1][0] == boxes[k][0]) k++;for (int i = j; i <= k; i++) {left[i] = j;right[i] = k;}j = k + 1;}// 动态规划vector dp(n + 1);for (int i = 1; i <= n; i++) {int l = leftBorder[i]; // 以i为右端点, 左侧最远能到达的位置dp[i] = dp[l - 1] + 2 + preSum[i] - preSum[l];if (left[l] < l) {// 若上面将卡车装满的切割点, 恰好切割在了连续相同码头的箱子中间// 则二选一进行转移int r = right[l]; // 连续相同码头的最后一个箱子// 切在最后一个箱子的右侧if (r + 1 <= i) dp[i] = min(dp[i], dp[r] + 2 + preSum[i] - preSum[r + 1]);}}return dp[n];}
};

在这里插入图片描述

动态规划+单调队列

下面来看一下单调队列的做法

动态规划的部分和上面一样,设dp[i]表示将[0, i]的全部箱子运输到对应码头,需要的最少行程数量。

考虑状态转移时,还是考虑最后一趟的情况,只不过我们不用贪心策略。我们设最后一趟运输的箱子的范围是[j, i],其中

  • 0 <= j <= i

并且需要满足题目中的2个约束

  • 这一趟的箱子个数不超过maxBoxes

    可以用 i - j + 1 <= maxBoxes 来判断

  • 这一趟的箱子总重量不超过maxWeight

    我们对箱子的重量预处理出前缀和后(假设为weight[i]),则可以用 weight[i] - weight[j - 1] <= maxWeight来判断

对于每一个i,我们可以枚举所有的j ∈ [0, i],来进行状态转移。但是这样的话,总体的时间复杂度就是 O(n2)O(n^2)O(n2),在这道题目的数据范围下,是会超时的。所以我们需要考虑对状态转移进行优化。

与上面同样的,我们设cnt[i]表示[0, i]区间内,相邻的但目的码头不同的箱子的次数。对于某个区间[j, i]的箱子,我们用transport(j, i)表示运输该区间所有箱子需要的行程次数,那么有transport(j, i) = 2 + cnt[i] - cnt[j],即来回2次行程,加上中间不同目的码头的箱子的次数。

状态转移方程为:dp[i] = dp[j - 1] +transport(j, i) ,即dp[i] = dp[j - 1] + 2 + cnt[i] - cnt[j]

其中需要满足

  • 0 <= j <= i
  • i - j + 1 <= maxBoxes
  • weight[i] - weight[j - 1] <= maxWeight

我们对状态转移方程稍微进行一下变换,由于对于某个状态dp[i],其计算需要枚举全部的j,(j为变量),那么我们将状态转移方程中j的部分放在一起,

dp[i] = dp[j - 1] - cnt[j] + 2 + cnt[i]

最后一项2 + cnt[i],可以看作一个常数c(我们在计算某个dp[i]时,i此时是固定不变的)

那么我们可以把前面关于变量j的部分提取出来,设g[j] = dp[j - 1] - cnt[j]

那么有dp[i] = g[j] + c

我们再观察一下j需要满足的条件,同样对条件不等式进行一下变换,把j放在一边,有

  • j >= i + 1 - maxBoxes
  • weight[j - 1] >= weight[i] - maxWeight

我们考虑两个数,j0 < j1,看下g[j0]g[j1]

  • g[j0] < g[j1],那么dp[i]应该尽可能由更小的g[j0]转移过来。如果j0能满足上面的约束条件,那么由于j1 > j0,则j1更能满足上面的约束条件,则此时应该选择更小的g[j0]进行转移;若j0不能满足上面的约束条件,那么j1 > j0,则j1有可能能满足约束条件,此时应该由g[j1]进行转移。

    所以,在g[j0] < g[j1]的情况下,dp[i]既可能由g[j0]转移过来,也可能由g[j1]转移过来,所以g[j0]g[j1]都需要进行保留

  • g[j0] >= g[j1],那么此时不需要考虑g[j0]了。为什么呢?首先,如果j0能满足约束条件,那么j1一定更能满足约束条件,那么在2个j都满足约束条件的情况下,可以选g[j1],一定不会比选g[j0]更差;如果j0不能满足约束条件,那么j1有可能满足约束条件。所以无论如何,都应该尽可能由g[j1]来进行转移

换句话说,对于某个i,我们准备计算dp[i],则需要枚举所有的j ∈ [0, i],我们需要用到g[j]

对于[0, i]这个区间内的所有j,对于两个相邻的jj0 < j1,若g[j0] < g[j1],则g[j0]g[j1]都有可能被dp[i]用到;而若g[j0] >= g[j1],则只有g[j1]可能被用到。也就是说,右侧的更小的值,能够把左侧更大的值给挡住,左侧更大的值不可能作为答案。

所以,我们可以只维护单调递增的那些g[j],那么该用单调队列还是单调栈呢?

答案是单调队列。由于队列里的g[j]是单调递增的,则我们计算dp[i]时,先取队头,因为队头是最小的g[j],而我们dp[i]的定义就是要取最小的行程数。看下这个j是否满足约束条件,若满足,则dp[i]应当由这个g[j]转移过来;若不满足约束条件,则对于更大的i,此j一定更不满足约束条件(观察上面的不等式即可),所以该g[j]不可能再被后续更大的i所用上。此时应当弹出队头。一直弹出队头,直到遇到一个满足约束条件的j,用此时的g[j]来计算出dp[i]。由于队列中每个j只可能入队和出队一次,一共要计算n个状态,平摊到每个状态上的计算量就是 O(1)O(1)O(1),总的时间复杂度是 O(n)O(n)O(n)。

这个单调队列的优化,不是那么容易能看出来的,感觉需要大量的做题经验。(反正我是虽然能推出限制条件的不等式和状态转移方程,但你让我想破脑袋我也想不出要用单调队列。QAQ

// C++ 384ms  动态规划+单调队列
typedef long long LL;
class Solution {
public:int boxDelivering(vector>& boxes, int portsCount, int maxBoxes, int maxWeight) {int n = boxes.size();vector preWeight(n + 1); // 重量的前缀和for (int i = 1; i <= n; i++) preWeight[i] = preWeight[i - 1] + boxes[i - 1][1];// 这个前缀和好像没什么用?  不!有用!// 需要每个区间内的行程数, 包括区间2个端点, 加上区间内相邻不同数字的个数总和vector preCnt(n + 1);preCnt[1] = 0; // n = 1for (int i = 1; i < n; i++) {preCnt[i + 1] = preCnt[i];if (boxes[i][0] != boxes[i - 1][0]) preCnt[i + 1]++;}// preCnt[i] - preCnt[j] 就是中间不同的点的个数// 本质就是将一个线段, 在某些约束下, 切割成若干个子线段// dp[i] 表示运输[0, i]这些区间内的箱子, 需要的最少行程次数vector dp(n + 1);// 需要枚举前一个分割点j, j从1开始// dp[i] = dp[j - 1] + preCnt[i] - preCnt[j] + 2// 其中需要满足 // i - j + 1 <= maxBoxes// preWeight[i] - preWeight[j - 1] <= maxWeight// 状态转移变换// dp[i] = dp[j - 1] - preCnt[j] + preCnt[i] + 2// 令 g[j] = dp[j - 1] + preCnt[j]// 则 dp[i] = g[j] + preCnt[i] + 2// 需要满足的限制条件是// j >= i + 1 - maxBoxes// preWeight[j - 1] >= preWeight[i] - maxWeight// 单调队列, 队列中保持单调递增deque q; // q中存j的值, 需要一个双端队列for (int i = 1; i <= n; i++) {// 队头不满足条件的先弹出while (!q.empty()) {int j = q.front();if (j < i + 1 - maxBoxes || preWeight[j - 1] < preWeight[i] - maxWeight) q.pop_front();else break;}// 队尾需要和当前的比较, 并保持递增while (!q.empty()) {int j = q.back();if (dp[j - 1] - preCnt[j] >= dp[i - 1] - preCnt[i]) q.pop_back();else break;}q.push_back(i);int j = q.front();dp[i] = dp[j - 1] - preCnt[j] + preCnt[i] + 2;}return dp[n];}
};

image-20221209112925820

总结

这道题目还是非常的有难度的,难度估分达到了2600分。我花了2天才把这道题搞明白,太不容易了!(TAT

此题考察的知识点也比较多,需要将多种经典算法组合起来,包含了动态规划,前缀和,单调队列或贪心。值得好好消化理解。

单调队列的优化思路尤其难想,需要对等式,不等式进行变换,以及观察规律。

相关内容

热门资讯

喜欢穿一身黑的男生性格(喜欢穿... 今天百科达人给各位分享喜欢穿一身黑的男生性格的知识,其中也会对喜欢穿一身黑衣服的男人人好相处吗进行解...
发春是什么意思(思春和发春是什... 本篇文章极速百科给大家谈谈发春是什么意思,以及思春和发春是什么意思对应的知识点,希望对各位有所帮助,...
网络用语zl是什么意思(zl是... 今天给各位分享网络用语zl是什么意思的知识,其中也会对zl是啥意思是什么网络用语进行解释,如果能碰巧...
为什么酷狗音乐自己唱的歌不能下... 本篇文章极速百科小编给大家谈谈为什么酷狗音乐自己唱的歌不能下载到本地?,以及为什么酷狗下载的歌曲不是...
家里可以做假山养金鱼吗(假山能... 今天百科达人给各位分享家里可以做假山养金鱼吗的知识,其中也会对假山能放鱼缸里吗进行解释,如果能碰巧解...
华为下载未安装的文件去哪找(华... 今天百科达人给各位分享华为下载未安装的文件去哪找的知识,其中也会对华为下载未安装的文件去哪找到进行解...
四分五裂是什么生肖什么动物(四... 本篇文章极速百科小编给大家谈谈四分五裂是什么生肖什么动物,以及四分五裂打一生肖是什么对应的知识点,希...
怎么往应用助手里添加应用(应用... 今天百科达人给各位分享怎么往应用助手里添加应用的知识,其中也会对应用助手怎么添加微信进行解释,如果能...
客厅放八骏马摆件可以吗(家里摆... 今天给各位分享客厅放八骏马摆件可以吗的知识,其中也会对家里摆八骏马摆件好吗进行解释,如果能碰巧解决你...
苏州离哪个飞机场近(苏州离哪个... 本篇文章极速百科小编给大家谈谈苏州离哪个飞机场近,以及苏州离哪个飞机场近点对应的知识点,希望对各位有...