stack 与 queue
1. stack 的介绍


前面已经介绍过 string,vector,list 等容器,其底层数据结构是顺序表或链表。本篇的栈与队列同属数据结构,但 stack 和 queue 有不同的原因和用途,C++把它们归属于配接器。
什么是 stack ?它是一种容器适配器(container adaptor),特别设计为后进先出 (LIFO)的数据结构。对于容器,类的模板参数除了代表数据类型 的 T,另一个参数一般是 allocator,即空间配置器/内存池。对于 stack 配接器,空间配置器参数变成了 Container (容器),参数默认值是 deque,这是配接器与容器的第一个不同。


相比前几个容器各种各样的接口,stack 大大简化,接口主要是栈的相关操作,例如压栈 push、出栈 pop 以及获取栈顶元素 top 等。具体成员函数包括:

2. queue 的介绍


什么是 queue?与 stack 一样,作为容器适配器(container adaptor),特别设计为先进先出(FIFO)的数据结构,队尾入,队头出。相比容器,同样有一个代表数据类型 的模板参数 T,且另一个参数变成了 Container (容器),默认值是 deque,与 stack 一致,可见这是适配器的共同点。
queue 的接口同样十分简单,大多是队列的相关操作,例如入队 push、出队 pop 以及获取队头 front 或队尾 back 的数据等。具体成员函数包括:

3. stack 与 queue 的使用
若需要使用栈 或队列 的数据结构,直接用对应的 stack 或 queue 即可,只需包含对应头文件 <stack> 或 <queue>。
下面用几道OJ题演示其使用。
例题 3.1

题意解析:要求实现栈的相关操作,包括栈的初始化、入栈、出栈、获取栈顶元素。同时,此题关键是要实现常数时间内获取栈中最小元素。
实现思路:该题关键是常数时间内获取最小元素。可用两个栈实现,特别维护一个有序栈。一个栈正常使用,有序栈除了第一个元素,后续元素当且仅当小于等于栈顶才入栈。这样,有序栈的栈顶就可以存储最小元素,且维护了一个小于栈底元素的有序序列。当有序栈栈顶元素与普通栈栈顶元素相同时,就需要出栈,保证两个栈的数据一致性。
代码示例 3.1
cpp
class MinStack
{
private:
// int 为栈中元素类型
stack<int> _st; // 普通栈
stack<int> _minst; // 有序栈
public:
MinStack()
{
// 默认构造:初始化堆栈对象
// 此处可删除默认构造,也可显式写但不写内容
/* 因为,编译器默认生成默认构造,成员变量初始化一定会走构造函数的初始化列表,
内置类型,有缺省值用缺省值初始化,没有则初始化为随机值;自定义类型,一定调用
自己的构造函数初始化。此处使用的两个 stack 成员变量是库中实现好的类,不管写
不写,两个自定义类型的成员变量最终都会调用自己的构造函数初始化。*/
}
// 压栈:普通栈正常入,有序栈为空或者 val 小于等于栈顶元素
void push(int val)
{
_st.push(val);
if (_minst.empty() || val <= _minst.top())
_minst.push(val);
}
// 出栈:普通栈正常出,有序栈栈顶元素与普通栈栈顶元素相同,就需要出栈
void pop()
{
if (_st.top() == _minst.top())
_minst.pop();
_st.pop();
}
// 获取栈顶元素:返回普通栈栈顶元素
int top() { return _st.top(); }
// 获取栈中最小元素:返回有序栈栈顶元素
int getMin() { return _minst.top(); }
};
例题 3.2
牛客(nowcoder)JZ31------栈的压入序列与弹出序列

题意解析:给出两个序列,一个序列是元素的入栈顺序,一个序列是元素的出栈顺序,要求检查该出栈序列是否可能对应入栈序列。
实现思路:根据入栈序列模拟,是否能实现该出栈顺序。入栈序列的元素逐个入栈,每入一个则检查出栈序列,此时的栈顶元素是否该出栈,元素匹配则出栈,不匹配则连续入栈。可能匹配上就连续匹配,则要连续出栈。直到入栈序列遍历完,则匹配结束。此时,栈出完为空或出栈序列走到末尾,说明模拟成功;若栈没有出完,不为空或出栈序列没有走完,则说明模拟失败。
代码示例 3.2
cpp
class Solution
{
public:
// pushV:入栈序列 popV:出栈序列
bool IsPopOrder(vector<int>& pushV, vector<int>& popV)
{
size_t popi = 0; // 用于遍历出栈序列
stack<int> _st; // 模拟用的栈
for (auto& e : pushV) // 遍历入栈序列,逐个入栈,不断比较栈顶元素
{
// 先入栈
_st.push(e);
// 再比较栈顶元素(是否与待出栈的元素对应)
// 1、若匹配则出栈,出栈可能是连续的,需要用循环
// 2、若不匹配,则进入下一轮入栈比较
while (!_st.empty() && _st.top() == popV[popi])
{
_st.pop(); // 出栈
++popi; // 出栈序列往后走
}
/* 需注意:出栈过程可能是连续的,可能入栈序列遍历的中途出空,因此 while 循环
出栈的过程中,除了不匹配,栈为空也不能出栈。例如:入栈序列[1,2,3,4,5],出栈
序列[4,3,2,1,5]。1、2、3、4 依次入栈,4 成为栈顶元素,与待出栈的值匹配,则
出栈。3 成为新栈顶元素,同时待出栈的值也变成 3,连续匹配则连续出栈,2、1同理
,while 循环中途栈连续出空。空栈是不能访问栈顶的,因此需要判空进行边界检查。
*/
}
// 入栈序列遍历结束则模拟结束,此时栈为空则模拟成功,不为空则模拟失败
return _st.empty(); // 栈为空则返回 true,否则返回 false
}
};
例题 3.3

