斜率优化 DP

前言:
原来我以为数位 DP 已经够邪恶了
直到我遇见了他

毁灭你,与你有何相干。

这是之前寒假集训的最后一节课
而作者由于太蒻了
所以不能说是融会贯通吧至少也可以说是一窍不通
斜优是一个蒟蒻迈向牛马走向神犇的第一步
学会了就可以水好多好多蓝紫题啦!
所以闲话少叙,Lets Go!

前置知识:
能做出一道 DP 橙题
听说过平面直角坐标系

注:关于具体的时间复杂度证明和严格正确性分析
蒟蒻作者实在无能为力
这个好像貌似应该没啥用吧(bushi)(逃)(doge 保命)
所以基本都只会给出感性理解

Example 01[P3195 玩具装箱]

根据一位 dalao 的启示,斜率优化 DP 先不用管斜率,先写暴力 DP,再套优化
题意简述:
给出一个数组 A,要求把该数组分成若干段,设每一段左右端点为[L,R],最小化 (RL+i=LRA[i]K)2 的值
其中 K 为常数
1n5×104

Solution

首先考虑可以设 DP[i] 为使第 i 个元素成为该段末尾的最小代价
显然 DP[n] 即为答案
容易想到朴素转移

DP[i]=minj=1i1DP[j]+(Sum[i]Sum[j]+ij1K)2

这个转移是 O(n2)
肯定是会 TLE
思考怎么优化
蒟蒻貌似现在只学了一个单调队列优化
所以我们试试
但是发现有两个问题:

  1. 这个貌似没有什么可以淘汰的
    当然可以直接维护最小值啦
  2. 关键是一个事情——单纯的 DP[j] 当然是可以维护 Min
    后面的部分如果不带平方也可以乱搞过
    但这个平方……很难搞,非常难搞

平方是要拆的,但怎么拆,非常重要
如果我们直接暴力开干是非常麻烦的
我们根据古希腊掌管CCF的神的启示
将其按照和i有关,和j有关和都没有关分类
当然我们给它起个名字
 i 有关的叫 fi
 j 有关的叫 fj
都没关系的叫 shit
那么我们拆开得到

DP[i]=minj=1i1 DP[j]+(fi+fj+shit)2

DP[i]=minj=1i1 DP[j]+fi2+fj2+shit2+2fifj+2shitfi+2shitfj

现在我们先不用管其中的这三个函数分别是啥
作者一开始就是总去先想这个函数
然后成功炸掉了自己的 CPU
接下来用刚才的方法再分一次
别蒙,第一步就是加法交换律和结合律

DP[i]=minj=1i1 (fi2+2shitfi)+(DP[j]+2shitfj+fj2)+2fifj+shit2

我们用大写的  Fi Fj SHIT 表示和 i 有关,和 j 有关和都无关
得到

DP[i]=minj=1i1 Fi+Fj+2fifj+SHIT

当我们现在发现了一个问题
其中有一项 2fifj 是既和 i 有关也和 j 有关的
那我们没有办法了
但为了统一,我们还是把他分成两个大写的函数
Gi Gj 吧(其中的 2 可以随便乘进去一个函数)
那我们就得到了一个式子

DP[i]=min Fi+Fj+GiGj+SHIT

接下来我们暂时设当前的 j 即为最优转移
则还可以去掉这个 min
直接就是

DP[i]= Fi+Fj+GiGj+SHIT

此时我们发现这个 SHIT 也可以直接加到 FiFj
于是最简式子即为

DP[i]=Fi+Fj+GiGj

你不禁要问了:这一大套究竟有啥用呢?
其实并没有什么用,只是让你烧一会CPU而已
现在看一下我们最后得到的不等式
这个就是斜率优化的模板不等式
斜率优化就是通过一系列操作
将取最小值的复杂度搞到 O(1)
现在我们终于可以来看看斜率是啥东西了
斜率,按某度某科的说法,是一条直线的倾斜程度
但用人话讲,他就是一条直线的一个参数

