每日一题 —— LC. 1687 从仓库到码头运输箱子(难度很大,但值得好好消化的一道题)

本文探讨了一个复杂的运输问题,目标是利用动态规划、前缀和及单调队列等算法,找到将箱子从仓库运送至不同码头的最短行程路径。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

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

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

给你一个箱子数组 boxes和三个整数 portsCount, maxBoxesmaxWeight ,其中 b o x e s [ i ] = [ p o r t s i , w e i g h t i ] boxes[i] = [ports_i, weight_i] boxes[i]=[portsi,weighti]

p o r t s i ports_i portsi 表示第 i 个箱子需要送达的码头, w e i g h t s i weights_i weightsi 是第 i 个箱子的重量。
portsCount 是码头的数目。
maxBoxesmaxWeight 分别是卡车每趟运输箱子数目和重量的限制。
箱子需要按照 数组顺序 运输,同时每次运输需要遵循以下步骤:

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

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

提示:

  • 1 < = b o x e s . l e n g t h < = 1 0 5 1 <= boxes.length <= 10^5 1<=boxes.length<=105
  • 1 < = p o r t s C o u n t , m a x B o x e s , m a x W e i g h t < = 1 0 5 1 <= portsCount, maxBoxes, maxWeight <= 10^5 1<=portsCount,maxBoxes,maxWeight<=105
  • 1 < = p o r t s i < = p o r t s C o u n t 1 <= ports_i <= portsCount 1<=portsi<=portsCount
  • 1 < = w e i g h t s i < = m a x W e i g h t 1 <= weights_i <= maxWeight 1<=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<vector<int>>& boxes, int portsCount, int maxBoxes, int maxWeight) {
        int n = boxes.size();
        
        // vector<LL> preSum(n + 1); // 重量的前缀和
        // for (int i = 1; i <= n; i++) preSum[i] = preSum[i - 1] + boxes[i - 1][1];
        // 这个前缀和好像没什么用? 因为下面已经计算了leftBorder了

        // 需要每个区间内的行程数, 包括区间2个端点, 加上区间内相邻不同数字的和
        vector<int> 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<int> 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<int> 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<vector<int>>& boxes, int portsCount, int maxBoxes, int maxWeight) {
        int n = boxes.size();
        // 需要每个区间内的行程数, 包括区间2个端点, 加上区间内相邻不同数字的和
        vector<int> 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<int> 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<int> left(n + 1);
        vector<int> 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<int> 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 ( n 2 ) 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<vector<int>>& boxes, int portsCount, int maxBoxes, int maxWeight) {
        int n = boxes.size();
        
        vector<LL> preWeight(n + 1); // 重量的前缀和
        for (int i = 1; i <= n; i++) preWeight[i] = preWeight[i - 1] + boxes[i - 1][1];
        // 这个前缀和好像没什么用?  不!有用!

        // 需要每个区间内的行程数, 包括区间2个端点, 加上区间内相邻不同数字的个数总和
        vector<int> preCnt(n + 1);
        preCnt[1] = 0; // n = 1
        for (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<int> 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<int> 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

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

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

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值