题意解析:用两个栈实现队列的4个操作:入队 push、出队 pop、判空 empty、获取队头元素 peek。
实现思路:栈后进先出,栈顶入,栈顶出;队列先进先出,队尾入,队头出。栈顶对应队尾,但出数据的方向相反。因此,栈模拟队列的关键在于如何出队,队头对应栈底,但栈底不能出数据。可以通过两个栈辗转数据,数据从一个栈到另一个栈的过程其实相当于一次逆序,原本的栈底成了另一个栈的栈顶。相当于两个栈,一个栈顶为队尾,另一个栈顶为队头。只需要一个栈专门入,一个栈专门出即可。
代码示例 3.3
cpp
class MyQueue
{
private:
stack<int> _pushST; // 专门入栈(入队),栈顶即队尾
stack<int> _popST; // 专门出栈(出队),栈顶即队头
// 两种情况:
// 1、队列为空,则两个栈都为空
// 2、队列不为空,则数据在push栈或pop栈,取决于上一次操作将数据辗转到了哪里
public:
MyQueue()
{
}
// 入队:从队尾入,入push栈
void push(int x)
{
// 先确保数据在push栈中(pop栈为空即可)
// pop栈为空说明数据都在push栈,或者两个栈都为空(队列为空),两种情况都允许入队
while (!_popST.empty())
{
_pushST.push(_popST.top());
_popST.pop();
}
_pushST.push(x); // push栈栈顶即队尾
}
// 出队:从队头出,pop栈出
int pop()
{
// 先确保数据在pop栈中
while (!_pushST.empty())
{
_popST.push(_pushST.top());
_pushST.pop();
}
// 还需要防止两个栈都为空(队列为空),有数据才可以出队
assert(_popST.empty() != true); // assert断言检查
// 有数据,允许出队,pop栈栈顶即队头
int ret = _popST.top();
_popST.pop();
return ret;
}
// 获取队头元素:pop栈的栈顶元素即队头元素
int peek()
{
// 先确保数据都在pop栈中
while (!_pushST.empty())
{
_popST.push(_pushST.top());
_pushST.pop();
}
// 队列可能为空,避免访问空栈,断言检查
assert(_popST.empty() != true);
return _popST.top();
}
// 判空:push栈与pop栈同时为空
bool empty()
{
return _pushST.empty() && _popST.empty();
}
};
例题 3.4

题意解析:给出一棵二叉树,层序遍历所有结点(逐层遍历,从左往右),并将结点值存储到一个二维数组中返回,二维数组的一行则代表一层,一行一列则代表一个结点。
实现思路:这道题的关键在于如何获取一层结点存储到二维数组中,需要区分结点的层级。
- 方法1:借助例题 3.1 中最小栈的思路,使用两个队列。一个队列专门存储结点,一个队列专门存储结点的层级。例如根结点3入队,层级为1,同步入另一个队列。第一层入完,再入第二层,根结点出队,根结点的层级出队,根结点的左孩子、右孩子依次入队,同时,根据根结点的层级可以获取其孩子的层级,同步入另一个队列。第二层入完,再入第三层,第三层同理,根结点的左孩子为队头,先出,则其左、右孩子先入,入队顺序符合层序遍历的顺序,重复即可。
方法1用了两个队列,有一定的空间消耗。稍作改良,也可以用一个队列。
- 方法2:逐个结点入队变成逐层入队。只需要借助1个额外变量 levelSize 即可。如果我们知道了当前层的结点个数,我们就能控制结点整层入或整层出。队列中就不会上下层结点混杂,同一时间只存在一层结点,轻松获取层数。那么,如何获取每层结点数呢?其实就是队列的元素个数。根结点整层入,队列元素个数为1,第一层结点数为1,根据结点数就能整层出,出完即可存储一层元素;再入第二层,根结点的左、右孩子,结点数为2,队列元素个数为2,同样根据结点数整层出完并存储。实际遍历过程中,队列仍然会同时存在上下层结点,出队的本质不会改变,结点还是一个个出。但知道了上层结点数,就可以控制上层结点连续出完并存储。出队的同时,上层结点的左、右孩子连续入队,上层结点出完的同时,相当于下层结点全部入完,此时队列的元素个数就相当于新一层的结点个数。对于二维数组结点是整层出即可整层存储。
代码示例 3.4
cpp
class Solution
{
public:
vector<vector<int>> levelOrder(TreeNode* root)
{
vector<vector<int>> vv; // 二维数组存储遍历的值
queue<TreeNode*> q; // 队列存储逐层结点
int levelSize = 0; // 当前层的结点个数
if (root)
{
// 先将根结点入队
q.push(root);
levelSize = 1; // 当前层结点个数为1
}
while (!q.empty())
{
vector<int> v; // 存储当前层所有结点
// 根据当前层结点数将结点整层出队,同时将左右孩子整层入队
while (levelSize--)
{
TreeNode* front = q.front(); // 临时存储当前结点
q.pop(); // 结点出队
v.push_back(front->val); // 将结点值存入临时的一维数组
// 左右孩子依次入队
if (front->left)
q.push(front->left);
if (front->right)
q.push(front->right);
}
// 此时,上层结点整层出完,下层结点整层入队,队列元素个数即下一层结点数
// 临时一维数组中存储了上层整层的所有结点值,存入二维数组即可
vv.push_back(v);
levelSize = q.size();
}
return vv;
}
};
4. stack 与 queue 的底层实现
4.1 适配器模式
适配器是设计模式的一种(设计模式共23种,迭代器模式属于其中之一)。
设计模式是一套被反复使用、多数人知晓、分类编目的、代码经验的设计总结。其功能是将一个类的接口转换成客户希望的另外一个接口。
例如日常生活中,中国家庭电路的标准电压是220V,出于绝对安全和特定功能的考虑,许多设备在入户的220V电压基础上,通过专用设备转换出了更低的直流或交流电压。如:手机充电器、笔记本电脑电源适配器,将220V交流电转换为5V、12V、20V等安全的直流电;智能家居设备(如智能门锁、网络摄像头、音箱)、LED灯带、USB接口的灯具,通常使用12V或24V直流电驱动。各种++适配器在其中扮演了转换的角色++。
如图,用户现有一个两插头的实例,因此目标接口是两孔接口(支持两插头),但现有接口(被适配者接口)是三孔接口,只支持三插头,目标与现有不匹配。适配器既有目标接口(两孔接口),又持有三插头实例(可插入三孔接口),可实现现有到目标的转换。

