背景——引入单调队列
滑动窗口
分析
对于这样一个问题,我们采用单调队列来解决。那么什么是单调队列。
如其名就是具有单调性的队列。维护这样一个队列的好处是,当我们想求最大值的时候,我们只需要保证队列是单调递增的,于是每次取出队头就是我们的最大值。比起直接遍历,极大提高了效率。接下来我们以滑动窗口这个例子来一起了解一下具体怎么实现。
首先我们定义一个数组用来存储元素的下标。之后第一步就是把第一个元素的下标存储进去,然后依次存储下一个元素的下标。这里我们先以滑动窗口的最小值为例子往下讲解。 第一个元素下标进队列,此时队列为,因为储存的是下标。对应的值为 接下来第二个元素,为3,比队列中的上一个元素大,能维持从尾到头单调递减,入队 于是队列为,对应的值为
然后第三个元素,为-1,比上一个元素小,由于要维持单调递减,于是上一个元素就得出队 于是就变成了,其对应的值也就变成了,但是现在-1还不能着急入队,因为此时进去还是 不能满足单调性,于是我们需要把1(元素的值)出队,于是变成了,满足条件,入队,其列 应该变为,对应的值为,此时由于滑动窗口已经有三个元素了,于是我们需要输出队 头元素
接下来第四个元素,为-3,根据上述流程,,为了维持单调性,出队,然后再 入队,于是队列为,队中元素为,同时我们需要输出
后续依次类推即可。
细节处理
需要注意的是,我们需要将这个单调队列的大小维护在这个大小,因为这个滑动窗口是不断移动的。如下图,
当我们滑动窗口的区间为数字之间,根据上述单调性入队出队规则,队列中的元素应该为,对应的值应该为,接下来我们考虑滑动窗口后移动
根据上述规则,此时6应该入队,此时队列应该变为,对应的值为,输出队头元素对应的值,是不是不太对了,3已经划走了,怎么还会输出3呢?
他都已经划走了,那就不关他的事了,将他从队头丢出去,所以我们这个队列最多维护个元素。此时队列变为,对应的值为,输出队首,正确!
代码
#include<iostream>
#include<algorithm>
using namespace std;
const int N = 1e6 + 10, M = 100010;
int a[N], q[M];
int n, k;
int main()
{
scanf("%d%d", &n, &k);
for (int i = 0; i < n; i++)
scanf("%d",&a[i]);
int hh = 0, tt = 0;
for (int i = 0; i < n; i++)
{
while (hh <= tt && i - k + 1 > q[hh])
hh++;
while (hh <= tt && a[i] < a[q[tt]])
tt--;
q[++tt] = i;
if (i >= k - 1)
cout << a[q[hh]] << " ";
}
puts(" ");
//最大值即再维护依次一次单调性即可
hh = 0, tt = 0;
for (int i = 0; i < n; i++)
{
while (hh <= tt && i - k + 1 > q[hh])
hh++;
while (hh <= tt && a[i] > a[q[tt]])
tt--;
q[++tt] = i;
if (i >= k - 1)
cout << a[q[hh]] << " ";
}
}
单调队列优化dp
对于一个优化问题,我们首先可以先想一下他的暴力做法,然后在看一下是否可以优化。
对于单调队列优化的 dp 问题,一般满足在一个可动区间求最大值或者最小值。如果暴力枚举那个区间,一般会超时,于是我们可以选择将最大值放入队头的最大值或者最小值,通过这样维护我们可以减去暴力枚举的过程,优化了时间复杂度。接下来我们看来两个例题来熟悉一下。
琪露诺
分析
定义状态 f[i] 表示从 i 这个点开始往后跳获得的最大冰冻指数
此时 ;
,此时
我们先想到暴力的做法,就是枚举 j 的取值范围,依次记录下最大值,但是对于这样一个滑动区间的最大值,
由此可以利用单调队列的优化,对于这样的问题我们可以把我们最大的元素放在队头
代码
//定义状态f[i]表示从i这个点开始往后跳获得的最大冰冻指数
//f[i]=max(f[j])+a[i];此时i+l<=j<=i+r;
//f[i+1]=max(f[j])+a[i],此时a+l+1<=j<=i+r+1
//由此可以利用单调队列的优化,我们储存最大的元素放在队头
#include<iostream>
#include<algorithm>
#include<cstring>
using namespace std;
const int N=2e5+10;
int a[N],q[N],f[N];
int hh,tt,n,r,l,ans=-1e9;
int main()
{
cin>>n>>l>>r;
for(int i=0;i<=n;i++)
cin>>a[i];
memset(f,0xcf,sizeof f);
f[0]=0;
hh=0,tt=0;
for(int i=0;i<=n-l;i++)
{
while(q[hh]<=i-(r-l+1)&&hh<=tt)//维护滑动窗口
hh++;
while(hh<=tt&&f[q[tt]]<f[i])//队列末尾的元素小于待插入的元素,那么他就没有存在的意义了
tt--;//让其出队
q[++tt]=i;//将下标入队
f[i+l]=f[q[hh]]+a[i+l];
}
for(int i=n-r+1;i<=n;i++)
ans=max(ans,f[i]);
cout<<ans;
return 0;
}
切蛋糕
分析
同样的我们先来思考一下暴力做法,实际上我们要求的是这个区间和的最大值,于是我们可以利用前缀和的知识,我们先求出,再求出这样只需要两个式相减,即,我们便得到了区间的和,然后我们依次枚举,记录下其中的最大值即可。这样的时间复杂度是的。于是我们接下来对他进行优化。
我们选择将其中一个数字固定,当 确定的时候,就也是一个常数。于是接下来就是要维护中最小的 这样得到的答案一定是在一定时最优的,就省去了枚举端点的时间,时间复杂度也就降到了。
代码
#include<iostream>
#include<algorithm>
#include<cstring>
using namespace std;
const int N=5e5+10;
int a[N],q[N],sum[N];//q[]储存下标
int hh,tt,ans=-1e9;
int main()
{
int n,m;
cin>>n>>m;
for(int i=1;i<=n;i++)
cin>>a[i],sum[i]=sum[i-1]+a[i];//预处理出前缀和
hh=0,tt=0;
for(int i=1;i<=n;i++)
{
while(hh<=tt&&i-q[hh]>m)//即最大区间只能是m,超过了就要往后滑动了
hh++;
while(hh<=tt&&sum[q[tt]]>sum[i])//这里要维护一个单调递减的队列,下式说明了是减去
tt--;
ans=max(ans,sum[i]-sum[q[hh]]);
q[++tt]=i;//下标入队
}
cout<<ans;
return 0;
}
例题
Mowing the Lawn G
分析
由于不能连续选择超过的牛,于是我们可以定义状态表示第头牛选还是不选
转移前一个选还是不选的最大值
从到区间,表示到区间选了的,但由于我们的是不选的,于是就变成了 此时j的取值范围是
;于是我们只需要用单调递增的队列维护max中的值
代码
#include<iostream>
#include<algorithm>
#include<cstring>
using namespace std;
typedef long long ll;
const int N=1e5+10;
int a[N],q[N];
ll sum[N],f[N][2];
int main()
{
int n,m;
cin>>n>>m;
for(int i=1;i<=n;i++)
cin>>a[i],sum[i]=sum[i-1]+a[i];
int hh=0,tt=0;
for(int i=1;i<=n;i++)
{
f[i][0]=max(f[i-1][0],f[i-1][1]);
while(hh<=tt&&i-q[hh]>m)
hh++;
f[i][1]=f[q[hh]][0]-sum[q[hh]]+sum[i];
while(hh<=tt&&f[i][0]-sum[i]>f[q[tt]][0]-sum[q[tt]])
tt--;
q[++tt]=i;
}
cout<<max(f[n][1],f[n][0]);
return 0;
}
PTA-Little Bird
分析
题目大体意思,如果飞到的树的高度小于当前树的高度,不管中间有多高,都不会增加劳累值,否则每次加 1
假设现在在 i 位置上,想要飞到 j 位置上,定义 为劳累度
于是 ,从 到
劳累值越小越有利,所以我们需要维护一个 单调递减的队列
除此之外,当和队尾相同时,也就是不增加劳累度的时候,如下图,
如上图,可以跳到 3−7 这个区间,跳到 3 和 4 的时候,都是消耗劳累度的,但是我们选择哪个更好呢,当然是越大的越好,因为高度越高,下一次越有利,如上图,当我们跳到 4 ,下一次跳到 3 的时候就不增加劳累度。但是当我们第一次跳到 3 的时候(紫色部分),再跳到后面的 3 增加劳累度,于是当 时候我们还需维护 d 的单调增加找出最大值。
代码
#include<iostream>
#include<algorithm>
#include<cstring>
using namespace std;
const int N=1e6+10;
int a[N],q[N],f[N];
int main()
{
int n;cin>>n;
for(int i=1;i<=n;i++)
cin>>a[i];
int t;cin>>t;
while(t--)
{
int m;cin>>m;
int hh=1,tt=1;q[tt]=1;//由于是从1开始的,所以得先把1给初始化进去
for(int i=2;i<=n;i++)
{
while(hh<=tt&&i-q[hh]>m)
hh++;
f[i]=a[q[hh]]>a[i]?f[q[hh]]:f[q[hh]]+1;
while(hh<=tt&&(f[i]<f[q[tt]]||(f[i]==f[q[tt]]&&a[q[tt]]<=a[i])))//维护一个单调递减的的f和一个单调增的a,因为高度越高,下一次越有利
tt--;
q[++tt]=i;
}
cout<<f[n]<<endl;
}
}
WIL
分析
代码
//选择不超过d大小区间将其全部置为0,于是我们首先想到贪心,我们只要选择这样的一个大小为d且区间和最大的区间即可。于是我们可以通过枚举算出sum[x]-sum[x-d]的最大值,我们需要用队列维护这个最大值
#include<iostream>
#include<algorithm>
#include<cstring>
using namespace std;
typedef long long ll;
const int N=2e6+10;
int n,d,l,hh=0,tt=0,q[N];
ll p,sum[N],a[N];
int main()
{
cin>>n>>p>>d;
for(int i=1;i<=n;i++)
{
cin>>a[i];
sum[i]=sum[i-1]+a[i];
}
int ans=d;q[tt]=d;l=1;//初始化最长区间为d
for(int i=d+1;i<=n;i++)
{
// while (hh <= tt && i - d > q[hh]) {
// hh++;
// }
while(hh<=tt&&sum[i]-sum[i-d]>sum[q[tt]]-sum[q[tt]-d])
tt--;//维护单调递增的队列
q[++tt]=i;
//然后维护这个不超过p的最大区间,这里i就代表了r
while(hh<=tt&&sum[i]-sum[l-1]-sum[q[hh]]+sum[q[hh]-d]>p)//因为队头是最大值
{
l++;//不符合了区间太大了,于是缩小
while(hh<=tt&&l-q[hh]+1>d)
++hh;//维护单调队列的长度
}
ans=max(ans,i-l+1);
}
cout<<ans;
return 0;
}