斜率的英文是 Slope,具体求法如图
在一条直线上随意选两个点
用纵坐标之差减去横坐标之差
注意这个斜率是有正负的
所以这个不能瞎减
必须用同一个点作为被减数
但顺序却很无所谓
因为换个顺序分数上下都变成了原来的相反数
分数值并不会变
接下来再介绍一个概念:截距
截距是一条直线与 Y 轴交点的 Y 坐标
具体地讲,如果这条直线的解析式是 y=kx+b
那么截距就是这个 b
接下来我们在把原来得到的式子转化一下
(此处先不用考虑正负号的问题,因为都可以搞到方程里)

Fj=GiGj+(DP[i]+Fi)

我们发现这个和解析式 y=kx+b 很像
又由于 Fi 可以直接 O(1) 求出
完全可以整体求出最后一项最小值之后再统计答案
然后我们发现这个式子中想要最小化的一项就是我们的截距 b
所以这相当于什么呢
相当于我们每遍历到一个数
都往点集里加了一个点
然后求的最小值相当于每次设定一个统一的斜率
求过这些点直线的最小截距
当然,暴力的想法是遍历每个点求一下
但这样肯定时间复杂度没有变,并不优
思考能否剔除掉一些没有用的点
显然有两种点是完全没有用的

首先是图中黄色线段所连接的两个点
如果他们横坐标相同,那么靠上的点一定不优
其次一种比较难想
见图中两条绿色线段连起来的三个点
严格地说,如果一个平面内有三个点 A B C且满足 XA<XB<XC
S(LINE)表示这条线段的斜率
那么如果有 S(AB)>S(BC)
B 点一定不是最优解

如图的直线 AB
如果给出的斜率 >S(AB),靠右的 B 更优;(绿线)
否则靠左的 A 更优。(黄线)
有了这个原理我们就可以看看了

这是我们刚才的那个三角形
可以看出来 S(BC)<S(AC)<S(AB)
那么我们可知
如果给出的斜率 S0<=S(AC) 则满足 S0<S(AB) 此时一定有 A 比 B 优
如果给出的斜率 S0>S(AC) 则满足 S0>S(BC) 此时一定有 C 比 B 优
由此得证
这样搞出来的其实就是这个点集的下凸壳

我们可以满足所有可能的转移点点一定在这个下凸壳上
而且也一定满足这些凸壳上的点随着横坐标增大斜率单调递增
这个其实就可以用单调栈/单调队列来维护了
但搞出了凸壳说穿了还是常数优化
但是很多题除了已经列出的性质外还满足两个点

  1. 给出的点横坐标单调不降
  2. 给出的斜率同样单调不降

而这才是单调队列的用武之地
再看一眼线段 AB 的图片
我们发现随着给出斜率的增加左面的点越来越容易被淘汰
而如果后面的点已被淘汰那前面的就更白扯了
而当你一通淘汰之后剩下的对头即为最优答案
这就可以用单调队列了
其实有的题不满足如上性质,但也有一些方法进行处理,但作者暂时不会

好了,现在我们重新看一下这道题
这是我们的朴素转移式

DP[i]=DP[j]+(Sum[i]Sum[j]+ij1K)2 (Min)

我们把它拆开按上面的方法弄一下

DP[i]=DP[j]+((Sum[i]+i1K)(Sum[j]+j))2 (Min)

DP[i]=DP[j]+(Sum[i]+i1K)2+(Sum[j]+j)22(Sum[i]+i1K)(Sum[j]+j) (Min)

2(Sum[i]+i1K)(Sum[j]+j)+DP[i](Sum[i]+i1K)2=DP[j]+(Sum[j]+j)2

DP[j]+(Sum[j]+j)2=2(Sum[i]+i1K)(Sum[j]+j)+DP[i](Sum[i]+i1K)2

这里我们把 Sum[x]+x 处理出来叫做 A[x], Sum[x]+x1K 处理出来叫做 B[x]
原式变为

DP[j]+A[j]2=2A[j]B[i]+DP[i]B[i]2

