AcWing 301. 任务安排2


题目传送门

转载链接
参考阅读链接

一、分析过程

1、与上一题的区别

\(AcWing\) \(300\) 任务安排\(1\)中,我们使用了费用提前计算的技巧,实现了本题平方级别的算法,对于本题增加的数据范围,\(n<=300000\),显然平方级别的算法不足以解决问题,需要想办法在线性的时间内解决本题。

2、引入凸包概念

本题需要使用凸包(凸壳)优化,先给一下白话版本的凸包定义

给定二维平面上的一堆点中,凸包就是将最外层的点连接起来构成的凸多边形,它能包含点集中所有的点

如上图所示,点集中外层的点构成的凸多边形就构成了能够包含所有点的凸包,其中连接相邻顶点构成的边越来越平缓,或者说斜率越来越小构成的一组点叫做上凸壳,而相邻的边,斜率越来越大的一组点叫做下凸壳。本题主要考虑的是下凸壳。

关于斜率,同学们可以认为是直线与横轴在第一象限的夹角,夹角越小,斜率越小,夹角越大,斜率越大,换句话说:斜率越小,越贴近x轴,斜率越大,越贴近y轴。

如图所示,开始只有顶点\(A、B\)构成的凸包,然后加入第三点\(C_1\),显然\(BC_1\)的斜率是高于\(AB\)的,因此\(AB\)\(BC_1\)构成了一个下凸壳
但是如果新加的点不是\(C_1\)而是\(C_2\)\(BC_2\)的斜率小于\(AB\),那么\(AB\)\(BC2\)不能构成下凸壳了,因为不能作为点集的下边界,不能包含在\(AB\)下面却在\(AC_2\)上面的点,(这样就不是凸包,而是凹包了!)

因此,加入\(C_2\)后,\(AC_2\)将成为下凸壳新的边界了(用句人话说就是\(C_2\)\(B\)更靠外,用了\(C_2\)才能把所有点“”着包进来~)。
对于平面上的三点\(A(x1,y1),B(x2,y2),C(x3,y3)\),并且\(x1 < x2 < x3,y1 < y2 < y3\)
\(AB\)的斜率小于\(BC\)的斜率才能使得\(AB\)\(BC\)形成下凸壳。

3、凸包优化的思路

对于状态转移方程
\(f[i] = min(f[j] + (sc[i] - sc[j])*st[i] + (sc[n] - sc[j])*S)\)
联系题意,\(j\)表示的其实是第\(i\)个批次任务的前一个批次任务的最后一个节点,在上一题中,通过遍历每一个可能的节点,然后找到合理的节点\(j\)

由于在求\(f[i]\)时,\(sc[i]\),\(sc[j]\),\(S\)都是已知量,对式子做下简单变形,得到:
\(f[j] = (st[i] + S) * sc[j] + f[i] - sc[i] * st[i] - sc[n] * S\)
把变化的\(f[j]\),\(sc[j]\)放到了左侧,把一些在确定\(i\)时刻时的常量放到了右侧。

我们需要做的是,找到合适的\(j\),使得\(f[i]\)最小,将\(f[j]\)看作\(y\)\(sc[j]\)看作\(x\)\(st[i] + S\)看作\(k\)\(f[i] - sc[i] * st[i] - sc[n] * S\)看作\(b\),式子就化作了\(y = kx + b\)这样简单的一次函数,\(f[i]\)最小时,截距也就最小,所以问题就转化为了在所有\((sc[j],f[j])\)构成的点集中,找到一条斜率为\(st[i] + S\)的直线,使得截距最小。

如图所示,我们用一条斜率为\(k = st[i] + S\)的直线从下方不断往上移动,最先碰到点集中的点时,此时的截距最小\(f[i]\)也就最小。

可以确定的是,最先碰到的一定是凸多边形边界的顶点,而不会是凸包内部包含的点,所以,凸包以外的点可以排除了。

现在,需要确定的是,使得截距最小的到底是凸包上的哪一点?
图中直线上移最先遇见的显然是\(B\)点,那在什么情况下最先遇见的不是\(AC\)而是\(B\)点呢,只需要
\(AB\)的斜率小于\(k\)\(BC\)的斜率大于\(k\)即可

4、单调队列补强凸包优化

(1) 以队列维护下凸壳

也就说,我们需要维护一个队列(用来装凸包的下凸壳),自队头到队尾斜率递增,从头遍历队列,最先遇见大于\(k\)的斜率的端点就是所求的\(j\)点!

我们在遍历\(i\)的时候逐步的去求\(f[i]\),求\(f[i]\)的时候用到了\(0\)\(i-1\)(注:可以想一下原始版本的状态转移方程推导过程),也就说所有的\(j\),每次求出了\(i\)也需要将\(i\)作为\(i + 1\)状态中\(j\)的候选者加入到点集中:上图中的点集分别是\((sc[0],f[0]),(sc[1],f[1])\),...,每个人即是生产者,同时也是消费者。

