DP优化笔记

DP 的效率取决于 3 方面:1. 状态总数 2. 每个状态的决策数 3. 状态转移计算量。这三方面都可以进行优化。

  1. 状态总数优化:相当于搜索的剪枝,去除无用状态;使用降维,设计 DP 状态时尽量使用低维的 DP。
  2. 决策数量优化:即状态转移方程的优化,减少决策数量,如斜率优化,四边形不等式优化。
  3. 状态转移计算量优化:如用预处理减少递推时间,用 Hash 表,线段树,树状数组等减少枚举时间。

一. 一般优化

几种基础的优化方式。

1. 倍增优化

动态规划是多阶段递推,可以使用倍增法将阶段性的线性增长加速为成倍增长。经典应用有 ST表,背包中的二进制拆分等。

2. 数据结构优化

如果题目与简单的区间操作有关,如区间查询,区间修改等,可以用线段树或者树状数组进行优化。把区间操作的复杂度优化到 O(logn),从而降低复杂度。

2.1 树状数组优化

例题 1 P3287

首先注意到一个性质:对于在 [L,R] 区间内加 1 的操作,将 R 设为 n 一定是最优的。因为:

  1. 如果 Rn,会导致 R 后面的数相对变小,不利于形成更长的单调不降序列。

  2. 如果 R=n,至少不会使单调不降序列变短。

f[i][j] 表示做过 j 次区间操作,每次操作起点不超过 i,且以 i 结尾的最长单调不降序列的最长长度,状态转移方程为:

f[i][j]=maxx<i,yj,a[x]+ya[i]+j{f[x][y]+1}

这是一个二维区间问题,可以使用二维树状数组进行优化。

#include<bits/stdc++.h>
using namespace std;
const int N=10009;
int n,k,ans,a[N],f[N][509],bit[509][5509];
int lsb(int x){return x&(-x);}
void update(int x,int y,int z){
for(int i=x;i<=k+1;i+=lsb(i)){
for(int j=y;j<=5500;j+=lsb(j)) bit[i][j]=max(bit[i][j],z);
}
}
int query(int x,int y){
int res=0;
for(int i=x;i;i-=lsb(i)){
for(int j=y;j;j-=lsb(j)) res=max(res,bit[i][j]);
}
return res;
}
int main(){
ios::sync_with_stdio(false);
cin>>n>>k;
for(int i=1;i<=n;i++) cin>>a[i];
for(int i=1;i<=n;i++){
for(int j=k;j>=0;j--){
f[i][j]=query(j+1,a[i]+j)+1;
ans=max(ans,f[i][j]);
update(j+1,a[i]+j,f[i][j]);
}
}
cout<<ans;
return 0;
}

2.2. 线段树优化

例题 1 P2605

定义 f[i][j] 为前 i 个基站中第 j 个基站建在 i 处的最小值,状态转移方程为:

f[i][j]=minj1k<i{f[k][j1]+p[k][i]}

其中 p[k][i] 表示区间 [k,i] 内没有被基站 i,k 覆盖的村庄的赔偿费用。

如果直接计算的话,需要 i,j,k 三重循环,复杂度 O(n3),如何优化?

  1. 滚动数组:发现 j 只与 j1 有关,可以用滚动数组优化 j ,将复杂度降为 O(n2),优化后的状态转移方程:

    f[i]=min1k<i{dp[k]+p[k][i]}

  2. 区间操作的优化:方程中的 p 数组计算 [i,k] 内的赔偿费用,是一个区间求和问题,用线段树优化。

