单调队列
食用指南:
对该算法程序编写以及踩坑点很熟悉的同学可以直接跳转到代码模板查看完整代码
只有基础算法的题目会有关于该算法的原理,实现步骤,代码注意点,代码模板,代码误区的讲解
非基础算法的题目侧重题目分析,代码实现,以及必要的代码理解误区
题目描述:
-
给定一个大小为 n≤106 的数组。
有一个大小为 k 的滑动窗口,它从数组的最左边移动到最右边。
你只能在窗口中看到 k 个数字。
每次滑动窗口向右移动一个位置。
以下是一个例子:
该数组为 [1 3 -1 -3 5 3 6 7],k 为 3。
你的任务是确定滑动窗口位于每个位置时,窗口中的最大值和最小值。输入格式
输入包含两行。
第一行包含两个整数 n 和 k,分别代表数组长度和滑动窗口的长度。
第二行有 n 个整数,代表数组的具体数值。
同行数据之间用空格隔开。输出格式
输出包含两个。
第一行输出,从左至右,每个位置滑动窗口中的最小值。
第二行输出,从左至右,每个位置滑动窗口中的最大值。输入样例:
8 3
1 3 -1 -3 5 3 6 7
输出样例:
-1 -3 -3 -3 3 3
3 3 5 5 6 7 -
题目来源:https://www.acwing.com/problem/content/156/
题目分析:
-
暴力法:
遍历数组,以数组中每个元素作为结尾构造窗口
遍历窗口内k个数,比较得出最值
最差时间复杂度:O(n·k),1012ms,严重超时 -
单调队列法:
维护一个长度为k的队列
用队尾遍历数组,队尾每次tail++尾插入一个数则和队列中当前最值比较,
arr[i]存储第i个数作为队尾时队列中的最值当队尾和队头长度差异超过k时,队头head++
-
下面开始介绍单调队列,还没有看单调栈的同学一定一定要先看懂单调栈
算法原理:
模板算法:
单调队列:
1. 和单调栈的区别:
- 栈/队列内元素范围:
- 节点i对应的过程栈中的节点范围可以从arr[0]到arr[i-1]
- 节点i对应的过程队列中的节点范围只能从arr[i-k+1]到arr[i-1]
- 舍弃元素:
- 单调栈中的元素被出栈是因为该元素不满足大于或小于新节点
- 单调队列中的元素被出队列是因为该元素可能位于head前面,或者该元素和前一个元素构成了逆序对/顺序对
- 存储内容:
- 由于单调栈不存在不可以取左侧某一元素情况,所以不必存储索引,直接存储值即可
- 单调队列由于长度有限,存在不可越界存储最值情况,所以只能存储索引
- 目标点 & 目标单调性:
-
单调栈的满足题意的目标点一定在栈顶,且栈底到栈顶是单调的
-
单调队列的满足题意的目标点只能在队列头
由于目标点在队列头,所以
当求最大值时,希望队列内部单调递减;
当求最小值时,希望队列内部单调递增
-
2. 和单调栈的相同:
- 过程量:
- 每个节点的单调栈都是经过栈顶和该节点进行对比的,每个节点的单调栈都不同
- 每个节点的定长单调队列也是经过队列尾和该节点对比的,每个节点的单调队列都不同
- 辅助变量:
- 单调栈 或 单调队列 都是在遍历序列时,对序列内容进行存储的辅助记录结构
3. 单调队列的队头队尾作用:
- 单调队列长度不是一定为K,可以比K短,尤其当加入某一点后打破原有队列内单调性的情况
- head的作用:存储当前head - tail的最值索引 & 维护单调队列长度
- tail的作用:遍历序列 & 将当前点值和当前单调队列内节点值进行比较,更新单调队列
写作步骤:
遍历到数组第i个点时
- 考虑队头是否应该出队
- 队尾和第i数是否破坏单调性,若破坏,将队尾出队
- 队头队尾都考量之后,第i个点次数可以入队
- 此时队头就是答案
代码实现:
- 求每个窗口内最小值时:
#include <iostream>
#include <cstring>
using namespace std;
const int N = 1000010;
int que[N], arr[N];
int head = 0, tail = -1;
int main(){
int n = 0, k = 0;
cin >>n >>k;
for(int i=0; i<n; i++){
cin >>arr[i];
while (head <= tail && i-que[head]+1 > k) head++;
while (head <= tail && arr[i] < arr[que[tail]]) tail--; //此处arr[i] <= arr[que[tail]]也可
que[++tail] = i;
if (i+1 >= k)
cout <<arr[que[head]]<<" ";
}
}
- 求每个窗口内最大值时:
#include <iostream>
#include <cstring>
using namespace std;
const int N = 1000010;
int que[N], arr[N];
int head = 0, tail = -1;
int main(){
int n = 0, k = 0;
cin >>n >>k;
for(int i=0; i<n; i++){
cin >>arr[i];
while (head <= tail && i-que[head]+1 > k) head++;
while (head <= tail && arr[i] > arr[que[tail]]) tail--; //此处arr[i] >= arr[que[tail]]也可
que[++tail] = i;
if (i+1 >= k)
cout <<arr[que[head]]<<" ";
}
}
代码误区:
1. 为什么单调队列的目标点是队头,能不能是队尾?
-
不能
因为只有在队头的时候,我们保证队列长度时可以知道是否已经将队头删除,保证了没有越界情况若目标点设置在队尾,则队尾的值可能是队头元素,当队头由于队列长度一定时出队列后,队尾还越界的保留着原队头元素
既然要求目标点设置在队头,
那么求窗口内最大值的时候,该窗口对应的队列应该是单调递减的
当求窗口内最小值的时候,该窗口对应的队列应该是单调递增的
2. 为什么此处不采用循环队列?
- 已经知道了所有数据的长度为N
采用队列结构完全可以将所有数据存储得下
3. 为什么此处tail初始化为-1 且 tail指向元素而非空节点?
- 因为每次遍历到数组一个点i时,都要将数组arr[i]和队列尾进行比较,判断队尾是否出队
- 想要que[tail]每次都存储的是元素而非空节点,在添加元素时采用que[++tail] = i
则开头tail需要初始化为-1
4. 和单调栈的步骤对比:
- 单调栈:3步
- 通过比较,栈顶出栈
- 输出栈顶作为答案
- 当前元素入栈
- 单调队列:4步
- head控制队列长
- 通过比较,tail–
- 当前元素尾插入队
- 输出队首作为答案
本篇感想:
-
单调队列本质也是一个起辅助作用的过程队列
理解了为什么窗口内最值要设在队头
单调队列本身长度可以不到窗口长度
head 的作用 & tail 的作用
以及代码写作的四个步骤
就真的理解了单调队列 -
博客突破30大关,老样子,放图庆祝一下
-
看完本篇博客,恭喜已登 《练气境-后期》
第35篇左右将进入图论,对于dfs bfs不太熟悉的同学看这里:【算法设计】用C++类和队列实现图搜索的广度优先遍历算法
距离登仙境不远了,加油