Bootstrap

一文速通stack和queue的理解与使用

🌟🌟hello,各位读者大大们你们好呀🌟🌟
🚀🚀系列专栏:【C++的学习】
📝📝本篇内容:stack;stack的基本概念;stack的接口;queue;queue的基本概念;queue的接口;priority_queue;priority_queue的基本概念;priority_queue的接口;仿函数;容器适配器;deque;deque的简单了解;deque的优缺点
⬆⬆⬆⬆上一篇:一文搞懂反向迭代器之C++模拟实现list
💖💖作者简介:轩情吖,请多多指教(> •̀֊•́ ) ̖́-

1.stack

1.1.stack的基本概念

在我们C++的STL中也实现了栈,也就是stack,它的特性是“先进后出”(First In Last Out,FILO)。允许插入和删除的一端称为栈顶,另一端称为栈底。stack是作为容器适配器被实现的,容器适配器即是对特定类封装作为底层的容器,并提供一组特定的成员函数来访问其元素,将特定的类作为底层的,元素特定容器的尾部(即栈顶)被压入和弹出。
在这里插入图片描述
在底层的容器需要有这些成员函数进行操作:empty()、back()、push_back()、pop_back()
我们STL标准库中vector、list都是符合的,但是默认没有为stack指定特定的容器,底层都是使用的deque。
在这里插入图片描述

1.2.stack的接口

stack函数说明
stack()构造一个空的栈
size_t size()const;栈有效元素的个数
value_type& top();栈顶的值
void push (const value_type& val);压入一个数据,俗称压栈
void pop();出栈,将数据弹出
bool empty()const;是否为空
#include <iostream>
#include <stack>
using namespace std;
int main()
{
	//默认构造
	stack<int> st;

	//压栈
	st.push(10);
	st.push(11);
	st.push(12);

	//查看大小
	cout <<"stack size:" << st.size() << endl;//3

	//查看栈顶元素
	cout <<"stack top:"<< st.top() << endl;//12

	//出栈
	st.pop();
	st.pop();
	st.pop();

	if (st.empty())//true
	{
		cout << "stack is empty" << endl;
	}

	return 0;
}

在这里插入图片描述

还可以指定容器,对于deque我们稍后再讲,以及对于它的底层实现有不理解的可以看我下一篇博客

#include <stack>
#include <iostream>
#include <vector>
#include <list>
using namespace std;
int main()
{
	stack<int, list<int>> st1;//底层容器使用的是list
	stack<int, vector<int>> st2;//底层容器使用的是vector

	return 0;
}

2.queue

2.1.queue的基本概念

谈到stack就必定会谈到queue,queue就是队列。它的特性是“先进先出”(First In First Out,FIFO),它与stack不同,stack只能在一端操作,但是queue不一样,它的插入操作(进队)只能在表尾,队列的删除操作(出队)只能在表头。能够进行插入元素的一端我们称为队尾,允许删除元素的一端称为队头或队首。
在这里插入图片描述

底层和stack一样,是使用了其他的容器来适配,该容器需要支持这些操作:empty()、size()、front()、back()、push_back()、pop_front()。
我们的list是符合这个条件的(vector没有头插,效率太低),但是底层默认使用的是deque
在这里插入图片描述

2.2.queue的接口

stack函数说明
queue()构造一个空的队列
bool empty()const;判断队列是否为空
size_t size()const;返回队列有效元素的个数
value_type& front();返回队头元素的引用
value_type& back();返回队尾元素的引用
void push (const value_type& val);将元素在队尾入队列
void pop();将队头元素出队列
#include <iostream>
#include <queue>
using namespace std;
int main()
{
	//默认构造
	queue<int> q;
	
	//入队列
	q.push(10);
	q.push(11);
	q.push(12);
	q.push(13);
	
	//有效元素个数
	cout << "size:" << q.size() << endl;//4

	//队头元素
	cout << "front:" << q.front() << endl;//10

	//队尾元素
	cout << "back:" << q.back() << endl;//13

	//出队列
	q.pop();
	q.pop();
	q.pop();
	q.pop();

	//判空
	if (q.empty())//true
	{
		cout << "queue is empty" << endl;
	}

	return 0;
}

在这里插入图片描述

3.priority_queue

3.1.priority_queue的基本概念

