单调队列
单调队列是用来干嘛的?
用处:求序列中所有固定长度区间的最值。
听不懂?直接看题吧。
洛谷模板题
单调队列就是用来快速解决上述问题的一个数据结构。
如果使用暴力,即两层循环枚举,时间复杂度显然是
O
(
n
k
)
O(nk)
O(nk),而使用单调队列只需要
O
(
n
)
O(n)
O(n)的时间复杂度即可解决。
单调队列是如何解决这个问题?
单调队列,顾名思义即队列中是单调的。
解决以上问题的关键也就在这两个词,”单调“和”队列“。
- 为什么是队列这个数据结构,而不是其他的数据结构?
- 如何维护队列的单调性?
- 为什么维护了队列的单调性就能解决这个问题?
大家应该和我一样,在刚接触单调队列时心里都应该有这样几个疑问。下面就一一解决这几个疑问。
我们先来将就着看一下这个单调队列的具体实现方法,再去研究它每一步的意义。
维护滑动窗口最小值的具体实现:
队尾的意义:滑动窗口所框住的最后一个元素。
队首的意义:滑动窗口在当前位置的最小元素。
队列中的元素:均为当前滑动窗口内的元素,但并不是所有在滑动窗口内元素都在队列中,元素信息包含值和在序列中的下标。
以下三步即为单调队列的执行过程:
- 从前往后扫描序列,设当前扫描到的元素为 n o w now now, n o w now now在序列中的下标为 n o w . i d x now.idx now.idx,当前队首为 h e a d head head,队尾为 t a i l tail tail
- 如果 n o w now now< t a i l tail tail则 t a i l tail tail出队,直到 n o w > = t a i l now>=tail now>=tail,或者队列为空, n o w now now入队。
- 如果 t a i l . i d x − h e a d . i d x + 1 > k tail.idx-head.idx+1>k tail.idx−head.idx+1>k, h e a d head head出队。
以下是
n
=
7
,
k
=
3
n=7,k=3
n=7,k=3 序列为
7
、
6
、
8
、
12
、
9
、
10
、
3
7、6、 8、12、9、 10、 3
7、6、8、12、9、10、3的模拟情况
步骤分析:
不难发现,因为步骤2的保证,所以队列中的元素始终是保持单调递增的。
那么为什么要这样做呢?我的理解是这是一种基于贪心思想的操作。
保留一个元素在队内是因为以下两种情况:
- 它之前可能是滑动窗口中的最小值;
- 在滑动窗口移动后可能成为最小值。
然而,由于 n o w < t a i l now<tail now<tail,对于队尾元素,这两种可能都不会存在。
- 对于第一种情况,由于 n o w < t a i l = h e a d now<tail=head now<tail=head,那么最小值显然应该更新为 n o w now now,所以 t a i l tail tail出队
- 对于第二种情况,设 n o w now now(设为 Y Y Y)入队前, t a i l tail tail(设为 X X X).由于此时滑动窗口已经包含到 n o w now now,所以当滑动窗口接着往后移,如果滑动窗口仍然包含 X X X,那么必定仍然包含 Y Y Y。而 Y < X Y<X Y<X,所以最小值不可能为 X X X。因此可以将 X X X出队。
而步骤3则是为了保证队列中的元素都是当前滑动窗口中的元素。
至于为什么要选择队列呢?大概就只是因为滑动窗口移动的过程正好符合队列先进先出的性质吧。
最后回顾以下所有的实现步骤,可以发现涉及了从队尾和队首出队,所以需要使用双端队列实现单调队列。
C++STL封装了deque,但是用数组模拟双向队列可以优化掉入队、出队以及队列初始化和清空的复杂度。
最后给出例题的参考代码、
#include <bits/stdc++.h>
#define maxn 1000100
using namespace std;
int q[maxn], a[maxn];
int n, k;
void getmin() {
int head = 0, tail = 0;
for (int i = 1; i < k; i++) {
while (head <= tail && a[q[tail]] >= a[i]) tail--;
q[++tail] = i;
}
for (int i = k; i <= n; i++) {
while (head <= tail && a[q[tail]] >= a[i]) tail--;
q[++tail] = i;
if (q[head] <= i - k) head++;
printf("%d ", a[q[head]]);
}
}
void getmax() {
int head = 0, tail = 0;
for (int i = 1; i < k; i++) {
while (head <= tail && a[q[tail]] <= a[i]) tail--;
q[++tail] = i;
}
for (int i = k; i <= n; i++) {
while (head <= tail && a[q[tail]] <= a[i]) tail--;
q[++tail] = i;
if (q[head] <= i - k) head++;
printf("%d ", a[q[head]]);
}
}
int main() {
scanf("%d%d", &n, &k);
for (int i = 1; i <= n; i++) scanf("%d", &a[i]);
getmin();
printf("\n");
getmax();
printf("\n");
return 0;
}
复杂度证明
空间复杂度显然是
O
(
n
)
O(n)
O(n)的。
由于每个元素最多只进队和出队一次,所以时间复杂度也是
O
(
n
)
O(n)
O(n)的。
单调栈
用途
直接看模板题吧洛谷P5788
暴力解决这个问题的话需要
O
(
n
2
)
O(n^2)
O(n2)的时间复杂度,而使用单调栈却只需要
O
(
n
)
O(n)
O(n)。
具体实现
相对于单调队列,单调栈就显得容易理解的多了。
算法描述
- 从前往后扫描序列(用 a [ ] a[ ] a[]表示),设当前扫描到元素为 n o w now now,栈顶为 t o p top top
- 如果
n
o
w
now
now<=
t
o
p
top
top,将
n
o
w
now
now的下标入栈;否则,now即为第一个大于栈顶的元素,弹出栈顶,重复2。
算法分析
当遇到一个新的元素时,它可能是前面元素的答案,也可能不是。
需要知道它是否是前面元素的答案,就需要拿它和前面所有没找到答案的元素去比较。
为减少比较次数,我们显然希望先和较小的元素比较。
所以我们希望之前没找到答案的元素是有序的。
回归到题目,每个元素是要找后面第一个比自己大的元素,而这些元素都没找到答案,所以很显然它们会随着下标递减。
综上所述,只需要使用栈就可以维护之前未找到答案元素的有序性,从而减少比较次数。
复杂度分析
空间复杂度显然是
O
(
n
)
O(n)
O(n)的。
每个元素只有一次入栈和出栈,所以时间复杂度度也是
O
(
n
)
O(n)
O(n)的。
例题参考代码
#include<bits/stdc++.h>
using namespace std;
const int maxn=3e6+5;
int a[maxn],st[maxn],ans[maxn];
int main()
{
int n;
cin>>n;
for(int i=1;i<=n;i++)
cin>>a[i];
int top=0;
for(int i=1;i<=n;i++)
{
while(top && a[i]>a[st[top]]){
ans[st[top]]=i;
top--;
}
st[++top]=i;
}
for(int i=1;i<=n;i++)
cout<<ans[i]<<' ';
return 0;
}