一、stack栈
介绍
1.栈是一种特殊的线性表,其元素遵循“后进先出”的原则,即仅允许在在表的一端进行插入、删除操作,这一模式被称为“后进先出”或LIFO(last in fisrt out)。
2.从底层实现来看,stack是作为容器适配器被实现的,什么是容器适配器?我们来解释一下先。
先来看看我们身边的适配器。比方说,你有注意到笔记本电脑的充电器吗?
其实,笔记本的充电器就是一个适配器。适配器要做的就是电压的转换。一般来说,电脑的电池电压为14V左右,而标准电压为220V,要想给电脑充电,这就需要对标准电压进行转换,这就是适配器发挥的作用,适配就是转换的意思。
适配器是一种设计模式,该模式将一个类的接口转换为用户希望的另一个类的接口,你可以认为适配器是一种转换器。
容器适配器,能让程序员选择一种合适的底层数据结构。如stack,之前我们写c语言时,要用到栈的数据结构时,是不是得还先模拟一个栈出来?
那现在就不用这么麻烦了,要用到栈和队列的地方,直接用就行。
3.stack的底层容器可以是 任何容器类模板 or 其他特定的容器类。
这些容器类应该支持以下操作:
empty:判空操作
back:获取尾部元素操作
push_back:尾部插入元素操作
pop_back:尾部删除元素操作
栈的底层实现(简易版):
可以看到,栈的这些方法都是直接复用容器的方法,体现了代码复用的思想。
这里的Container就是容器类,它可以是vector、deque、list,也可以省略不传。若省略,那默认情况下是deque类。
使用
使用时要包<stack>头文件。
栈提供的方法有:判空、取大小、取栈顶数据、压栈(插入)、出栈(删除)、交换 还有emplace安放。
这里对其中几点方法做个说明:
1.构造函数:
stack<Type, Container> (<数据类型,容器类型>) stackName;
数据类型一定要有,容器类型可以是vector、deque、list。(容器类型可省略,默认是deque)
2.在用top()前,得判断下栈是否为空。只有不为空的时候,才能调用top(),不然会发生段错误。
这是因为,在栈为空时,stack 的top函数返回的是超尾-1,而不是NULL。
3.和上面的top一样,在调用pop函数前,也要确保栈中至少有一个元素。
如果栈为空,调用pop会抛出EmptyStackException异常。
4.关于emplace,意为“安放”,就是说,构造一个新的元素放入栈顶。emplace和push很像,这里做一下区分。
push的话,得是你先构造好对象,然后push插进栈里;emplace则是主动帮你调用构造函数,构造出对象,然后插进栈里。
示例:
#include<iostream>
#include<stack>
using namespace std;
int main() {
stack<int> st;
st.push(1);
st.push(2);
st.push(3);
while (!st.empty()) {
cout << st.top() << " ";
st.pop();
}
cout << endl;
return 0;
}
从底层来看,Container是一个模板,你传数组or链表都可以。
来看看它是怎么适配的:
二、queue队列
介绍
1.队列是一种特殊的线性表,它只能在队尾插入数据,队头出数据,这一模式被称为“先进先出”或FIFO(first in first out)。
2.从底层实现来看,queue也是作为容器适配器被实现的。
展示下queue简易版的底层实现:
底层容器可以是标准容器类模板之一,也可以是其他专门设计的容器类。
该底层容器应至少支持以下操作:
empty:检测队列是否为空
size:返回队列中有效元素的个数
front:返回队头元素的引用
back:返回队尾元素的引用
push_back:在队列尾部入队列
pop_front:在队列头部出队列
标准容器类deque和list满足了这些要求。也就是说,Container的位置可以传deque/ list过去,也可以省略不传。当省略不传时,那默认Container是deque。
(为什么vector不满足呢?因为vector没有头删。之前我们说过,vector嫌头删效率低就没实现)
使用
使用时要包头文件<queue>。
根据队列的特性,实现的方法有:判空、取大小、取队头、取队尾、(队尾)插入、(队头)删除、交换 和emplace安放。
几点说明:
1.构造函数:
queue<Type, Container> (<数据类型,容器类型>) queueName;
在初始化时必须要有数据类型,容器可省略,省略时默认为deque 类型。
所以构造一个queue可以是这样两种情况:
queue<int> q; queue<int,list<int>> q;
示例:
#include<iostream>
#include<queue>
#include<vector>
using namespace std;
int main() {
queue<int> q;
q.push(1);
q.push(2);
q.push(3);
while (!q.empty()) {
cout << q.front()<<" ";
q.pop();
}
cout << endl;
return 0;
}
这张图说明了,为什么deque是容器适配器:
三、priority_queue优先级队列
介绍
1.优先级队列是一种特殊的队列结构,它队列中的顺序并非插入的顺序,而是按权重来排序。
它给每个元素加上了优先级,每次出队的元素是队列中优先级最高的那个元素。
注:不是越大的元素,优先级就越高。
那优先级是如何评定的呢?其实,优先级队列在创建伊始,它的优先级就已经定好了,默认为降序。
那要如何排升序呢?一会会在仿函数那说到。
2.优先级队列 底层被实现为容器适配器。
底层容器可以是任何标准容器类模板,也可以是其他特定设计的容器类。
容器应该可以通过随机访问迭代器访问,并支持以下操作:
empty():检测容器是否为空
size():返回容器中有效元素个数
front():返回容器中第一个元素的引用
push_back():在容器尾部插入元素
pop_back():删除容器尾部元素
容器类vector和deque满足这些需求。如果容器类被省略不写,那默认使用vector。
3.需要支持随机访问迭代器,以便始终在内部保持堆结构。
priority_queue是用堆实现的,因此priority_queue就是堆,所有需要用到堆的位置,都可以考虑使用priority_queue。
注:默认情况下priority_queue是大堆。
使用
使用时要包头文件<queue>。
注:取顶端数据不是用front了!是用top。
示例:
#include<iostream>
#include<queue>
using namespace std;
int main() {
priority_queue<int> q;
q.push(4);
q.push(12);
q.push(3);
while (!q.empty()) {
cout << q.top()<<" ";
q.pop();
}
cout << endl;
return 0;
}
模拟实现
priority_queue具有队列的所有特性,只是在此基础上又在内部加入了排序。它的底层原理是用堆实现的。
现在我们来模拟实现一下它的简易版,这样能更好地理解它的底层原理。
➡️复用代码
先把top、empty这种,通过复用代码而实现的 函数给写了:
#pragma once
#include<iostream>
#include<vector>
using namespace std;
namespace jzy
{
template<class T,class Container = vector<T>> //T是优先级队列中的 数据存储类型
class priority_queue //Container是优先级队列中的数据结构
{
public:
void push(const T& val) {}
void pop() {}
T top() {
return _con.front(); //直接复用容器的方法
}
bool empty() {
return _con.empty();
}
size_t size() {
return _con.size();
}
private:
T _val;
Container _con;
};
}
➡️push
push的实现:(大根堆)先尾插再向上调整。
void AdjustUp(Container& _con) {
int child = _con.size()-1;
int parent = (child - 1) / 2;
while (child > 0){
if (_con[child] > _con[parent]) {
swap(_con[child], _con[parent]);
child = parent;
parent = (child - 1) / 2;
}
else {
break;
}
}
}
void push(const T& val) {
_con.push_back(val);
AdjustUp(_con);
}
➡️pop
pop的实现:先把首尾交换,让优先级最高的元素来到队尾,以便尾删。再向下调整。
void AdjustDown(Container& _con) {
int parent = 0;
int child = 2 * parent + 1;
while (child < _con.size()) {
if (child + 1 < _con.size() && _con[child + 1] > _con[child]) {
child++;
}
if(_con[parent] <_con[child]) {
swap(_con[parent], _con[child]);
parent = child;
child = 2 * parent + 1;
}
else {
break;
}
}
}
void pop() {
swap(_con.front(), _con[_con.size()-1]);
_con.pop_back();
AdjustDown(_con);
}
➡️如何排降序
目前排的是升序,那要想排降序,要怎么办呢?
我倒是有个很朴素的办法:每次插入元素,向上调整时,我把小的往上调,不就行了吗?
其实就只要改个符号:
void AdjustUp(Container& _con) {
int child = _con.size()-1;
int parent = (child - 1) / 2;
while (child > 0){
if (_con[child] < _con[parent]) { //这里原本是>,现在给改成<。越小越往上调
swap(_con[child], _con[parent]);
child = parent;
parent = (child - 1) / 2;
}
else {
break;
}
}
}
但是,这种方法并不好。我想排升序,要把符号改成>;想排降序,又要改回<,麻烦。
能不能用泛型解决呢?
其实在STL库里,就是用泛型解决的,我们来学习一下。
STL里,priority_queue有3个模板参数:
template <class T, class Container = vector<T>, class Compare = less<typename Container::value_type>>
第三个模板参数Compare,定义了比较的方式。就是说,我们通过定义Compare类,来制定比较的规则。
不过,要想理解参数Compare,我们得先学习一个知识点:仿函数。
仿函数
仿函数(functor)不是函数,它描述的是对象,只不过这个对象重载了operator(),所以它使用起来像函数一样。
比方说,你定义了一个A类,用A实例化出对象a。此时a是一个普通的对象。
但当你在A里面重载了operator()(即函数调用运算符),那a就是仿函数,就可以这样用:
class A
{
……
int operator() (int x,int y){
return x+y;
}
};
int mian(){
A a;
cout<<a(1,2); //A实例化出的对象a,就可以像函数一样使用,a就叫仿函数
return 0;
}
关于():
我一开始大为不解,怎么圆括号()也能重载?
没错!()叫做”函数调用运算符“,我们在调用函数时,往往要传参,此时就用到这个运算符。
所以说,要想让对象成为仿函数,只需要在它的类里面重载operator()。
用仿函数实现升降序
先实现两个类:Greater、Less,分别用于定义升序、降序(这俩类实现为模板)。
然后在priority_queue类中增加一个模板参数compare,此参数用于接收 比较方式 的类型(是升序,还是降序)。
compare默认为降序。当我们想要升序,那就在传参时,实例化出Greator类型的对象,传过去即可。
//先实现Less和Greater两个类,来定义比大小的规则
template<class T>
struct Less //less意为越来越小,即降序
{
bool operator() (const T& x, const T& y) {
return x > y;
}
};
template<class T>
struct Greater //升序
{
bool operator() (const T& x, const T& y) {
return x < y;
}
};
template<class T,class Container = vector<T>,class Compare=Less<T>> //增加模板参数,默认为降序
class priority_queue
{
Compare com;
public:
void AdjustUp(Container& _con) {
int child = _con.size()-1;
int parent = (child - 1) / 2;
while (child > 0){
if (com(_con[child],_con[parent])) {
swap(_con[child], _con[parent]);
child = parent;
parent = (child - 1) / 2;
}
else {
break;
}
}
}
void AdjustDown(Container& _con) {
Compare com;
int parent = 0;
int child = 2 * parent + 1;
while (child < _con.size()) {
if (child + 1 < _con.size() && com(_con[child+1],_con[child])) {
child++;
}
if(com(_con[child], _con[parent])) {
swap(_con[parent], _con[child]);
parent = child;
child = 2 * parent + 1;
}
else {
break;
}
}
}
void push(const T& val) {
_con.push_back(val);
AdjustUp(_con);
}
void pop() {
swap(_con.front(), _con[_con.size()-1]);
_con.pop_back();
AdjustDown(_con);
}
……
};
注:因为我们展开了STL库,所以在命名Less/Greater时,注意不要写成less/greater,不然就和库里的重名了,编译器就就晕了 不知道用哪个。
测试:排升序
#include"priority_queue.h"
using namespace jzy;
int main() {
priority_queue<int,vector<int>,Greater<int>> pq;
pq.push(7);
pq.push(3);
pq.push(8);
pq.push(2);
pq.push(12);
pq.push(6);
pq.push(3);
while (!pq.empty()) {
cout << pq.top() << " ";
pq.pop();
}
return 0;
}
模拟实现的总代码
priority_queue.h:
#pragma once
#include<iostream>
#include<vector>
using namespace std;
namespace jzy
{
template<class T>
struct Less //less意为越来越小,即降序
{
bool operator() (const T& x, const T& y) {
return x > y;
}
};
template<class T>
struct Greater //升序
{
bool operator() (const T& x, const T& y) {
return x < y;
}
};
template<class T,class Container = vector<T>,class Compare=Less<T>>
class priority_queue
{
Compare com;
public:
void AdjustUp(Container& _con) {
int child = _con.size()-1;
int parent = (child - 1) / 2;
while (child > 0){
if (com(_con[child],_con[parent])) {
swap(_con[child], _con[parent]);
child = parent;
parent = (child - 1) / 2;
}
else {
break;
}
}
}
void AdjustDown(Container& _con) {
Compare com;
int parent = 0;
int child = 2 * parent + 1;
while (child < _con.size()) {
if (child + 1 < _con.size() && com(_con[child+1],_con[child])) {
child++;
}
if(com(_con[child], _con[parent])) {
swap(_con[parent], _con[child]);
parent = child;
child = 2 * parent + 1;
}
else {
break;
}
}
}
void push(const T& val) {
_con.push_back(val);
AdjustUp(_con);
}
void pop() {
swap(_con.front(), _con[_con.size()-1]);
_con.pop_back();
AdjustDown(_con);
}
T top() {
return _con.front();
}
bool empty() {
return _con.empty();
}
size_t size() {
return _con.size();
}
private:
T _val;
Container _con;
};
}
四、deque双端队列
(这个容器了解即可,这个容器很少用)
介绍
1.deque是“double-ended queue”的缩写。
虽然叫双端队列,但它和队列没啥关系。deque是一种双开口的"连续"空间的数据结构。双开口的含义是:可以在头尾两端进行插入和删除操作,且时间复杂度为O(1)。
deque支持随机访问,也支持任意位置的插入、删除。似乎,deque结合了vector和list的优点。
但,deque真的是完美的吗?
并不是!deque随机访问的效率比较低。至于为什么,那先让我们了解一下它的底层实现。
底层实现
和vector不同,deque的底层并不是 真正意义上的连续的空间,而是由一段段连续的小空间拼接而成的,这些小空间不一定是连续的,可能是位于内存的不同区域。如图:
这里的map,并不是STL里的map容器。而是数组,数组的每个元素都是 指向另一块连续空间(缓冲区buffer)的 指针。我们插入的元素,实际上是存进buffer里的。
可见,deque对空间利用率很高,几乎没什么空间的浪费。
➡️如何进行扩容呢?
当一个buffer存满了,要尾插,此时不会给当前buffer扩容(因为它是大小固定的),而是再开一个buffer空间,尾插的内容存进新的buffer里。指针数组的下一个元素指向新的buffer。头插同理。下图可以帮助我们理解:
可见,头插、尾插不需要挪动数据,效率自然高。
如果map满了,那就再开一个更大的map,如何把数据拷到新的map里。
➡️关于deque的迭代器,它的原理很复杂,由四个指针组成。node指向中控区的指针数组,first和node分别指向一段空间的开始和结束,cur指向访问到的位置。如果访问的元素不在当前空间,cur就等于下一个空间的first,再继续访问。
➡️当需要随机访问时,deque得先完整复制到一个vector上,如何vector排序后,再复制deque。这就导致了deque的随机访问效率较vector更低。
关于deque的小结
优势:1.相比vector,deque的头插、头删不需要挪动数据,而是直接开新buffer插入,效率很高;
在扩容时,也无需开新空间、拷数据,而是直接开个新buffer,效率高。
2.相比list,deque的底层是连续的存指针的map数组,空间利用率比较高,不需要存储额外字段。况且,deque还支持随机访问,虽然效率不高。
劣势:1.不适合遍历。
因为在遍历时,deque的迭代器要频繁的去检测其是否移动到某段小空间的边界,导致效率低下。
而序列式场景中,可能需要经常遍历,因此在实际中,需要线性结构时,大多数情况下优先考虑vector和list,deque的应用并不多,而目前能看到的一个应用就是,STL用其作为stack和queue的底层数据结构。
2.中间插入、删除的效率并不高。(因为下标需要经过计算,并且要挪动元素)