单调队列优化DP总结
单调队列优化 \(DP\),就是利用单调性在 \(O(1)\) 的时间内进行状态转移,可以极大的提高程序的运行效率。
前置芝士
单调队列,顾名思义,就是满足队列中的元素要么单调递增,要么单调递减。运用单调队列可以在 \(O(1)\) 的时间里求出区间的最值。
滑动窗口(单调队列模板题)
这道题是单调队列最简单的应用。值得一提的是,单调队列维护区间最小值时要使队列元素单调递增;维护区间最大值的时候要使队列元素单调递减。这个性质比较显然,就不做过多说明了。
关于单调队列有一个很有趣的梗,如果一个 \(OIer\) 比你学的时间短,还比你强,那你就要被 \(q.pop__back()\) 了。没错,这个“你”指的就是蒟蒻本人。其实也可以发现,在 \(queue\) 头文件的队列中,是不存在 \(q.pop__back()\) 这种操作的,于是就需要用到 \(deque\) ,也就是双端队列。其实也可以用数组模拟,但是蒟蒻还是更习惯用 \(STL\)。
其实上面的这个梗在本题中可以这样解释,对于 \(i<j\) 且 \(x_i<x_j\) ,那么 \(i\)
就必然无法成为最优解了。虽然说在一些右端点 \(\le j-1\) 的区间,\(x_j\) 显然无法成为最大值。但是这些的区间最大值已经输出过了。于是就可以愉快地 \(q.pop__back(i)\) 了。
关于以上的瞎扯到此为止,现在进入正题。
琪露诺(单调队列优化 \(DP\) 入门题)
好像本题是所有初学者的入门题。。。。
题意
有一列格子依次编号为 \(0\)~\(N\) ,每个格子都有一个数值(可能为负数),起点在 \(0\) 号格子,每次跳跃最多跳 \(r\) 个格子,最少跳 \(l\) 个格子。每跳到一个格子上,就会加上这个格子的数值(包括起点,但起点的数值一定为 \(0\) ),终点为\([n-r+1,n]\) 中的任意一个格子。求最终能得到最大的数值之和。
思路
感觉蒟蒻题意说的不是很清楚。。。可以去看一看题目描述。
首先可以看出这是一道 \(dp\) 题,因为本题显然不满足局部最优解可以推出整体最优解的贪心性质。
定义 \(f[i]\) 表示跳到第 \(i\) 个格子时的最大数值之和,那么状态转移方程就可以很容易得出:
\(f[i]=min(f[j]+a[i])\),\((i-r \leq j \leq i-l)\)。
最终的答案为 \(max(f[i])\),\((n-r+1 \leq i \leq n)\)。
这样就可以愉快地 AC TLE 本题了。因为本题的时间复杂度显然不允许上界为 \(O(n^2)\) 的程序通过。
于是就要用到单调队列优化。
观察一下上面的状态转移方程。显然可以将 \(a[i]\) 移到括号外面去,因为在求\(f[i]\) 时,\(a[i]\) 为一个常数。(步骤一:分离常数)
设 \(f[j_0]\) 为当 \((i-r \leq j \leq i-l)\) 时 \(f[j]\) 的最大值。那么显然,\(f[i]=f[j_0]+a[i]\)。现在要做的就是维护 \(f[i]\) 的区间最大值,于是就用到了单调队列。(步骤二:确定维护元素)
由于本题中 \([1,l-1]\) 这几个点显然无法被跳到,于是直接从 \(i=l\) 开始枚举。每次枚举之前都有一个新的值能成为区间最大值,也就是 \(f[i-l]\) 。
由于是入门题,这里简单解释一下。在枚举 \(i-1\) 的时候,\(f[i-1]\) 能从\([i-1-r.i-1-l]\) 转移过来,而现在往前进了一个格子(非跳跃),那么能转移过来的区间就变成了 \([i-r,i-l]\) ,那么\(f[i-l]\) 就可以成为被转移的对象了。同时比 \(f[i-l]\) 小的队中元素就可以出队了。因为\(f[i-l]\) 无法转移到的状态在此之前已经被转移了,所以接下来会被转移的状态中能被队中元素转移的,也就一定能够被 \(i-l\) 转移了。那么比 \(f[i-1]\) 小的就没有存在意义了。同时,如果有一个老大哥特别大,比以后进队的所有元素都要大,设这位老大哥就稳居队首,但是一旦 \(i\) 往后转移到 \(i-q.front() \ge r+1\) 时,那么这位老大哥就无法转移了,此时就要 \(q.pop__front()\) 了。(步骤三:队首队尾元素出队判断)
最后,有负数的原因,\(ans\) 的初值要设为 \(-\infty\) 。
以上就是蒟蒻总结出来的单调队列优化基本步骤。当然肯定有亿些例外。
code:
#include<bits/stdc++.h>
using namespace std;
const int N=2e5+10;
int f[N],ans=-999999999,n,l,r,a[N];
deque<int> q;
int main()
{
memset(f,128,sizeof(f));
f[0]=0;
cin>>n>>l>>r;
for(int i=0;i<=n;i++) cin>>a[i];
for(int i=l;i<=n;i++)
{
while(q.size()&&f[q.back()]<=f[i-l]) q.pop_back();
q.push_back(i-l);
while(q.size()&&q.front()<i-r) q.pop_front();
f[i]=f[q.front()]+a[i];
if(i+r>n) ans=max(ans,f[i]);
}
cout<<ans<<endl;
return 0;
}
切蛋糕(入门题+1)
题意
给定 \(n\) 个数字,求出区间长度不超过 \(m\) 的最大连续子段。
思路
朴素的做法。首先用前缀和记录下前 \(i\) 个数字之和。先枚举区间的左端点 \(l\)
,然后不断枚举 \(r\) ,直到 \((r-l \geq m+1)\)位置,每一次用 \((sum[r]-sum[l-1])\) 去更新答案。
这种做法的复杂度是 \(O(nm)\) 的,所以需要用到单调队列优化,
同样对于上面的答案转移,当 \(r\) 确定的时候,\(sum[r]\) 就也是一个常数。于是接下来就是要维护 \([r-l,r-1]\) 中最小的 $sum[i] $ 这样得到的答案一定是在 \(r\) 一定时最优的,就省去了枚举端点的时间,时间复杂度也就降到了 \(O(n)\)。
code:
#include<cstdio>
#include<deque>
using namespace std;
const int N=5e5+10;
const int INF=0x3f3f3f3f;
int ans=-INF,n,m,a[N],sum[N];
deque<int> q;
int main()
{
// freopen("233.in","r",stdin);
scanf("%d%d",&n,&m);
for(int i=1;i<=n;i++) scanf("%d",&a[i]),sum[i]=sum[i-1]+a[i];
q.push_back(0);
for(int i=1;i<=n;i++)
{
while(q.size()&&i-q.front()>m) q.pop_front();
ans=max(ans,sum[i]-sum[q.front()]);
while(q.size()&&sum[q.back()]>sum[i]) q.pop_back();
q.push_back(i);
}
printf("%d\n",ans);
return 0;
}
宝物筛选(单调队列优化多重背包)
题意
给定 \(n\) 种物品,每种物品都有一个价值 \(w_i\)、体积 \(v_i\) 和数量 \(s_i\)。给定一个容量为 \(V\) 的背包,求最高的价值。
思路
经典的单调队列优化多重背包模板题。对于一般的状态转移方程:
\(f[j]=min(f[j],f[j-k*v_i]+k*w_i)\) \((1 \leq k \leq s_i)\) 。
时间复杂度为 \(O(nms)\) ,有些良心出题人甚至会将物品的数量开的特别大,但是体积有特别小,于是朴素的状态转移方程的上界就会达到 \(O(nm^2)\) 级别。显然无法通过本题。
首先,看下面这一组例子。
V=13 w=2 v=3 s=3;
f[4]=min(f[4],f[4-3*1]+3*1);
f[7]=min(f[7],f[7-3*1]+3*1,f[7-3*2]+3*2);
f[10]=min(f[10-3*1]+3*1,f[10-3*2]+3*2,f[10-3*3]+3*3);
发现左边的数 \(i\) 有什么规律了吗?没错,这些数除以 \(v\) 的余数都相同,并且他们能够被转移的状态也是同余的。也就是说,对于每一个余数 \(r\) \((0 \leq r \leq v-1)\) ,\(f[k*v+r]\) 能够转移的状态余数必然也是 \(r\) ,也就是:
\(f[k*v+r]=max(f[t*v+r])+w*(k-t))\) \((k-s \leq t \leq k-1)\)。
于是就可以对每一种余数分别使用单调队列优化。同样分离出转移方程右边的常数。于是就需要维护一个 \(f[t*v+r]-w*t\) 单调递减的队列。但是注意,如果队列中只存储下表,在出队的时候直接判断:
\(f[q.back()*v+r]-w*q.back() \leq f[k*v+r]-w*k\) ,会得到一个错误的答案。
这是为什么呢? 因为进队时的 \(f[k*v+r]\) 其实是 \(f[i-1][k*v+r]\) ,而到了出队时就极有可能已经将 \(f[i-1][k*v+r]\) 的值转移成 \(f[i][k*v+r]\) 了,也就是说在出队的时候判断 \(f[k*v+r]\) 时可能其中的状态中已经包含了现在枚举的第 \(i\) 个物品。而我们需要判断的则是不包含 \(i\) 的\(f[i-1][k*v+r]\) 。这也是在初学01背包和完全背包时最容易写错的地方。
最后,关于本题的细节。由于 \(v_i\) 可能为 \(0\) ,那么此时就可以直接全部放进背包中并记录下来(\(\approx\) 白嫖)。
code:
#include<cstdio>
#include<deque>
using namespace std;
const int N=40020;
deque<int> q;
int n,m,v,w,s,f[N],d[N],ans;
int max(int a,int b){return a>b?a:b;}
int min(int a,int b){return a<b?a:b;}
int main()
{
scanf("%d%d",&n,&m);
for(int i=1;i<=n;i++)
{
scanf("%d%d%d",&w,&v,&s);
if(v==0)
{
ans+=w*s;
continue;
}
s=min(s,m/v);
for(int j=0;j<v;j++)
{
q.clear();
d[0]=f[j];
q.push_back(0);
for(int k=1;k<=(m-j)/v;k++)
{
d[k]=f[k*v+j]-w*k;
if(q.size()&&k-s>q.front()) q.pop_front();
if(q.size()) f[k*v+j]=max(f[k*v+j],d[q.front()]+w*k);
while(q.size()&&d[q.back()]<d[k]) q.pop_back();
q.push_back(k);
}
}
}
printf("%d\n",f[m]+ans);
return 0;
}
/*
V=13 w=2 v=3 s=3;
f[4]=min(f[4],f[4-3*1]+3*1);
f[7]=min(f[7],f[7-3*1]+3*1,f[7-3*2]+3*2);
f[10]=min(f[10-3*1]+3*1,f[10-3*2]+3*2,f[10-3*3]+3*3);
*/

浙公网安备 33010602011771号