4.2 STL标准库中 stack 和 queue 的底层结构

我们思考一个问题:能否用其他容器封装转换一下变成栈?其实是可行的,因为数据结构之间是可以转换的。
栈需要在一端(栈顶)插入或删除,只要支持该特性就可以使用。vector 和 list 底层数据结构分别是顺序表和链表,都可以支持一端插入或删除,因此,它们天然符合栈的结构,只需要简单封装即可。
例如:
代码示例 4.2.1
cpp
#include <iostream>
#include <vector>
#include <list>
// 通过模板参数Container,就无需指定栈底层是vector还是list
// 自由选择栈的底层结构,顺序栈(数组),或链式栈(链表),或其他数据结构
template<class T, class Container = vector<T>>
class stack
{
private:
Container _con;
};
// 例如:
int main()
{
stack<int> st1; // 元素类型为int,容器默认为vector
stack<char, list<char>> st2; // 元素类型为char,指定list容器
return 0;
}
C语言中栈的实现就是用的顺序表,只不过是以静态或动态数组的形式,vector 容器本质是相同的,只不过多了层封装,list 同理。之前C语言都使用顺序表实现栈,只是因为顺序表更优,内存连续,且无需额外的成员变量 next 指针管理结点。这并不意味着链表不可用,特殊场景下,例如单个结点占用内存较大时,用链表实现栈反而比顺序表更优,各结点内存单独分配,动态申请。
为什么说 stack 是容器适配器(container adaptor)?因为它的实现基于适配器模式,将一个类的接口转换成客户希望的另外一个接口,类内部实现的就是适配转换的过程,即顺序表/链表,或者说容器 Container 到栈的过程。
队列同理,只不过是从后进先出(LILO)的栈的数据结构变为先进先出(FIFO)的队列的数据结构而已。
4.3 stack 的模拟实现
由于成员变量 Container _con 是自定义类型,默认构造/拷贝构造/析构/赋值重载等默认成员函数都会调用 _con 容器自身实现的,例如使用 vector 容器适配转换成 stack,默认调用标准库中 vector 已实现的默认成员函数。
这也是为什么不从零实现栈,反而借助其他容器封装实现的原因,代码复用即可,大大简化了模拟过程,需要关注的则是栈的相关操作,如压栈、出栈、获取栈顶元素等。
代码示例 4.3.1
cpp
#pragma once
#include<deque>
namespace david
{
template<class T, class Container = std::deque<T>>
class stack
{
public:
stack() {};
// 压栈
void push(const T& x)
{
_con.push_back(x); // 统一把尾部当作栈顶
}
// 出栈
void pop()
{
_con.pop_back();
}
// 获取栈顶元素
T& top()
{
// 不可用方括号[],容器未必是vector,list不支持该运算符重载
return _con.back();
}
const T& top() const
{
return _con.back();
}
// 返回栈中元素个数
size_t size() const
{
return _con.size();
}
// 判空
bool empty() const
{
return _con.empty();
}
private:
Container _con;
};
}
// 使用
// 可将stack类的实现放到头文件,包含头文件再使用,此处为演示直接在类下面使用
#include <iostream>
#include <vector>
#include <list>
int main()
{
david::stack<int, std::vector<int>> st1;
st1.push(1);
st1.push(2);
st1.push(3);
while (!st1.empty())
{
int top = st1.top();
st1.pop();
std::cout << top << " ";
}
std::cout << "\n";
david::stack<int, std::list<int>> st2;
st2.push(4);
st2.push(5);
st2.push(6);
while (!st2.empty())
{
int top = st2.top();
st2.pop();
std::cout << top << " ";
}
std::cout << "\n";
david::stack<int> st3; // 不传容器类型则使用缺省值
st3.push(7);
st3.push(8);
st3.push(9);
while (!st3.empty())
{
int top = st3.top();
st3.pop();
std::cout << top << " ";
}
std::cout << "\n";
return 0;
}
运行结果:
4.4 queue 的模拟实现
队列要求先进先出(FIFO),一端入,另一端出,使用 vector 就不太合适了,list 反而更适合。而且标准库中 vector 只支持尾插尾删,不支持头插头删,或者说只能间接支持,例如 insert 和 erase 可间接实现头插/头删。当然,顺序表头插/头删效率太低了,对于队列而言,没必要特别支持 vector。
代码示例 4.4.1
cpp
#pragma once
#include<deque>
namespace david
{
template<class T, class Container = std::deque<T>>
class queue
{
public:
queue() {};
// 入队
void push(const T& x)
{
_con.push_back(x); // 统一将尾部当作队尾
}
// 出队
void pop()
{
_con.pop_front(); // 统一将头部当作队头
}
// 返回队尾元素
T& back()
{
return _con.back();
}
const T& back() const
{
return _con.back();
}
// 返回队头元素
T& front()
{
return _con.front();
}
const T& front() const
{
return _con.front();
}
// 返回队列中元素个数
size_t size() const
{
return _con.size();
}
// 判空
bool empty() const
{
return _con.empty();
}
private:
Container _con;
};
}
// 使用
#include <iostream>
#include <vector>
#include <list>
int main()
{
david::queue<int, std::vector<int>> q;
q.push(1);
q.push(2);
q.push(3);
// q.pop();
return 0;
}
基于 vector 问题,queue 的使用要引申一点:模板的按需实例化。
模板类的成员函数按需实例化,只在使用时实例化,并非全部实例化。例如出队操作 pop 模拟实现中仍采用 pop_front 进行头删,但 vector 并不直接支持头删,因此,实例化时 queue<int, std::vector<int>> q 使用 vector 容器是有问题的。但上述代码是可以编译通过的。
问题就在于按需实例化,没有使用就没有实例化。代码中只使用了 push 函数,push 底层使用的是尾插 push_back,vector 支持自然没有问题。但如果解除注释,pop 函数被使用就需要实例化,此时编译就会因为无法找到 pop_front 报错。

