栈与队列容器:适配器、deque 与 priority_queue
栈和队列自己不存数据,只规定数据从哪一端进出。priority_queue 更极端:它连进出顺序都不规定,只规定谁先出去。
为什么需要适配器
用 C 写一个栈,先得决定底层是数组还是链表,再手写 push、pop、top。换一种元素类型,整套重写。队列更麻烦:数组实现要处理假溢出,链表实现要维护头尾两个指针。逻辑只有十几行,边界条件却每次都得重新检查。
C++ 的做法是复用已有的容器,只暴露符合 LIFO 或 FIFO 语义的那部分接口。把一个类的接口转换成另一个接口,这种结构叫适配器(Adapter) 。stack、queue、priority_queue 都属于容器适配器(Container Adaptor),严格来说不是容器。
现实里的电源适配器做的是同一件事:把 220V 转成手机能接受的电压。它不发电,只转换接口。适配器也不存数据,它把底层容器的接口收窄到语义允许的范围。

图里左边是欧洲壁式插座,中间是转换插头,右边是美标电插头。转换插头不发电,只改变接口形状------容器适配器也一样,不造数据,只改接口。
设计模式有二十多种,适配器是其中一种,迭代器也是。C++ 用到的模式没有 Java 那么多,但这两个在 STL 里随处可见。
📖 参考:《数据结构知识库》第二节(栈)、第三节(队列)
适配器对底层容器的要求
stack 只在一端进出:
- 压栈 =
push_back - 弹栈 =
pop_back - 取栈顶 =
back - 判空、大小 =
empty、size
queue 一端进、另一端出:
- 入队 =
push_back - 出队 =
pop_front - 取队头 / 队尾 =
front/back - 判空、大小 =
empty、size
priority_queue 要维护堆,还需要随机访问:
- 入堆 =
push_back+ 向上调整 - 出堆 = 交换堆顶与堆尾、
pop_back+ 向下调整 - 取堆顶 =
front - 随机访问 =
operator[] - 判空、大小 =
empty、size
汇总成表:
| 适配器 | 必须支持的操作 | 默认底层容器 | 默认比较 |
|---|---|---|---|
stack |
push_back pop_back back empty size |
std::deque<T> |
无 |
queue |
push_back pop_front front back empty size |
std::deque<T> |
无 |
priority_queue |
operator[] front push_back pop_back empty size |
std::vector<T> |
std::less<T> |
模板参数 Container 把"用哪个容器"变成编译期决策,没有虚函数,没有运行时查找。只要底层容器提供上表里的操作,适配器就能工作;缺了哪个,就在调用处编译失败。
手写 stack 与 queue
标准库的 std::stack 在 <stack>,std::queue 在 <queue>。下面是等价的手写版本,模板参数带默认值 std::deque<T>:
cpp
#pragma once
#include <deque>
#include <cstddef>
namespace bit {
template<class T, class Container = std::deque<T>>
class stack {
public:
void push(const T& x) { _con.push_back(x); }
void pop() { _con.pop_back(); }
std::size_t size() const { return _con.size(); }
bool empty() const { return _con.empty(); }
T& top() { return _con.back(); }
const T& top() const { return _con.back(); }
private:
Container _con;
};
}
queue 只换三个动作:pop 调 pop_front,取队头用 front,另补一个 back:
cpp
#pragma once
#include <deque>
#include <cstddef>
namespace bit {
template<class T, class Container = std::deque<T>>
class queue {
public:
void push(const T& x) { _con.push_back(x); }
void pop() { _con.pop_front(); }
std::size_t size() const { return _con.size(); }
bool empty() const { return _con.empty(); }
T& front() { return _con.front(); }
T& back() { return _con.back(); }
const T& front() const { return _con.front(); }
const T& back() const { return _con.back(); }
private:
Container _con;
};
}
三处接口细节值得说明,它们都不改变逻辑:
size、empty加了const。查询不该要求对象可变,否则const stack&连元素个数都问不了。top、front、back补了const重载。只读场景返回const T&,避免通过引用改到内部元素。pop返回void,与标准库一致。取值和删除分成两步:先top()拿到值,再pop()删掉。
pop 不返回值还有一个更硬的理由:如果返回 T,拷贝构造抛异常时元素已经被删掉,值却没交到调用者手里,数据就丢了。拆成两步,取值失败时容器状态不变。
调用时只改第二个模板参数,接口完全不变:
cpp
#include "stack.h"
#include "queue.h"
#include <vector>
#include <list>
bit::stack<int> st1; // 默认 deque
bit::stack<int, std::vector<int>> st2; // vector
bit::stack<int, std::list<int>> st3; // list
bit::queue<int> q1; // 默认 deque
bit::queue<int, std::list<int>> q2; // list
遍历的方式只有一种:反复取顶端或队头,再弹出。循环结束时容器已经空了,所以想保留数据得先拷贝。
按需实例化:模板的错误为什么迟到
下面这行声明可以编译通过:
cpp
bit::queue<int, std::vector<int>> q; // 声明本身没问题
调用 push、front、back 也没问题,vector 都有。直到调用 pop(),编译器才去展开 _con.pop_front(),此时 vector 没有这个成员,错误才出现:
cpp
q.pop(); // error: 'class std::vector<int>' has no member named 'pop_front'
原因是类模板的成员函数按需实例化(on-demand instantiation) :实例化一个类模板时,只有被调用的成员函数才会生成具体代码,没调用的成员不检查依赖模板参数的部分。声明一个 queue<int, vector<int>>,pop 没有被调用,里面的 pop_front 就不会被检查。
模板的检查分两个阶段:
- 定义阶段检查不依赖模板参数的语法,比如少了分号、括号不匹配。
- 实例化阶段检查依赖模板参数的部分,比如
Container到底有没有pop_front。
因此,错误位置通常出现在调用点,而不是声明点。第一次遇到容易误判成别的问题。这个特性也有好的一面:容器只需满足"实际用到的操作",不必满足完整接口。
vector 与 list:优缺点互补
deque 的设计动机,来自 vector 和 list 各自无法回避的缺点。
| 维度 | vector | list |
|---|---|---|
| 空间 | 一整块连续空间,按需扩容 | 按需申请节点,不扩容 |
| 尾插 / 尾删 | 均摊 O(1),扩容时整体搬家 | O(1) |
| 随机访问 | O(1),直接地址加偏移 | 不支持 |
| 头插 / 头删 | O(n),要搬元素 | O(1) |
| 中间插入 / 删除 | O(n) | O(1)(已知位置) |
| 缓存利用率 | 高,物理连续 | 低,逐节点跳转 |
| 空间开销 | 扩容可能浪费(例如 100 扩到 200) | 每个节点两个指针 |
| 扩容代价 | 开新空间、拷贝全部元素、释放旧空间 | 不存在扩容 |
vector 的优点是尾插尾删和随机访问,这两个都来自物理空间连续;缓存命中率高也是同一个原因。缺点是扩容和头部、中间插入删除,同样是"必须保持连续"的代价。
list 反过来:按需申请,任意位置插入删除 O(1),但随机访问要逐节点走,缓存不友好,每个节点还有两个指针的额外开销。
同为 O(n log n) 的排序,vector 能比 list 快好几倍。算法量级一样,差别在常数:排序要大量随机访问和比较,连续内存的缓存命中率远高于链表逐节点跳转。
两者优缺点都很明显,于是有了把它们缝在一起的想法。
deque:把 vector 和 list 缝在一起
deque 是 double-ended queue,双端队列。名字里有 queue,但它和先进先出没有关系;它允许两端插入删除,也支持随机访问。不要因为名字把它和 queue 绑在一起。
它的结构可以概括为:一段一段的小数组,用指针数组串起来 。