此时就基本结束了
我们看一下点的信息
Point(2A[j],DP[j]+A[j]2)
SlopeB[i]
我们发现 A,B 数组均是单调递增的完美符合性质
So Coding...

#include<bits/stdc++.h>
using namespace std;
typedef long long ll;
typedef long double ld;
const int N=50098;
ll dp[N],A[N],B[N];
struct Point{ll x,y;int u;};
//dp[i]=dp[j]+B[i]^2-2B[i]A[j]+A[j]^2
//dp[j]+A[j]^2=B[i]*2A[j]+(dp[i]-B[i]^2)
//Point(2A[j],dp[j]+A[j]^2)
inline Point mp(ll x,ll y,int u){Point tmp;tmp.x=x,tmp.y=y,tmp.u=u;return tmp;}
inline ld S(Point a,Point b){return (ld)(a.y-b.y)/(ld)(a.x-b.x);}
inline ll Pow(ll x){return x*x;}
class Deque{
	public:
		Point front(){return k[ff];}
		Point fnxt(){return k[ff+1];}
		Point back(){return k[tt];}
		Point bpre(){return k[tt-1];}
		bool empty(){return ff>tt;}
		bool single(){return ff>=tt;}
		void pop_front(){++ff;}
		void pop_back(){--tt;}
		void push(Point x){k[++tt]=x;}
	private:
		Point k[N];
		int ff=1,tt=0;
};
Deque q;
int main(){
	int n;
	ll tmp=0,sum=0,L;
	Point d;
	scanf("%d%lld",&n,&L);
	for(int i=1;i<=n;i++)scanf("%lld",&tmp),sum+=tmp,A[i]=sum+i,B[i]=sum+i-1-L;
	dp[1]=Pow(A[1]-1-L),q.push(mp(2*A[1],dp[1]+Pow(A[1]),1));
	for(int i=2,u;i<=n;i++){
		while(!q.empty()&&q.back().x==2*A[i])q.pop_back();
		while(!q.single()&&S(q.front(),q.fnxt())-(ld)B[i]<1e-7)q.pop_front();
		u=q.front().u;
		dp[i]=dp[u]+Pow(B[i])-2*B[i]*A[u]+Pow(A[u]),d=mp(2*A[i],dp[i]+Pow(A[i]),i);
		while(!q.single()&&S(q.back(),q.bpre())>S(q.back(),d))q.pop_back();
		q.push(d);
	}
	printf("%lld\n",dp[n]);
	return 0;
}

过样例,submit,WA 0pt
我们发现还有一个细节,即任意一个 dp 有可能由 dp[0] 转移而来
所以要在一开始插一个虚点
每一道题插虚点的方法都不太一样
本题 A[0]=0 没有问题
但是 dp[0] 到底是 0 还是 L2
我们经过思考发现应当是 0
即表示可以从第一个到第 k 个都放到一组里
肯定不能在凭空付一个 L2
所以插一个虚点 (0,0) 即可
ACCode

#include<bits/stdc++.h>
using namespace std;
typedef long long ll;
typedef long double ld;
const int N=50098;
ll dp[N],A[N],B[N];
struct Point{ll x,y;int u;};
//dp[i]=dp[j]+B[i]^2-2B[i]A[j]+A[j]^2=dp[j]-(B[i]-A[j])^2
//dp[j]+A[j]^2=B[i]*2A[j]+(dp[i]-B[i]^2)
//Point(2A[j],dp[j]+A[j]^2)
inline Point mp(ll x,ll y,int u){Point tmp;tmp.x=x,tmp.y=y,tmp.u=u;return tmp;}
inline ld S(Point a,Point b){return (ld)(a.y-b.y)/(ld)(a.x-b.x);}
inline ll Pow(ll x){return x*x;}
class Deque{
	public:
		Point front(){return k[ff];}
		Point fnxt(){return k[ff+1];}
		Point back(){return k[tt];}
		Point bpre(){return k[tt-1];}
		bool empty(){return ff>tt;}
		bool single(){return ff>=tt;}
		void pop_front(){++ff;}
		void pop_back(){--tt;}
		void push(Point x){k[++tt]=x;}
	private:
		Point k[N];
		int ff=1,tt=0;
};
Deque q;
int main(){
	int n;
	ll tmp=0,sum=0,L;
	Point d;
	scanf("%d%lld",&n,&L);
	for(int i=1;i<=n;i++)scanf("%lld",&tmp),sum+=tmp,A[i]=sum+i,B[i]=sum+i-1-L;
	q.push(mp(0,0,0));
	for(int i=1,u;i<=n;i++){
		while(!q.single()&&S(q.front(),q.fnxt())-(ld)B[i]<0)q.pop_front();
		u=q.front().u;
		dp[i]=dp[u]+Pow(B[i]-A[u]),d=mp(2*A[i],dp[i]+Pow(A[i]),i);
		while(!q.empty()&&q.back().x==2*A[i])q.pop_back();
		while(!q.single()&&S(q.back(),q.bpre())>S(q.back(),d))q.pop_back();
		q.push(d);
	}
	printf("%lld\n",dp[n]);
	return 0;
}

