单调队列学习笔记(~超详细)

概念

顾名思义,单调队列的重点分为 “单调” 和 “队列”,“单调” 指的是元素的的 “规律”——递增(或递减),“队列” 指的是元素只能从队头和队尾进行操作(注意,是可以在队尾进行操作的,这和正常的队列是有一定区别的,我们待会再说为什么需要这样做。)。
这种数据结构通常用于滑动窗口区间最值问题。

例题引入

给出一个长度为 n n n的数组,编程输出每 k k k个连续的数中的最大值和最小值。


暴力解法

我们拿到这题,最暴力的想法很简单,对于每一段 i → i + k − 1 i\rightarrow{i+k-1} ii+k1的序列,逐个比较来找出最大值(和最小值)。
暴力代码:

/*
*邮箱:unique_powerhouse@qq.com
*blog:https://me.csdn.net/hzf0701
*注:文章若有任何问题请私信我或评论区留言,谢谢支持。
*/
#include<bits/stdc++.h>	//POJ不支持

#define rep(i,a,n) for (int i=a;i<=n;i++)//i为循环变量,a为初始值,n为界限值,递增
#define per(i,a,n) for (int i=a;i>=n;i--)//i为循环变量, a为初始值,n为界限值,递减。
#define pb push_back
#define IOS ios::sync_with_stdio(false);cin.tie(0); cout.tie(0)
#define fi first
#define se second
#define mp make_pair

using namespace std;

const int inf = 0x3f3f3f3f;//无穷大
const int infs = -0x3f3f3f;//无穷小
const int maxn = 1e5;//最大值。
typedef long long ll;
typedef long double ld;
typedef pair<ll, ll>  pll;
typedef pair<int, int> pii;
//*******************************分割线,以上为自定义代码模板***************************************//

int nums[maxn];
int n,k;
int main(){
	//freopen("in.txt", "r", stdin);//提交的时候要注释掉
	IOS;
	while(cin>>n>>k){
		for(int i=1;i<=n;i++)cin>>nums[i];
		int maxx,minn;
		for(int i=1;i<=n;i++){
			maxx=infs,minn=inf;
			for(int j=i;j<=i+k-1;j++){
				if(nums[j]>maxx)maxx=nums[j];
				if(nums[j]<minn)minn=nums[j];
			}
			cout<<maxx<<" "<<minn<<endl;
		}
	}
	return 0;
}

这个算法时间复杂度约为 O ( n ∗ k ) O(n*k) O(nk) 。很显然,这其中进行了大量重复工作,除了开头 ( k − 1 ) (k-1) (k1) 个和结尾 ( k − 1 ) (k-1) (k1) 个数之外,每个数都进行了 k k k 次比较,而题中若 n n n的数据范围给的很大 ,当 k k k稍大的情况下,显然会TLE。


优先队列优化解法

我们知道,对于优先队列而言,它会自动排序,我们利用两个优先队列自定义优先级,获取当前队列中的最大值序号和最小值序号。我们具体看代码。

/*
*邮箱:unique_powerhouse@qq.com
*blog:https://me.csdn.net/hzf0701
*注:文章若有任何问题请私信我或评论区留言,谢谢支持。
*/
#include<bits/stdc++.h>	//POJ不支持

#define rep(i,a,n) for (int i=a;i<=n;i++)//i为循环变量,a为初始值,n为界限值,递增
#define per(i,a,n) for (int i=a;i>=n;i--)//i为循环变量, a为初始值,n为界限值,递减。
#define pb push_back
#define IOS ios::sync_with_stdio(false);cin.tie(0); cout.tie(0)
#define fi first
#define se second
#define mp make_pair

using namespace std;

const int inf = 0x3f3f3f3f;//无穷大
const int infs = -0x3f3f3f;//无穷小
const int maxn = 1e5;//最大值。
typedef long long ll;
typedef long double ld;
typedef pair<ll, ll>  pll;
typedef pair<int, int> pii;
//*******************************分割线,以上为自定义代码模板***************************************//

