【动态规划】常见基础DP优化技巧(二)
Little_Cake_qwq · · 算法·理论
upd on 26.8.16:修改了一处笔误。
前言
感谢 @jucason_xu 讲解的部分芝士点,让我们一起膜拜他!
有问题可以在评论区或私信提出。
一些前置芝士:单调队列、单调栈、线段树、矩阵。
正文
一般来说,DP 转移的优化一般需要保证:状态合理的设计。
其实就是数组开的下,否则一切对于时间上的优化都是空谈。
I. 线段树优化 DP
有时候我们需要快速进行区间求值来帮助 DP 转移,而线段树就很好的支持了这种操作。
先看例题吧。
1. P1848 [USACO12OPEN] Bookshelf G
考虑朴素 DP。设计
我们从
我们把假设某个时刻单调栈内情况如下图:
枚举到
而
综上,我们需要一个支持区间加,区间查询的数据结构,这不就是线段树吗?时间复杂度
:::success[AC code]
const int N=1e5+8;
int n,m;
int h[N],w[N];
int st[N],top;
struct SGT {
#define mid ((l+r)>>1)
#define ls o<<1
#define rs o<<1|1
int mn[N<<2],tag[N<<2];
void pd(int o) {
tag[ls]+=tag[o];
tag[rs]+=tag[o];
mn[ls]+=tag[o];
mn[rs]+=tag[o];
tag[o]=0;
}
void upd(int o,int l,int r,int L,int R,int v) {
if(L<=l&&r<=R) {
tag[o]+=v;
mn[o]+=v;
return ;
}
pd(o);
if(L<=mid) upd(ls,l,mid,L,R,v);
if(mid<R) upd(rs,mid+1,r,L,R,v);
mn[o]=min(mn[ls],mn[rs]);
}
int qry(int o,int l,int r,int L,int R) {
if(L<=l&&r<=R) return mn[o];
pd(o);
int res=INF;
if(L<=mid) res=min(res,qry(ls,l,mid,L,R));
if(mid<R) res=min(res,qry(rs,mid+1,r,L,R));
return res;
}
}sgt;
int f[N];
signed main()
{
read(n);
read(m);
rep(i,1,n) read(h[i]),read(w[i]),w[i]=w[i-1]+w[i];
st[++top]=0;
h[0]=INF;
int pos=0;
rep(i,1,n) {
sgt.upd(1,0,n,i-1,i-1,f[i-1]+h[i]);
while(top&&h[st[top]]<h[i]) {
sgt.upd(1,0,n,st[top-1],st[top]-1,h[i]-h[st[top]]);
top--;
}
st[++top]=i;
while(pos<i&&w[i]-w[pos]>m) pos++;
f[i]=sgt.qry(1,0,n,pos,i-1);
}
print(f[n]);
return 0;
}
:::
这里的线段树是普通的下标维护,我们再看一道权值线段树优化 DP 的题目。
2. AT_dp_q Flowers
仍先考虑朴素 DP,设
我们发现:与转移相关的东西其实不是下标,它只决定了你 DP 的顺序。真正与转移相关的是值域(即
我们发现
时间复杂度
:::success[AC code]
const int N=2e5+8;
int n;
int h[N],a[N],f[N];
struct BIT {
#define lowbit(x) x&(-x)
int w[N];
void upd(int pos,int v) {
while(pos<=n) {
w[pos]=max(w[pos],v);
pos+=lowbit(pos);
}
}
int qry(int pos) {
int res=0;
while(pos) {
res=max(w[pos],res);
pos-=lowbit(pos);
}
return res;
}
}fenwick;
signed main()
{
read(n);
rep(i,1,n) read(h[i]);
rep(i,1,n) read(a[i]);
rep(i,1,n) {
f[h[i]]=fenwick.qry(h[i]-1)+a[i];
fenwick.upd(h[i],f[h[i]]);
}
print(fenwick.qry(n));
return 0;
}
:::
总结
为啥第一题是以普通下标建立线段树,第二题却是值域呢?因为第一题中,以
II. 斜率优化 DP
感谢 @jucason_xu 对于此部分芝士的讲解。
斜率优化,其实就是单调队列优化的升级版。只要你能推出式子,看出是到斜率优化的题目,敲出板子,那这道题就没了。
概括的讲,如果转移式子形如
3. AT_dp_z Frog 3
依旧考虑暴力 DP:设计
考虑优化。我们把转移式中的括号展开:
我们发现
此时有人就能发现了:如果我们设
这不就是在一些一次函数中,查询 但我不会啊。我们思考一下怎么去优化。
我们可以画一个图:
紫色部分就是我们要求的答案。容易发现,这个是凸包的一部分。于是:每个
这就和单调队列优化 DP 很像。前面也提到过:
斜率优化,其实就是单调队列优化的升级版。
那么我们就可以利用相同的方法优化 DP。先考虑什么时候弹出队头:假设某个时候
由于
接下来看如何弹出队尾。首先你得知道为啥要弹出对尾:
如图,橙色为我们维护的凸包。但是绿线并没有经过这个凸包,这就是为啥要弹出队尾:因为有的直线根本就做不了贡献。都说单调队列优化 DP 是:比你小的比你强,你就可以退役了。那么斜率优化就是:比你小的发力比你早,你也可以退役了。(学 OI 太难了 TwT)。
考虑怎么弹出队尾:我们找出
那么这道题就没了。时空复杂度
:::success[AC code]
const int N=2e5+8;
int n,c;
int h[N];
int f[N];
int hd,tl,q[N];
double spole(int x,int y) {
return 1.0*(f[x]+h[x]*h[x]-f[y]-h[y]*h[y])/(h[x]-h[y]);
}
signed main()
{
read(n);read(c);
rep(i,1,n) read(h[i]);
q[hd=tl=1]=1;
rep(i,2,n) {
while(hd<tl&&spole(q[hd+1],q[hd])<=2*h[i]) hd++;
f[i]=f[q[hd]]+(h[i]-h[q[hd]])*(h[i]-h[q[hd]])+c;
while(hd<tl&&spole(q[tl-1],q[tl])>=spole(q[tl],i)) tl--;
q[++tl]=i;
}
print(f[n]);
return 0;
}
:::
总结
其实上面的斜率优化是
斜率优化理解了是很好写的。当然如果你是 DS 大手子你就可以忽略了。
III.决策单调性
什么是决策单调性呢?设
如果说某个题目需要你划分出
打个表就可以了。
其实决策单调性是个很大的东西:单调队列,斜率优化也算一种决策单调性优化。不过有的时候,式子并不是很好用以上两种方法维护,那就是决策单调性优化的时候了。
那么怎么用上这个性质呢?考虑分治。
假设我们处理
- 先找到
l,r 的中点mid ,在[pl,pr] 中找到mid 的决策点p(mid) 。 - 递归处理
j\in[l,mid-1],p(j)\in[p(l),p(mid)] 以及j\in[mid+1,r],p(j)\in[p(mid),p(r)] 。
显然时间复杂度是
但假如我们区间没有个数限制,即只有一维呢?考虑 CDQ 分治。假设处理区间
- 先递归处理
[l,mid] ,然后用[l,mid] 的值更新[mid+1,r] ,再递归处理[mid+1,r] 。 - 用
[l,mid] 的值更新[mid+1,r] 时,采用上面的决策单调性分治即可。
其实关于决策单调性的优化还有二分栈、二分单调队列的方式,不过笔者并没有掌握很熟悉,所以不会提及。
4. CF868F Yet Another Minimization Problem
考虑暴力 DP。设
显然这个东西满足四边形不等式,按照上面分治即可。那么难点就在于如何快速计算
我们借用莫队的思想,每一层均摊是
:::success[AC code]
const int N=1e5+8;
int n,k;
int a[N];
int f[N][28];
int res,L=1,R;
int tot[N];
int w(int ql,int qr) {
while(ql<L) res+=tot[a[--L]]++;
while(R<qr) res+=tot[a[++R]]++;
while(ql>L) res-=--tot[a[L++]];
while(R>qr) res-=--tot[a[R--]];
return res;
}
void solve(int l,int r,int pl,int pr,int j) {
if(l>r) return ;
int mid=(l+r)>>1;
int pos;
f[mid][j]=INF;
rep(i,pl,min(pr,mid))
if(f[mid][j]>f[i-1][j-1]+w(i,mid))
f[mid][j]=f[i-1][j-1]+w(i,mid),pos=i;
solve(l,mid-1,pl,pos,j);
solve(mid+1,r,pos,pr,j);
}
signed main()
{
read(n);read(k);
rep(i,1,n) read(a[i]);
rep(i,1,n) f[i][0]=INF;
f[0][0]=0;
rep(j,1,k) solve(1,n,1,n,j);
print(f[n][k]);
return 0;
}
:::
IV. 根号分治优化 DP
有的时候 DP 复杂度为
具体的,对于
当然,有的时候分治是相反的,怎么搞还得看具体的题目。
5. P10761 [BalticOI 2024] Trains
设计
本题直接暴力是
对于
接下来我们考虑
时间复杂度
:::success[AC code]
const int N=1e5+8;
const int p=1e9+7;
int n,ans;
int a[N],b[N];
int f[N],g[N][408];
signed main()
{
n=read();
rep(i,1,n) a[i]=read(),b[i]=read();
f[1]=1;
rep(i,1,n) {
rep(j,1,sqrt(n)) if(i-j>0) g[i][j]=(g[i][j]+g[i-j][j])%p;
rep(j,1,sqrt(n)) f[i]=(f[i]+g[i][j])%p;
ans=(ans+f[i])%p;
if(a[i]>sqrt(n)) {
rep(j,1,b[i]) if(i+j*a[i]<=n) {
f[i+j*a[i]]+=f[i];
f[i+j*a[i]]%=p;
} else { break; }
}
else if(i+a[i]<=n&&a[i]) {
g[i+a[i]][a[i]]+=f[i];
g[i+a[i]][a[i]]%=p;
if(i+a[i]*(b[i]+1)<=n) {
g[i+a[i]*(b[i]+1)][a[i]]+=p-f[i];
g[i+a[i]*(b[i]+1)][a[i]]%=p;
}
}
}
write(ans);
return 0;
}
:::
6. P6189 [NOI Online #1 入门组] 跑步
整数拆分问题,考虑两种暴力做法:
-
设计
f_{i,j} 表示最大数为i 得出j 的方案数,那么有:f_{i,j}=\sum_{k=1}^i f_{k,j-i} ,可以用前缀和优化。 -
设计
f_{i,j} 表示i 个数得出j 的方案数,即完全背包。对于i,j ,我们可以创造0 为i ,那么f_{i,j}\Leftarrow f_{i-1,j} ;我们也可以选择给i 的前缀加1 ,那么f_{i,j}\Leftarrow f_{i,j-i} 。
两种方法不由得让我们想道根号分治。考虑一个性质:最多只会有
对于第一个段为最大值小于
对于第二个段为最小值大于等于
最后计算答案,我们枚举第一段得出的数
const int N=1e5+8;
int n,p,B;
int s[338][N],f[N];
int g[338][N];
signed main()
{
n=read(),p=read();
B=sqrt(n);
f[0]=1;
rep(i,0,B-1) s[i][0]=1;
rep(j,1,n) {
rep(i,1,min(j,B-1)) f[i]=s[i][j-i];
rep(i,1,B-1) s[i][j]=((ll)(s[i-1][j]+f[i]))%p;
}
g[0][0]=1;
rep(i,1,n/B) rep(j,i,n) {
if(j>=B) g[i][j]=g[i-1][j-B];
g[i][j]=((ll)(g[i][j]+g[i][j-i]))%p;
}
int ans=0;
rep(j,0,n) {
int cur=0;
rep(i,0,n/B) cur=(cur+g[i][n-j])%p;
ans=(ans+((ll)s[B-1][j]*cur)%p)%p;
}
write(ans);
return 0;
}
:::
总结
其实大多数根号分治优化 DP 是极典的,只要你看出来时间复杂度是和
V. 矩阵优化 DP
差点忘了这个。最好想最难调的一集。
我们知道:无论是普通的矩阵还是 min-plus 矩阵都是满足结合律的。有时 DP 的某一维状态极少,并且与前面相关的状态也很少,那么就可以用矩阵加速从
比如说斐波那契数列:
我们发现当
(一共
由于矩阵满足结合律,于是:
通过矩阵快速幂即可优化到
7. P15234 「CROI · R3」浣熊的长木桥
首先考虑开始没有砖块的方案数。
由于砖块的样子,不难发现其一定是由若干个
考虑状压 DP。设
不难发现:这个
则有转移为:
这个转移可以用矩阵快速幂优化。
接着考虑开始有砖块的方案数。设当前矩阵为
最终答案就为
:::success[AC code]
const int N=1e5+8;
const int p=1e9+7;
struct matrix {
int r,c;
int a[4][4];
matrix() { memset(a,0,sizeof(a)); }
matrix operator *(const matrix &qwq) const {
matrix res;
res.r=r;res.c=qwq.c;
rep(i,0,res.r-1) rep(j,0,res.c-1)
rep(k,0,qwq.r-1) res.a[i][j]=(res.a[i][j]+a[i][k]*qwq.a[k][j]%p)%p;
return res;
}
}base,f;
matrix qpow(matrix base,int k) {
matrix res;
res.r=res.c=base.r;
for(int i=0;i<res.r;i++)
res.a[i][i]=1;
while(k) {
if(k&1) res=res*base;
base=base*base;
k>>=1;
}
return res;
}
int n,m;
struct node {
int x,y;
bool operator <(const node &qwq) const {
if(y==qwq.y) return x<qwq.x;
return y<qwq.y;
}
}a[N];
signed main()
{
read(n);read(m);
rep(i,1,m) read(a[i].x),read(a[i].y),a[i].x--;
sort(a+1,a+m+1);
base.r=base.c=4;
base.a[0][3]=base.a[1][3]=
base.a[2][3]=base.a[3][3]=
base.a[1][0]=base.a[2][0]=
base.a[3][1]=base.a[3][2]=1;
base.a[3][0]=2;
f.r=4;f.c=1;
f.a[3][0]=1;
int lst=0;
rep(i,1,m) {
int p=1<<a[i].x;
if(a[i].y==a[i+1].y) p|=1<<a[i+1].x,i++;
f=qpow(base,a[i].y-lst)*f;
matrix tmp;
tmp.r=4;tmp.c=1;
rep(st,0,3) if((st&p)==0)
tmp.a[st|p][0]+=f.a[st][0];
f=tmp;
lst=a[i].y;
}
f=qpow(base,n-lst)*f;
print(f.a[3][0]);
return 0;
}
:::
本题是一般矩阵的优化。接下来我们看一道 min-plus 矩阵优化的题目:
8. P2886 [USACO07NOV] Cow Relays G
定义
其中
那么转移就变成了:
如果你的直觉较好,那么就可以发现:其实
那么这道题就没了。时间复杂度
:::success[AC code]
const int N=108;
int n,m,s,t,l;
int dot[N*10];
int x[N*10],y[N*10],z[N*10];
struct matrix {
int r,c;
int a[N][N];
matrix() {memset(a,0x3f3f3f,sizeof(a));}
matrix operator *(const matrix &qwq) const {
matrix res;res.r=r;res.c=qwq.c;
rep(i,1,res.r) rep(j,1,res.c) rep(k,1,qwq.r)
res.a[i][j]=min(res.a[i][j],a[i][k]+qwq.a[k][j]);
return res;
}
}base;
matrix qpow(matrix x,int y) {
matrix res=x;
y--;
while(y) {
if(y&1) res=res*x;
y>>=1;
x=x*x;
}
return res;
}
signed main()
{
l=read();m=read();
s=read();t=read();
rep(i,1,m) x[i]=read(),dot[++n]=y[i]=read(),dot[++n]=z[i]=read();
sort(dot+1,dot+n+1);
n=unique(dot+1,dot+n+1)-dot-1;
s=lower_bound(dot+1,dot+n+1,s)-dot;
t=lower_bound(dot+1,dot+n+1,t)-dot;
base.r=base.c=n;
rep(i,1,m) {
y[i]=lower_bound(dot+1,dot+n+1,y[i])-dot,
z[i]=lower_bound(dot+1,dot+n+1,z[i])-dot;
base.a[y[i]][z[i]]=base.a[z[i]][y[i]]=x[i];
}
base=qpow(base,l);
write(base.a[s][t]);
return 0;
}
:::
总结
矩阵加速是较板的,毕竟就推个式子、写个快速幂。当然矩阵还可以与线段树、树链剖分组成动态 DP,不过这里并不会过多说明。(其实就是笔者太菜了)
VI. 非常见 DP 优化
其实就是剪枝卡常(?
不过在大多数时候这个是必要的,良好的卡常技巧其实可以起到很大的作用。
9. CF2041C Cube
看到
如果说我们对于每个
:::success[AC code]
const int N=12;
int n;
int a[N+1][N][N];
vector<int> p[N+1];
int f[N+1][1<<N][1<<N];
signed main()
{
read(n);
rep(i,1,n) rep(j,0,n-1) rep(k,0,n-1)
read(a[i][j][k]);
rep(i,0,(1<<n)-1) p[__builtin_popcount(i)].pb(i);
rep(i,1,n) for(auto p1:p[i]) for(auto p2:p[i])
f[i][p1][p2]=INF;
f[0][0][0]=0;
rep(i,0,n-1) for(auto p1:p[i]) rep(j,0,n-1) if(((1<<j)&p1)==0)
for(auto p2:p[i]) rep(k,0,n-1) if(((1<<k)&p2)==0) f[i+1][p1|(1<<j)][p2|(1<<k)]=
min(f[i+1][p1|(1<<j)][p2|(1<<k)],f[i][p1][p2]+a[i+1][j][k]);
print(f[n][(1<<n)-1][(1<<n)-1]);
return 0;
}
:::
总结
状态的合理剪枝可以帮助你在考场上获得更多的分数,并且极为实用。还有记得:unsigned long long 的乘法取模比 long long 快。
最后
感谢 @jucason_xu!!!
有问题可以在评论区、私信说。感谢各位阅读。