[Example 02 任务安排问题]

题意简述:
机器上有N个需要处理的任务,它们被分成若干批,每批包含相邻的若干任务。
从时刻0开始,这些任务被分批加工,第i个任务单独完成所需的时间是Ti
每批任务开始前,机器需要启动时间S,完成这批任务所需时间是各个任务需要时间的总和。
同一批任务将在同一时刻完成。
每个任务的费用是它的完成时刻乘以其费用Fi
求最小的总费用。

Basic [ACP3195 任务安排1]

1<N<=5000
0<=S,Ti,Ci<=100

Solution

这题有一个点很不好处理
就是费用还和这个完成时刻有关
我们发现完成时刻肯定会加一个前缀和
但是还有若干个 S (到此为止总共分成了多少组)
思考貌似我们可以记录一个 DP[i][j] 表示到第 i 个数总共分成了 j 组的最小代价
t 是时间的前缀和,c 是费用的前缀和
然后转移即为

dp[i][x]=minj=1i1 dp[j][x1]+(t[i]+xS)(c[i]c[j])

这个转移是 O(n3)
考虑优化(因为一般斜率优化很少有让你优化二维转移方程的)
算了还是看题解吧
这题里面涉及到了一个思想:部分费用提前计算
啥意思呢?
就是我们在求一个 DP 值的过程中就提前维护出来这次开机对后面的影响
就是因为我们搞了这一波开机
后面的所有任务都会受影响
与其到后面一个一个的加还不如我提前就整好了
所以新版的 DP 方程即为

dp[i]=minj=1i1 dp[j]+t[i](c[i]c[j])+S(c[n]c[j])

这是一个 O(n2) 的转移,通过这题已经够用了

Extra 01[ACP3195 任务安排2]

1<N<=300000
0<=S,Ti,Ci<=512

Solution

仿照以前的思路
先去掉 min 再分组

dp[i]=dp[j]+t[i]c[i]t[i]c[j]+Sc[n]Sc[j]

(dp[j]Sc[j])+(t[i]c[i]dp[i]+Sc[n])t[i]c[j]=0

(dp[j]Sc[j])=t[i]c[j]+(dp[i]Sc[n]t[i]c[i])

要求最小化最后一项,仍然是求下凸壳
点的坐标为 (c[j],dp[j]Sc[j])
斜率为 t[i]
仍然满足横坐标单调不降和斜率单调不降
可以直接套板子
虚点还是直接插 (0,0) 即可
Code