int nums[maxn];
int maxx[maxn],minn[maxn];//存储最大值和最小值。
int n,k;
//注意比较函数对象的写法,自定义优先级需要反着来,可以参考greater<int> 就是从小到大排序,说明反着来的,这里不做解释。
struct cmp1{
	bool operator()(int i,int j){
		//从大到小排序
		return nums[i]<nums[j];
	}
};
struct cmp2{
	bool operator()(int i,int j){
		//从小到大排序
		return nums[i]>nums[j];
	}
};
int main(){
	//freopen("in.txt", "r", stdin);//提交的时候要注释掉
	IOS;
	while(cin>>n>>k){
		for(int i=1;i<=n;i++)cin>>nums[i];
		int cnt=0;
		priority_queue<int,vector<int>,cmp1> q_max;//维护最大值优先队列
		priority_queue<int,vector<int>,cmp2> q_min;//维护最小值优先队列
		//先将前k个值入队。
		for(int i=1;i<=k;i++){
			q_max.push(i);
			q_min.push(i);
		}
		//取队头元素存储
		maxx[cnt]=nums[q_max.top()];
		minn[cnt]=nums[q_min.top()];
		cnt++;
		//接下来开始遍历接下来的元素。
		for(int i=k+1;i<=n;i++){
			q_max.push(i);
			q_min.push(i);
			//判断队头元素是否过期。
			while(i-q_max.top()>=k)
				q_max.pop();
			//存储
			maxx[cnt]=nums[q_max.top()];
			while(i-q_min.top()>=k)
				q_min.pop();
			minn[cnt]=nums[q_min.top()];
			cnt++;
		}
		for(int i=0;i<cnt;i++){
			cout<<maxx[i]<<" "<<minn[i]<<endl;
		}
	}
	return 0;
}

这种方法代码比较长,但却很高效,我们来分析一下,优先队列每次加入新元素时都需要 l o g n logn logn的时间来调整堆。那么这个算法平均时间复杂度为 O ( n ∗ l o g n ) O(n*logn) O(nlogn),和第一种方法相比,确实优化了不少。


单调队列(正片开始)

用优先队列处理这道题确实有着不错的表现,但仍没有达到一个线性时间 O ( n ) O(n) O(n)。而有了上面单调队列的概念,我们自然会想到用单调队列优化:

这题目要求的是每连续的 个数中的最大(最小)值,很明显,我们以查找小值为例:当一个数进入所要 “寻找” 最小值的范围中时,若这个数比其前面(先进队)的数要小,显然,前面的数会比这个数先出队且不再可能是最小值

也就是说——当满足以上条件时,可将前面的数 “弹出”,再将该数真正 push 进队尾。这就相当于维护了一个递增的队列,符合单调队列的定义,减少了重复的比较次数,不仅如此,由于维护出的队伍是查询范围内的且是递增的,队头必定是该查询区域内的最小值,因此输出时只需输出队头即可

显而易见的是,在这样的算法中,每个数只要进队与出队各一次,因此时间复杂度被降到 O ( n ) O(n) O(n)了 。而由于查询区间长度是固定的,超出查询空间的值再大也不能输出,因此还需要 p o s pos pos 数组记录第 i i i个队中的数在原数组中的位置,以弹出越界的队头。

这样是不是有点懵,没事,我们来看一个例子,看看单调队列是怎么实现这个操作的。

假设原序列为:

1 3 -1 -3 5 3 6 7
设我们的 k k k为3

因为我们始终要维护队列保证递增的状态,所以这个队列在操作过程中会是这样:

操作队列状态当前区间和最小值
1 入队{1} [ 1 , 1 ] , 最 小 值 为 1 [1,1],最小值为1 [1,1]1
3 比 1 大,3 入队{1 3} [ 1 , 2 ] , 最 小 值 为 1 [1,2],最小值为1 [1,2]1
这里注意我们快要到达窗体限制长度,接下来我们要记录我们取出来的最小值和下标,及时剔除过期元素。
-1 比队列中所有元素小,所以清空队列 -1 入队{-1} [ 1 , 3 ] , 最 小 值 为 − 1 [1,3],最小值为-1 [1,3]1
-3 比队列中所有元素小,所以清空队列 -3 入队{-3} [ 2 , 4 ] , 最 小 值 为 − 3 [2,4],最小值为-3 [2,4]3
5 比 -3 大,直接入队{-3 5} [ 3 , 5 ] , 最 小 值 为 − 3 [3,5],最小值为-3 [3,5],3
3 比 5 小,5 出队,3 入队{-3 3} [ 4 , 6 ] , 最 小 值 为 − 3 [4,6],最小值为-3 [4,6],3
-3 已经在窗体外,所以 -3 出队;6 比 3 大,6 入队{3 6} [ 5 , 7 ] , 最 小 值 为 3 [5,7],最小值为3 [5,7],3
7 比 6 大,7 入队{3 6 7} [ 6 , 8 ] , 最 小 值 为 3 [6,8],最小值为3 [6,8],3

相信我们经过这个过程之后,大概知道利用单调队列的工作流程了,实现单调队列总共有两种方法,一种是利用数组实现,我们在数组上进行表面的入队和出队操作,这种我们就不受普通队列的限制,我们可以自定义。那么如果我们要使用STL中的队列,由于我们要在两端进行操作,故我们会选择双端队列deque来实现,接下来就介绍两种实现方法解决我们的例题。

单调队列解决例题

数组实现单调队列

/*
*邮箱:unique_powerhouse@qq.com
*blog:https://me.csdn.net/hzf0701
*注:文章若有任何问题请私信我或评论区留言,谢谢支持。
*/
#include<bits/stdc++.h>	//POJ不支持

#define rep(i,a,n) for (int i=a;i<=n;i++)//i为循环变量,a为初始值,n为界限值,递增
#define per(i,a,n) for (int i=a;i>=n;i--)//i为循环变量, a为初始值,n为界限值,递减。
#define pb push_back
#define IOS ios::sync_with_stdio(false);cin.tie(0); cout.tie(0)
#define fi first
#define se second
#define mp make_pair

using namespace std;

const int inf = 0x3f3f3f3f;//无穷大
const int infs = -0x3f3f3f;//无穷小
const int maxn = 1e5;//最大值。
typedef long long ll;
typedef long double ld;
typedef pair<ll, ll>  pll;
typedef pair<int, int> pii;
//*******************************分割线,以上为自定义代码模板***************************************//

int n,k,nums[maxn],q[maxn],pos[maxn];//q为我们的单调队列,pos数组记录队中元素下标,为了剔除过期元素。
int st,ed;//表示队头和队尾
void get_min(){
	//先将前k-1个入队。
	st=1,ed=0;
	for(int i=1;i<k;i++){
		while(st<=ed&&q[ed]>=nums[i])ed--; //维护单调递增序列,和队尾元素进行比较
		ed++;
		q[ed]=nums[i];//获取当前的队尾元素。
		pos[ed]=i;//记录每个元素的下标。
	}
	for(int i=k;i<=n;i++){
		while(st<=ed&&q[ed]>=nums[i])ed--;
		ed++;
		q[ed]=nums[i];
		pos[ed]=i;
		//关键一步,取队头元素。
		while(pos[st]<i-k+1)st++;//队头元素过期,出队。
		cout<<q[st]<<" ";
	}
	cout<<endl;
}
//和求最小值同样的解法,只不过现在是要维护单调递减序列。
void get_max(){
	//先将前k-1个入队。
	st=1,ed=0;
	for(int i=1;i<k;i++){
		while(st<=ed&&q[ed]<=nums[i])ed--; //维护单调递减序列,和队尾元素进行比较
		ed++;
		q[ed]=nums[i];//获取当前的队尾元素。
		pos[ed]=i;//记录每个元素的下标。
	}
	for(int i=k;i<=n;i++){
		while(st<=ed&&q[ed]<=nums[i])ed--;
		ed++;
		q[ed]=nums[i];
		pos[ed]=i;
		//关键一步,取队头元素。
		while(pos[st]<i-k+1)st++;//队头元素过期,出队。
		cout<<q[st]<<" ";
	}
	cout<<endl;
}
int main(){
	//freopen("in.txt", "r", stdin);//提交的时候要注释掉
	IOS;
	while(cin>>n>>k){
		for(int i=1;i<=n;i++){
			cin>>nums[i];
		}
		get_min();
		get_max();
	}
	return 0;
}

STL双端队列实现

这里要注意的就是我们没必要开一个数组来存储下标了,我们直接将下标入队,通过下标直接可以判断队头元素是否过期,这样更快,具体看代码。一定要细心,因为双端队列的函数较多。

/*
*邮箱:unique_powerhouse@qq.com
*blog:https://me.csdn.net/hzf0701
*注:文章若有任何问题请私信我或评论区留言,谢谢支持。
*/
#include<bits/stdc++.h>	//POJ不支持

#define rep(i,a,n) for (int i=a;i<=n;i++)//i为循环变量,a为初始值,n为界限值,递增
#define per(i,a,n) for (int i=a;i>=n;i--)//i为循环变量, a为初始值,n为界限值,递减。
#define pb push_back
#define IOS ios::sync_with_stdio(false);cin.tie(0); cout.tie(0)
#define fi first
#define se second
#define mp make_pair

using namespace std;

const int inf = 0x3f3f3f3f;//无穷大
const int infs = -0x3f3f3f;//无穷小
const int maxn = 1e5;//最大值。
typedef long long ll;
typedef long double ld;
typedef pair<ll, ll>  pll;
typedef pair<int, int> pii;
//*******************************分割线,以上为自定义代码模板***************************************//

int n,k,nums[maxn],pos;//pos记录队头元素下标,为了剔除过期元素。
int st,ed;//表示队头和队尾
void get_min(){
	deque<int> q;//单调递增队列
	//先将前k-1个入队。
	for(int i=1;i<k;i++){
		while(!q.empty()&&nums[q.back()]>=nums[i])q.pop_back();//维护单调递增序列。
		q.push_back(i);  //我们这里要存储下标。
	}
	for(int i=k;i<=n;i++){
		while(!q.empty()&&nums[q.back()]>=nums[i])q.pop_back();
		q.push_back(i);
		//关键一步,判断队头元素是否过期
		while(!q.empty()&&q.front()<i-k+1)q.pop_front();//过期,删除
		cout<<nums[q.front()]<<" ";
	}
	cout<<endl;
}
//和求最小值同样的解法,只不过现在是要维护单调递减序列。
void get_max(){
	deque<int> q;//单调递减队列
	//先将前k-1个入队。
	for(int i=1;i<k;i++){
		while(!q.empty()&&nums[q.back()]<=nums[i])q.pop_back();//维护单调递减序列。
		q.push_back(i);  //我们这里要存储下标。
	}
	for(int i=k;i<=n;i++){
		while(!q.empty()&&nums[q.back()]<=nums[i])q.pop_back();
		q.push_back(i);
		//关键一步,判断队头元素是否过期
		while(!q.empty()&&q.front()<i-k+1)q.pop_front();//过期,删除
		cout<<nums[q.front()]<<" ";
	}
	cout<<endl;
}
int main(){
	freopen("in.txt", "r", stdin);//提交的时候要注释掉
	IOS;
	while(cin>>n>>k){
		for(int i=1;i<=n;i++){
			cin>>nums[i];
		}
		get_min();
		get_max();
	}
	return 0;
}

总结

单调队列的单调性是要我们自己去维护的,这一定得谨记,因为这本来就是我们创建的数据结构,操作都得我们来完成,利用单调队列的情况虽然很少,但如果需要,这能起到意想不到的作用。

posted @   unique_pursuit  阅读(214)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· 微软正式发布.NET 10 Preview 1:开启下一代开发框架新篇章
· 没有源码,如何修改代码逻辑?
· PowerShell开发游戏 · 打蜜蜂
· 在鹅厂做java开发是什么体验
· WPF到Web的无缝过渡:英雄联盟客户端的OpenSilver迁移实战
点击右上角即可分享
微信分享提示