1687. 从仓库到码头运输箱子
你有一辆货运卡车,你需要用这一辆车把一些箱子从仓库运送到码头。这辆卡车每次运输有 箱子数目的限制 和 总重量的限制 。
给你一个箱子数组 boxes
和三个整数 portsCount
, maxBoxes
和 maxWeight
,其中
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
是码头的数目。
maxBoxes
和 maxWeight
分别是卡车每趟运输箱子数目和重量的限制。
箱子需要按照 数组顺序 运输,同时每次运输需要遵循以下步骤:
卡车从 boxes
队列中按顺序取出若干个箱子,但不能违反 maxBoxes
和 maxWeight
限制。
对于在卡车上的箱子,我们需要 按顺序 处理它们,卡车会通过 一趟行程 将最前面的箱子送到目的地码头并卸货。如果卡车已经在对应的码头,那么不需要 额外行程 ,箱子也会立马被卸货。
卡车上所有箱子都被卸货后,卡车需要 一趟行程 回到仓库,从箱子队列里再取出一些箱子。
卡车在将所有箱子运输并卸货后,最后必须回到仓库。
请你返回将所有箱子送到相应码头的 最少行程 次数。
提示:
- 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
。可以看到i
和j
的移动,具有单调的性质(向同一个方向移动),所以,求解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 = 4
,maxWeight = 9
,portsCount = 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个行程,即切割点左侧部分的回程,以及切割点右侧部分的去程。
其次,我们需要考虑切割点左右两个箱子,x
和y
的目的码头是否相同。
- 若
x
和y
的目的码头相同,则不切割时,从x
走到y
消耗的行程为0 - 若
x
和y
的目的码头不同,则不切割时,从x
走到y
消耗的行程为1
我们假设,从切割点左侧部分,走到箱子x
处,消耗的总行程为dx
;从切割点右侧的y
,走到末尾,消耗的总行程为dy
。
当
x
和y
的目的码头相同时- 不切割时,总的行程为
dx + dy
- 切割时,总的行程为
dx + dy + 2
,切割后比切割前,多出的行程数为2
- 不切割时,总的行程为
x
和y
的目的码头不同时- 不切割时,总的行程为
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
,对于两个相邻的j
,j0 < 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];
}
};
总结
这道题目还是非常的有难度的,难度估分达到了2600分。我花了2天才把这道题搞明白,太不容易了!(TAT
此题考察的知识点也比较多,需要将多种经典算法组合起来,包含了动态规划,前缀和,单调队列或贪心。值得好好消化理解。
单调队列的优化思路尤其难想,需要对等式,不等式进行变换,以及观察规律。