图中下半部分是 map(中控数组),每个格子存一个指针;上半部分是一块块缓冲区,元素在块内连续存放。map 扩容时只搬这些指针,元素本身不动。
- 中控数组(map) 是一个指针数组,每个元素指向一块缓冲区(buffer)。
- 缓冲区 是定长小数组,元素在块内连续存放。
- 第一块缓冲区通常放在中控数组的中间,这样头插可以向左扩展,尾插可以向右扩展,两边都有余量。
尾插时,如果最后一块缓冲区没满,直接放;满了就新开一块缓冲区,把指针放进中控数组的下一个位置。中控数组自己满了也要扩容,但扩容只拷贝指针,不拷贝元素。
头插对称:如果第一块缓冲区前面还有空间,直接往前放;否则在前面新开一块缓冲区,中控数组向左扩展。
随机访问为什么不能直接除
如果所有缓冲区都满,第 i 个元素的位置很好算:
- 在第
i / n块缓冲区(n是每块能放的元素个数) - 块内偏移是
i % n
但头插会破坏这个前提。头插之后,第一块缓冲区通常是半满的,不能直接用 i / n,否则会算错块号。
实际实现改成以迭代器为基准:先算当前元素在本块内的偏移,加上要走的步数,再处理跨块。operator[] 本质上调用迭代器的 operator+,而 operator+ 又转成 operator+=,和日期类"加天数"的写法类似。
这也是 deque 随机访问比 vector 慢的原因:vector 是"起始地址 + i",deque 要经过若干次判断、除法、取模和指针解引用。
迭代器的四个指针
deque 的迭代器不是裸指针,而是一个结构体,里面四个指针:
| 成员 | 含义 |
|---|---|
cur |
当前元素的位置 |
first |
当前缓冲区的起始位置 |
last |
当前缓冲区的结束位置 |
node |
当前缓冲区指针在中控数组中的位置(二级指针) |