#include<bits/stdc++.h>
using namespace std;
typedef long long ll;
const int N=200009;
const ll INF=0x3f3f3f3f3f3f3f3f;
int tot,head[N],nxt[N],to[N];
ll n,k,now,d[N],c[N],s[N],w[N],st[N],ed[N],f[N];
struct segment_tree{
int l,r;
ll val,tag;
}t[N*4];
void addedge(int x,int y){
nxt[++tot]=head[x];
head[x]=tot;
to[tot]=y;
}
void init(){
n++,k++;
d[n]=INF;w[n]=INF;
for(int i=1;i<=n;i++){
st[i]=lower_bound(d+1,d+1+n,d[i]-s[i])-d;
ed[i]=lower_bound(d+1,d+1+n,d[i]+s[i])-d;
if(d[ed[i]]>d[i]+s[i]) ed[i]--;
addedge(ed[i],i);
}
}
void pushup(int p){
int ls=p*2,rs=p*2+1;
t[p].val=min(t[ls].val,t[rs].val);
}
void pushdown(int p){
int ls=p*2,rs=p*2+1;
if(t[p].tag){
t[ls].val+=t[p].tag;t[ls].tag+=t[p].tag;
t[rs].val+=t[p].tag;t[rs].tag+=t[p].tag;
t[p].tag=0;
}
}
void build(int p,int l,int r){
t[p].l=l;t[p].r=r;t[p].tag=0;
if(l==r){t[p].val=f[l];return ;}
int mid=l+(r-l)/2;
build(p*2,l,mid);
build(p*2+1,mid+1,r);
pushup(p);
}
void update(int p,int L,int R,int c){
if(L>R) return ;
if(L<=t[p].l&&R>=t[p].r){t[p].val+=c;t[p].tag+=c;return ;}
pushdown(p);
int mid=t[p].l+(t[p].r-t[p].l)/2;
if(L<=mid) update(p*2,L,R,c);
if(R>mid) update(p*2+1,L,R,c);
pushup(p);
}
ll query(int p,int L,int R){
if(L>R) return INF;
if(L<=t[p].l&&R>=t[p].r) return t[p].val;
pushdown(p);
int mid=t[p].l+(t[p].r-t[p].l)/2;
ll res=INF;
if(L<=mid) res=min(res,query(p*2,L,R));
if(R>mid) res=min(res,query(p*2+1,L,R));
return res;
}
int main(){
ios::sync_with_stdio(false);
cin>>n>>k;
for(int i=2;i<=n;i++) cin>>d[i];
for(int i=1;i<=n;i++) cin>>c[i];
for(int i=1;i<=n;i++) cin>>s[i];
for(int i=1;i<=n;i++) cin>>w[i];
init();
for(int i=1;i<=n;i++){
f[i]=now+c[i];
for(int j=head[i];j;j=nxt[j]){
int y=to[j];
now=now+w[y];
}
}
ll ans=f[n];
for(int i=2;i<=k;i++){
build(1,1,n);
for(int j=1;j<=n;j++){
f[j]=query(1,1,j-1)+c[j];
for(int k=head[j];k;k=nxt[k]){
int y=to[k];
update(1,1,st[y]-1,w[y]);
}
}
ans=min(ans,f[n]);
}
cout<<ans;
return 0;
}

二. 单调队列优化

1. 单调队列

顾名思义,单调队列的重点分为「单调」和「队列」。

「单调」指的是元素的「规律」——递增(或递减)。

「队列」指的是元素只能从队头和队尾进行操作。

最大子序列之和

一个长度为 n 的整数序列 a,从中找出一个长度不超过 m 的连续子序列,使得该子序列中数的和最大。

定义 s[i]=j=1ia[j],那么连续子序列 [l,r] 的和即为 s[r]s[l1],原问题转化为:找出两个数 x,y,使得 s[y]s[x] 最大且 yxm

枚举右端点 i,当 i 固定时,找到左端点 j,使得 j[im,i1]s[j] 最小。

对于 j2<j1<is[j2]>s[j1] ,那么 s[j2] 永远不会成为最优选择。因此答案集合一定是“下标位置递增,对应的 s 值也递增”的序列。这就是单调队列。

我们对每个 i 执行一下操作:

  1. 将队头每一个距离超过 m 的数值弹出
  2. 此时队头就是答案。
  3. 不断删除队尾,直到队尾对应的 s 值小于 s[i],加入 i

例题 1 T331286

将工匠按照 s[i] 从小到大排序,可以按照顺序进行线性 DP。设 f[i][j] 表示第 i 个工匠刷前 j 块所能获得的最大报酬。

i 个工匠不刷:f[i][j]=f[i1][j]

j 块木板不刷:f[i][j]=f[i][j1]

如果第 i1 个工匠刷第 k 块木板,则第 i 个工匠要刷第 k+1j 块木板,粉刷总数不超过 Li,且必须粉刷 Si。所以 k+1SijjkLi

f[i][j]=maxjLikSi1{f[i1][k]+Pi(jk)}

可以把 i 看做定值,把 j,k 分为 2 项,那么状态转移方程整理为