priority_queue是优先级队列,它也是一种容器适配器,它每一次插入元素,底层就会进行调整,保证它的第一个元素是最大的(最小的)。
它类似于堆,在堆中可以随时插入元素,并且只能检索最大的堆元素(优先级队列中位于顶部的元素)
底层的容器应该是能够支持随机访问迭代器访问的,并且要支持以下操作:empty()、size()、front()、push_back()、pop_back()
在标准容器中,我们的deque和vector都是符合要求的,但是如果没有自己指定容器,那么默认为vector,需要支持随机访问迭代器,以便始终在内部保持堆结构。容器适配器通过在需要时自动调用算法函数make_heap、push_heap和pop_heap来自动完成此操作。
在这里插入图片描述
我们可以看到其模板参数中还有一个Compare,这其实就是为了使用者能够根据自己的需求,选择大堆还是小堆
我稍微看图来理解一下,具体的实现看我模拟实现的文章,这边主要还是使用为主
在这里插入图片描述
上图假设为vector中所开辟的空间,可能看上去没有什么,那么转换为树型结构呢?如下图
在这里插入图片描述
可以根据公式来看一下:
若i>0,i位置结点的双亲序号:(i-1)/2=parent;i=0,i为根节点编号,无双亲结点
设n为数组的大小,若2i+1<n,左孩子序号:2i+1=leftchild;2i+1>=n否则无左孩子
设n为数组的大小,若2i+2<n,右孩子序号:2i+2=rightchild;2i+2>=n否则无右孩子

可以看到我们的树中,根节点的元素是最小的,其余子树的根节点的元素也是小于孩子节点元素的。
这个事我们的小根堆,大根堆就是反过来,根节点的元素是最大的,其余子树的根节点的元素也是大于孩子节点元素的
我们的priority_queue默认情况下是大堆,其底层是按照小于比较的

3.2.priority_queue的接口

priority_queue函数说明
priority_queue();构造一个空的优先级队列
priority_queue(InputIterator first, InputIterator last);使用迭代器构造
bool empty() const;判断优先级队列是否为空
size_t size()const;优先级队列有效元素的个数
const value_type& top() const;返回优先级队列中最大(最小)的元素,即堆顶的元素
void push (const value_type& val);往优先级队列中插入元素
void pop();删除优先级队列中最大(最小)的元素,即堆顶的元素
#include <iostream>
#include <queue>
using namespace std;
int main()
{
	//默认构造,大堆
	priority_queue<int> pq;

	//插入元素
	pq.push(10);
	pq.push(1);
	pq.push(32);
	pq.push(23);
	pq.push(16);

	//priority_queue的有效元素个数
	cout << "priority_queue size:" << pq.size() << endl;//5

	//最大元素
	cout<<"priority_queue top1:"<<pq.top()<<endl;//32

	//删除堆顶元素
	pq.pop();

	//现在堆顶是第二大元素
	cout << "priority_queue top2:" << pq.top() << endl;//23

	pq.pop();
	pq.pop();
	pq.pop();
	pq.pop();
	//叛空
	if (pq.empty())//true
	{
		cout << "priority_queue is empty" << endl;
	}

	return 0;
}

在这里插入图片描述

3.3.仿函数

前面我们提到过,我们的优先级队列可以通过传递模板参数改变到底是存储大堆还是小堆,接下来先演示一下
在这里插入图片描述
可以看到我们的优先级队列的模板参数传递时多了一个greater类,它是在functional头文件中的,它其实就是一个普通的类中重载了运算符(),这是用来优先级队列内部改变比较的方法,从而达到改变大小堆的切换,还是那句话,具体的实现看我的模拟实现文章,这边主要讲述使用

当优先级队列所存储的元素是自定义类型的时候,那么这个自定义类型需要重载比较运算符,这是因为优先级队列的底层为了保证堆的特性,进行比较调整
下面是演示代码:

#include <iostream>
#include <queue>
#include <functional>
using namespace std;
struct Date
{
	Date(int year=0,int month=0,int day=0)
		:_year(year),
		_month(month),
		_day(day)
	{}


	//对于自定义的类,需要重载小于<、大于>
	bool operator>(const Date& d)const
	{
		if (_year > d._year)
		{
			return true;
		}
		else if (_year == d._year && _month > d._month)
		{
			return true;
		}
		else if (_year == d._year && _month == d._month && _day > d._day)
		{
			return true;
		}
		return false;
	}

