Loading

斜率优化 DP

前置知识

斜率优化

引入

考虑以下问题

在数轴上存在 \(n\) 个点,第 \(i\) 个点的坐标为 \(x_i\),点权为 \(w_i\),将两个点连接的代价为 \((x_i-x_j)^2+w_i+w_j\),求将点 \(1\) 和点 \(n\) 连接的最小代价,连接具有传递性,也就是说,若 \(i\)\(j,k\) 分别连接,那么 \(j,k\) 也连接,每个点只能连接两次,一个连接不能覆盖其他连接。

容易发现这是一个 DP 问题。

\(f_i\) 表示将点 \(1\) 与点 \(i\) 连接的最小代价,则有:

\[f_i=\min_{j<i} f_j+(x_i-x_j)^2+w_i+w_j \]

直接 DP 的时间复杂度是 \(O(n^2)\) 的,是否存在什么优化方法?

斜率优化

实现

我们先将 DP 方程转化一下:

\[\begin{aligned}f_i & =f_j+(x_i-x_j)^2+w_i+w_j \\ f_i &=f_j+x_i^2+x_j^2-2x_ix_j+w_i+w_j\\f_i-x_i^2-w_i & =f_j+x_j^2+w_j-2x_ix_j\\ (f_i-x_i^2-w_i) & =(-2x_j)(x_i)+(f_j+x_j^2+w_j)\end{aligned}\]

发现了什么?

如果我们设

\[\begin{cases}y=f_i-x_i^2-w_i\\k=-2x_j\\x=x_i\\b=f_j+x_j^2+w_j\end{cases} \]

那么 DP 方程就变成了 \(y=kx+b\),这是一个我们无比熟悉的形式,也就是一次函数(直线)的解析式。

我们发现,如果已知了 \(f_i\),那么对应的 \(k,b\) 都是已知的,也就是已知了这条直线。因此,我们可以将这条直线插入李超线段树中。求 \(f_i\) 时只需要在李超线段树对应的横坐标 \(x_i\) 查询纵坐标最小值就可以得到 \(f_i-x_i^2-w_i\),再把 \(x_i^2+w_i\) 加回去就是 \(f_i\)

for(int i=1;i<=n;i++){
    f[i]=tree.ask(1,-V,V,x[i])+x[i]*x[i]+w[i];
    line[i]={-2*x[i],f[i]+x[i]*x[i]+w[i]};
    tree.add(1,-V,V,i);
}

这样 DP 的时间复杂度就被优化到了 \(O(n\log n)\)

  • 是否存在更优做法?

如果说给定的点是按横坐标排好序的,也就是说横坐标单调递增,那么我们可以换一种优化方式得到更优的复杂度:

\[\begin{aligned}f_i-x_i^2-w_i &=f_j+x_j^2+w_j-2x_ix_j \\ (f_i-x_i^2-w_i) &=(f_j+x_j^2+w_j)-(2x_i)(x_j)\end{aligned} \]

\[\begin{cases}b=f_i-x_i^2-w_i\\y=f_j+x_j^2+w_j\\k=2x_i\\x=x_j\end{cases} \]

那么我们就得到了 \(b=y-kx\) 这样的方程形式,其中,\(b\) 包含 \(f_i\),是要求的,而 \(k\) 对于 \(i\) 已知。

我们可以将每一个 \(f_i\) 看作一个平面直角坐标系上的点 \((x_j,f_j+x_j^2+w_j)\),那么我们的问题就变成了下列形式:

已知一条直线的斜率,且这条直线经过平面上若干点中的一个,求该直线截距的最小值。

不难发现,这条直线经过的点一定在这些点的下凸壳上,且该直线的斜率比上一条直线大,比下一条直线小。

这个点被称为最优决策点,也就是我们 \(f\) 的转移点,该点对应的 \(j\) 就是最小值取到的 \(j\)

我们发现,当 \(x_i\) 单调时,\(x,k\) 都是单调的,这时,我们就可以用单调队列维护下凸壳,将 DP 的时间复杂度优化到 \(O(n)\)

for(int i=1;i<=n;i++){
    while(hh<tt&&slope(q[hh],q[hh+1])<k[i]) hh++;
    f[i]=f[q[hh]]+(x[i]-x[q[hh]])*(x[i]-x[q[hh]])+w[i]+w[q[hh]];
    while(hh<tt&&slope(i,q[tt-1])<slope(q[tt-1],q[tt])) tt--;
    q[++tt]=i;
}

概括

总的来说,斜率优化可以优化符合下列形式的 DP 转移时间复杂度:

\[a_i+b_j+c_id_j=0 \]

其中,\(c_i,d_j\) 不同时与 \(f\) 有关。

优化方式有很多种:

  • 单调队列维护凸壳:要求 \(x,k\) 均单调,时间复杂度为 \(O(n)\)

  • 单调栈维护凸壳加二分:要求 \(x\) 单调,时间复杂度为 \(O(n\log n)\)

  • 李超线段树优化:没有要求,时间复杂度为 \(O(n\log n)\),代码复杂度低。

  • 平衡树动态维护凸包:没有要求,时间复杂度为 \(O(n\log n)\),代码复杂度高。

  • CDQ 分治优化:没有要求,时间复杂度为 \(O(n\log ^2n)\)

其中,除了李超线段树优化之外,其他方法均是将 \((d_j,b_j)\) 看成点,将 \(a_i,c_i\) 分别看成一条直线的截距和斜率,在已知斜率的条件下求截距的最值。

而李超线段树将 \(d_j,b_j\) 看成直线的斜率和截距,在已知横坐标的情况下求纵坐标的最值。

例题讲解

P3195 玩具装箱

\(f_i\) 表示处理到第 \(i\) 件的最小费用。