图中 node 指向中控数组里的某个格子,该格子指向当前缓冲区;first 和 last 圈住这块缓冲区,cur 指向当前元素。四个指针配合,迭代器才能像连续空间一样移动。
迭代器的 ++ 做两件事:
++cur。如果cur != last,当前缓冲区还没走完,结束。- 如果
cur == last,当前缓冲区走完了:node + 1找到下一块缓冲区的指针,解引用得到新缓冲区起点,更新first、last,再让cur = first。
node 是二级指针,因为它指向的是中控数组里的某个位置,而那个位置存放的是缓冲区的地址。
deque 自身只维护两个迭代器:start 指向第一个元素,finish 指向最后一个元素的下一个位置。begin() 返回 start,end() 返回 finish。头插改 start,尾插改 finish。
头插、尾插与中间插入
尾插的流程:
finish.cur != finish.last:当前缓冲区还有空位,在cur处构造,然后++cur。- 否则:新开一块缓冲区,把指针放到
node + 1,调整finish到新缓冲区起点,再构造。
头插对称,方向相反。

图中 0--7、8--15 分别放在两块缓冲区,尾插 0、1、2 进入第三块。start 始终指向第一个元素,finish 指向最后一个元素的下一个位置;跨缓冲区的跳转由迭代器的 node 负责。
中间插入和删除是 deque 的短板。给定中间位置的迭代器,插入或删除要挪动元素:可以只在当前缓冲区挪,也可以跨缓冲区整体挪。两种做法都是 O(n)。
这里有一个明确的取舍。如果允许每块缓冲区大小不同,中间插入可以只扩当前块,代价小;但随机访问就不能直接除,必须一块一块减掉前面的元素个数,效率进一步下降。SGI 版本选择固定每块缓冲区大小,牺牲中间插入删除,保住随机访问的可计算性。
所以 deque 的定位很清楚:
- 头尾插入删除:O(1),效率很高。
- 随机访问:O(1),但常数比
vector大。 - 中间插入删除:O(n),不如
list。
它没有替代 vector 和 list,只是补上了"两端都要动,又想要随机访问"这个场景。
deque 的性能实测
deque 的迭代器满足随机访问迭代器的要求,std::sort 可以接受它。但排序的核心是反复随机访问和交换,deque 每次访问都要经过中控数组多跳一层。
下面两组测试使用同样的随机数据,数据量为一百万:
cpp
#include <algorithm>
#include <deque>
#include <vector>
#include <cstdio>
#include <cstdlib>
#include <ctime>
void test_op1()
{
std::srand(static_cast<unsigned>(std::time(nullptr)));
const int N = 1000000;
std::deque<int> dq;
std::vector<int> v;
for (int i = 0; i < N; ++i)
{
auto e = std::rand() + i;
v.push_back(e);
dq.push_back(e);
}
int begin1 = std::clock();
std::sort(v.begin(), v.end());
int end1 = std::clock();
int begin2 = std::clock();
std::sort(dq.begin(), dq.end());
int end2 = std::clock();
std::printf("vector:%d\n", end1 - begin1);
std::printf("deque:%d\n", end2 - begin2);
}
第二组比较"直接排 deque"和"拷进 vector 排完再拷回":
cpp
void test_op2()
{
std::srand(static_cast<unsigned>(std::time(nullptr)));
const int N = 1000000;
std::deque<int> dq1;
std::deque<int> dq2;
for (int i = 0; i < N; ++i)
{
auto e = std::rand() + i;
dq1.push_back(e);
dq2.push_back(e);
}
int begin1 = std::clock();
std::sort(dq1.begin(), dq1.end());
int end1 = std::clock();
int begin2 = std::clock();
std::vector<int> v(dq2.begin(), dq2.end()); // 拷贝到 vector
std::sort(v.begin(), v.end());
dq2.assign(v.begin(), v.end()); // 再拷回 deque
int end2 = std::clock();
std::printf("deque sort:%d\n", end1 - begin1);
std::printf("deque copy vector sort, copy back deque:%d\n", end2 - begin2);
}
实测的方向是稳定的,倍数随编译器和构建类型变化:
| 测试 | 观察结果 |
|---|---|
Debug 构建,sort(vector) vs sort(deque) |
deque 慢约 4 倍 |
Release 构建,sort(vector) vs sort(deque) |
deque 慢约 2 倍 |
| g++ -O2 复核 | deque 慢约 20%--30% |
直接排 deque vs 拷进 vector 再拷回 |
后者快约 15%--50% |
Debug 和 Release 差距不同,是因为 deque 的 operator[] 会调用多层小函数(迭代器的 operator+、operator+=)。Release 下这些函数被内联展开,部分开销消失;剩下的差距来自中控数组的跳转和跨缓冲区。编译器内联策略不同,最终倍数就不同。
拷贝方案占优的原因也清楚:排序要做 O(n log n) 次随机访问,两次 O(n) 的拷贝只占很小一部分。数据量越大,这个比例越划算;数据量很小时,两次完整遍历反而可能盖过收益。
结论不是"deque 排序一定慢一倍",而是三条判断:
- 需要大量随机访问或排序时,先拷进
vector,排完再拷回。 - 只在两端操作、偶尔按下标访问时,
deque是合适的。 - 具体倍数要用目标平台和构建类型实测,不能直接套别人机器上的数字。
这也是 stack 和 queue 默认用 deque 合理的原因:它们只碰两端,不排序,不遍历,deque 的短板正好用不到。
priority_queue:堆的适配器
priority_queue 也定义在 <queue> 里,不需要单独的头文件。它同样没有迭代器,接口只有 push、pop、top、size、empty。
默认行为是大堆 :数值大的优先级高,top() 返回最大值。默认底层容器是 vector<T>,默认比较器是 std::less<T>。
它名字叫优先级队列,底层是堆。应用视角叫"优先级队列",数据结构视角叫"堆",同一个东西。
堆的数组表示
堆是一棵完全二叉树,物理上存在数组里,逻辑上按层序排列:
text
9
/ \
7 8
/ \ /
4 5 6
数组: [9, 7, 8, 4, 5, 6]
下标: 0 1 2 3 4 5
下标关系:
| 关系 | 公式 |
|---|---|
| 左孩子 | 2 * i + 1 |
| 右孩子 | 2 * i + 2 |
| 父亲 | (i - 1) / 2 |
大堆要求每个父亲都大于等于孩子,兄弟之间没有大小关系。右孩子下标是 2i + 2,减 2 除以 2 和减 1 除以 2 结果相同,所以父亲统一写成 (i - 1) / 2。
push 与 pop
push 分两步:
- 新元素放到数组末尾(
push_back)。 - 从末尾开始向上调整:和父亲比较,比父亲大就交换,直到根或不再大于父亲。
pop 不能直接删堆顶,否则整个堆结构会乱。做法是:
- 堆顶和堆尾交换。
pop_back()删掉原来的堆顶(现在在末尾)。- 从根开始向下调整:选较大的孩子,比孩子小就交换,直到叶子或不再小于孩子。
向上调整的前提是"插入前已经是一个堆";向下调整的前提是"左右子树各自是堆"。这两个前提是堆操作能成立的关键。
完整实现
cpp
#pragma once
#include <vector>
#include <functional>
#include <cstddef>
#include <utility>
namespace bit {
template<class T, class Container = std::vector<T>, class Compare = std::less<T>>
class priority_queue {
public:
priority_queue() = default;
void push(const T& x) {
_con.push_back(x);
AdjustUp(static_cast<int>(_con.size()) - 1);
}
void pop() {
std::swap(_con.front(), _con.back());
_con.pop_back();
AdjustDown(0);
}
const T& top() const { return _con.front(); }
std::size_t size() const { return _con.size(); }
bool empty() const { return _con.empty(); }
private:
void AdjustUp(int child) {
int parent = (child - 1) / 2;
while (child > 0) {
if (_com(_con[parent], _con[child])) { // less: 父亲小,孩子上浮
std::swap(_con[parent], _con[child]);
child = parent;
parent = (child - 1) / 2;
} else {
break;
}
}
}
void AdjustDown(int parent) {
int child = parent * 2 + 1;
while (child < static_cast<int>(_con.size())) {
if (child + 1 < static_cast<int>(_con.size())
&& _com(_con[child], _con[child + 1])) {
++child; // 选较大的孩子
}
if (_com(_con[parent], _con[child])) {
std::swap(_con[parent], _con[child]);
parent = child;
child = parent * 2 + 1;
} else {
break;
}
}
}
Container _con;
Compare _com;
};
}
用 std::less<T> 时,_com(parent, child) 为真表示父亲小于孩子,交换后大的上浮,得到大堆。换成 std::greater<T>,比较方向反过来,得到小堆。
实现里的四个细节
一、默认比较器可以换成自定义仿函数。 把模板参数写成 Compare = Less<T>,堆的逻辑不变,比较逻辑完全交给 Less:
cpp
template<class T, class Container = std::vector<T>, class Compare = Less<T>>
class priority_queue { /* ... */ };
Less 的定义必须出现在这行之前,否则默认模板实参找不到名字。标准库的 std::less<T> 可以直接用,自定义版本则要先声明。
二、区间构造有一个整数实参的坑。 加上这个构造函数很方便:
cpp
template<class InputIterator>
priority_queue(InputIterator first, InputIterator last)
: _con(first, last) {
for (int i = static_cast<int>(_con.size()) - 2; i >= 0; --i)
AdjustDown(i);
}
它是无约束的模板。写 priority_queue<int> pq(5, 10); 时,编译器会把 5、10 当成迭代器,InputIterator 推成 int,然后在 _con(first, last) 处失败------vector 没有接受两个 int 的区间构造。
C++17 可以用 std::enable_if_t<!std::is_integral_v<InputIterator>> 把它限制成真正的迭代器;或者干脆不提供区间构造,需要建堆时用 std::make_heap。标准库的 priority_queue 到 C++23 才加入区间构造,也是这个原因。
三、建堆循环有无符号下溢。 写成:
cpp
for (int i = (_con.size() - 1 - 1) / 2; i >= 0; --i)
size() 返回 size_t。空容器时 size - 2 回绕成一个巨大的无符号数,单元素时也一样;再转成 int 是实现定义行为。常见平台上恰好得到 -1,循环不执行,看起来能跑。先转 int 再减更稳:
cpp
for (int i = static_cast<int>(_con.size()) - 2; i >= 0; --i)
四、访问控制与 const。 AdjustUp、AdjustDown 是内部细节,应放在 private;top、empty、size 不修改对象,应加 const,否则 const priority_queue& 连堆顶都取不了。比较器存成成员 Compare _com; 比每次调用时临时构造更清晰;对无状态仿函数,两种写法开销相同。
仿函数:让比较变成参数
如果比较逻辑写死在代码里,想要小堆就得改源码,或者维护两个版本的堆。C 的做法是传函数指针,但函数指针的类型声明繁琐,C++ 更常用仿函数(Functor) ,也叫函数对象(Function Object)。
仿函数是一个类,重载了函数调用运算符 operator():
cpp
template<class T>
struct Less {
bool operator()(const T& x, const T& y) const { return x < y; }
};
template<class T>
struct Greater {
bool operator()(const T& x, const T& y) const { return x > y; }
};
Less<int> lt; 创建的是一个对象,但 lt(1, 2) 看起来和函数调用一样。标准库已经提供了 std::less<T> 和 std::greater<T>,在 <functional> 里。
仿函数的好处是:比较逻辑变成一个类型参数,编译期确定,可以被内联,没有函数指针的间接调用开销。
拿冒泡排序演示:
cpp
template<class Compare>
void bubble_sort(int* a, int n, Compare com)
{
for (int i = 0; i < n - 1; ++i) {
bool swapped = false;
for (int j = 0; j < n - 1 - i; ++j) {
if (com(a[j + 1], a[j])) {
std::swap(a[j], a[j + 1]);
swapped = true;
}
}
if (!swapped) break;
}
}
bubble_sort(a, n, Less<int>()); // 升序
bubble_sort(a, n, Greater<int>()); // 降序
比较逻辑由传进来的对象决定,排序函数本身不用改。std::sort 的第三个参数也是同样的机制。
类型与对象的区别
这是最容易混的一处:
cpp
// priority_queue 的第三个模板参数是类型,不带括号
bit::priority_queue<int, std::vector<int>, std::greater<int>> minHeap;
// sort 的第三个参数是对象,带括号
std::sort(v.begin(), v.end(), std::greater<int>());
priority_queue是类模板,参数是类型 ,写std::greater<int>。sort是函数模板,参数是对象 ,写std::greater<int>(),括号创建匿名对象。
同一个 greater<int>,一个当类型用,一个当对象用。记住参数位置决定写法。
topK
priority_queue 最典型的应用是 topK。求前 k 大,维护一个大小为 k 的小堆:
- 前 k 个元素直接入堆。
- 从第 k + 1 个开始,如果元素大于堆顶,弹出堆顶、插入新元素。
- 遍历结束,堆里就是前 k 大,堆顶是第 k 大。
时间复杂度 O(n log k),空间 O(k)。另一种做法是全部入大堆(std::make_heap 建堆 O(n),逐个插入则 O(n log n)),再 pop k - 1 次,总复杂度 O(n + k log n);k 接近 n 时才划算,k 远小于 n 时不如前者。
常见误区
"stack、queue、priority_queue 是容器。" 它们是容器适配器。容器有迭代器,适配器没有。stack 和 queue 如果提供迭代器,就能绕过 top / front 直接改中间元素,LIFO 和 FIFO 的约束就失效了。
"适配器有 clear()。" 标准 stack、queue、priority_queue 都没有 clear()。清空只能 while (!c.empty()) c.pop();,或者用空对象重新赋值。
"queue 也可以用 vector。" vector 没有 pop_front。声明和 push 能过,调用 pop() 时才报错,这是按需实例化造成的。
"deque 和 queue 是亲戚。" 名字像,语义无关。deque 是双端队列,允许两端插入删除,不要求先进先出。
"deque 能替代 vector 和 list。" 中间插入删除是 O(n),随机访问常数比 vector 大,它只是补位,不是替代。
"priority_queue 默认是小堆。" 默认 std::less<T>,是大堆,top() 是最大值。要小堆得显式传 std::greater<T>。
"priority_queue 传比较器要加括号。" 第三个模板参数是类型,写 std::greater<int>,不加括号;sort 的第三个参数是对象,才加括号。
"空容器调用 top() 或 front() 只是返回垃圾值。" 是未定义行为(UB),不是返回垃圾值。调用前必须 empty() 检查。
"deque 也有 capacity() 和 reserve()。" 没有。它按需追加缓冲区,容量不是单一数字,无法预留连续空间。
本节要点
stack、queue、priority_queue是容器适配器,复用底层容器,只暴露语义允许的接口。- 类模板的成员函数按需实例化:接口用错不会立刻报错,调用到才报。
stack默认deque,priority_queue默认vector;前者只要两端操作,后者需要按下标维护堆。deque是"中控数组 + 定长缓冲区",头尾 O(1)、随机访问 O(1) 但常数更大、中间插入删除 O(n)。- 大量随机访问或排序时,
deque先拷进vector再排,通常更快。 priority_queue底层是堆:push向上调整,pop交换堆顶堆尾后向下调整;比较逻辑用仿函数传入。- 仿函数是重载了
operator()的类;priority_queue传类型,sort传对象。
KIRACRIMSON