4.5 deque 的简单介绍(了解)
在 stack (栈) 和 queue (队列) 模拟实现中,都采用 deque (双端队列) 作为默认容器。值得澄清的是,deque 和 queue 虽然都是队列,但不可混为一谈。
- queue 是容器适配器(container adaptor),deque 是容器(container);
- queue 要求先进先出,deque 不要求。

4.5.1 vector 和 list 的优缺点
为什么会出现 deque 呢?因为不管是 vector 还是 list,它们的优缺点都太明显了。
vector 优点:
- 尾插/尾删效率高,支持下标随机访问
- 物理空间连续(高速缓存利用率高)
vector 缺点:
- 需要扩容,扩容有一定代价(移动数据有效率损耗,2倍扩容存在空间浪费)
- 头部或中间插入/删除效率低
list 优点:
- 按需申请/释放空间,无需扩容
- 支持任意位置的插入/删除,效率高
list 缺点:
- 不支持随机访问
4.5.2 定义与接口
定义
deque (双端队列):一种双开口的"连续"空间的数据结构,类似 vector 顺序表和 list 链表的缝合怪。双开口含义是:可在头尾两端插入/删除,且时间复杂度为 O(1)。
- deque 解决了 vector 头插/头删效率低的问题,且不需要搬移元素,融合了 list 的特点
- 相比 list,内存相对连续,空间利用率比较高,且支持随机访问,融合了 vector 的特点

接口
可预见,deque 容器必然也结合了 vector 和 list 的接口,尾插、尾删、头插、头删,包括方括号 \[\] 运算符重载,通过下标+方括号 \[\] 的方式随机访问。


4.5.3 原理介绍
deque 并不是真正连续的空间,而是由一段段连续的小空间(buffer缓冲数组)拼接而成,但相比 list,它接近连续,空间利用率更高。各个小的缓冲数组相当于"链表的结点",管理各个"结点"的不是 next 指针,或 prev 指针,而是中控数组。


C语言中介绍过,数组名就是指针,arr0 相当于指针运算 arr + 0。同理,buf00 相当于指针运算 *(*(buf + 0) + 0)。第一层,内层的指针运算与解引用 *(buf + 0) 获取中控数组中下标为0处的数组指针;第二层,外层的指针运算与解引用 *(*(buf + 0) + 0) 获取缓冲数组中下标为0处的元素。这便是双端队列实现随机访问的关键,效率不如顺序表,但也还不错。
缓冲数组类似链表结点,按需申请,无需扩容,缓冲数组满了就申请新的缓冲数组,只需要把新的 buf 填入中控数组即可。但双端队列同样需要扩容 ,中控数组是动态申请的,中控数组的大小决定了 buf 的个数 ,缓冲数组大小通常固定,间接决定了总元素个数。因此,随着开辟的缓冲数组的增加,中控数组就需要扩容。
双端队列优势在于扩容代价低。顺序表扩容需要移动所有元素,但++中控数组只需要拷贝指针,扩容需要的空间小,需要移动的数据量也少++。中控数组扩容1个元素(指针)只需要4字节空间(64位下为8字节),就可以多1个缓冲数组,大大减少了移动数据量和空间需求。
这里需要提及一个细节:第一个缓冲数组并非放在中控数组的开始位置,其通常位于中控数组的中间位置。头插就往前/往左走,开辟新的缓冲数组,插入元素到数组末尾,从后往前依次插入;尾插就往后/往右走,从前往后依次插入,如果放满N个元素则在后面开辟新的缓冲数组,继续插入新元素。

4.5.4 迭代器
双端队列本质仍是假象的连续空间,实际分段连续,为了维护实现"整体连续"以及随机访问,任务落在了 deque 的迭代器身上,因此 deque 的迭代器设计就非常关键,其实现也比较复杂,因为需要支持随机访问,所以 deque 的 iterator 必定是随机迭代器。

