『任务安排 斜率优化及其变形』
<更新提示>
<第一次更新>
<正文>
任务安排1#
Description#
N个任务排成一个序列在一台机器上等待完成(顺序不得改变),这N个任务被分成若干批,每批包含相邻的若干任务。
从时刻0开始,这些任务被分批加工,第i个任务单独完成所需的时间是Ti。在每批任务开始前,机器需要启动时间S,而完成这批任务所需的时间是各个任务需要时间的总和(同一批任务将在同一时刻完成)。
每个任务的费用是它的完成时刻乘以一个费用系数Ci。请确定一个分组方案,使得总费用最小。
例如: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≤N≤50000)。 第二行是S(0≤S≤50)。 下面N行每行有一对数,分别为Ti和Ci,均为不大于100的正整数,表示第i个任务单独完成所需的时间是Ti及其费用系数Ci。
Output Format#
一个数,最小的总费用。
Sample Input#
5
1
1 3
3 2
4 3
2 3
1 4
Sample Output#
153
解析#
很明显,这是一道最优化问题,我们可以使用动态规划来求解这个问题。
设f[i]代表到第i个任务为止的最小总花费。由于每一批任务的机器启动时间会对之后任务的花费产生影响,所以我们需要利用花费提前计算的技巧,将一批任务的花费广义的定义为它本身带来的花费一个以及之后产生的额外花费,那么就能写出状态转移方程了。
在上式中,sumT和sumC代表时间T和费用系数C的前缀和,我们选择一个最优的决策点j,对状态i进行转移,表示将第j+1到i这些物品分为一组,并累加了这一批物品的机器启动时间对未来物品的影响。
这个状态转移方程的时间复杂度为O(n2),显然会超时。
观察这个方程的形式,我们考虑展开这个方程。设找到了最优的决策点j,则:
这是斜率优化的标准形式,我们将fj看做y,将sumCj看做x,将S+sumTi看做k,将fi−sumTi∗sumCi−S∗sumCn看做b,那么该方程就是一个直线的方程。
利用『玩具装箱TOY 斜率优化DP』,『土地征用 Land Acquisition 斜率优化DP』两文中数形结合的斜率优化技巧,我们就可以解决本题了。
但是,为了更好的了解斜率优化的套路,本文将再次用代数的方法从头讲解如何优化该方程。
从式子着手,我们再进行推导:
设有关i的常量sumTi∗sumCi+S∗sumCn=p(i),有关j的变量fj−sumCj∗(S+sumTi)=val(j),则原式即为:
在枚举到一个i时,设有两个决策点x,y且满足x<y,若决策点y优于决策点x,当且仅当:
由于x<y,前缀和sumC是递增的,所以sumCy>sumCx,即sumCy−sumCx>0。那么:
观察发现,sumTi+S为常量,fy−fxsumCy−sumCx为两点(sumCx,fx),(sumCy,fy)所在直线的斜率。
此时,决策点y优于决策点x,由于sumTi递增(随着i的增加而增加),那么在以后的决策中,决策点x就再也不可能优于决策点y了。
这就是斜率优化的决策单调性。由线性规划的知识可知,我们需要维护的决策点应该满足两两之间的斜率递增,形成一个下凸壳的形状,那么我们就可以设计出如下的算法:
维护一个单调队列,其相邻两点斜率递增,并约定队首存储每一次转移的最优决策点。对于每一个i∈[1,n],我们执行如下步骤:
1. 利用决策单调性,在队尾执行删除操作,将不优的点踢出队列
2. 得到队头的最优决策点,转移得到fi的值
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≤Ti≤521。
解析#
Ti可能为负数,也就是说sumTi不一定具有单调性了,看看我们之前的斜率优化算法会出什么问题。
显然,由于sumFi还是有单调性的,所以我们仍然可以线性地维护下凸壳。但是,由于sumTi的单调性不确定了,我们的决策单调性就失效了,就是这一部分:
fy−fxsumCy−sumCx<sumTi+S观察发现,sumTi+S为常量,fy−fxsumCy−sumCx为两点(sumCx,fx),(sumCy,fy)所在直线的斜率。
此时,决策点y优于决策点x,
由于sumTi递增(随着i的增加而增加),那么在以后的决策中,决策点x就再也不可能优于决策点y了。
被删除线划掉的这一部分性质失效了,但是我们知道,这个式子还是有用的,所以最优决策点就是单调队列里面相邻两点斜率第一个大于sumTi+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,|Ti|,|Fi|≤100。
解析#
这一次sumTi和sumFi的单调性都没了,我们必须考虑怎样维护下凸壳。
一种方法是平衡树,sumFi不具有单调性意味着我们可能要在凸壳的任何一个位置动态地插入一个点,我们可以使用平衡树来维护凸壳,这需要用到计算几何的知识。
更好的方法的利用cdq分治来解决本题:
对于fi,我们可以用f1到fi−1的任何一个点来更新,对于决策集合,我们需要sumCi单调递增才能维护最优决策的下凸壳,这就对应了一个二维偏序问题。所以我们想到了一个用cdq分治来做斜率优化的方法。
先将每一个任务对应的sumT,sumC,id值存在一个结构体中,并在结构体中预留一个位置val,存状态转移方程中和j有关的部分,本题中存fj,一开始不知道fj时为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],然后归并排序即可。
为什么这样一定是正确的呢?显然在分治过程中每一个fi都会被每一个可能最优值fj更新,这样就保证了动态规划的正确性。
总的来说,我们先将元素排序为斜率关键值(本题中为sumT)有序的,然后利用cdq分治过程中部分有序的特点,保证左半边是横坐标关键值(本题中为sumC)有序的,每一次用左半边构造下凸壳,用决策单调性更新右半边的dp值,就能解决这类由于单调性出锅的斜率优化问题,其时间复杂度为O(nlog2n),有一个大约为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;
}
#
<后记>
【推荐】还在用 ECharts 开发大屏?试试这款永久免费的开源 BI 工具!
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 35岁程序员的中年求职记:四次碰壁后的深度反思
· 继承的思维:从思维模式到架构设计的深度解析
· 如何在 .NET 中 使用 ANTLR4
· 后端思维之高并发处理方案
· 理解Rust引用及其生命周期标识(下)
· 35岁程序员的中年求职记:四次碰壁后的深度反思
· 当职场成战场:降职、阴谋与一场硬碰硬的抗争
· ShadowSql之.net sql拼写神器
· 无需WebView,Vue也能开发跨平台桌面应用
· 使用MCP C# SDK开发MCP Server + Client