#include<bits/stdc++.h>
using namespace std;
typedef long long ll;
typedef long double ld;
const int N=300009;
int n,S;
ll dp[N],t[N],c[N];
struct Point{ll x,y;int u;};
inline Point mp(ll x,ll y,int u){Point tmp;tmp.x=x,tmp.y=y,tmp.u=u;return tmp;}
inline ld Slope(Point a,Point b){return (ld)(a.y-b.y)/(ld)(a.x-b.x);}
class Deque{
	public:
		Point front(){return k[ff];}
		Point fnxt(){return k[ff+1];}
		Point back(){return k[tt];}
		Point bpre(){return k[tt-1];}
		bool empty(){return ff>tt;}
		bool single(){return ff>=tt;}
		void pop_front(){++ff;}
		void pop_back(){--tt;}
		void push(Point x){k[++tt]=x;}
	private:
		Point k[N];
		int ff=1,tt=0;
}q;
int main(){
	int a,b;
	Point d;
	scanf("%d%d",&n,&S);
	for(int i=1;i<=n;i++)scanf("%d%d",&a,&b),t[i]=t[i-1]+a,c[i]=c[i-1]+b;
	q.push(mp(0,0,0));
	for(int i=1,u;i<=n;i++){
		while(!q.single()&&Slope(q.front(),q.fnxt())<t[i])q.pop_front();
		u=q.front().u,dp[i]=dp[u]+t[i]*(c[i]-c[u])+S*(c[n]-c[u]),d=mp(c[i],dp[i]-S*c[i],i);
		while(!q.empty()&&q.back().x==c[i])q.pop_back();
		while(!q.single()&&Slope(q.back(),q.bpre())>Slope(q.back(),d))q.pop_back();
		q.push(d);
	}
	printf("%lld\n",dp[n]);
	return 0;
}
Extra 02[ACP3195 任务安排3]

1<N<=300000
0<=S,Ci<=512
512<=Ti<=512

Solution

这题和以前的唯一区别是斜率不一定单调了
然而这个横坐标仍然是单调的
也就意味着单调队列仍然成立
其中的斜率单调递增,也就意味着统计答案可以直接二分
也不需要 popfront
所以可以把单调队列换成单调栈
虚点什么的都差不多
开干!

Tips:

  1. 本题如果 RP<+1010000 的话只用朴素 double Slope 会被卡
    需要手动转乘
  2. 乘负数 MD 要变号!
    我也不知道题解咋写的
    Code
#include<bits/stdc++.h>
using namespace std;
typedef long long ll;
const int N=300007;
struct Point{ll x,y;}k[N];
inline ll cmp(ll a){return (a>0)?1:-1;}
inline ll xx(int a,int b){return k[a].x-k[b].x;}
inline ll yy(int a,int b){return k[a].y-k[b].y;}
ll dp[N],t[N],c[N],S;
int n,st[N],tt=0;
int query(ll val){
	int l=1,r=tt-1,mid,ans=tt;
	while(l<=r){
		mid=(l+r)>>1;
		if(yy(st[mid],st[mid+1])*cmp(xx(st[mid],st[mid+1]))>=val*xx(st[mid],st[mid+1])*cmp(xx(st[mid],st[mid+1])))ans=mid,r=mid-1;
		else l=mid+1;
	}
	return st[ans];
}
int main(){
	scanf("%d%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];
	k[0]={0,0},st[++tt]=0;
	for(int i=1,j;i<=n;i++){
		j=query(t[i]);
		dp[i]=dp[j]+t[i]*c[i]-t[i]*c[j]+S*c[n]-S*c[j];
		k[i]={c[i],dp[i]-S*c[i]};
		while(tt>0&&k[st[tt]].x==k[i].x&&k[st[tt]].y>=k[i].y)tt--;
		while(tt>1&&yy(st[tt],st[tt-1])*xx(st[tt],i)*cmp(xx(st[tt],i)*xx(st[tt],st[tt-1]))>=yy(st[tt],i)*xx(st[tt],st[tt-1])*cmp(xx(st[tt],i)*xx(st[tt],st[tt-1])))tt--;
		st[++tt]=i;
	}
	printf("%lld\n",dp[n]);
	return 0;
}
posted on   2025ing  阅读(6)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· 物流快递公司核心技术能力-地址解析分单基础技术分享
· .NET 10首个预览版发布:重大改进与新特性概览!
· 单线程的Redis速度为什么快?
· 展开说说关于C#中ORM框架的用法!
· Pantheons:用 TypeScript 打造主流大模型对话的一站式集成库
点击右上角即可分享
微信分享提示