map 为中控数组,1个结点管理1个缓冲数组。deque 的迭代器封装了4个值:node 是二级指针,指向中控数组中的某个元素,即迭代器当前指向的 buffer;first 和 last 分别指向缓冲数组的开始与结束,cur 代表当前 buffer 的第i个元素。
deque 是如何借助其迭代器维护假想连续的结构呢?
deque 的成员变量有4个:map、map_size、start 和 finish。map 是指向中控数组的指针,map_size 是中控数组的大小,即元素个数,这2个变量方便扩容。重点是后2个变量,start 和 finish。start 对应 begin() 函数返回的迭代器,finish 对应 end() 函数返回的迭代器。

从图中可以看到,start 代表整个双端队列的开始,finish 代表整个双端队列的结束。start.node 指向中控数组的第一个 buffer,start.first 和 start.last 分别指向 buffer 的开始与结束,start.cur 指向 buffer 的第一个元素;finish.node 指向中控数组的最后一个 buffer,finish.first 和 finish.last 分别指向 buffer 的开始与结束,finish.cur 指向 buffer 的最后一个元素的下一位置。
那么 deque 容器的迭代器如何借助这两个成员变量进行遍历呢?
代码示例 4.5.1
cpp
iterator it = begin(); // 将begin返回的迭代器拷贝给it
while (it != end()) // 如果it等于end,说明it走到结尾
{
cout << *it << " ";
it++;
}
/*
1、begin()的实现返回start迭代器即可;end()的实现返回finish迭代器即可。
2、!=不等于的实现只需要比较cur是否相等即可。cur代表的是某个元素,元素在整个中控数组所有缓冲
数组中都是唯一的。
3、*解引用的实现只需要解引用cur即可,*cur,返回cur指向的元素。
4、前置++或者后置++的实现需要考虑:cur是否走到当前缓冲数组的末尾。
a. 若cur没有走到末尾,当前缓冲数组还有元素没有遍历,只需要++cur指向下一元素即可;
b. 若cur走到数组末尾,当前缓冲数组已经遍历完毕,则需要先更新node指向下一个缓冲数组,即
++node,然后更新first和last,指向下一个buffer的开始与结束,即first=*node,
last=*(node+N),cur指向下一个buffer的首元素,即cur=*node。继续++则回到①中情况
5、当it走到结尾,it和finish相等,则遍历结束
*/
基于上述结构,头插、尾插如何实现也很清楚了。
- 尾插:找到 finish,根据 cur 是否等于 last 判断当前 buffer 满没满。cur != last 说明没满,直接插入元素并 ++cur 即可;cur == last 说明满了,需要开辟新的缓冲数组,将指向新 buffer 的 node 从前往后插入中控数组并更新 finish(first/last分别指向新buffer的开始与结束,并++node),再插入新元素并更新 cur。当然,开辟缓冲数组前,需要先判断中控数组是否有多余空间存储新结点,中控数组满了需要扩容,先申请新空间,然后拷贝各个结点,最后更新 map 和 map_size。
- 头插:找到 start,判断 cur 是否等于 first。cur == first 说明头插不了,需要开辟新的缓冲数组,将 node 从后往前插入到中控数组并更新 start(first/last分别指向新buffer的开始与结束,并--node),再插入新元素并更新 cur(头插需注意,新元素是从buffer尾部依次向前插入);cur != first 说明可以直接在当前 buffer 插入,直接插入元素并 ++cur 即可。当然,头插同样需要考虑中控数组的扩容问题。
deque 的迭代器是随机迭代器,因此还需要支持 operator\[\]。

上面是 deque 的源码,可知其迭代器的 operator\[\] 实际复用了 operator+,operator+ 实际复用了 operator+=。相当于 arr2 本质是指针运算 *(arr + 2)。至于 + 是先拷贝临时对象,对临时对象进行 += 并返回,并不影响原本的对象。因此这3个运算符重载是可以互相复用的。
重点是 += 的逻辑。需要注意,start 和 finish 指向的首尾两个 buffer 并不确定是满的,头插/尾插可能只用了部分空间。但 start 和 finish 中间的 buffer 一定是满的,而且 buffer 的大小通常是固定的。因此 += 的实现,有一个关键的计算式 offset = n + (cur - first)。

