斜率优化dp模型整理

300. 任务安排1(300. 任务安排1 - AcWing题库

思路:很明显这些任务是按顺序排好的,我们能执行的操作只是对它们进行分批,我们可以发现每一批之前的开始时间s,影响的不仅仅是当前这一批的结束时间,而是将后面所有的结束时间都往后推了s,那么我们直接把对于后面的影响全部提到当前位置来算,那么批与批之间就相对独立一点了。

我们这么来定义,定义dp[i]表示第i个物品作为当前这批的末尾,那么我们就可以通过对上一批的末尾进行讨论进而划分批次,上一批的末尾可以从1一直到i-1。

令sc作为费用的前缀和,st作为时间的前缀和

那么状态转移就是:
dp[i]=min(dp[j]+(sc[i]-sc[j])*st[i]+s*(sc[n]-sc[j]));

//因为前面的s对于当前的影响已经被计算到之前批次中了,所以我们这里只用计算当前批次的影响即可。

那么显然时间复杂度是O(n^2),对于这题的数据范围来说是可以ac的。

另外因为这道题求的是最小值,所以说,我们初值要赋成正无穷。

#include<bits/stdc++.h>
using namespace std;
#define int long long
int t[6000],c[6000],dp[6000];
signed main()
{
    int n,s;
    scanf("%lld%lld",&n,&s);
    for(int i=1;i<=n;i++) 
    {
        scanf("%lld%lld",&t[i],&c[i]);
        c[i]+=c[i-1];
        t[i]+=t[i-1];
    }
    memset(dp,0x3f,sizeof dp);
    dp[0]=0;
    for(int i=1;i<=n;i++)
    {
        for(int j=0;j<i;j++)
        {
            dp[i]=min(dp[i],dp[j]+(c[i]-c[j])*t[i]+s*(c[n]-c[j]));
        }
    }
    cout<<dp[n];
}

301. 任务安排2(301. 任务安排2 - AcWing题库

在题意上较之上题没有变化,但是n的数据范围变大了。

所以递推的式子还是如上: dp[i]=min(dp[j]+(sc[i]-sc[j])*st[i]+s*(sc[n]-sc[j]));

但显然不能再用暴力去循环了,那么换个思路。我们来观察下这个式子。很容易发现,与j有关的只有两类:dp[j],sc[j],如果我们将两者一个视为自变量,一个视为因变量,那么很显然是一个一次函数:

dp[j]=(st[i]+s)*sc[j]+dp[i]-sc[i]*st[i]-s*sc[n];

显然i确定后,st[i]+s是确定的,另外对于j来说,一个j,只有一个dp[j]和一个sc[j],那么实际上就相当于将所有的j的点画在坐标系中,然后一条斜率确定的,经过这些点的直线,求满足条件的直线的截距的最小值。

显然如果找截距最小的话,应该是图中红线上的点(相当于凸包的下边界),很容易发现当截距确定后,红色边界上面的点产生的截距小于红色边界上的点。那么我们该如何去找,我们确定i之后实际还需要sc[i]和dp[j]才能求出我们需要的值。如图中的绿线,它经过的点,显然是从下边界中找到两个斜率,一个大于它,一个小于它,由此确定出它经过哪个点。那么如果我们维护出一个存斜率的单调队列,就很容易查找目标位置,可以用二分来查找。

但是再观察一下式子,我们会发现(st[i]+s)和sc[j]都随着j的变大而变大,所以很明显,当前的斜率如果大于一部分斜率的话,那么后面的只会更大,所以前面的根本不会再被访问到,那么就没有存下来的意义,于是我们实际可以在O(1)的时间复杂度内完成查找。

这么来说,每个点可以通过的线有很多条,现在已知有哪些点了,需要找出一条斜率已知的直线通过哪个点的截距最小,显然可以通过维护已知点的下凸壳得到,另外,凸壳边的斜率不等价于将这个点放入时的直线斜率。所以尽管放入的点对应的直线的斜率越来越大,但是凸壳的斜率也会有大于和小于的。当然如图中的绿线,这个点被放入后,k2线后面的两个点便不会再作为结果中的点了,因为有更合适的点出现了。

所以我们查询和放入时的删除操作不同,查询的时候,是这么考虑的,如果k1没被用到,也即k1<k,那么后续放入的线的k只会更大,那么至少得从2开始找(因为当前这个小于k2),所以压根用不到k1,那么我们是将点1弹出。

在放入的时候,很显然当前的绿线一旦放入,点3和点4就不会再作为凸壳的边了,因为新放入的点显然与2之间连的边变成凸壳的下边界了。

所以我们查询的时候是按照凸壳的边来查询的,修改的时候修改的也是凸壳的边,所以我们实际上维护的是凸壳下边界。

#include<bits/stdc++.h>
using namespace std;
typedef long long ll;
ll t[300010],c[300010],dp[300010],q[300010],hh,tt;
int main()
{
    int n,s;
    scanf("%d%d",&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];
    }
    hh=0,tt=1;//0是需要被放入的
    for(int i=1;i<=n;i++)
    {
        while(hh<tt&&(dp[q[hh+1]]-dp[q[hh]])<(c[q[hh+1]]-c[q[hh]])*(t[i]+s) ) hh++;
        int j=q[hh];
        dp[i]=dp[j]-(t[i]+s)*c[j]+c[i]*t[i]+s*c[n];
        //这一步要保证队列中至少有两个元素,否则直接放入即可
        while(hh+1<tt&&(dp[q[tt-1]]-dp[q[tt-2]])*(c[i]-c[q[tt-2]])>=(dp[i]-dp[q[tt-2]])*(c[q[tt-1]]-c[q[tt-2]])) tt--;
        q[tt++]=i;
    }
    cout<<dp[n];
}

一定要明确的就是凸壳的斜率和点 被放入时对应的直线斜率不是一回事,凸壳的斜率只是用来辅助查找点的,与直线斜率没有直接关系,另外凸壳的斜率的维护也要注意,查询和插入对应的是不同的修改策略。

302. 任务安排3(302. 任务安排3 - AcWing题库

思路:这题变化的地方在于t的范围,t可能为负值, 范围很大很明显没有办法暴力,那么还是来考虑优化:

dp[j]=(st[i]+s)*sc[j]+dp[i]-sc[i]*st[i]-s*sc[n];

这里直线的斜率由于t为负,那么就可能就不是单增了,所以在查询的时候是没办法修改的,因为当前的直线没用到凸壳的某个斜率,不代表后面的直线不会用到,但是凸壳仍然是维护一个单增的序列(这个应该是凸壳的性质。)直线斜率为负也没关系,仍然是找第一个大于直线斜率的凸壳斜率,所以不用担心,如下图:

所以相当于这里凸壳的维护不做更改,仅仅查询改变一下即可。也就是通过二分来查询符合要求的点,然后修改的维护同前。

#include<bits/stdc++.h>
using namespace std;
typedef long long ll;
ll t[300010],c[300010],dp[300010],q[300010],hh,tt;
int main()
{
    int n,s;
    scanf("%d%d",&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];
    }
    hh=0,tt=1;//0是需要被放入的
    for(int i=1;i<=n;i++)
    {
        int l=hh,r=tt-1;
        while(l<r)
        {
            int mid=(l+r)/2;
            if((dp[q[mid+1]]-dp[q[mid]])>=(c[q[mid+1]]-c[q[mid]])*(t[i]+s) ) r=mid;
            else l=mid+1;
        }
        int j=q[l];
        dp[i]=dp[j]-(t[i]+s)*c[j]+c[i]*t[i]+s*c[n];
        //这一步要保证队列中至少有两个元素,否则直接放入即可
        while(hh+1<tt&&(double)(dp[q[tt-1]]-dp[q[tt-2]])*(c[i]-c[q[tt-2]])>=(double)(dp[i]-dp[q[tt-2]])*(c[q[tt-1]]-c[q[tt-2]])) tt--;
        q[tt++]=i;
    }
    cout<<dp[n];
}

 ps:这里相乘那一块儿有可能溢出long long,故而使用double 

摘自网上资料:
其实double可以存储比unsigned long long更大的数字

原因是无符号 long long 会存储精确整数,而 double 存储尾数有限的精度和一个指数。

这允许 double 存储非常大的数字(大约10^308 ),一个 double 中有大约15个(近16个)有效的十进制数字,其余的可能的小数是零(实际上是未定义的,但是你可以假定为”零”更好的理解)。

一个无符号long long 只有19个数字,但是每一个数字都被精确定义。

也就是说double的精度(15或16位)比long long(19位)位要小,但是由于double只表示十进制的15或16位有效数字和它的指数,所以负值取值范围为 -1.7976E+308 到 -4.94065645841246544E-324,正值取值范围为 4.94065645841246544E-324 到 1.797693E+308。

对于long long
其64位的范围应该是[-2^63 ,2^63],既-9223372036854775808~9223372036854775807。
它的存储方法就是按位存储。有符号位就有一位符号位,没有符号位就64位全部来存储这个数。

正是double不同于long long的存储方法,使得它虽然只有64位但是可以比同样是64位的long long 类型取值范围大很多。

 303. 运输小猫(303. 运输小猫 - AcWing题库

题目大意:现在有m只猫,在n座山上玩,已知相邻两座山的间距,以及每只猫在哪座山上的游玩时间,一旦游玩结束,猫就变成等待状态,饲养员从1号山出发去接猫,只能接在等待状态的猫,其余的猫都不可以被接走。求猫的等待时间的最小和。

我们可以通过山的间距算出每座山到1的距离,又因为速度是1m/s,那么距离和时间相当于1:1的关系,设出发时间为s,那么到达第i座山的时间就是s+di,这座山上的某只猫的玩耍时间为ti的话,那么ti + wi=s+di,所以对每只猫来说,如果不用等待,那么显然s=ti-di,这即饲养员的最早出发时间,它的自身属性相当于就是ai=ti-di,另外注意到,饲养员的出发时间可以为负值,所以di>ti也无所谓。那么分析到这里,看似又没办法继续分析了。这里既然有这么多猫,我们按照ai排个序看看,因为我们排序的依据时每只猫恰好被接走时饲养员的最早出发时间,那么我们要一次接走一整段上的猫,肯定要以这段中的最晚时间为准,那么这段中猫的等待时间则为(ai-a1)+(ai-a2)+...+(ai-ai),所以我们可以将猫分段,那么这个问题就与我们之前的问题有些关联了。

定义dp[i][j]表示第i只猫恰好被第j个饲养员收走,我们可以通过找上一段猫的结尾位置来更新状态:

dp[i][j]=dp[k][j-1]+ai*(i-k+1)-(a[k+1]+a[k+2]+...+a[i])

令s[i]=a[1]+a[2]+...+a[i]:

则:dp[i][j]=dp[k][j-1]+ai(i-k+1)-(s[i]-s[k]);

移项可得:

dp[i][j]-ai*(i-k+1)+s[i]=dp[k][j-1]+s[k]

dp[k][j-1]+s[k]=a[i]*k+dp[i][j]-a[i]*(i+1)+s[i];

那么就跟上题关联起来了,而且这里的a[i]单增,我们甚至都不需要用二分来查找。

哦对了,另外,为了优化程序我们改一下两个维度,定义dp[j][i],因为我们循环的第一维是j的那一维,这样定义的话,磁盘读取会快一些。

#include<bits/stdc++.h>
using namespace std;
typedef long long ll;
const int N=100010,M=100010,P=110;

ll t[N],d[N],a[N],s[N];
ll dp[P][M];
int q[M],hh,tt;
ll get(int i,int j)
{
    return dp[j-1][i]+s[i];
}
int main()
{
    int n,m,p;
    scanf("%d%d%d",&n,&m,&p);
    for(int i=2;i<=n;i++)
    {
        scanf("%lld",&d[i]);
        d[i] += d[i-1];
    }
    for(int i=1;i<=m;i++)
    {
        int h;
        scanf("%d%lld",&h,&t[i]);
        a[i]=t[i]-d[h];
    }
    sort(a+1,a+1+m);
    for(int i=1;i<=m;i++) s[i]=s[i-1]+a[i];
    memset(dp,0x3f,sizeof dp);
    for(int i=0;i<=p;i++) dp[i][0]=0; 
    for(int j=1;j<=p;j++)
    {
        //dp[k][j-1]+s[k]=a[i]*k+dp[i][j]-a[i]*(i+1)+s[i];
        hh=0,tt=1;
        q[0]=0;
        for(int i=1;i<=m;i++)
        {
            while(hh+1<tt&&(get(q[hh+1],j)-get(q[hh],j))<=a[i]*(q[hh+1]-q[hh])) hh++;
            int k=q[hh];
            dp[j][i]=dp[j-1][k]+a[i]*(i-k)-(s[i]-s[k]);
            while(hh+1<tt&&(get(q[tt-1],j)-get(q[tt-2],j))*(i-q[tt-1])>=
            (get(i,j)-get(q[tt-1],j))*(q[tt-1]-q[tt-2])) tt--;//我们维护的是上一层,所以这里是get(i,j)
            q[tt++]=i;
        }
    }
    cout<<dp[p][m];
}

ps:这里查询的时候就要严格保证队列中至少有两个元素再进循环,因为队列重复使用,没清。

斜率优化dp主要有两种类型,都是首先将状态转移的式子变形成一个一元函数,然后通过斜率与截距的关系来优化。

优化涉及到两种类型,一种是斜率和自变量都单增,那么维护单调队列即可实现查找,一种是仅自变量单增,那么就需要用二分来查找,另外凸包下边界的维护对于两者来说是一样的。 

  • 26
    点赞
  • 22
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值