容易得到 DP 方程:

\[f_i=\min_{j<i} f_j+(s_i+i-s_j-j-L-1)^2 \]

其中,\(s\)\(c\) 的前缀和。

将其化为斜率优化的一般形式:

\(a_i=s_i+i,b_i=s_j+L+1\),则

\[\begin{aligned}f_i &=f_j+(s_i+i-s_j-j-L-1)\\f_i & =f_j+(a_i-b_j)^2\\f_i &=f_j+a_i^2-2a_ib_j+b_j^2\\(f_i-a_i^2) &=(f_j+b_j^2)-(2a_i)(b_j)\end{aligned} \]

同样的,设

\[\begin{cases}y=f_j+b_j^2\\x=b_j\\k=2a_i\\b=f_i-a_i^2\end{cases} \]

容易发现,\(s\) 是单调的,所以 \(a,b\) 都是单调的,故 \(x,k\) 单调,可以使用单调队列优化。

#include <iostream>
#include <cstring>
#include <cmath>
#include <cstdio>
#include <algorithm>

using namespace std;
const int N=50100;
#define int long long
#define A(i) (s[i]+i)
#define B(i) (A(i)+L+1)
#define X(i) B(i)
#define Y(i) (f[i]+B(i)*B(i))
#define slope(i,j) (Y(j)-Y(i))/((X(j)-X(i)))

int n,L,hh,tt;
int f[N],s[N],q[N];

signed main(){
	scanf("%lld%lld",&n,&L);
	for(int i=1;i<=n;i++){
		scanf("%lld",&s[i]);
		s[i]+=s[i-1];
	}
	hh=1;tt=1;
	for(int i=1;i<=n;i++){
		while(hh<tt&&slope(q[hh],q[hh+1])<2*A(i)) hh++;
		f[i]=f[q[hh]]+(A(i)-B(q[hh]))*(A(i)-B(q[hh]));
		while(hh<tt&&slope(i,q[tt-1])<slope(q[tt-1],q[tt])) tt--;
		q[++tt]=i;
	}
	cout<<f[n]<<'\n';
	return 0;
}

P4027 货币兑换

\(f_i\) 表示第 \(i\) 天能够拥有的最大钱数。

\(A_i,B_i\) 表示第 \(i\) 天花 \(f_i\) 的钱能够得到的 A 券和 B 券的数量。

考虑到必然存在一种最优的买卖方案满足每次买进操作使用完所有的人民币,每次卖出操作卖出所有的金券,故有:

\[\begin{cases}a_iA_i+b_iB_i=f_i\\\frac{A_i}{B_i}=\text{Rate}_i\end{cases} \]

未知数只有两个,故解得:

\[\begin{cases}A_i=\frac{f_i\text{Rate}_i}{a_i\text{Rate}_i+b_i}\\B_i=\frac{f_i}{a_i\text{Rate}_i+b_i}\end{cases} \]

对卖和不卖分类讨论:

如果第 \(i\) 天不卖出,则有 \(f_i=f_{i-1}\)

如果第 \(i\) 天卖出,设卖出的券的买入天数为 \(j\),则有

\[f_i=\min_{j<i}a_iA_j+b_iB_j \]

化简:

\[\begin{aligned}f_i&=a_iA_j+b_iB_j\\f_i&=b_i(\frac{a_i}{b_i}A_j+B_j)\end{aligned} \]

设:

\[\begin{cases}k=A_j\\b=B_j\\x=\frac{a_i}{b_i}\end{cases} \]

用李超线段树维护,每次插入一条斜率为 \(A_i\),截距为 \(B_j\) 的直线,查询 \(\frac{a_i}{b_i}\) 对应的最大值。

考虑到 \(x\) 是小数,可以对 \(x\) 离散化。

#include <iostream>
#include <cstring>
#include <cmath>
#include <cstdio>
#include <algorithm>

const int N=100100;
using namespace std;
#define mid ((l+r)>>1)

int n,pos;
double a[N],b[N],r[N],x[N],bx[N],f[N];

struct Line{
	double k,b;
}line[N];

double calc(int id,int pos){
	return line[id].k*bx[pos]+line[id].b;
}

bool More(int id1,int id2,int pos){
	return calc(id1,pos)>calc(id2,pos);
}

struct ST{
	int a[N<<2];
	void add(int p,int l,int r,int id){
		if(l==r){if(More(id,a[p],l)) a[p]=id;return ;}
		if(More(id,a[p],mid)) swap(a[p],id);
		if(More(id,a[p],l)) add(p<<1,l,mid,id);
		if(More(id,a[p],r)) add(p<<1|1,mid+1,r,id);
	}
	double query(int p,int l,int r){
		double res=calc(a[p],pos);
		if(l==r) return res;
		if(pos<=mid) res=max(res,query(p<<1,l,mid));
		else res=max(res,query(p<<1|1,mid+1,r));
		return res;
	}
}tree;

int main(){
	scanf("%d%lf",&n,&f[0]);
	for(int i=1;i<=n;i++){
		scanf("%lf%lf%lf",&a[i],&b[i],&r[i]);
		x[i]=a[i]/b[i];bx[i]=x[i];
	}
	sort(bx+1,bx+n+1);
	for(int i=1;i<=n;i++){
		pos=lower_bound(bx+1,bx+n+1,x[i])-bx;
		f[i]=max(f[i-1],b[i]*tree.query(1,1,n));
		double d=a[i]*r[i]+b[i];
		line[i]=Line{f[i]*r[i]/d,f[i]/d};
		tree.add(1,1,n,i);
	}
	printf("%.3lf\n",f[n]);
	return 0;
}

更多例题

posted @ 2023-07-06 15:31  TKXZ133  阅读(127)  评论(1编辑  收藏  举报