f[i][j]=Pi·j+maxjLikSi1{f[i1][k]Pi·k}

比较任意两个决策 k1,k2,满足 k1<k2<Si

k1 一定会比 k2 更早从 [jLi,Si1] 里删除,那么如果 f[i1][k1]Pi·k1 小于 f[i1][k2]Pi·k2,那么 k1 就比 k2 更不优。

综上,可以维护一个 ki 递增,f[i1][ki]Pi·ki 递减的单调队列。

整个算法的时间复杂度为 O(nm)

#include<bits/stdc++.h>
using namespace std;
typedef long long ll;
struct worker{ll s,l,p;}w[109];
ll n,m,f[109][16009],q[16009];
bool cmp(const worker&a,const worker&b){return a.s<b.s;}
ll calc(ll i,ll k){return f[i-1][k]-w[i].p*k;}
int main(){
ios::sync_with_stdio(false);
cin>>n>>m;
for(ll i=1;i<=m;i++) cin>>w[i].l>>w[i].p>>w[i].s;
sort(w+1,w+1+m,cmp);
for(ll i=1;i<=m;i++){
ll l=1,r=0;
for(ll k=max(0ll,w[i].s-w[i].l);k<=w[i].s-1;k++){
while(l<=r&&calc(i,q[r])<=calc(i,k)) r--;
q[++r]=k;
}
for(ll j=1;j<=n;j++){
f[i][j]=max(f[i-1][j],f[i][j-1]);
if(j>=w[i].s){
while(l<=r&&q[l]<j-w[i].l) l++;
if(l<=r) f[i][j]=max(f[i][j],calc(i,q[l])+w[i].p*j);
}
}
}
cout<<f[m][n];
return 0;
}

例题 2 T331284

f[i] 表示把前 i 个数分成若干段,在满足每段中所有的数和不超过 M 的前提下,各段的最大值之和最小是多少,状态转移方程:

f[i]=min0j<i,k=j+1iAkM{f[j]+maxj+1kiAk}

若采用枚举 j 的方法,复杂度是 O(n2),显然会超时,但 maxj+1kiAk 不容易用多项式表示,很难找到单调性。

基于“及时排除不可能的决策”,需要考虑一个决策 j 何时是可能出现的。

假设 j(0j<i) 可能成为最优决策,是否可以通过邻项构造冲突性质。

设决策 j1 优于 j,可知

f[j1]+maxjkiAkf[j]+maxj+1kiAk

首先,既然上式成立,则 k=jiAkM,其次,因为 f[j1]f[j],所以可以假设 maxjki{Ak}=maxj+1ki{Ak},当且仅当 Aj<maxjki{Ak} 时成立。

如果上述两个命题同时成立,即 j1 优于 j,如果向确保 j1 不优于 j,则上述两性质不能同时成立。