list 内存不连续,结点按需申请,频繁申请/释放结点容易导致效率损耗,就像扩容十次1字节和扩容一次10字节,扩容涉及系统调用,系统调用越频繁开销越。而 deque 单个结点一次申请一段连续空间,相同数据量扩容次数少的多。
vector 不直接支持头插,需要搬移数据,效率很低。而且数据量越大,扩容一次的开销越大。deque 虽然也要扩容,但中控数组扩容代价小得多,不需要直接拷贝数据,只需要拷贝若干指针即可。
deque 通过中控数组(map)和迭代器(iterator)维护了"整体连续"的假象,支持O(1)的随机访问,尽管在海量数据遍历和随机访问的极致性能上略逊于 vector。
deque 的缺点也很明显:
- 迭代器设计略显复杂,随机访问效率不及原生的顺序表;
- 中间位置的插入/删除效率很低。为了保证缓冲数组大小固定,需要搬移数据;
- 不适合遍历(致命缺陷)。遍历时,deque 迭代器要频繁检测是否遍历到 buf 边界,导致效率低下。
实践中,需要线性结构通常优先考虑 vector 和 list,deque 的应用并不多,而目前能看到的一个应用就是,STL用其作为 stack 和 queue 的底层数据结构:
- 栈和队列没有迭代器,无需遍历;
- 相比 vector,deque 支持头部插入/删除,时间复杂度为O(1),支持队列两端插入/删除。且扩容代价更小,无需搬移拷贝数据,对于大型栈更友好;
- 相比 list,deque 空间相对连续,结点管理代价更低(存储更少的额外字段/指针),空间利用率更高。
下面通过代码实践验证 deque 相比 vector 的随机访问效率。sort 底层是快排,需要大量随机访问元素。先对 deque 和 vector 中的相同数据各自排序,对比耗时。再对比 deque 与相同数据从 deque 拷贝到 vector 中排序再拷贝回来的效率。
代码示例 4.5.2
cpp
#include <iostream>
#include <vector>
#include <deque>
#include <algorithm>
using namespace std;
#include <cstdlib> // srand/rand
#include <ctime> // time
void test_op1()
{
srand(time(0));
const int N = 1000000; // 一百万个数据
deque<int> dq;
vector<int> v;
for (int i = 0; i < N; ++i)
{
// rand 最多生成3万多随机数,+i避免重复数据
auto e = rand() + i;
// 相同数据分别存入vector和deque中
v.push_back(e);
dq.push_back(e);
}
// clock返回从程序开始执行到函数被调用时的处理器时钟打点数
// 要将其转换为秒,需要除以宏CLOCKS_PER_SEC
// Windows上,CLOCKS_PER_SEC通常是1000,所以clock()的返回值单位相当于毫秒
// 排序vector中的百万数据,计算耗时
int begin1 = clock();
sort(v.begin(), v.end());
int end1 = clock();
// 排序deque中相同的百万数据,计算耗时
int begin2 = clock();
sort(dq.begin(), dq.end());
int end2 = clock();
printf("vector:%d\n", end1 - begin1); // 单位毫秒
printf("deque:%d\n", end2 - begin2);
}
void test_op2()
{
srand(time(0));
const int N = 1000000;
deque<int> dq1;
deque<int> dq2;
for (int i = 0; i < N; ++i)
{
auto e = rand() + i;
dq1.push_back(e);
dq2.push_back(e);
}
// 排序deque中相同的百万数据,计算耗时
int begin1 = clock();
sort(dq1.begin(), dq1.end());
int end1 = clock();
// 将相同百万数据从deque拷贝到vector再排序,排序后再拷贝回去,计算耗时
int begin2 = clock();
vector<int> v(dq2.begin(), dq2.end()); // 拷贝到vector
sort(v.begin(), v.end()); // 排序
dq2.assign(v.begin(), v.end()); // 拷贝回deque
int end2 = clock();
printf("deque sort:%d\n", end1 - begin1);
printf("deque copy vector sort, copy back deque:%d\n", end2 - begin2);
}
int main()
{
test_op1();
test_op2();
return 0;
}
Debug下运行结果(4倍到5倍多):

Release下运行结果(1.5倍到2倍多):

5. priority_queue
5.1 priority_queue 的介绍

priority_queue,即优先队列 ,同样是容器适配器。优先队列其实就是**堆**,只不过一个是从应用的角度,一个是从数据结构的角度。priority_queue 头文件与 queue 相同。其成员函数和栈类似,值得注意的是 pop 和 top,取优先级高的元素进行删除或获取。默认大的值优先级高,若想改为小的值优先级高,则需要用到仿函数 Compare。

priority_queue 默认容器采用 vector。堆是一棵完全二叉树,大堆每一棵子树的根结点大于孩子结点,小堆每一棵子树的根结点小于孩子结点。priority_queue 插入/删除结点需要频繁调用 AdjustUp/AdjustDown 向上/向下调整,恢复堆结构。AdjustUp/AdjustDown 中又要频繁使用下标计算获取父子结点,随机访问频率高,用 deque不如 vector。
5.2 priority_queue 的使用
代码示例 5.2.1
cpp
#include <iostream>
#include <queue>
using namespace std;
int main()
{
// 默认大的值优先级高(建大堆,排降序)
priority_queue<int> pq;
pq.push(4);
pq.push(1);
pq.push(5);
pq.push(8);
pq.push(2);
pq.push(6);
pq.push(7);
pq.push(3);
while (!pq.empty())
{
cout << pq.top() << " ";
pq.pop();
}
cout << endl;
return 0;
}
运行结果:
力扣(LeetCode)215------数组中第K个最大元素

解题思路:利用优先队列存储数据,然后选取堆中前k-1个最大元素并删除,此时堆顶元素就是第k个最大元素。
代码示例 5.2.2
cpp
class Solution
{
public:
int findKthLargest(vector<int>& nums, int k)
{
priority_queue<int> pq(nums.begin(), nums.end());
for (int i = 0; i < k-1; i++) pq.pop();
return pq.top();
}
};
5.3 priority_queue 的模拟实现
5.3.1 仿函数

