栈 与 队列

本篇博客来谈一谈队列的爱恨情仇(仅包括算法竞赛常用的部分)。

  作为两个 O I OI OI中最常见的数据类型,栈与队列可以说是必不可少的。

1.栈

  相信所有人都对电梯不陌生,对于单门开的电梯而言,如果其横向面积够小(只能容下一个人),那么显然先进电梯的人只能当在他后面进来的人出来后才能出电梯,而栈就像是这样的一个电梯,它仅允许栈顶的元素出栈,如图:在这里插入图片描述

在图中,最大的一个上端不封口的矩形就代表一个栈,每一组元素仅能从 b e g i n begin begin一端进行插入。例如,现在栈里共有 A , B , C , D , E , F A,B,C,D,E,F A,B,C,D,E,F六个元素,如果我们要插入 G G G这个元素,那么我们只能将 G G G放到 E E E的上面,如果我们要把 A A A从栈中删去,我们必须要将 B , C , D , E , F B,C,D,E,F B,C,D,E,F全部出栈,然后才能将 A A A从栈里删去。栈的这一性质也可以用"先进后出“来表示。即”LIFO“原则。

  栈的特性决定了其在算法中重要的地位,先进后出可以用于模拟许多生活中的事情,例如浏览器的“回退”功能,括号匹配类,都是典型的栈型数据。最为常用的暴力算法 D F S DFS DFS依靠系统栈来实现,函数之间的包含关系可以用栈来维护……由此可见栈利用的广泛性。

1.手写栈

  栈的手写维护可以特别简单:仅维护其"LIFO"性质。而这一步维护完全可以通过对出入栈过程的处理来实现。

  首先,栈的栈底可以确定一般情况下是不会发生改变的,因此我们维护栈时会变化的量只有栈顶指针和栈内元素。可以考虑用数组来维护。

  设栈顶下标为 t o p top top,且其初始值为0(即栈底坐标)。最初情况下栈的状态即为空。所以我们认为只要 t o p top top值为0时栈为空。以这一条件为前提,我们可以定义栈的大小可以表示为 t o p − e n d ( 0 ) top-end(0) topend(0) t o p top top。显然当

入栈操作为: t o p top top值加一。
出栈操作为: t o p top top值减一。

时,便可以保证对栈进行操作时一定能保持其性质。

int num[Size];//储存栈
int top;//储存栈顶坐标

void put(int x) {
	num[++top] = x;
}//入栈操作

bool pop() {
	if (top) {
		top--;
		return false;
	}//判断栈是否为空
	return true;
}//出栈操作
// int main()内
put(1);//栈内元素为 1
put(3);//栈内元素为 1,3
put(2);//栈内元素为 1,3,2
pop();//栈内元素为 1,3; 返回 false;
put(4);//栈内元素为 1,3,4
int n = num[top];// n为 4
pop();//栈内元素为 1,3; 返回 false;
pop();//栈内元素为 1; 返回 false;
pop();//栈为空; 返回 false;
pop();//栈为空; 返回 true;

  可以看到,由于储存时top值每次加一,且top对应的值是栈顶的值,当我们出栈时,top的值减一,栈顶变为之前的第二层(从上到下),而当我们再次入栈时,之前所赋的值会被新的栈顶所覆盖,所以先入后出的性质可被维护。

  上述代码中,可以看到,在 p o p pop pop函数中加了一个返回值,这一细节可以用来判断栈是否为空。当栈为空时,我们在调用 p o p pop pop时,这一函数将返回 f a l s e false false,代表栈已为空。如果将 p o p pop pop函数中 t o p − − top-- top的操作去掉,那么这个函数将变为 e m p t y empty empty(判断栈是否为空)。若再将返回 b o o l bool bool类型改为返回 t o p top top的值,该函数将变为 s i z e size size(返回栈的大小),如下:

bool empty() {
	if(top) {
		return false;
	}
	return true;
}

int size() {
	reurn top;
}

  当我们调用栈顶时,由于 t o p top top指向栈顶元素,所以我们可以直接调用 n u m [ t o p ] num[top] num[top]来调用栈顶。

2.STL

  C++与C比较,最大的区别在于多了一个STL
  在 S T L STL STL库中,有一个数据结构——stack(位于#include<stack>库中)。它给我们提供了一个满足栈性质的数据结构。

  在stack中有一下几种操作:

  push(n) 往栈中压入 n n n
  pop()将栈顶弹出
  top()返回栈顶
  empty()若栈为空,返回true。否则,返回false
  size()返回栈的大小(元素量)。

  至于生明方法……
  还是最接地气的stack<变量类型> 变量名的格式。

  操作的效果与手写栈一样。但是,stack不支持直接调用栈中的某一值。

实例如下:

#include <stack>
#include <cstdio>

using namespace std;

int main() {
	stack <int > s;
	s.push(1);
	printf("%d ", s.top());
	s.push(3);
	printf("%d ", s.top());
	s.push(2);
	printf("%d\n", s.top());
	s.pop();
	printf("%d\n", s.top());
	s.push(4);
	printf("%d\n", s.top());
	printf("size: %d\n", s.size());
	while (s.size()) {
		s.pop();
	}
	if (s.empty())
		printf("empty? true");
	else 
		printf("empty? false");
	return 0;
}

输出结果:
在这里插入图片描述

3.单调栈

  单调性是最美的性质

  首先,给出一个例题:
L a r g e s t   R e c t a n g l e   i n   a   H i s t o g r a m Largest\ Rectangle\ in\ a\ Histogram Largest Rectangle in a Histogram

  这就是单调栈的一个经典应用。

  单调栈,即满足元素单调递增(或递减)的栈。

  实现方法便是在入栈时判断一下,若要入栈的数据比栈顶元素要大(或小),就将数据入栈,否则就将比当前数据大的元素全部出栈。这样即可维护栈的单调性。

  单调栈可以用于处理大量特殊的数据,但有些难判断(所以多敲代码,多多理解,就能知道该用什么算法)。


2.队列

  相信所有人都排过队吧。排队肯定是先排的先出队(排完了),而后排的就需要多排一会儿插队可耻!!!-。队列就类似于这样的一个过程:在不允许插队的情况下将数据排一个队。如图:在这里插入图片描述
在图中,两条平行线间代表一个队列, h a n d hand hand端为队头, e n d end end端为队尾。

  在该队列中,如果我们想将 k k k加入队列,它必须从 e n d end end端入队,绝对不能从 h a n d hand hand端加入队列(除非是双端队列)。如果我们想将 a a a移出队列,我们可以直接将 a a a移出,但是如果我们想将 n n n移出队列,那么我们只能在将从 h a n d hand hand n n n所在位置的所有元素全部出队后才能将 n n n出队(除非用的是双端队列)。即,在队列中的元素(除优先队列和双端队列等特殊维护的队列外)必须遵守先进先出(FIFO)原则。

1.手写维护(经典)

  对于队列,可以参考上图及栈的操作,我们可以设 h a n d hand hand储存队头坐标, t a i l tail tail储存队尾坐标,那么 t a i l − h a n d tail -hand tailhand就是队列的长度,当 t a i l − h a n d = 0 tail -hand = 0 tailhand=0时,说明队列为空。

  按照队列的规则,我们可以想到:当我们将一个数据加入队列时,我们将 t a i l tail tail++。如图:
在这里插入图片描述

  当我们将队首出队时,我们将 h a n d hand hand++。如图:
在这里插入图片描述

这样就能方便地维护队列的规则。

int num[Size];//储存队 
int hand, tail;//储存队头、队尾坐标

void put(int x) {
	num[tail++] = x;
}//入队操作 

bool pop() {
	if (hand < tail) {
		hand++;
		return false;
	}//判断队是否为空
	return true;
}//出队操作
//在int main()内
put(1);//队内元素为 1
put(3);//队内元素为 1,3
put(2);//队内元素为 1,3,2
pop();//队内元素为 3,2; 返回 false;
put(4);//队内元素为 3,2,4
int n = num[hand];// n为 1
pop();//队内元素为 2,4; 返回 false;
pop();//队内元素为 4; 返回 false;
pop();//队为空; 返回 false;
pop();//队为空; 返回 true;

  跟栈相似,队列的队头坐标即为 h a n d hand hand ,但是要注意的是,如果按照上方的代码来敲,即按照 t a i l − h a n d = 0 tail - hand = 0 tailhand=0 时为空来维护。那么, t a i l tail tail指向的是队尾的下一位。

  与栈相比,对按上述方法维护的队列而言,其对应的 e m p t y empty empty函数中需要判断的条件变为了 t a i l − h a n d tail - hand tailhand 是否为0。若是,则返回 t r u e true true 表示队列为空,否则返回 f a l s e false false 表示队列不为空。 s i z e size size函数对应的返回值为 t a i l − h a n d tail - hand tailhand 的值。如下:

bool empty() {
	if(tail - hand) {
		return false;
	}
	return true;
}

int size() {
	reurn tail - hand;
}

2.手写维护(循环)

  可以发现,上面的实现方法会导致我们 h a n d hand hand 下标以前的数据在出队后全部成为冗余数据,浪费空间。如图中打叉部分
在这里插入图片描述

  面对这种情况,我们可以发现,传统的手写队列的所占空间中,可能有极大的一部分是冗余空间。为了优化传统写法的空间复杂度,可以考虑进行对冗余空间的重新利用(回收垃圾)。

  我们可以发现,每次我们出队后,我们队头前的数据已经没有意义了。因此,我们可以将储存这些数据所用的空间用来储存新的入队数据,即对空间进行循环利用。这样就可以在一定程度上优化其空间复杂度,这就是循环队列的思想。

  按照能简单尽量简单的原则,我们可以将优化后的队列看做是一个环(毕竟是循环)。如图:
在这里插入图片描述

  根据这个模型,我们就可以将队列以一定的大小的数组(大于等于队列中最多同时拥有的元素量)储存下来。从而进行手写维护。

  显然,在这个环里,当我们的 t a i l tail tail 对应到 j j j 所对应的元素时,再次进队时,由于环的大小大于等于队列中同时存在的元素数量。所以,此时我们的 a a a 所对应的元素一定已经出队。如图:
在这里插入图片描述

  所以我们将 t a i l tail tail 对应到 a a a 并将入队的元素赋值到 a a a 中。如图:
在这里插入图片描述

  当 h a n d hand hand 对应到 j j j 时,同理,一定有 a a a 一定已经入队。如图:在这里插入图片描述
  当我们出队时,我们直接令 h a n d hand hand 指向 a a a 即可。如图:
在这里插入图片描述

其他情况与正常队列相同。

3.STL(循环)

  C++与C比较,最大的区别在于多了一个STL
  在 S T L STL STL中,也存在一个代表循环队列的数据结构:queue(包含于库#include <queue>)。它主要支持以下操作:

  push(n) n n n加入队列。
  pop()将队首弹出。
  front()返回队首元素。
  back()返回队尾元素
  size()返回队列大小(元素数量)
  empty()若队列为空,返回true,否则,返回false

  至于生明方法……
  还是最接地气的queue <变量类型> 变量名的格式。

  这一数据结构与手写的循环队列的使用方式一样。且其功能上并没有任何其他不足之处,可以放心使用(如果放的元素过多,可能会爆空间)。
  实例如下:

#include <queue>
#include <cstdio>

#define Size 10000

using namespace std;

queue <int> q;

int main() {
	q.push(1);//队内元素为 1
	printf("%d\n", q.front());
	q.push(3);//队内元素为 1,3
	printf("%d\n", q.front());
	q.push(2);//队内元素为 1,3,2
	printf("%d\n", q.front());
	q.pop();//队内元素为 3,2;
	q.push(4);//队内元素为 3,2,4
	printf("%d\n", q.front());
	printf("size: %d", q.size()); 
	int n = q.front();// n为 1
	int m = q.back();
	printf("%d %d\n", n, m);
	q.pop();//队内元素为 2,4;
	printf("%d\n", q.front());
	q.pop();//队内元素为 4;
	printf("%d\n", q.front());
	printf("empty? %d\n", q.empty());
	q.pop();//队为空;
	printf("empty? %d\n", q.empty());
	return 0;
}

输出:
在这里插入图片描述

4.单调队列

  先举个实例~~
  最大子序和

  这个题就是一个模板题(虽然用其他方法应该也能写)。

  本着能优即优的态度,我们将求子序和用前缀和优化为求两的前缀和的值。这样,题目就变为了求两个值 i i i j j j,令它们的前缀和 s u m [ i ] sum[i] sum[i] s u m [ j ] sum[j] sum[j] 的差最大,且 i i i j j j 之间的差小于 M M M

  类似于单调栈,单调队列维护的也是一组单调递增的数据。在本题中,我们可以发现,对于确定的一个区间右端点而言,如果同时存在两个可能的区间左端点,如果更靠左的端点的前缀和比更靠右的还小,那么更靠左的端点肯定没有更靠右的优。因此,在这种情况下,更靠左的端点没有意义。

  由上方的推断可以得出:可能为最优结果的端点集合一定是一个下标递增且前缀和递增的序列。当我们用队列来储存时,队列中元素必定单调递增,即为单调队列。

  对于该题,我们维护队列的方法如下:
  1.判断队头与当前点的距离是否超过 M M M 。如果超过,将队首弹出,此时队头就是当右端点为当前点时的最优抉择。
  2不断删除队尾,直至队尾的前缀和小于当前点的前缀和或队列为空。然后将当前点入队。

  代码如下:

#include<cstdio>
#include<bitset>
#include<algorithm>

#define Size 300010
#define INF 1 << 28

using namespace std;

int maxn = -INF;
int qmin_hand , qmin_end , qmin[Size];
int Bas[Size] , sum[Size] , f[Size];

int main() {
	int n, m;
	scanf("%d%d", &n, &m);
	for (int i = 1; i <= n; i++) {
		scanf("%d", &Bas[i]);
	}
	sum[1] = Bas[1];
	for (int i = 1; i <= n; i++)
	{
		sum[i] = Bas[i] + sum[i - 1];
	}
	for (int i = 1; i <= n; i++) {
		
		while (qmin[qmin_hand] < i - m && qmin_hand <= qmin_end)
			qmin_hand++;
		maxn = max(maxn, sum[i] - sum[qmin[qmin_hand]]);
		while (sum[i] <= sum[qmin[qmin_end]] && qmin_hand <= qmin_end) {
			qmin_end--;
		}
		qmin[++qmin_end] = i;
		
	}
	printf("%d", maxn);
	return 0;
}
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值