决策单调性优化DP
更新日志
update 2024/10/31: 更新分治优化与单调队列优化 update 2024/11/01: 更正单调队列优化部分表述,更新斜率优化,附上例题代码简介
决策单调性,通常用于“最优类”DP(每个状态均由一个最优决策点转移而来),指的是最优决策点具有单调性,如单调右移等。
分治优化
概念
考虑每次解决一个问题区间,而每次都解决区间中点,并且根据中点信息分别解决左右两半区间,也就是分治的思想。
思路
因为决策单调性的存在,所以我们可以同时储存“当前问题区间”与“可能存在的最优决策点区间”。
每次我们都计算出当前区间的中点的值,并且记录它的最优决策点。由于决策单调性,以单调右移为例,我们可以肯定,它左半边区间内的最优决策点必然在中点最优决策点左侧或相等,右半边同理,必然在右侧或相等,那么就缩小了寻找最优决策点的范围,同时减少了时间复杂度。
这只是一个框架,我们可以加入更多的优化。举个例子,假如状态的转移与区间信息和有关,那么每次转移时,我们可以使用莫队的思想,如例题所示,等等。
例题
代码
#include<bits/stdc++.h>
using namespace std;
typedef long long ll;
const ll inf=1e18;
const int N=2e5+5,K=25;
int n,k;
int a[N];
ll f[N][K];
int nl,nr;
int v[N];
ll cans;
ll calc(int l,int r){
while(nl>l)cans+=v[a[--nl]]++;
while(nl<l)cans-=--v[a[nl++]];
while(nr<r)cans+=v[a[++nr]]++;
while(nr>r)cans-=--v[a[nr--]];
return cans;
}
void solve(int d,int l,int r,int bl,int br){
int mid=l+r>>1,bml=bl,bmr=min(br,mid);
ll mans=inf;int mfrm;
for(int j=bml;j<=bmr;j++){
ll res=f[j-1][d-1]+calc(j,mid);
if(res<mans){
mans=res;
mfrm=j;
}
}
f[mid][d]=mans;
if(l==r)return;
solve(d,l,mid,bl,mfrm);
solve(d,mid+1,r,mfrm,br);
}
int main(){
ios::sync_with_stdio(false);
cin.tie(0);cout.tie(0);
cin>>n>>k;
for(int i=1;i<=n;i++){
cin>>a[i];
f[i][0]=inf;
}
f[0][0]=0;
nl=nr=n+1>>1;v[a[nl]]++;
for(int d=1;d<=k;d++){
solve(d,1,n,1,n);
}
cout<<f[n][k];
return 0;
}
单调队列优化
概念
介于单调性,我们可以很轻松地想到维护一个单调队列,储存最优决策点。
思路
单调队列中的单调性,是指决策最优性的单调。也就是说,靠近队头的必然比靠近队尾的更优,那么每次都只需要由队头元素转移即可。
下面考虑如何维护这个单调队列。
首先考虑有效性,如果决策点存在一个作用范围,那么每次计算新的状态时,都要现检查是否超出了队头的作用范围,如果是,由于决策单调性,队头就永远不会被再次用到了,所以弹出。
其次考虑单调性,这个需要在往单调队列中插入已经完成了的新状态(供下一次转移)来维护。我们考虑维护队尾,假如队尾元素不比当前元素更优,那么我们就应该弹出它,因为新元素必然比其更优(前提是存在单调性)。
具体考虑“优劣”定义,就在于实际实现了。
拓展(动态作用范围)
其实这部分就是二分队列,但我感觉这个名字有些不明所以、偏离重点。
有时候,作用范围是在动态更新的,也就是说,新来的状态,并没有单独的作用范围,而是在一定范围内比之前的状态更优,从而“抢占”了之前状态的作用范围。
换句话说,出现这个决策点后,原来的一些元素的最优决策点变成了它,就可以视作一次作用范围的更新。
我们借助斜率优化(这里的讲解会比较抽象,如果看不懂可以考虑先看看斜率优化)的思路,将这些决策点转移为一些直线,x坐标表示由这些决策转移去的点,y坐标表示价值。毫无疑问,每一个点(需要更新的),都只需要取它所在的x坐标对应的最大/最小y值(考虑是最大化问题还是最小化问题)。
这些直线必然会产生交点,交点的作用就是标识着最优决策的变化。比如,交点前 \(A\) 更优,经过了与 \(B\) 的交点,\(B\) 就更优了。
那么,这个所谓的交点,实际上,就是作用范围的右边界。这个问题就可以使用单调队列维护。
具体地,我们还是以思路部分的思路进行讨论。
- 有效性:我们肯定,一个点的作用范围右边界不会右移,那么,如果当前点已经超出了它的作用范围,弹出即可。
- 单调性:这是重点部分。我们在这里就要考虑到动态更新区间了。具体的,我们每次都找出队尾的直线与队尾前一条直线、即将加入的直线的交点(可以二分查找),假如后者在前者之前——我们在这里可以想象三根直线,更有利于理解——就能说明即将加入的直线作用范围覆盖了队尾的作用范围。或者这么说,新加入的直线与倒数第二条直线的交点在与队尾直线的交点之前,又因为新加入直线的 \(k\) 要大于队尾的 \(k\) ,所以新加入的直线必然是绝对优于队尾直线、并且覆盖了队尾直线的作用范围的。那么,队尾直线就失去了价值,可以弹出了。
例题
LG1912诗人小G
(注:用到了拓展部分的思路)
代码
#include<bits/stdc++.h>
using namespace std;
typedef long double ld;
void print(ld num){
cout<<num;
}
const int N=1e5+5;
int n,l,p;
string s[N];
int len[N],sum[N];
int hd=1,tl=0;
int q[N];
int lst[N];
ld f[N];
ld qpow(ld a,int b){
ld res=1;
while(b){
if(b&1)res*=a;
a*=a;
b>>=1;
}
return res;
}
ld calc(int j,int i){
return f[j]+qpow(abs(sum[i]-sum[j]-1-l),p);
}
int find(int a,int b){
int l=b,r=n+1;
while(l<r){
int m=l+r>>1;
if(calc(a,m)>=calc(b,m))r=m;
else l=m+1;
}
return l-1;
}
void solve(){
cin>>n>>l>>p;
for(int i=1;i<=n;i++){
cin>>s[i];
len[i]=s[i].size();
sum[i]=sum[i-1]+len[i]+1;
}
hd=0;tl=0;
for(int i=1;i<=n;i++){
while(hd<tl&&find(q[hd],q[hd+1])<i)hd++;
f[i]=calc(q[hd],i);
lst[i]=q[hd];
while(hd<tl&&find(q[tl-1],q[tl])>=find(q[tl],i))tl--;
q[++tl]=i;
}
if(f[n]>1e18)cout<<"Too hard to arrange\n";
else{
cout<<f[n]<<"\n";
stack<int> hh;
for(int i=n;i!=0;i=lst[i])hh.push(i);
for(int i=1;i<=n;i++){
cout<<s[i];
if(hh.top()==i){
cout<<"\n";
hh.pop();
}else cout<<" ";
}
}
cout<<"--------------------\n";
}
int main(){
ios::sync_with_stdio(false);
cin.tie(0);cout.tie(0);
cout<<fixed<<setprecision(0);
int t;cin>>t;
while(t--)solve();
return 0;
}
斜率优化
概念
构建一个二维坐标系,通常横坐标为决策点、纵坐标为某个公式值,维护一个凸包。
思路
这个优化的重点就在于坐标系中相邻两点连接线的斜率,我们会细致讲解。
通常情况下,我们首先需要证明这个问题是具有单调性的。这个先记着,具体维护什么的单调性后面再讲。
通常情况下,我们需要推出一个公式,这个公式的作用是判断两个已有决策点对于一个待更新点来说谁更优的。(其实,它的另一个作用是确认直线的斜率,这个我们后面再讲,这里为了清晰理解,只需要记住括号外的概念即可。)
假设待更新点是 \(i\) ,两个决策点分别为 \(j,k\)。这个公式通常长这个样子:
\[G(j,k)>F(i) \]中间的不等符号不一定,要看实际情况。这个格式的意思,就是左边全是 \(j,k\) 有关式子,右边全是 \(i\) 有关式子。
这个公式如何得来呢?不固定,但通常情况下,有一种较为简单的方式:
我们考虑原始的状态转移方程,比如说:
\[f_i = \max_j(f_j+\rm{calc}(j,i)) \]当然,这里 \(\max\) 只是一个象征,表示最优化,具体是最大还是最小需要看题目要求。\(\rm{calc}\) 只是一个格式,其内容就是你的具体转移方程。
好,现在我们我们就考虑如何根据这个式子判断两个决策点谁更优。毫无疑问,假设我们是要取最大值,且 \(j\) 优于 \(k\),必有:\[f_j+\rm{calc}(j,i) > f_k+\rm{calc}(k,i) \]我们对这个公式进行变形,变形成上述形式,就得到了一个我们需要的公式了。完毕。
以上内容与斜率基本无关,下面我们就要考虑斜率优化了。
先给定义吧,用 \(G(j,k)\) 代表两个决策点之间的斜率,\(F(i)\) 代表一条代表待更新点的直线的斜率。
下述斜率优化所有内容都基于公式为 \(G(j,k)>F(i)\) 的情况,具体不等符号请灵活变通。
根据上述定义,只要两个决策点之间的斜率大于待更新直线的斜率,那么直线右边的决策点就优于左边的决策点。反之同理。
不难想到,如果我们维护所有可能的决策点,那么从左到右(右侧为正方向,越向右决策点越“新”),斜率应该是单调递减的。
简要证明,假如有一段斜率比前一段大,那么这段直线右端点绝对比左端点更优,左端点就没有存在的必要的,直接删除即可。事实上,这就是后面要说的维护单调性的方式。
好,那么,对于每一个待更新点,我们都去寻找第一条斜率小于 \(F(i)\) 的连线,它的左端点就是这个待更新点的最优决策点。
简要证明,我们首先保证了斜率单调递减,那么第一条斜率小于 \(F(i)\) 的连线,它后面的所有斜率都是小于 \(F(i)\) 的,意思就是它左边的节点要更优。而这条连线左端点就是最优的决策点。
这里提一下两个若智的特殊情况:
- 目前没有点,不存在,必然有一个初始化,不然无法动态规划。
- 目前只有一个点,那么只有它能当转移点不是吗?拓展一下,假如说最右侧的直线斜率都大于 \(F(i)\),那么根据定义,最右边的端点就是当前的最优决策点。
具体实现
单调队列
使用单调队列有一个前提: \(F(i)\) 单调递减。也就是说,新的待更新直线斜率必然大于先前的待更新直线斜率。
这样的话,我们就可以使用单调队列维护了。更具体的,每次单调队列的队头必然是最优决策点。
如何维护这个性质呢?我们只需要保证第一条直线就是第一条斜率小于当前的 \(F(i)\) 的直线即可。
也就是说,我们每次要找的 第一条斜率小于 \(F(i)\) 的连线,保证是第一条连线。
维护方法非常简单,因为 \(F(i)\) 单调递减,假如说当前的第一条连线斜率大于了 \(F(i)\),那么它就永远大于后续的所有 \(F(i)\) 了。意义是,它左侧的节点,永远都不会再成为最优决策点,就可以出队了。
接下来考虑维护单调性,我们每次都从队尾插入节点,每次对比当前的最后一条直线与即将加入的连线斜率(这里的连线不是 \(F(i)\) 了,而是 \(G(t,i)\),\(t\) 是实时变化的当前队尾决策点),如果不满足单调递减的性质,就弹出当前队尾节点(这时候新节点还没插入),直到队列中只剩下一个决策点或者满足了单调递减的性质为止。
如果你不太明白边界情况,那么就请看一下思路部分结尾的两个(事实上三个)若智特殊情况,应该就能解答你的疑惑了。
二分
如果插入的 \(F(i)\) 没有单调性,那么每次都二分查找第一条斜率小于当前的 \(F(i)\) 的直线即可。单调性的维护和单调队列方法是一致的,不再细说。
例题
代码
#include<bits/stdc++.h>
using namespace std;
typedef long long ll;
typedef long double ld;
const int N=5e4+5;
int n;ll l;
ll c[N];
ll dp[N];
ld f(int i){
return 2.0*c[i];
}
ld g(int j,int k){
return 1.0*(dp[j]+(c[j]+l)*(c[j]+l)-dp[k]-(c[k]+l)*(c[k]+l))/(c[j]-c[k]);
}
ll calc(int j,int i){
return (c[i]-c[j]-l)*(c[i]-c[j]-l);
}
int q[N];
int hd,tl;
int main(){
ios::sync_with_stdio(false);
cin.tie(0);cout.tie(0);
cin>>n>>l;l++;
for(int i=1;i<=n;i++){
cin>>c[i];
c[i]+=c[i-1]+1;
}
q[hd=tl=1]=0;
for(int i=1;i<=n;i++){
while(hd<tl&&g(q[hd],q[hd+1])<=f(i))hd++;
dp[i]=dp[q[hd]]+calc(q[hd],i);
while(hd<tl&&g(q[tl-1],q[tl])>=g(q[tl],i))tl--;
q[++tl]=i;
}
cout<<dp[n];
return 0;
}
WQS二分
概念
通常用于优化二维DP到一维DP,缩减复杂度。
具体的,它通常用于优化形似“固定了特定内容个数的最优化问题”的DP优化。
思路
WQS二分通常是搭配着斜率优化和单调队列使用了,建议先学一下这两个。
具体的,我们考虑一个东西,暂且称之为“偏差量”(可能不标准)。,用 \(\Delta x\) 表示
WQS二分的重点其实就是去除“固定特定内容个数”这个限制,来降维,具体的实现就要靠 \(\Delta x\)。
如何使用 \(\Delta x\)?一般来说,我们在进行转移时,额外的把 \(f_i\) 的值加上/减去 \(\Delta x\)。
这是个很令人迷惑的操作,下面详细地解释它的作用。
首先,我们设 \(g_i\) 表示 \(i\) 的最优决策共选取了多少节点。
那么,毫无疑问,\(g_i\) 越大,\(i\) 得到/失去的 \(\Delta x\) 就越多。
为了方便讲述,我们假设这是个最小化问题,且得到 \(\Delta x\)。那么,一些 \(g\) 大的状态,其代价就会变大,就会影响后续的选择。
更明白地,\(\Delta x\) 越大,最优决策点就越倾向于 \(g\) 小的决策点。
那么,我们就可以通过控制 \(\Delta x\) 的大小,来控制最优状态的 \(g\) 大小。只要不存在客观无解的状况,我们都可以通过找到一个恰好的 \(\Delta x\) 来使得最终的答案恰好选取了要求的个数。
那么,我们只需要二分 \(\Delta x\) 就可以解决这种问题了。
假设原复杂度是 \(O(n^2)\),优化之后的复杂度就变成了 \(O(n\log{k})\),令 \(k\) 表示 \(\Delta x\) 的取值范围。
例题
代码
#include<bits/stdc++.h>
using namespace std;
const int N=5e4+5,M=1e5+5;
struct edge{
int s,t,v;
int col;
}es[M*2];
int n,m,need;
vector<int> blk,wht;
bool cmp(edge a,edge b){
return a.v==b.v?a.col<b.col:a.v<b.v;
}
struct DSU{
int fa[N*2];
void init(int n){
for(int i=0;i<=n;i++){
fa[i]=i;
}
}
int find(int x){
if(fa[x]!=x)fa[x]=find(fa[x]);
return fa[x];
}
void merge(int a,int b){
a=find(a);b=find(b);
fa[a]=b;
}
bool same(int a,int b){
return find(a)==find(b);
}
}dsu;
int sum,num;
bool check(int ad){
dsu.init(n);
sum=num=0;
for(int i=1;i<=m;i++){
if(es[i].col==0)es[i].v+=ad;
}
sort(es+1,es+1+m,cmp);
int now=1;
for(int t=1;t<n;t++){
while(dsu.same(es[now].s,es[now].t)){
now++;
}
dsu.merge(es[now].s,es[now].t);
sum+=es[now].v;
if(es[now].col==0)num++;
}
for(int i=1;i<=m;i++){
if(es[i].col==0)es[i].v-=ad;
}
return num>=need;
}
int main(){
ios::sync_with_stdio(false);
cin.tie(0);cout.tie(0);
cin>>n>>m>>need;
for(int i=1;i<=m;i++){
cin>>es[i].s>>es[i].t>>es[i].v>>es[i].col;
}
int l=-105,r=105;
int ans;
while(l<=r){
int m=l+r>>1;
if(check(m)){
l=m+1;
ans=m;
}
else r=m-1;
}
check(ans);
cout<<sum-need*ans;
return 0;
}