仿函数 (Functor),全称函数对象 (Function Object)。简单来说,仿函数就是一个**"像函数"的对象**。通过重载函数调用运算符
()使这个类的对象可以像函数一样使用。只要一个类 (class) 或结构体 (struct) 重载了 operator(),它就是一个仿函数。仿函数一般下为空类,没有成员变量,但也可以携带参数。普通函数是全局的,不使用全局变量或者用临时变量保存结果重新传参,下一次函数调用就无法记住上一次的调用结果。仿函数作为对象,可以通过构造函数传入参数,保存在成员变量携带,可记录调用状态或调用结果。
仿函数可作为模板参数,编译器在编译期就能确切知道调用的是哪个类的 operator(),因此极大概率会被内联展开,消除函数调用的栈帧开销。部分情况下需要使用函数指针调用函数,指针跳转会影响编译器判断普通函数能否内联展开。
代码示例 5.3.1.1
cpp
#include <iostream>
#include <algorithm>
using namespace std;
// 普通函数
bool myfunction(int i, int j)
{
return (i < j); // 返回bool值,i<j的比较结果
}
// 仿函数
class myclass
{
public:
bool operator() (int i, int j)
{
return (i < j); // 返回bool值,i<j的比较结果
}
};
// 仿函数也可以携带参数
struct Adder
{
// 成员变量
int base; // 记录上次调用结果/状态
// 成员函数
Adder(int b) // 构造函数
: base(b)
{
// 通过初始化列表初始化成员变量,保存状态/配置参数
}
int operator() (int x) const // ()运算符重载
{
return x + base; // 返回整数加base后的结果
}
};
int main()
{
// 普通函数
int arr[6] = { 4, 1, 3, 2, 6, 5 };
for (int i = 0; i < 6; i++)
{
cout << arr[i] << " ";
}
cout << endl;
// 函数名即函数指针,将普通函数myfunction作为函数指针传给sort,根据函数比较规则进行排序
sort(arr, arr + 6, myfunction);
for (int i = 0; i < 6; i++)
{
cout << arr[i] << " ";
}
cout << endl;
// 仿函数
int arr2[6] = { 4, 1, 3, 2, 6, 5 };
for (int i = 0; i < 6; i++)
{
cout << arr2[i] << " ";
}
cout << endl;
// 将仿函数传给sort,sort内调用myclass::operator()进行比较排序
myclass mc; // 类名即类型名,定义1个有名对象 mc,即仿函数
sort(arr2, arr2 + 6, mc);
// sort(arr2, arr2 + 6, myclass()); // 或使用匿名对象作为仿函数
for (int i = 0; i < 6; i++)
{
cout << arr2[i] << " ";
}
cout << endl;
Adder add10(10); // 创建对象/仿函数,配置参数10保存到成员变量base中携带
cout << add10(5) << endl; // 输出 5+base
cout << add10.operator()(5) << endl; // 即 add10(5)
cout << add10(20) << endl; // 再次调用,配置参数不变,输出 20+base
return 0;
}
运行结果:

仿函数可以实现为类模板。在 <functional> 库中早已实现了部分仿函数,例如 less 和 greater,一个小于比较,一个大于比较。
代码示例 5.3.1.2
cpp
// 仿函数less(小于)模拟实现
template<class T>
class Less
{
public:
bool operator()(const T& x, const T& y)
{
return x < y;
}
};
// 仿函数greater(大于)模拟实现
template<class T>
class Greater
{
public:
bool operator()(const T& x, const T& y)
{
return x > y;
}
};
普通函数控制比较规则
代码示例 5.3.1.3
cpp
#include <iostream>
using namespace std;
bool Less(int a, int b)
{
return (a < b);
}
bool Greater(int a, int b)
{
return (a > b);
}
// 冒泡排序
// 使用普通函数,想要控制比较规则需要借助函数指针 com
// 函数指针影响内联展开
inline void BubbleSort(int* a, int n, bool (*com)(int, int))
{
for (int i = 0; i < n; i++)
{
// 单趟
int flag = 0;
for (int j = 1; j < n - i; j++)
{
if (com(a[j], a[j - 1])) // <升序 >降序
{
swap(a[j], a[j - 1]);
flag = 1;
}
}
if (flag == 0) break;
}
}
int main()
{
int arr[6] = { 2, 4, 1, 3, 6, 5 };
BubbleSort(arr, 6, Greater);
for (auto e : arr)
{
cout << e << " ";
}
cout << endl;
BubbleSort(arr, 6, Less);
for (auto e : arr)
{
cout << e << " ";
}
cout << endl;
return 0;
}
运行结果:
仿函数控制比较规则
代码示例 5.3.1.4
cpp
#include <iostream>
using namespace std;
template<class T>
class Less
{
public:
bool operator()(const T& x, const T& y)
{
return x < y;
}
};
template<class T>
class Greater
{
public:
bool operator()(const T& x, const T& y)
{
return x > y;
}
};
// 冒泡排序
// 使用仿函数,可直接将其设为模板参数,方便内联展开
template<class Compare>
inline void BubbleSort(int* a, int n, Compare com)
{
for (int i = 0; i < n; i++)
{
// 单趟
int flag = 0;
for (int j = 1; j < n - i; j++)
{
if (com(a[j], a[j - 1])) // <升序 >降序
{
swap(a[j], a[j - 1]);
flag = 1;
}
}
if (flag == 0) break;
}
}
int main()
{
int arr[6] = { 2, 4, 1, 3, 6, 5 };
BubbleSort(arr, 6, Less<int>()); // 使用Less的匿名对象
for (auto e : arr)
{
cout << e << " ";
}
cout << endl;
BubbleSort(arr, 6, Greater<int>()); // 使用Greater的匿名对象
for (auto e : arr)
{
cout << e << " ";
}
cout << endl;
return 0;
}
运行结果:
当然,我们也可以发散想象力,实现更多不同比较规则的仿函数。假如冒泡排序中比较的不是整数呢?如果比较的是两个日期类对象呢?比较日期需要先比年份,再比月份,最后比日份,这个比较规则比单纯的大于/小于要复杂的多,但借助仿函数可以轻松实现。
当然,我们也可以坚持用 greater 或 less,它们内部调用的是大于 > 或小于 <,日期类通过运算符重载的方式同样可以实现该比较规则。但在实践中,我们不一定能为此单独修改某个类。例如库中的 vector 或 list 等容器,我们就无法修改,如果它们没有重载对应的运算符,或者它们重载的运算符比较规则不符合需求,那我们就必须使用仿函数。
5.3.2 模拟实现