	bool operator<(const Date& d)const
	{
		if (_year < d._year)
		{
			return true;
		}
		else if (_year == d._year && _month < d._month)
		{
			return true;
		}
		else if (_year == d._year && _month == d._month && _day < d._day)
		{
			return true;
		}
		return false;
	}

	int _year;
	int _month;
	int _day;

};



namespace lnb
{
	//这里便于理解,模拟实现一下greater,和less
	template<class T>
	struct greater
	{
		bool operator()(const T& x,const T& y)const//重载运算符()
		{
			return x > y;
		}
	};

	template<class T>
	struct less
	{
		bool operator()(const T& x, const T& y)const
		{
			return x < y;
		}

	};

}
int main()
{
	priority_queue<Date, vector<Date>, lnb::less<Date>> pq;
	pq.push(Date(2024, 1, 1));
	pq.push(Date(2024, 2, 5));
	pq.push(Date(2024, 12, 2));
	pq.push(Date(2024, 7, 9));
	pq.push(Date(2024, 1, 4));
	
	const Date& d = pq.top();//注意top()的返回值是const属性的
	cout << "priority_queue top:" <<d._year<<"/"<<d._month<<"/"<<d._day<<endl;// 2024/12/ 2


	return 0;
}

上面的代码也实现了一下greater和less类,默认情况下,我们的priority_queue用来比较的类是less。
大家把一个类中重载了()运算符的类叫做仿函数,为什么呢?因为它的使用像一个函数,使用类来模拟函数行为的技术,也叫作函数对象

//一个简单的仿函数使用
#include <iostream>
using namespace std;
struct Compare
{
	bool operator()(const int& x, const int& y)const
	{
		return x > y;
	}
};
int main()
{
	//bool flag=Compare()(1, 2);//使用匿名对象来直接调用
	Compare com;
	bool flag=com(1, 2);.//像函数一样调用
	if (flag)
	{
		cout << "大于" << endl;
	}
	else
	{
		cout << "小于" << endl;
	}

	return 0;
}

4.容器适配器

在我们前面的讲述的stack、queue、priority_queue都是容器适配器。
适配器是一种设计模式,该种模式是将一个类的接口转换成客户希望的另外一个接口。
虽然stack和queue中也可以存放元素,但在STL中并没有将其划分在容器的行列,而是将其称为容器适配器,这是因为stack和队列只是对其他容器的接口进行了包装,STL中stack和queue默认使用deque,priority_queue默认使用vector
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

5.deque

5.1.deque的简单了解

之前提及了那么多次的deque,现在该讲它了,它的底层是非常复杂的,我们只需要对它有个简单的了解即可,如果想要学习具体的完整源码可以去看候捷老师的STL源码剖析,如果需要电子版,可以私信我

deque是一种双向开口的连续线性空间,能够在头尾两端分别做元素的插入和删除操作,和vector相比,vector只是单向开口的连续线性空间,虽然从技术观点上来说,vector也可以在首尾两端进行操作,但是它的头部操作需要进行移动数据,效率极差。我们的deque能够在常数时间内对头部进行操作,并且我们的deque没有所谓容量的概念,因为它是动态地以分段连续空间组合而成,随时可以增加一段新的空间并链接起来。deque的迭代器也是随机迭代器,能够支持随机访问,但是其底层是经过了封装的,极其复杂。

deque采用一块连续的map作为主控,map是一小块连续的空间,其中的每一个元素都是指针,指向另一段连续线性空间,称为缓冲区。所以说deque并不是真正连续的空间,而是由一段段连续的小空间拼接而成的,实际deque类似于一个动态的二维数组。
在这里插入图片描述
我们来看一下STL源码剖析中的图来更好的理解

// 见 deque_buf_size()。BufSize 默认值为 0 的唯一理由是为了闪避某些
// 编译程序在处理常数算式(constant expressions)时的臭虫。
// 默认使用alloc为配置器
template <class T, class Alloc = alloc, size_t BufSiz = 0>
class deque {
public: // Basic types
    typedef T value_type;
    typedef value_type* pointer;
    typedef size_t size_type;
 
public: // Iterators
    typedef deque_iterator<T, T&, T*, BufSiz> iterator;
 
protected: // Internal typedefs
    // 元素的指针的指针(pointer of pointer of T)
    typedef pointer* map_pointer;
 
protected: // Data members
    iterator start;  // 表现第一个节点。
    iterator finish; // 表现最后一个节点。
    map_pointer map; // 指向 map,map是块连续空间,
                     // 其每个元素都是个指针,指向一个节点(缓冲区)
    size_type map_size; // map内有多少指标
    ...
};

上面的代码也是摘取自STL源码剖析,其中我们可以通过图和代码有个大致的了解,map中存储了指向各个缓冲区的指针,通过map我们就可以找到任意一个元素。其中比较重要的就是还有它的迭代器。
我们依旧集合图片和代码来看
在这里插入图片描述

template <class T, class Ref, class Ptr, size_t BufSiz>
struct deque_iterator { // 未继承 std::iterator
 
    typedef deque_iterator<T, T&, T*, BufSiz> iterator;
    typedef deque_iterator<T, const T&, const T*, BufSiz> const_iterator;
    static size_t buffer_size(){return deque_buf_size(BufSiz,sizeof(T));}
 
    // 未继承 std::iterator,所以必须自行撰写五个必要的迭代器相应型别
    typedef random_access_iterator_tag iterator_category; // (1)
    typedef T value_type;                                 // (2)
    typedef Ptr pointer;                                  // (3)
    typedef Ref reference;                                // (4)
    typedef size_t size_type;
    typedef ptrdiff_t difference_type;                    // (5)
    typedef T** map_pointer;
    typedef deque_iterator self;
 
    //保持与容器的联结
    T* cur; // 此迭代器所指之缓冲区中的现行(current)元素
    T* first; // 此迭代器所指之缓冲区的头
    T* last; // 此迭代器所指之缓冲区的尾(含备用空间)
    map_pointer node; // 指向管控中心
    ...
};

我们的迭代器内部的四个成员,能够保证找到任意一个元素。我们可以通过node来进行跳转到上一个或下一个缓冲区,通过first和last来保证因为插入或删除导致越界访问,cur指向当前访问的元素。
在我们deque中,也包含了start和finish两个迭代器,它们分别是begin()和end()的返回值,所以说要注意finish中cur的指向。因为迭代器访问会不停的往前迭代,那么最后的终止条件是遇到最后一个元素的下一个位置,所以说finish中的cur指向并不是最后一个元素。
看一下下面的这张图来理解一下,一个迭代器类型的对象的指向:
在这里插入图片描述

5.2.deque的优缺点

优点:
①相比vector来说,扩容的代价比较低,vector扩容需要搬用元素,而deque则只需要增加一段线性空间即可
②deque的头插头删、尾插尾删效率高,我们的vector在头插时可能需要扩容并且还要移动元素,因此效率极差
③支持随机访问,我们的list却没法做到
④其底层是连续空间,空间利用率比较高,不需要存储额外字段,这点list也做不到
缺点:
①deque的中间插入和删除就不是很好的了,因为这也是有可能需要移动大量的元素
这边有两种情况:每个buff数组不一样大,效率会提高,但是会影响随机访问效率;每个buff数组固定大小,牺牲中间插入删除效率,随机访问效率就变高了。我们的SGI的STL中使用是后者,为了保证随机访问
②最主要的缺点是没有vector和list的优点极致
③但是,deque有一个致命缺陷:不适合遍历,因为在遍历时,deque的迭代器要频繁的去检测其是否移动到某段小空间的边界,导致效率低下,而序列式场景中,可能需要经常遍历,因此在实际中,需要线性结构时,大多数情况下优先考虑vector和list,deque的应用并不多,而目前能看到的一个应用就是,STL用其作为stack和queue的底层数据结构

stack和queue选择deque作为底层默认的容器是因为deque完美的避开了缺点,展现出了优点。stack和queue不需要遍历(因此stack和queue没有迭代器),只需要在固定的一端或者两端进行操作。在stack中元素增长时,deque比vector的效率高(扩容时不需要搬移大量数据),内存利用率比list高;queue中的元素增长时,deque不仅效率高;出队时,头删效率也高,比vector强,而且内存使用率高,比list强。

🌸🌸C++STL之stack和queue的知识大概就讲到这里啦,博主后续会继续更新更多C++的相关知识,干货满满,如果觉得博主写的还不错的话,希望各位小伙伴不要吝啬手中的三连哦!你们的支持是博主坚持创作的动力!💪💪

;