初步感知
单调队列的含义
(一)定义
单调队列(Monotonic Queue)是一种特殊的数据结构,它是一个队列,并且队列中的元素具有单调性。这种单调性可以是单调递增或者单调递减或呈现出其他的样子。例如,在一个单调递减的队列中,队首元素是队列中最大的元素,并且随着元素的入队和出队操作,队列始终保持递减的性质。
(二)示例说明
假设我们有一个数列[5, 3, 4, 1, 2]
,如果我们要构建一个单调递减的队列,且要保证队首维护最大值的话,就有以下操作。
- 开始时,队列空,当5进入队列,队列中只有一个元素5。
- 接着3要入队,因为3 < 5,为了保持单调递减的性质,5出队,3入队,此时队列中元素为3。
- 当4入队时,4 > 3,3不出队,4入队,队列变为
[3, 4]
。 - 对于1入队,4和3依次出队,1入队,队列变为1。
- 最后2入队,1出队,队列变为2。
从这个过程可以看出,单调队列能够动态地维护队列元素的单调性。
但是他有什么用处呢?
深入思考
解决的问题类型
滑动窗口问题
给定一个数组和一个固定大小的滑动窗口,要求在窗口滑动过程中,快速求出窗口内的最大值或最小值等统计信息。例如,在一个长度为 n n n 的数组 n u m s nums nums 中,有一个大小为 k k k 的滑动窗口,需要求出每个滑动窗口内的最大值。
如果使用暴力解法,对于每个滑动窗口,需要遍历窗口内的 k k k 个元素来找到最大值,时间复杂度为 O ( n × k ) \cal{O(n \times k)} O(n×k)。而使用单调队列可以将时间复杂度降低到 O ( n ) \cal{O(n)} O(n) (原理后面再说)。
动态规划中的优化问题
在一些动态规划问题中,状态转移方程可能涉及到一个区间内的最优值选择。单调队列可以帮助我们高效地维护这个区间内的最优值,从而优化动态规划的计算过程。
如何写单调队列
其实对于单调队列而言,只有两个点需要考虑------一个是入队,一个是出队,只要我们把这两个点给考虑周全了,就没有问题了
入队操作思路
这里又要考虑两个点了,而且非常重要!(敲黑板)
维护单调性
当一个新元素要入队时,我们需要检查队列的单调性是否会被破坏 。如果是单调递减队列,且新元素大于队尾元素,那么就需要将队尾元素依次出队,直到新元素可以入队而不破坏单调性。
例如,在单调递减队列中,队列为[5, 3, 2]
,当元素 4 要入队时,因为 4 > 2,所以 2 出队,此时队列变为[5, 3]
,由于 4 > 3,3 也出队,队列变为[5]
,最后 4 入队,队列变为[5, 4]
。
记录有效信息
单调队列中的元素不仅要保持单调性,还需要记录有效的位置信息 。在滑动窗口问题中,我们通常会记录元素在原始数组中的下标。这样,当窗口滑动时,我们可以根据下标的范围来判断队列中的元素是否还在当前窗口内。
例如,在一个长度为 n n n 的数组上有一个大小为 k k k 的滑动窗口,队列中存储元素的下标。当窗口滑动时,我们可以通过检查下标的范围来确定元素是否已经滑出窗口。如果队首元素的下标小于当前窗口的起始下标,那么队首元素就已经不在当前窗口内,需要出队。
cpp
int tail=-1;
int a[INF],v[INF];//v为单调队列
while (tail>=0&&a[i]>a[v[tail]])tail--;
v[++tail]=i;//记录下标
出队操作思路
窗口滑动导致的出队
在滑动窗口问题中,当窗口滑动时,可能会导致队首元素离开窗口 。我们需要检查队首元素的位置信息,判断它是否还在窗口内。如果不在,就将队首元素出队。
例如,在一个大小为 3 的滑动窗口从数组[1, 2, 3, 4, 5]
的第一个位置开始滑动,初始时单调递减队列可能存储了元素[3, 2, 1]
及其下标。当窗口向右滑动一个位置时,元素 1 的下标已经不在当前窗口范围内,所以 1 需要从队首出队。
新元素入队导致的出队
就像前面说的,当新元素入队时,为了维护队列的单调性,可能会导致队尾元素出队。这种出队操作是为了确保队列始终保持单调的性质。
例如,在单调递减队列中,队列为 [6, 4, 3]
,当元素 5 要入队时,为了保持单调递减,3 和 4 需要依次出队,然后 5 入队,队列变为[6, 5]
。
cpp
while (tail>=head&&v[head]-i>m)head++;
例题实战
例题1
给定一个数组,再给定三个操作:
1 1 1 x x x:在数组末尾插入一个数 x x x
2 2 2:删除数组中的第一个数,如果数组为空则忽略。
3 3 3:查询当前数组中的最大值,并输出。
初始时数组为空。
解析
这是一道非常裸的单调队列的题目,把题目翻译一下就是要求实现一个可以进行特定操作的数据结构模拟,通过给定的三种操作来对一个初始为空的数组进行动态处理,并能随时查询当前数组中的最大值。整体解题思路围绕着如何高效地维护数组中的元素以及快速获取最大值展开,而单调队列在这里就起到了关键作用。
当接收到操作 1 时,意味着要向数组末尾插入一个新的数 ,此时就是对单调队列进行操作的时机。因为新插入的元素可能会影响到当前数组(从单调队列角度看就是队列所代表的元素集合)中的最大值情况,所以需要及时更新单调队列来维持其能正确反映数组最大值的功能,因此只要队尾元素小于即将插入的新元素,就将队尾元素出队(通过 tail--
操作),把那些比新元素小的、不可能再成为最大值的元素从队列中移除掉。然后再将新元素 通过 v[++tail]=x[cnt]
插入到队尾,这样就保证了队列在插入新元素后依然保持单调递减的特性,从而队首元素始终为当前数组中的最大值。
当接收到操作 2 时,即要删除数组中的第一个数,这时需要考虑对单调队列进行相应调整,因为数组的元素状态发生了改变,单调队列所代表的元素集合也需要同步更新,以确保其能继续准确反映数组的最大值情况。
首先进行了一些边界判断,如 if (st==cnt+1)continue; 来处理数组为空等特殊情况。重点在于当要删除的数组中的第一个数(即 x[st]
)恰好等于单调队列的队首元素(即 v[head]
)时,说明当前代表最大值的元素要被移除了,那么就需要通过 head++
操作将队首元素出队,使单调队列更新到下一个可能为最大值的元素成为队首,以此来保持与数组元素删除操作后的状态一致性,让单调队列依然能正确体现剩余数组元素中的最大值情况。
ACcode
cpp
#include<bits/stdc++.h>
using namespace std;
const int INF=2e6+10;
int v[INF],x[INF],head=0,tail=-1;
int st=1,cnt;
int main(){
int n;
scanf("%d",&n);
for (int i=1;i<=n;i++){
int opt;
scanf("%d",&opt);
if (opt==1){
scanf("%d",&x[++cnt]);
while (tail>=head&&v[tail]<x[cnt])tail--;
v[++tail]=x[cnt];
}else if (opt==2){
if (st==cnt+1)continue;
if (x[st]==v[head]){
head++;
}
st++;
}else {
printf("%d\n",v[head]);
}
}
return 0;
}
例题2
wwx家里有一个长度为 N N N 的衣柜,里面装有透明程度不同的黑丝,现在衣柜上只有一个长度为 K K K 的滑动窗体从最左端移至最右端,wwx只能看到窗口中的 K K K 个数,每次窗体向右移动一位,都会有一条新的黑丝出现,一条之前已经出现过的黑丝消失。
因为wwx在每次能选择的时候要光着脚来拍视频给你看,所以你想知道每个窗口内透明程度最高的黑丝是那一条,并输出它的透明程度,这样才能使你和wwx的关系更近一步。
解析
这道题也是非常裸的滑动窗口的题目,本质上就是一个滑动窗口求最值的问题,给定一个长度为 N N N 的数列(代表衣柜里黑丝的透明程度序列),有一个长度为 K 的滑动窗口从左至右移动,要求在每个窗口位置找出该窗口内透明程度最高的黑丝对应的透明程度值。
整体思路是利用单调队列这个数据结构来高效地维护窗口内的最值情况。单调队列在这里用于保持队列中的元素呈现一种单调性(在本题中是从队头到队尾元素值单调递减,以方便快速找到最大值),随着窗口的滑动动态更新最值信息,避免了每次窗口移动都去遍历窗口内所有元素来求最值这种低效的做法。
每次窗口向右移动一位,有新元素进入窗口范围时,就需要执行入队操作。也就是在代码中遍历输入数列的循环里,当 i i i 从 1 1 1 开始逐步增加到 n n n 的过程中,每一次遇到新的元素 a [ i ] a[i] a[i] 且当前窗口还未完全滑过整个数列时(即 i i i 的取值范围在合理区间内),都要调用 push
函数将这个新元素尝试加入到单调队列中。例如,最开始先对窗口内初始的 k k k 个元素依次执行入队操作,后续随着窗口继续右移,每出现一个新元素也执行同样的入队操作。
当窗口向右移动,最左边的元素要移出窗口范围时,就需要执行出队操作。我们可以通过一个指针 st
来标记窗口最左边元素的位置,每当窗口整体右移一位(也就是新元素入队后),要检查当前窗口最左边的元素(即 a[st]
)是否恰好是单调队列中队头对应的元素,如果是,就说明这个元素要随着窗口移动而移出窗口范围了,此时就需要将队头元素出队,并且同时更新窗口最左边元素的标记指针,使得后续操作对应的窗口范围始终是正确的。
如何进行操作
通过这样合理地在窗口滑动过程中适时进行单调队列的入队和出队操作,就能高效地维护每个窗口内的最大值情况,最终输出每个窗口对应的最大值(即透明程度最高的黑丝的透明程度值)。
ACcode
cpp
#include<bits/stdc++.h>
using namespace std;
const int INF=1e6+10;
int a[INF],vmi[INF],vmx[INF];
int ans1[INF],ans2[INF];
int t1=-1,h1=0,t2=-1,h2=0,st=1;
void push(int i){
while (t2>=h2&&vmx[t2]<a[i])t2--;
vmx[++t2]=a[i];
return;
}
void pop(){
if (a[st]==vmx[h2]){
h2++;
}
st++;
}
int main(){
int n,k;
cin>>n>>k;
for (int i=1;i<=n;i++){
cin>>a[i];
}
for (int i=1;i<=k;i++){
push(i);
}
ans2[k]=vmx[h2];
for (int i=k+1;i<=n;i++){
push(i);
pop();
ans2[i]=vmx[h2];
}
for (int i=k;i<=n;i++){
cout<<ans2[i]<<" ";
}
return 0;
}
例题3
今天是yjq的约会日,wwx同意给他穿他喜欢的衣服。wwx家里有一个长度为 N N N 的衣柜,里面装有性感程度不同的衣服,譬如黑丝,白丝,jk等(请自行脑补),
yjq作为wwx的boyfriend,自然希望wwx的性感程度总和最大,但wwx最多又只能穿 m ( m < n ) m(m<n) m(m<n) 件衣服。
请你帮他从这 n n n 件衣服中找出连续 的 k ( 1 ≤ k ≤ m ) k(1 \le k\le m) k(1≤k≤m) 件衣服,使得其上的总性感程度最大。
形式化地 ,在数列 { p n } \{p_n\} {pn} 中,找出一个子段 [ l , r ] ( r − l + 1 ≤ m ) [l,r](r-l+1\le m) [l,r](r−l+1≤m),最大化 ∑ i = l r p i \sum\limits_{i=l}^rp_i i=l∑rpi。
解析
这道题和上道题差不多,一个是求最大值,一个是求和最大,没有本质上的区别,所以就不过多赘述了。
ACcode
cpp
#include<bits/stdc++.h>
const int INF=1e6+10;
using namespace std;
int ans=INT_MIN,n,m;
int x[INF],sum[INF],l,r;
deque<int>q;
int main(){
cin>>n>>m;
for(int i=1;i<=n;i++){
cin>>x[i];
sum[i]=sum[i-1]+x[i];
}
q.push_back(0);
for(int i=1;i<=n;i++){
while(q.front()+m<i)q.pop_front();
if (ans<sum[i]-sum[q.front()]){
ans=sum[i]-sum[q.front()];
}
while(!q.empty()&&sum[q.back()]>=sum[i])q.pop_back();
q.push_back(i);
}
cout<<ans;
return 0;
}