库中 priority_queue 默认是小于 less,小于建大堆,排升序。
代码示例 5.3.2.1
cpp
#pragma once
#include<vector>
#include<algorithm>
namespace david
{
// 仿函数 Less类(小于号) 与 Greater类(大于号)
template<class T>
class Less
{
public:
bool operator()(const T& x, const T& y)
{
return x < y;
}
};
template<class T>
class Greater
{
public:
bool operator()(const T& x, const T& y)
{
return x > y;
}
};
// 默认容器用 vector
// 默认大的值优先级高,缺省值用小于Less,建大堆
template<class T, class Container = std::vector<T>, class Compare = Less<T>>
class priority_queue
{
public:
priority_queue() {};
void AdjustUp(int child) // 向上调整
{
Compare com; // Less<,Greater>
int parent = (child - 1) / 2;
while (child > 0)
{
// 大堆:将父结点与子结点中大的那个向上交换
if (com(_con[parent], _con[child]))
{
swap(_con[parent], _con[child]);
child = parent;
parent = (child - 1) / 2;
}
else break;
}
}
void push(const T& x) // 入队
{
// 元素插入到堆尾,再向上调整
_con.push_back(x);
AdjustUp(_con.size() - 1);
}
void AdjustDown(int parent) // 向下调整
{
Compare com; // Less<,Greater>
int child = parent * 2 + 1; // 默认左孩子
while (child < _con.size())
{
// 大堆:选大的孩子,如果右孩子大则child++
if (child + 1 < _con.size() &&
com(_con[child], _con[child+1])) child++;
// 大堆:将父结点与子结点中小的那个向下调整
if (com(_con[parent], _con[child]))
{
swap(_con[parent], _con[child]);
parent = child;
child = parent * 2 + 1;
}
else break;
}
}
void pop() // 出队
{
// 根结点与堆尾结点交换再删除,删除后向下调整新的根结点
swap(_con[0], _con[_con.size()-1]);
_con.pop_back();
AdjustDown(0);
}
// 获取堆顶元素
T& top()
{
return _con[0];
}
const T& top() const
{
return _con[0];
}
size_t size() const // 返回元素个数
{
return _con.size();
}
bool empty() const // 判空
{
return _con.empty();
}
private:
Container _con;
};
}
代码示例 5.3.2.2
cpp
#include <iostream>
using namespace std;
#include "MyPriority_Queue.h"
int main()
{
// david::priority_queue<int> pq;
// 默认仿函数Compare为less,建大堆,排降序;传greater建小堆,排升序
david::priority_queue<int, vector<int>, david::Greater<int>> pq;
pq.push(4);
pq.push(1);
pq.push(5);
pq.push(8);
pq.push(2);
pq.push(6);
pq.push(7);
pq.push(3);
while (!pq.empty())
{
cout << pq.top() << " ";
pq.pop();
}
cout << endl;
return 0;
}
运行结果:
5.3.3 库中实现的 heap

priority_queue 其实就是上述堆算法与 Container 结合组成的适配器。我们也可以单独使用库中这些堆算法。
- make_heap:建堆(必须支持随机访问,如普通的数组/顺序表,或者vector/deque等容器)。可将指定范围内的数据调整为堆结构,默认建大堆,可传仿函数更改;
- is_heap:判断范围内的数据是否符合堆结构。符合则返回 true,否则返回 false;
- push_heap:误区:该函数不插入元素,只负责向上调整算法恢复堆结构。需确保容器中的数据符合堆结构,然后在堆的末尾插入新元素,再调用该函数恢复堆结构。
- pop_heap:误区:该函数不删除元素,只负责向下调整算法恢复堆结构。需确保容器中存在数据,且符合堆结构。该算法会将根结点交换到末尾,需要手动删除末尾元素。
- sort_heap:堆排。需确保容器中的待排序数据符合堆结构。
代码示例 5.3.3.1
cpp
#include <iostream>
#include <algorithm>
#include <vector>
using namespace std;
int main()
{
// 定义数组
vector<int> array = { 4, 2, 3, 1, 5, 6 };
// 判断是否符合堆结构
if (is_heap(array.begin(), array.end())) cout << "array is heap!\n";
else cout << "array is not heap!\n";
// 建堆,调整为堆结构
make_heap(array.begin(), array.end());
// 再次判断是否符合堆结构
if (is_heap(array.begin(), array.end())) cout << "array is heap!\n";
else cout << "array is not heap!\n";
// 在堆末尾插入新数据 100
array.push_back(100);
// 再次判断是否符合堆结构
if (is_heap(array.begin(), array.end())) cout << "array is heap!\n";
else cout << "array is not heap!\n";
// 调用向上调整算法
push_heap(array.begin(), array.end());
// 再次判断是否符合堆结构
if (is_heap(array.begin(), array.end())) cout << "array is heap!\n";
else cout << "array is not heap!\n";
// 打印
for (auto e : array)
{
cout << e << " ";
}
cout << endl;
// 删除堆顶元素(默认大堆,删除最大的元素)
pop_heap(array.begin(), array.end());
array.pop_back();
// 再打印
for (auto e : array)
{
cout << e << " ";
}
cout << endl;
// 堆排序
sort_heap(array.begin(), array.end());
// 再打印
for (auto e : array)
{
cout << e << " ";
}
cout << endl;
return 0;
}
运行结果: