『任务安排 斜率优化及其变形』

<更新提示>

<第一次更新>


<正文>

任务安排1

Description

\(N\)个任务排成一个序列在一台机器上等待完成(顺序不得改变),这\(N\)个任务被分成若干批,每批包含相邻的若干任务。

从时刻\(0\)开始,这些任务被分批加工,第\(i\)个任务单独完成所需的时间是\(Ti\)。在每批任务开始前,机器需要启动时间\(S\),而完成这批任务所需的时间是各个任务需要时间的总和(同一批任务将在同一时刻完成)。

每个任务的费用是它的完成时刻乘以一个费用系数\(C_i\)。请确定一个分组方案,使得总费用最小。

例如:\(S=1\)\(T=\{1,3,4,2,1\}\)\(C=\{3,2,3,3,4\}\)。如果分组方案是\(\{1,2\},\{3\},\{4,5\}\),则完成时间分别为\(\{5,5,10,14,14\}\),费用\(C'=\{15,10,30,42,56\}\),总费用就是\(153\)

Input Format

第一行是\(N(1\leq N\leq 50000)\)。 第二行是\(S(0\leq S\leq 50)\)。 下面\(N\)行每行有一对数,分别为\(T_i\)\(C_i\),均为不大于\(100\)的正整数,表示第\(i\)个任务单独完成所需的时间是\(T_i\)及其费用系数\(C_i\)

Output Format

一个数,最小的总费用。

Sample Input

5
1
1 3
3 2
4 3
2 3
1 4

Sample Output

153

解析

很明显,这是一道最优化问题,我们可以使用动态规划来求解这个问题。

\(f[i]\)代表到第\(i\)个任务为止的最小总花费。由于每一批任务的机器启动时间会对之后任务的花费产生影响,所以我们需要利用花费提前计算的技巧,将一批任务的花费广义的定义为它本身带来的花费一个以及之后产生的额外花费,那么就能写出状态转移方程了。

\[f[i]=\min_{0\leq j<i}\{f[j]+sumT[i]*(sumC[i]-sumC[j])+S*(sumC[n]-sumC[j])\} \]

在上式中,\(sumT\)\(sumC\)代表时间\(T\)和费用系数\(C\)的前缀和,我们选择一个最优的决策点\(j\),对状态\(i\)进行转移,表示将第\(j+1\)\(i\)这些物品分为一组,并累加了这一批物品的机器启动时间对未来物品的影响。

这个状态转移方程的时间复杂度为\(O(n^2)\),显然会超时。

观察这个方程的形式,我们考虑展开这个方程。设找到了最优的决策点\(j\),则:

\[f_i=f_j+sumT_i*sumC_i-sumT_i*sumC_j+S*sumC_n-S*sumC_j\\f_j=sumC_j*(S+sumT_i)+f_i-sumT_i*sumC_i-S*sumC_n \]

这是斜率优化的标准形式,我们将\(f_j\)看做\(y\),将\(sumC_j\)看做\(x\),将\(S+sumT_i\)看做\(k\),将\(f_i-sumT_i*sumC_i-S*sumC_n\)看做\(b\),那么该方程就是一个直线的方程。

利用『玩具装箱TOY 斜率优化DP』『土地征用 Land Acquisition 斜率优化DP』两文中数形结合的斜率优化技巧,我们就可以解决本题了。

但是,为了更好的了解斜率优化的套路,本文将再次用代数的方法从头讲解如何优化该方程。

从式子着手,我们再进行推导:

\[f_i=\min\{f_j+sumT_i*sumC_i-sumT_i*sumC_j+S*sumC_n-S*sumC_j\}\\f_i=\min\{f_j-sumC_j*(S+sumT_i)\}+sumT_i*sumC_i+S*sumC_n \]

设有关\(i\)的常量\(sumT_i*sumC_i+S*sumC_n=p(i)\),有关\(j\)的变量\(f_j-sumC_j*(S+sumT_i)=val(j)\),则原式即为:

\[f_i=\min\{val(j)\}+p(i) \]

在枚举到一个\(i\)时,设有两个决策点\(x,y\)且满足\(x<y\),若决策点\(y\)优于决策点\(x\),当且仅当:

\[val(y)+p(i)<val(x)+p(i)\\val(y)<val(x)\\f_y-sumC_y*(S+sumT_i)<f_x-sumC_x*(S+sumT_i)\\f_y-sumC_y*S-sumC_y*sumT_i<f_x-sumC_x*S-sumC_x*sumT_i\\f_y-f_x+S*(sumC_x-sumC_y)<sumT_i*(sumC_y-sumC_x) \]

由于\(x<y\),前缀和\(sumC\)是递增的,所以\(sumC_y>sumC_x\),即\(sumC_y-sumC_x>0\)。那么:

\[\frac{f_y-f_x}{sumC_y-sumC_x}-S<sumT_i\\\frac{f_y-f_x}{sumC_y-sumC_x}<sumT_i+S \]

观察发现,\(sumT_i+S\)为常量,\(\frac{f_y-f_x}{sumC_y-sumC_x}\)为两点\((sumC_x,f_x),(sumC_y,f_y)\)所在直线的斜率。

此时,决策点\(y\)优于决策点\(x\),由于\(sumT_i\)递增(随着\(i\)的增加而增加),那么在以后的决策中,决策点\(x\)就再也不可能优于决策点\(y\)了。

这就是斜率优化的决策单调性。由线性规划的知识可知,我们需要维护的决策点应该满足两两之间的斜率递增,形成一个下凸壳的形状,那么我们就可以设计出如下的算法:

维护一个单调队列,其相邻两点斜率递增,并约定队首存储每一次转移的最优决策点。对于每一个\(i\in[1,n]\),我们执行如下步骤:

\(1.\) 利用决策单调性,在队尾执行删除操作,将不优的点踢出队列

\(2.\) 得到队头的最优决策点,转移得到\(f_i\)的值

\(3.\) 利用斜率维护下凸壳,将新的决策点\(i\)推入队列

这样我们就在\(O(n)\)的时间完成了动态规划。

\(Code:\)

#include<bits/stdc++.h> 
using namespace std;
const int N=50020;
long long n,S,T[N],C[N],f[N];
long long q[N],head,tail;
inline void input(void)
{
    scanf("%lld%lld",&n,&S);
    for (int i=1;i<=n;i++)
        scanf("%lld%lld",&T[i],&C[i]),
        T[i] += T[i-1] , C[i] += C[i-1];
}
inline double slope(int x,int y)
{
    return (1.0 * (f[x]-f[y])) / (1.0 * (C[x]-C[y]));
}
inline void dp(void)
{
    head = tail = 1;
    q[tail] = 0;
    for (int i=1;i<=n;i++)
    {
        while ( head<tail && slope(q[head],q[head+1]) < 1.0 * (T[i] + S) ) head++;
        int j = q[head];
        f[i] = f[j] + T[i] * ( C[i] - C[j] ) + S * ( C[n] - C[j] );
        while ( head<tail && slope(q[tail-1],q[tail]) > slope(q[tail-1],i) ) tail--;
        q[++tail] = i;
    }
}
int main(void)
{
    input();
    dp();
    printf("%lld\n",f[n]);
    return 0;
}

任务安排2

Description

题意同任务安排1,\(-512\leq T_i \leq 521\)

解析

\(T_i\)可能为负数,也就是说\(sumT_i\)不一定具有单调性了,看看我们之前的斜率优化算法会出什么问题。

显然,由于\(sumF_i\)还是有单调性的,所以我们仍然可以线性地维护下凸壳。但是,由于\(sumT_i\)的单调性不确定了,我们的决策单调性就失效了,就是这一部分:

\[\frac{f_y-f_x}{sumC_y-sumC_x}<sumT_i+S \]

观察发现,\(sumT_i+S\)为常量,\(\frac{f_y-f_x}{sumC_y-sumC_x}\)为两点\((sumC_x,f_x),(sumC_y,f_y)\)所在直线的斜率。

此时,决策点\(y\)优于决策点\(x\)由于\(sumT_i\)递增(随着\(i\)的增加而增加),那么在以后的决策中,决策点\(x\)就再也不可能优于决策点\(y\)了。

被删除线划掉的这一部分性质失效了,但是我们知道,这个式子还是有用的,所以最优决策点就是单调队列里面相邻两点斜率第一个大于\(sumT_i+S\)的点。

怎么找到这个点呢,二分查找就可以了,当然,每一次我们就不能删除队头的点了,因为失去单调性后,当前不优的点以后还可能有用。

\(Code:\)

#include<bits/stdc++.h> 
using namespace std;
const int N=300020;
long long n,S,T[N],C[N],f[N];
long long q[N],head,tail;
inline void input(void)
{
    scanf("%lld%lld",&n,&S);
    for (int i=1;i<=n;i++)
        scanf("%lld%lld",&T[i],&C[i]),
        T[i] += T[i-1] , C[i] += C[i-1];
}
inline double slope(long long x,long long y)
{
    return ( 1.0 * f[x] - 1.0 * f[y] ) / ( 1.0 * C[x] - 1.0 * C[y] );
}
inline int binary_search(long long val)
{
    if ( head == tail ) return q[head];
    int l = head , r = tail ;
    while ( l < r )
    {
        int mid = l+r >> 1;
        if ( slope(q[mid],q[mid+1]) > 1.0 * val ) r = mid;
        else l = mid + 1; 
    }
    return q[l];
}
inline void dp(void)
{
    head = tail = 1;
    q[tail] = 0;
    for (int i=1;i<=n;i++)
    {
        int j = binary_search( T[i] + S );
        f[i] = f[j] + T[i] * ( C[i] - C[j] ) + S * ( C[n] - C[j] );
        while ( head<tail && slope(q[tail],q[tail-1]) >= slope(i,q[tail]) ) tail--;
        q[++tail] = i;
    }
}
int main(void)
{
    input();
    dp();
    printf("%lld\n",f[n]);
    return 0;
}

这个就是我们直接可以得到的代码了,但是,这道题会因为斜率的精度被卡,所以我们还要把除法转换为乘法,以下是\(AC\)代码,直接计算斜率的代码可以得到\(80\)分。

\(Code:\)

#include<bits/stdc++.h> 
using namespace std;
const int N=300020;
long long n,S,T[N],C[N],f[N];
long long q[N],head,tail;
inline void input(void)
{
    scanf("%lld%lld",&n,&S);
    for (int i=1;i<=n;i++)
        scanf("%lld%lld",&T[i],&C[i]),
        T[i] += T[i-1] , C[i] += C[i-1];
}
inline int binary_search(long long val)
{
    if ( head == tail ) return q[head];
    int l = head , r = tail ;
    while ( l < r )
    {
        int mid = l+r >> 1;
        if ( f[q[mid+1]] - f[q[mid]] > val * ( C[q[mid+1]]-C[q[mid]] ) ) r = mid;
        else l = mid + 1; 
    }
    return q[l];
}
inline void dp(void)
{
    head = tail = 1;
    q[tail] = 0;
    for (int i=1;i<=n;i++)
    {
        int j = binary_search( T[i] + S );
        f[i] = f[j] - C[j] * ( T[i] + S ) + T[i] * C[i] + S * C[n];
        while ( head<tail && (f[q[tail]]-f[q[tail-1]]) * (C[i]-C[q[tail]]) >= (f[i]-f[q[tail]]) * (C[q[tail]]-C[q[tail-1]]) ) tail--;
        q[++tail] = i;
    }
}
int main(void)
{
    input();
    dp();
    printf("%lld\n",f[n]);
    return 0;
}

任务安排3

Description

题意同任务安排1,\(|T_i|,|F_i|\leq100\)

解析

这一次\(sumT_i\)\(sumF_i\)的单调性都没了,我们必须考虑怎样维护下凸壳。

一种方法是平衡树,\(sumF_i\)不具有单调性意味着我们可能要在凸壳的任何一个位置动态地插入一个点,我们可以使用平衡树来维护凸壳,这需要用到计算几何的知识。

更好的方法的利用\(cdq\)分治来解决本题:

对于\(f_i\),我们可以用\(f_1\)\(f_{i-1}\)的任何一个点来更新,对于决策集合,我们需要\(sumC_i\)单调递增才能维护最优决策的下凸壳,这就对应了一个二维偏序问题。所以我们想到了一个用\(cdq\)分治来做斜率优化的方法。

先将每一个任务对应的\(sumT,sumC,id\)值存在一个结构体中,并在结构体中预留一个位置\(val\),存状态转移方程中和\(j\)有关的部分,本题中存\(f_{j}\),一开始不知道\(f_{j}\)时为\(0\),没有实际意义。

然后,我们将结构体按\(sumT\)排序,在用\(cdq\)分治对\(sumC\)进行排序。并且,每一次分治前,我们在不破坏\(sumT\)有序性的前提下整体维护左右区间下标的有序性。对于求解区间\([l,r]\),我们先递归求解子问题\([l,mid]\),那么\([l,mid]\)内所有元素都是按照\(sumC\)严格有序的,并且已经求解了对应的\(val\)值。此时,我们就可以用区间\([l,mid]\)来转移\([mid+1,r]\)了。由于\([l,mid]\)\(sumC\)严格有序,所以我们可以用线性时间将\([l,mid]\)所对应的下凸壳构造出来。由于\([mid+1,r]\)此时还是\(sumT\)严格有序,所以我们就可以用决策单调性线性地进行动态规划。最后,我们再递归求解子问题\([mid+1,r]\),然后归并排序即可。

为什么这样一定是正确的呢?显然在分治过程中每一个\(f_i\)都会被每一个可能最优值\(f_j\)更新,这样就保证了动态规划的正确性。

总的来说,我们先将元素排序为斜率关键值(本题中为\(sumT\))有序的,然后利用\(cdq\)分治过程中部分有序的特点,保证左半边是横坐标关键值(本题中为\(sumC\))有序的,每一次用左半边构造下凸壳,用决策单调性更新右半边的\(dp\)值,就能解决这类由于单调性出锅的斜率优化问题,其时间复杂度为\(O(nlog_2n)\),有一个大约为\(3\)的小常数。

\(Code:\)

#include <bits/stdc++.h>
using namespace std;
const int N=5e5+20;
long long n,q[N],f[N],sumC,S;
struct work
{
    long long T,C,val,id;
    bool operator < (work p){return C == p.C ? val < p.val : C < p.C;}
}a[N],cur[N];
inline long long read(void)
{
    long long x = 0 , w = 0; char ch = ' ';
    while (!isdigit(ch)) w |= ch=='-' , ch = getchar();
    while (isdigit(ch)) x = x*10 + ch-48 , ch = getchar();
    return w ? -x : x;
}
inline void input(void)
{
    n = read() , S = read();
    for (int i=1;i<=n;i++)
        a[i].T = read() , a[i].C = read() ,
        a[i].T += a[i-1].T , a[i].C += a[i-1].C , a[i].id = i;
    sumC = a[n].C;
}
inline bool compare(work p1,work p2)
{
    return p1.T < p2.T;
}
inline long long up(int x,int y)
{
    return a[y].val - a[x].val;
}
inline long long down(int x,int y)
{
    return a[y].C - a[x].C;
}
inline void cdq(int l,int r)
{
    if ( l == r ){a[l].val = f[a[l].id]; return;}
    int mid = l+r >> 1 , head = 1 , tail = 0;
    int s = l , t = mid+1;
    for (int i=l;i<=r;i++) a[i].id <= mid ? cur[s++] = a[i] : cur[t++] = a[i];
    for (int i=l;i<=r;i++) a[i] = cur[i];
    cdq( l , mid );
    for (int i=l;i<=mid;i++)
    {
        while ( tail > 1 && up(q[tail-1],q[tail]) * down(q[tail],i) >= down(q[tail-1],q[tail]) * up(q[tail],i) )
            tail--;
        q[++tail] = i;
    }
    for (int i=mid+1;i<=r;i++)
    {
        while ( head < tail && up(q[head],q[head+1]) <= ( a[i].T + S ) * down(q[head],q[head+1]) )
            head++;
        int j = q[head];
        f[ a[i].id ] = min( f[ a[i].id ] , a[j].val - a[j].C * ( a[i].T + S ) + a[i].T * a[i].C + S * sumC );
    }
    cdq( mid+1 , r );
    int cnt = l-1 ; s = l , t = mid+1;
    while ( s <= mid && t <= r ) cur[++cnt] = a[s] < a[t] ? a[s++] : a[t++];
    while ( s <= mid ) cur[++cnt] = a[s++]; while ( t <= r ) cur[++cnt] = a[t++];
    for (int i=l;i<=r;i++) a[i] = cur[i];
}
int main(void)
{
    input();
    memset( f , 0x7f , sizeof f );
    sort( a+1 , a+n+1 , compare );
    f[0] = 0; cdq( 0 , n );
    printf("%lld\n",f[n]);
    return 0;
}


<后记>

posted @ 2019-05-08 15:14  Parsnip  阅读(790)  评论(1编辑  收藏  举报