所以,如果要确保 j(0j<i) 能成为最优决策,则除了 k=j+1iA[k]m 外,还要满足以下两个性质中任意一个:

  1. k=jiA[k]>m(即 j 是满足k=j+1iA[k]m 中最小的 j
  2. A[j]=maxjki{A[k]}

如何维护这两个条件:

  1. 只需预处理出对于每个 i,满足 k=j+1iA[k]m 中最小的 j,即为 c[i],在计算 f[i] 时对 c[i] 进行转移。
  2. 当一个新决策 j2 进入候选集合时,若候选集合中有一个 j1 满足 j1<j2A[j1]A[j2],则 j1 可悲排除。

综上所述,只需维护一个 j 单调递增, Aj 单调递减的队列,只有队列中的元素可能称为最优决策。

但转移方程中的 f[j]+maxj+1kiAk 没有单调性,不能取对头作为最优决策,而是要使用额外的数据结构(如 multiset),它与单调队列保存相同的候选集合,不过它以 f[j]+maxj+1kiAk 作为排序的一句,保证能快速查询最值。

最后,关于 maxj+1kiAk 有两种计算方式:

  1. 使用 ST 算法预处理,O(1) 查询。
  2. 单调队列中某一项的 maxj+1kiAk 的结果就是单调队列中下一个元素的 A 值。

时间复杂度为 O(nlogn)

#include<bits/stdc++.h>
using namespace std;
typedef long long ll;
const int N=1e5+9;
const ll INF=0x3f3f3f3f3f3f3f3f;
struct node{
ll id,x,y;
bool operator<(const node&a)const{
return x+y>a.x+a.y;
}
};
ll n,m,lft=1,rght=0,q[N<<3],a[N],sum[N],lg[N],st[N][30],c[N],f[N];
bool vst[N];
priority_queue<node> qe;
void init(){
lg[1]=0;
for(int i=2;i<=n;i++) lg[i]=lg[i>>1]+1;
for(int i=1;i<=n;i++) st[i][0]=a[i];
for(int i=1;i<=lg[n];i++){
for(int j=1;j<=(n-(1<<i)+1);j++){
st[j][i]=max(st[j][i-1],st[j+(1<<(i-1))][i-1]);
}
}
}
ll query(ll l,ll r){
ll len=lg[r-l+1];
return max(st[l][len],st[r-(1<<len)+1][len]);
}
int main(){
ios::sync_with_stdio(false);
cin>>n>>m;
for(int i=1;i<=n;i++){
cin>>a[i];
if(a[i]>m){cout<<-1;return 0;}
sum[i]=sum[i-1]+a[i];
}
init();
c[0]=1;
for(int i=1;i<=n;i++){
for(int j=c[i-1];j<=n;j++){
if(sum[i]-sum[j-1]<=m){c[i]=j;break;}
}
}
for(int i=1;i<=n;i++){
f[i]=min(INF,f[c[i]-1]+query(c[i],i));
while(lft<=rght&&sum[i]-sum[q[lft]]>m){vst[q[lft]]=1;lft++;}
while(lft<=rght&&a[q[rght]]<a[i]){vst[q[rght]]=1;rght--;}
q[++rght]=i;
if(lft<rght) qe.push((node){q[rght-1],f[q[rght-1]],a[i]});
while(!qe.empty()&&(vst[qe.top().id]||qe.top().y<query(qe.top().id+1,i))) qe.pop();
if(!qe.empty()) f[i]=min(f[i],qe.top().x+qe.top().y);
}
cout<<f[n];
return 0;
}

例题 3 P2627

f[i] 表示前 i 个数的最大子序列之和,有状态转移方程:

f[i]=maxikji{f[j1]+sum[i]sum[j]}

i 看作定值,上述方程变形为 f[i]=maxikji{f[j1]sum[j]}+sum[i]

可以用单调队列优化 ikji 中最大的 f[j1]sum[j],即维护一个递减的单调队列

#include<bits/stdc++.h>
using namespace std;
typedef long long ll;
int l=0,r=1,n,k,e[100009],q[100009];
ll sum[100009],f[100009],g[100009];
int main(){
ios::sync_with_stdio(false);
cin>>n>>k;
for(int i=1;i<=n;i++) cin>>e[i];
for(int i=1;i<=n;i++) sum[i]=sum[i-1]+e[i];
for(int i=1;i<=n;i++){
g[i]=f[i-1]-sum[i];
while(l<=r&&q[l]<i-k) l++;
while(l<=r&&g[q[r]]<g[i]) r--;
q[++r]=i;
f[i]=g[q[l]]+sum[i];
}
cout<<f[n];
return 0;
}

总结

当状态转移方程形如 f[i]=minL(i)<i<R(i){f[i]+val(i,j)},且:

  1. 决策 j 的取值范围 L(i)R(i) 是关于变量 i 的一次函数且具有单调性,即窗口长度保持不变。
  2. 优化的关键部分 val(i,j) 是关于变量 ij 的多项式函数,

就可以使用单调队列进行优化。

一般而言,val(i,j) 至少可以分为两部分:

  1. 对于第一部分仅与 i 相关,无论采取哪个 j,第一部分均相等,这样可以选出最优决策后再累加。
  2. 对于第二部分仅与 j 相关,当 i 发生改变时不会发生变化,这样保证原来较优的决策能保持最有,这样可以保持单调队列的单调性,及时排除不可能的决策。
posted @   11jiang08  阅读(39)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· 物流快递公司核心技术能力-地址解析分单基础技术分享
· 单线程的Redis速度为什么快?
· 展开说说关于C#中ORM框架的用法!
· Pantheons:用 TypeScript 打造主流大模型对话的一站式集成库
· SQL Server 2025 AI相关能力初探
点击右上角即可分享
微信分享提示