由于\(sc\)是前缀和,而且\(sc\)数组都是整数,所以随着\(i\)的增加,新加入的\((sc[j],f[j])\)一定是横纵坐标都超过之前的点的,我们需要不停的向点集中添加横纵坐标都增加的点,并且更新加入新点后点集的凸包。(越来越向右,是有方向滴~)

2、动态维护下凸壳队列

上面的下凸壳,是在进展到了\(i\)时的下凸壳点集,新加入的点,可能会替换掉原来队列中的点:
对于平面上的三点\(A(x1,y1),B(x2,y2),C(x3,y3)\),并且\(x1 < x2 < x3,y1 < y2 < y3\)\(AB\)\(BC\)能够作为凸包当且仅当\(AB\)的斜率要小于\(BC\)的斜率。也就说,原本凸包上有\(AB\)构成的边,加入\(C\)后,要想\(BC\)也作为凸包的边界,只需要\(BC\)的斜率大于\(AB\),否则,就要删除\(B\)点,因为\(AC\)肯定在\(B\)点的下方,\(B\)不能作为凸包的边界了。

3、出队列头

我们遍历\(i\)的时候,相当于不断的往点集中加点,并且更新凸包,在求\(f[i]\)时,只需要找到凸包中第一个大于\(k\)的斜率即可,由于凸包的边的斜率自左向右是递增的,所以我们可以维护一个队列,队头的斜率最小,并且自队头向队尾斜率是递增的。我们还需要注意求\(f[i]\)时候的\(k = st[i] + S\),随着\(i\)的增加\(k\)也在增加,所以队列中小于\(k\)的斜率肯定也小于后面的\(k\),不可能成为最优解的,所以在遍历队列找到第一个大于\(k\)的斜率时可以将小于\(k\)的斜率都删掉。

4、出队列尾

为了维护队列的单调性,插入新点\((sc[i],f[i])\)时,如果该点与之前相邻点的斜率不是高于之前队尾的斜率的,就要将队尾的斜率出队,踢出凸包的点集,直至找到一个合适的位置再插入队列。

将上面单调队列的思路实现为具体的代码:

单调队列中存储的必然是点的编号\(i\),其代表的斜率应该与其更接近队尾的相邻的点构成的斜率,所以初始情况下队头应该有个哨兵节点\(0\),后面要考虑的就是何时出队头,何时出队尾了。

出队头一方面是为了自小到大找到第一个大于\(k\)的斜率,另一方面是为了删掉小于\(k\)的斜率,一条线至少有两个点构成,所以只有队列中有不少于两个点的情况下才能出队头,队头存储的是\((sc[q[hh]],f[q[hh]])\),与后一个点\((sc[q[hh]+1],f[q[hh]+1])\)构成的斜率是\((f[q[hh]+1] - f[q[hh]]) / (sc[q[hh]+1] - sc[q[hh]])\),当该斜率小于\(st[i] + S\)时就要出队头考虑下一个斜率,为了避免除法实现时需要变形下改为乘法。

出完队头就找到了最优解\(j\),开始更新\(f[i]\),然后就是将\((sc[i],f[i])\)加入队列,同样是需要比较斜率大小,将该点放在合适的位置使得队列斜率递增,当\((f[i] - f[q[tt]]) / (sc[i] - sc[q[tt]]) > (f[q[tt]] - f[q[tt]-1]) / (sc[q[tt]] - sc[q[tt]-1])\)时才能加入到队尾。

另外,由于计算过程中的乘法可能会爆\(int\),所以本题的数组定义为\(long\) \(long\)类型。

#include 

using namespace std;
typedef long long LL;
const int N = 300010;
LL st[N];       //表示前i个任务完成所需的时间前缀和sum(Ti)
LL sc[N];       //表示前i个任务完成所需的费用前缀和sum(Ci)
LL f[N];        //总费用最小值
int q[N];       //单调队列

int main() {
    int n, s;
    cin >> n >> s;

    for (int i = 1; i <= n; i++) cin >> st[i] >> sc[i], st[i] += st[i - 1], sc[i] += sc[i - 1];
    int hh = 0, tt = 0;
    q[0] = 0;//加入哨兵
    for (int i = 1; i <= n; i++) {
        //出队头
        while (hh < tt && f[q[hh + 1]] - f[q[hh]] <= (st[i] + s) * (sc[q[hh + 1]] - sc[q[hh]])) hh++;

        //利用公式更新最优解
        f[i] = f[q[hh]] - (st[i] + s) * sc[q[hh]] + sc[i] * st[i] + s * sc[n];

        //出队尾
        while (hh < tt &&
               (f[q[tt]] - f[q[tt - 1]]) * (sc[i] - sc[q[tt]]) >= (f[i] - f[q[tt]]) * (sc[q[tt]] - sc[q[tt - 1]]))
            tt--;
        //加入
        q[++tt] = i;
    }
    //输出
    printf("%lld\n", f[n]);
    return 0;
}