首页 > 技术文章 > 斜率优化DP

EricQian 2021-08-12 13:48 原文

斜率优化DP

主要内容

形如这样的 \(\text{DP}\) 转移方程:

\[dp(i)=\max_{L_i\le j\le R_i}\{dp(j)+val(i,j)\} \]

满足:

  • \({L_i}\)\({R_i}\) 递增(前提条件)。
  • \(R_i≤i\)(转移条件)。
  • \(val(i,j)\)\(i\)\(j\) 相关(若只与 \(j\) 有关可以用单调队列优化DP)。

第一种理解方式

我们考虑将其变形:(先假设方程为 \(dp(i)=\max_{L_i\le j\le R_i}\{dp(h)+i\times j\}\)

\[dp(i)=\max_{L_i\le j\le R_i}\{dp(j)+i\times j\} \]

直接拆掉 \(\max\)

\[dp(i)=dp(j)+i\times j\\ dp(j)=-i\times j+dp(i)\\ [y]=[k]\times [x]+[b] \]

即:

  • 将只与 \(j\) 有关的放在左边作为 \([y]\)

  • \(i,j\) 都有关的放在中间做一次项,其中 \(i\) 做系数而 \(j\) 做变量。

  • 将只与 \(i\) 有关的放在右边做常数。

\[\bigstar\texttt{important} \]

当转移的时候,每一个决策点 \(j\) 可以看做一个个单点,分别在坐标轴的 \(([x_j],[y_j])\)(定义如上)的位置;决策时,就用斜率为 \([k_i]\) 的直线向下(求最大值)或向上(求最小值)去和决策点相交,交到的第一个点就是最优决策点。

上面的“相交”过程可以用二分等解决。

转移完毕了,我们要将 \(i\) 变为一个决策点以便以后的转移,将 \(i\) 放到 \(([x_i],[y_i])\) 上去,和当前决策队列末尾的两个点观察形成上凸或下凸,根据题目要求弹出队尾。

斜率优化就是通过这样转移实现每个点最多进队一次、出对一次的优化。

第二种理解方式

在学习李超线段树的时候,其实可以将原先的式子直接放在那里,即:

\[dp(i)=\max\{i\times j+dp(j)\}\\ [y]=[x]\times [k]+[b] \]

我们将 \(j\) 当做斜率,把与 \(j\) 相关的看做一条直线(注意与上面第一种相反,这里 \(j\) 是直线)。

统计答案:\(i\) 的答案就是直线 \(x=i\) 与所有直线交点的最高 \(/\) 最低值。

加入候选项:假设 \(i\) 的答案可以提供给(注意不是捞取,而是供给)\([L_i,R_i]\) 区间内的点,那么将 \(i\) 的直线变为线段,加入到值域为 \([L_i,R_i]\) 的区域即可。

可以用李超线段树维护,复杂度 \(\mathcal{O(n\log^2n)}\)

例题:

P4072 [SDOI2016]征途

非常裸的斜率优化,但是要注意值域较大,应该先将式子化简,减小运算的大小,防止运算过程中爆 \(\texttt{long}~\texttt{long}\)

P2120 [ZJOI2007]仓库建设

需要注意的是,这道题可能不用将 \(n\) 的元素全部分配玩(因为末尾可能有连续的 \(0\) 使不用在 \(n\) 处再分一段)。

因此我们需要再输出答案之前将答案与末尾所有连续非 \(0\) 的取 \(\min\) ,保证取到最大值。

\(\min\) 代码:

ans=dp[n];
for(int i=n;i>=1;i--)
	 if(!p[i+1]) ans=minll(ans,dp[i]);
	 else break;
printf("%lld\n",ans);

P5785 [SDOI2012]任务安排

这个题的 \([k]\) 并不单调,但是好在 \([x]\) 是单调的,所以我们可以维护队尾,用二分的方式找到交点。

P6302 [NOI2019] 回家路线

给定 \(n\) 个点,从 \(1\) 走到 \(n\)。其中有 \(m\) 条固定时间的路径,第 \(i\) 条路径从 \(u_i\)\(v_i\),在 \(st_i\) 时刻出发,在 \(ed_i\) 时刻到达。每次等车 \(t\) 的时间会增加 \(A\times t^2+B\times t+C\) 的花费,最后花费加上一个到达时间。使花费最小。

\(n\le 10^5;m\le 10^6;p_i,q_i\le 10^3\)

$\texttt{solution}$

首先可以想到一个 \(O(Tm)\) 的暴力 dp:设 \(dp_{i,s}\) 表示在第 \(s\) 秒到达第 \(i\) 个点的最小代价,可以枚举每一条边转移。

\(\texttt{code}\)

发现如果同时记录地点与时间单说空间复杂度就爆炸,考虑更改状态。

一个高妙的想法是:以一条条航班的编号为状态,\(dp_i\) 表示在经过第 \(i\) 条航线并到达 \(ed_i\) 时的最小代价,这样用一维变量同时概括了地点与时间两个信息。

可以列出转移方程:

\[dp_i=\min_{v_j=u_i}\{dp_j+A\times (st_i-ed_j)^2+B\times (st_i-ed_j)+C+(ed_i-st_i)+(st_i-ed_j)\}(ed_j\le st_i) \]

发现这是一个关于 \(i\)\(j\) 的转移方程,可以斜率优化(去掉 \(\min\) 后移项):

\[[dp_j+A\times (ed_j)^2-B\times ed_j-ed_j]=[2A\times st_i]\times [ed_j]+[dp_i−A\times(st_i)^2-B\times st_i-C-ed_i] \]

\[[y]=[k]\times [x]+[b] \]

关于按照 \(st_i\) 升序排序(让 \([k]\) 递增)还是按照 \(ed_i\) 排序(让 \([x]\) 递增),我自己在第一次做的时候没有想清楚,导致了长时间的卡顿,下面解释一下 \(ed_i\) 排序的错误之处:

在按照 \(ed_i\) 排序之后,我为了满足 \(ed_j\le st_i\) 的限制,在单调队列上二分出了 \(ed_j\le st_i\) 的最右端(记为 \(pos\)),并在 \([L,pos]\) 中再一次二分求出最佳决策点。

但最关键的是,单调队列中的所有决策点都是经过筛选的。假设存在决策点 \(j(ed_j\le st_i)\),对于 \(st_i\) 来说是更优,却在加入另一个决策点时被舍去了。

比如上面的图,会选择 \(p\) 作为决策,\(j\) 会被舍去。

还有一个注意点就是:在加入决策的时候,不能每次直接将 \(i\) 加入队列中,而是可以将它加入一个堆中。每次需要计算时再将堆中的决策点放到单调队列中。

总时间复杂度:\(O(m\log m)\)

$\texttt{code}$
#define Maxm 1000005
#define pa pair<int,int>
#define fi first
#define se second
typedef long long ll;
int n,m;
ll ans=infll,A,B,C,dp[Maxm],x[Maxm],y[Maxm],k[Maxm];
struct Way { int u,v; ll st,ed; }r[Maxm];
bool cmp(Way x,Way y){ return x.st<y.st; }
inline ll calc(int x) { return x*x*A+x*B+C; }
struct Destination
{
	 int nl=0; // 由于 vector 不支持 pop_front 操作,我用一个指针来表示起始位置
	 vector<int> q;
	 priority_queue<pa> qpre;
	 inline void Insert(int i,ll X,ll Y)
	 {
	 	 int nr;
	 	 while((nr=q.size())-nl>=2 && (Y-y[q[nr-1]])*(x[q[nr-1]]-x[q[nr-2]])
		  	   <=(y[q[nr-1]]-y[q[nr-2]])*(X-x[q[nr-1]])) q.pop_back(); // 队尾踢出凹凸性错误的元素
	 	 q.push_back(i);
	 }
	 inline void Delete(int S,ll K)
	 {
	 	 int tmp;
	 	 while(!qpre.empty() && -qpre.top().fi<=S) // 将堆中元素加入单调队列中
		  	 tmp=(int)qpre.top().se,Insert(tmp,x[tmp],y[tmp]),qpre.pop();
		 while(q.size()-nl>=2 && (y[q[nl+1]]-y[q[nl]])<=
		 	 K*(x[q[nl+1]]-x[q[nl]])) nl++; // 队首踢出决策较劣的元素
	 }
}pos[Maxm];
int main()
{
	 n=rd(),m=rd(),A=rd(),B=rd(),C=rd();
	 for(int i=1;i<=m;i++) r[i]=(Way){rd(),rd(),rd(),rd()};
	 sort(r+1,r+m+1,cmp),pos[1].q.push_back(0);
	 memset(dp,0x3f,sizeof(dp)),dp[0]=0;
	 for(int i=1,U,V,tmp;i<=m;i++)
	 {
	 	 x[i]=r[i].ed,k[i]=2ll*A*r[i].st,U=r[i].u,V=r[i].v;
		 pos[U].Delete(r[i].st,k[i]);
		 if((int)pos[U].q.size()<=pos[U].nl) continue; // 没有决策点
		 tmp=pos[U].q[pos[U].nl];
		 dp[i]=dp[tmp]+calc(r[i].st-r[tmp].ed)+r[i].ed-r[tmp].ed;
	 	 y[i]=dp[i]+A*r[i].ed*r[i].ed-B*r[i].ed-r[i].ed;
	 	 pos[V].qpre.push(pa(-r[i].ed,i));
	 }
	 for(int i=1;i<=m;i++) if(r[i].v==n) ans=minll(ans,dp[i]);
	 printf("%lld\n",ans);
	 return 0;
}

推荐阅读