写 C++ 的时候,很多人一开始接触stack和queue都有个疑惑:vector、list不是已经能存数据了吗?为什么还要单独搞两个容器出来?更奇怪的是,stack明明可以像vector一样连续存储,却不能用下标访问,只能从"顶"上取。这些规则的背后,其实藏着一个 C++ 容器设计的核心思想——容器适配器。这篇文章我想用实际代码和踩坑经历,把stack与queue的使用、底层结构、以及如何自己动手模拟实现讲清楚,适合刚学完 STL 基础、或者正在复习数据结构的同学参考。
先说结论:stack和queue不是一种新的存储结构,它们是一套全新的"操作规则"套在已有容器之上。理解这一点,模拟实现就变得非常自然,甚至你还能自己写出比标准库更适合特定场景的版本。
1. 先回答一个问题:你写代码的时候,什么时候是真的需要 stack / queue
好多人学完stack和queue就再也没用过,因为平时写业务代码,vector打天下,偶尔map查个东西,仿佛这俩容器是专门为面试准备的。但实际上,它们是那种"你不主动用,但很多底层组件在替你用"的结构。
1.1 函数调用背后的栈:不是你主动用,是系统在用
每次函数调用,系统都要开辟一块栈帧来存放局部变量、返回地址。后调用的函数先返回,这就是典型的 LIFO(后进先出)。虽然日常业务代码里你不会直接声明一个stack<int>去模拟函数调用栈,但理解了这个场景,就能明白为什么stack只提供push、pop、top这三个核心操作——因为栈这种结构本来就不需要"看中间"的能力,它只需要保证"最后进去的,最先出来"。
我见过不少同学在写括号匹配、表达式求值、DFS 非递归遍历时,用vector加一个tail下标硬模拟栈。效果其实差不多,但代码可读性很差,别人看半天不知道你这个tail是干嘛的。直接用std::stack,意图一目了然。
1.2 消息队列与任务队列:queue 解决的是"排队"问题
queue(FIFO,先进先出)最常见的场景就是任务调度。比如一个下载器,来了 10 个任务,先来的先下载,后来的排队。再比如消息队列中间件,本质上就是一个分布式的大queue。树和图的层序遍历(BFS)也是queue的经典应用。
换到业务代码里,如果你发现自己在用vector存任务,然后每次取出头部元素还要把后面所有元素往前挪,那这个操作的时间复杂度就是 O(n)。数据量大了之后,性能会非常难看。这时候用queue或者deque才是正确的选择。
1.3 我先给你一个判断标准
什么时候用stack?——当你发现自己写代码时,只需要关心最新加入的元素,而对更早的元素"暂时不关心"时。
什么时候用queue?——当你发现处理顺序必须保持"到达顺序",先到先得时。
如果两个都不是,那大概率用vector就够了。别为了用 STL 而用 STL,这是我在实际项目里被坑出来的教训。
2. stack 与 queue 的常规使用:接口不多,但坑都在边界
stack和queue的接口是所有 STL 容器里最简单的,加在一起不超过十个常用函数。但正因为简单,很多人反而记不住哪些接口存在、哪些不存在,运行时报错了才反应过来。
2.1 stack 的接口速查与典型用法
stack的核心接口如下:
| 接口 | 功能 | 注意事项 |
|---|---|---|
push(x) | 压入元素到栈顶 | 底层调用push_back |
pop() | 弹出栈顶元素 | 不返回被弹出的元素 |
top() | 返回栈顶元素的引用 | 需要确保栈非空 |
empty() | 判断栈是否为空 | true表示空 |
size() | 返回栈中元素个数 | 返回类型是size_t |
一个经典的括号匹配示例:
#include <iostream> #include <stack> #include <string> bool isMatching(const std::string& s) { std::stack<char> st; for (char c : s) { if (c == '(' || c == '[' || c == '{') { st.push(c); } else { if (st.empty()) { return false; // 没有左括号却来了右括号 } char left = st.top(); if ((c == ')' && left != '(') || (c == ']' && left != '[') || (c == '}' && left != '{')) { return false; } st.pop(); } } return st.empty(); // 栈里还残留左括号,说明不匹配 }有两个细节值得专门说。第一个:pop()不返回元素。这跟很多人学数据结构时写的pop()返回弹出来的值不一样。标准库这么做是为了避免"返回副本"带来的开销,同时保证异常安全——如果返回值需要拷贝构造,而拷贝构造抛异常,元素已经被删了,状态就不好恢复。所以标准库的做法是先top()拿到值,再pop()删除。
第二个:判断栈空之后再取top()。如果你对空栈调用top(),这是未定义行为,不一定会崩,但可能读到垃圾值。我调试过一段代码,问题就出在某个线程里栈被提前清空,另一个线程还在读top(),结果偶现乱码,排查了很久。
2.2 queue 的接口速查与典型用法
queue的接口和stack很像,但"头"和"尾"的概念要分清:
| 接口 | 功能 | 注意事项 |
|---|---|---|
push(x) | 尾部入队 | 底层调用push_back |
pop() | 头部出队 | 不返回被弹出的元素 |
front() | 返回队头元素的引用 | 也就是最早入队、最该被处理的那个 |
back() | 返回队尾元素的引用 | 最后一个入队的元素 |
empty() | 判断队列是否为空 | 同上 |
size() | 返回队列元素个数 | 同上 |
BFS 遍历二叉树或者图的经典骨架:
#include <queue> #include <vector> struct Node { int val; std::vector<Node*> children; }; void bfs(Node* root) { if (!root) return; std::queue<Node*> q; q.push(root); while (!q.empty()) { Node* cur = q.front(); q.pop(); // 处理当前节点 for (Node* child : cur->children) { q.push(child); } } }这里注意front()和back():front()是你当前要处理的数据,back()是刚入队的数据。在生产者消费者模型里,生产者往back()方向添加,消费者从front()方向拿走,分工非常清楚。
2.3 一个容易忽视的容器特性:stack 和 queue 默认底层是 deque
这是初学者最容易忽略的点。std::stack<T>的第二个模板参数默认是std::deque<T>,不是vector<T>。queue也一样。这意味着你写std::stack<int>时,内部其实是一个双端队列。
为什么要用deque而不是vector?因为deque支持在头部高效插入删除,这正好是queue出队需要的操作。stack虽然只用尾部操作,但vector扩容时会有"整体搬迁"成本,deque通过分段连续存储绕开了这一点,后面我会详细展开。
3. 容器适配器的本质:为什么 stack 底层不是它自己存数
如果只看接口,stack和queue简直是"最没技术含量"的容器。但一旦你问出这个问题——"为什么 stack 的底层是 deque?它自己不能存数吗?"——你就开始接触到 STL 设计里一个非常核心的概念了:适配器模式。
3.1 什么是容器适配器:不是新容器,是新"门卫"
stack本身不管理内存,它只是"组合"了一个已有的容器,然后把不需要的接口全部封死,只留下符合栈语义的操作。形象一点说,stack就像一个有严格规则的仓库管理员:所有货物必须从同一个门进,从同一个门出,你永远只能看到最上面那箱货是什么,不能绕到侧面去翻中间的货。
这个"门卫"在代码里长这样:
template <class T, class Container = std::deque<T>> class stack { public: void push(const T& x) { c.push_back(x); } void pop() { c.pop_back(); } T& top() { return c.back(); } bool empty() const { return c.empty(); } size_t size() const { return c.size(); } private: Container c; // 所有接口都是转发给这个底层容器 };看到没有,所谓的stack,本质上就是一个deque换了个马甲。它没有自己的数据成员,所有数据都存在那个Container c里。这也是为什么标准库语义里管它叫"容器适配器"——它适配了原有容器的能力,做了一层限制和转化。
3.2 为什么默认选 deque 而不是 vector 或 list
这是面试高频题,也是理解 STL 源码设计的钥匙。我们从三个候选者分别分析:
vector:连续内存,支持随机访问,尾部插入删除效率很高。但头部插入删除是 O(n),扩容时可能搬移所有元素。如果拿它做queue的底层,pop()就是 O(n),完全不可接受。list:双向链表,任意位置插入删除都是 O(1)。但每个元素要多存两个指针,内存开销大;而且缓存局部性差,遍历性能通常比不过连续存储的容器。deque:分段连续存储,既有接近vector的缓存友好度,又支持头尾 O(1) 插入删除,还不需要像vector那样整体搬迁。唯一的问题是随机访问比vector慢,因为要多一次指针跳转,但stack和queue根本不需要随机访问。
所以标准库选deque是精准卡住了需求:这两个容器需要的操作,deque都能高效完成,而且内存开销比list小。
3.3 适配器模式的第二个好处:可以换底
因为stack对底层容器只要求"支持push_back、pop_back、back",所以理论上任何满足这些接口的容器都能当它的底层。你可以显式传第二个模板参数:
std::stack<int, std::vector<int>> st; // 用 vector 做底层 std::stack<int, std::list<int>> st2; // 用 list 做底层 std::queue<int, std::list<int>> q; // queue 也可以用 list这里有个非常实用的技巧:在写算法题的时候,如果题目明确只需要栈的功能,但元素数量已知且不大,用std::stack<int, std::vector<int>>往往比默认的deque更快,因为vector的内存更连续,缓存命中率更高。反之,如果queue会频繁 push/pop,且元素生命周期很短,默认deque是更平衡的选择。
3.4 适配器模式帮你"限制 API",避免误用
stack封死了begin()、end()、operator[]这些接口,这不是缺陷,而是特性。它让你从语法层面就无法对栈做"遍历"和"随机访问",只能在顶上操作。
这种"限定接口"的思路在日常工程里非常值钱。我见过有人维护一个全局vector,所有模块都能往里塞数据、按下标读数据,三个月后没人敢动这段代码,因为不知道哪个模块在哪个下标上写了什么东西。如果当初用stack或queue把数据流卡成单通道,绝不会乱成这样。在大型工程里,减少"能做的事",往往比增加"能做的事"更安全。
4. 手写一个 Stack 容器:从能跑到能用
理论讲再多,不如自己写一遍。模拟实现的价值在于,你会把"接口用什么底层容器转发"这件事彻底搞明白。而且写完之后你再去看标准库源码,会发现基本就是同一套思路。
4.1 版本一:基于 vector 的简易 Stack
先写一个麻雀虽小五脏俱全的版本:
#include <vector> #include <cassert> template <typename T> class MyStack { public: void push(const T& x) { _c.push_back(x); } void pop() { assert(!_c.empty()); _c.pop_back(); } T& top() { assert(!_c.empty()); return _c.back(); } const T& top() const { assert(!_c.empty()); return _c.back(); } bool empty() const { return _c.empty(); } size_t size() const { return _c.size(); } private: std::vector<T> _c; };这个版本里有两个 C++ 模板编程的重点,值得展开说。
第一个是const 成员函数的重载。top()写了两个版本,一个非 const 版本返回T&,允许调用方修改栈顶元素;一个 const 版本返回const T&,只允许读取。如果你只写一个非 const 版本,那么一个const MyStack<int>&对象就无法调用top()。这两个版本名字相同、参数列表相同,靠const限定符区分,属于合法的重载。
第二个是底层容器的接口对齐。vector有push_back、pop_back、back,正好对应stack的push、pop、top。所以这个模拟类本身不需要管理内存、不需要写拷贝构造,因为std::vector已经帮你做好了所有资源管理。这也体现了组合优先于继承的思想。
4.2 版本二:模板化容器参数,做一个真正的适配器
上面的版本硬编码了vector,还不够"适配器"。我们把它泛化成模板参数,这时候你会发现,代码跟标准库的布局几乎一模一样:
#include <deque> #include <cassert> template <typename T, typename Container = std::deque<T>> class MyStack { public: using value_type = typename Container::value_type; using size_type = typename Container::size_type; using reference = typename Container::reference; using const_reference = typename Container::const_reference; void push(const T& x) { _c.push_back(x); } void push(T&& x) { _c.push_back(std::move(x)); } void pop() { assert(!_c.empty()); _c.pop_back(); } reference top() { assert(!_c.empty()); return _c.back(); } const_reference top() const { assert(!_c.empty()); return _c.back(); } bool empty() const { return _c.empty(); } size_type size() const { return _c.size(); } private: Container _c; };这里做了三个升级:
- 支持任意满足接口的底层容器,默认
deque。 - 用
using从底层容器提取类型别名,这样外部使用MyStack<int>::reference时,语义跟标准库一致。 - 增加了右值引用的
push(T&& x)重载,可以避免临时对象的拷贝。
第三个升级的实际效果很直观:如果你st.push(std::make_pair(1, 2))这种临时对象,右值重载会把临时对象直接"移动"到底层容器里,省掉一次拷贝构造。虽然现代编译器有复制省略优化,但移动语义在某些场景(比如存std::string大对象)能明显省下开销。
4.3 版本三:支持迭代器构造和花括号初始化
到这一步,你的模拟stack已经能工作了,但用起来还是有点别扭:没法用一段已有数据直接初始化。给构造函数传迭代器是 STL 容器的惯例:
template <typename T, typename Container = std::deque<T>> class MyStack { public: // 默认构造 MyStack() = default; // 用底层容器构造 explicit MyStack(const Container& c) : _c(c) {} // 用迭代器区间构造 template <typename InputIt> MyStack(InputIt first, InputIt last) : _c(first, last) {} // ... 其余接口同上 };有了迭代器构造函数,你可以这样写:
std::vector<int> v = {1, 2, 3, 4, 5}; MyStack<int, std::vector<int>> st(v.begin(), v.end());注意explicit的使用。MyStack(const Container& c)标记成explicit,是为了防止编译器隐式地把一个deque<int>转换成MyStack<int>,这种隐式转换在容器领域通常不是你想要的。
5. 手写一个 Queue 容器:两种底层路线和循环队列优化
queue的模拟实现比stack多了一个"大难题":怎么高效出队。用vector当底层,出队是 O(n);用list,出队是 O(1) 但内存开销大;用deque,两边都快,但它内部数据结构复杂。这一章我们分路线讲清楚。
5.1 基于 list 的简易 Queue
list提供的接口天然匹配队列语义:push_back入队,front看队头,pop_front出队。实现起来非常直接:
#include <list> #include <cassert> template <typename T> class MyQueue { public: void push(const T& x) { _c.push_back(x); } void pop() { assert(!_c.empty()); _c.pop_front(); } T& front() { assert(!_c.empty()); return _c.front(); } const T& front() const { assert(!_c.empty()); return _c.front(); } T& back() { assert(!_c.empty()); return _c.back(); } const T& back() const { assert(!_c.empty()); return _c.back(); } bool empty() const { return _c.empty(); } size_t size() const { return _c.size(); } private: std::list<T> _c; };这个版本没有任何黑魔法,所有接口都是直接转发给std::list。唯一值得注意的是front()和back()各需要两个重载版本(const/非 const),比stack多了一套,因为队列有两个"观察窗口"。
5.2 自己实现底层容器:循环队列才是重头戏
如果你的目标不只是"转发给 list",而是想深入数据结构,那我建议你亲自写一个循环队列作为queue的底层。很多教材里循环队列都用固定数组实现,这里我用动态增长的版本,更适合实际工程。
先看静态版本的精髓,理解"循环"是怎么转起来的:
template <typename T> class CircularQueue { public: explicit CircularQueue(size_t cap = 8) : _data(new T[cap]), _cap(cap), _front(0), _rear(0), _size(0) {} bool push(const T& x) { if (_size == _cap) { return false; // 队列满 } _data[_rear] = x; _rear = (_rear + 1) % _cap; ++_size; return true; } bool pop() { if (_size == 0) { return false; } _front = (_front + 1) % _cap; --_size; return true; } T& front() { return _data[_front]; } size_t size() const { return _size; } bool empty() const { return _size == 0; } private: T* _data; size_t _cap; size_t _front; size_t _rear; size_t _size; };这里最关键的是_rear = (_rear + 1) % _cap和_front = (_front + 1) % _cap。数组下标到末尾后,取模回到开头,形成一个逻辑上的圆环。判满用的是_size == _cap,而不是教材里常见的(rear + 1) % cap == front——后者会浪费一个存储位,前者更直接,而且不需要额外区分"空"和"满"。
但静态版有个使用限制:队列会满。真实场景里队列长度往往不可预知,所以要写成动态扩容版本。扩容的核心思路是:分配一块新数组,把原队列按逻辑顺序拷贝过去,重置 front 和 rear。
template <typename T> class CircularQueue { public: explicit CircularQueue(size_t cap = 4) : _data(new T[cap]), _cap(cap), _front(0), _rear(0), _size(0) {} ~CircularQueue() { delete[] _data; } void push(const T& x) { if (_size == _cap) { _resize(_cap * 2); } _data[_rear] = x; _rear = (_rear + 1) % _cap; ++_size; } void pop() { if (_size == 0) return; _front = (_front + 1) % _cap; --_size; } T& front() { return _data[_front]; } const T& front() const { return _data[_front]; } bool empty() const { return _size == 0; } size_t size() const { return _size; } private: void _resize(size_t newCap) { T* newData = new T[newCap]; for (size_t i = 0; i < _size; ++i) { newData[i] = _data[(_front + i) % _cap]; } delete[] _data; _data = newData; _cap = newCap; _front = 0; _rear = _size; // _size 不变,新数组里下一个入队位置就是 _size } T* _data; size_t _cap; size_t _front; size_t _rear; size_t _size; };扩容后_rear的赋值是很多人会写错的地方。因为拷贝后新数组是紧密排列的,队尾下一个空位就是_size处,所以_rear = _size,注意这里不是_rear = 0。我当初写这段时,直接_rear = 0,结果 push 第二个元素就覆盖了第一个。回头想,_rear永远指向"下一次写入的位置",不管怎么写,保证这个语义就对了。
5.3 用户定义的 queue 该用哪条路线
如果你只是练习数据结构,用list转发最简单,两小时能搞定。如果你想要一个真正能放进工程里的容器,手写循环队列更有价值——连续内存、缓存友好、扩容 O(n) 但均摊 O(1)。
如果你既不想写底层,又嫌默认deque不够快,直接std::queue<T, std::list<T>>换个底层即可,但这条路的性能上限受制于链表的节点分散。工程建议是:默认用标准库deque,数据量可控且追求极致性能时用std::queue<T, std::vector<T>>配合自己维护一个"可丢弃头部"的版本。
6. 模拟实现中容易翻车的细节:迭代器、const 与空容器
写一个"能用"的模拟容器只需要二十分钟,但写一个"不翻车"的模拟容器需要至少半天。这一章我把我踩过的坑集中列出来,每一条都是真实调试过的。
6.1 const 版本缺失导致的编译错误
新手最容易犯的错是只写一个非 const 的top(),然后试图在 const 成员函数里调用它,编译直接报错。
这种错误的信息往往很长,从模板实例化一层一层抛出来,核心就一句话:"你调用的函数不是 const 成员函数,无法在 const 对象上调用。"
在给模拟容器加接口时,我建议你先把"哪些接口需要 const 版本"列出来:
empty()、size():必须是 const。它们不修改容器,标准库也把它们定义为 const 成员函数。top()、front()、back():需要两个版本(const 和非 const)。因为你既要能从 const 容器里"读"数据,也要能从非 const 容器里"改"数据。push()、pop():非 const,天然不带const限定符。
一个常见的写法失误是这样的:
bool empty() { return _c.empty(); } // 忘了 const这个版本放在非 const 对象上没问题,但一个const MyStack<int>&对象调用empty()就会失败。因为 const 对象只能调用 const 成员函数。所以模拟实现要给所有"只读"函数加上const,而且要形成肌肉记忆。
6.2 对空容器调用 top()/pop():未定义行为的可怕之处
模拟实现里如果不用assert,空容器调用top()会发生什么?答案是"不确定"。可能返回一个垃圾值,可能直接段错误,也可能碰巧正常工作。这种不确定比崩溃更可怕,因为它在测试环境可能跑得很好,上线后偶发崩溃。
我的建议:模拟实现里加assert(include<cassert>),至少 debug 模式下能立刻暴露问题。标准库的做法是"不做检查",把性能放到第一位,这是标准库和教学代码的重要差异。
如果你希望自己的模拟容器在 release 下也能给出明确错误,可以显式检查并抛异常:
void pop() { if (_c.empty()) { throw std::runtime_error("pop on empty stack"); } _c.pop_back(); }虽然会拖慢一点点性能,但在工程初期,明确的错误信息远比节省一次empty()检查更有价值。
6.3 底层容器的类型别名:不只是锦上添花
如果你只是写一个临时用的模拟容器,类型别名可以不写。但一旦你想让它接近标准库的体验,必须加上:
using value_type = typename Container::value_type; using size_type = typename Container::size_type; using reference = typename Container::reference; using const_reference = typename Container::const_reference;注意typename关键字不能省略。因为Container是一个模板参数,编译器在解析Container::value_type时不知道它是一个类型还是一个成员变量,需要你显式告诉它"这是一个类型"。
这一步的意义在于,范型代码可以这样写:
template <typename StackType> void printTop(StackType& st) { typename StackType::reference r = st.top(); r = r + 1; // 修改栈顶元素 }如果模拟容器没有提供reference,这个通用函数就没法工作。我是在写一个通用算法库时意识到这点的——所有容器统一提供value_type等类型别名,算法才能做到真正的泛型。
6.4 右值引用 push 与万能转发的区分
我见过一些代码在模拟容器里写:
void push(T& x) { _c.push_back(x); }这个版本只能接受左值。如果调用st.push(42),虽然42可以绑定到const T&,但无法绑定到T&(非 const 左值引用不能绑定右值),编译会报错。改成:
void push(const T& x) { _c.push_back(x); } void push(T&& x) { _c.push_back(std::move(x)); }两个重载,左值和右值都照顾到。用std::move(x)而不是直接_c.push_back(x),是为了让底层容器把 x 当作右值处理,触发移动构造函数,避免深拷贝。
这里有一个容易和"万能引用"搞混的点。push(T&& x)中的T&&是右值引用,不是万能引用,因为T已经被类模板参数确定了,不再处于"推导"状态。想让引用语义更灵活,可以加一个转换构造函数或者提供emplace接口,比如:
template <typename... Args> void emplace(Args&&... args) { _c.emplace_back(std::forward<Args>(args)...); }emplace与push的区别在于:push先构造一个对象再拷贝/移动进容器;emplace直接把构造参数传入容器,在容器内部就地构造。对std::string、自定义大对象这类类型,emplace能省一次临时对象的构造和析构。
7. 实测对比:模拟实现与标准库的性能边界和使用建议
理论和代码都写完了,最后做一个实测对比,让数据说话。我用一个包含 100 万个整数的测试:连续 push 100 万次,再连续 pop 100 万次,分别测试标准库stack(deque 底层)、std::stack<int, std::vector<int>>、以及我自己写的基于std::vector的MyStack。
测试环境是某台普通 x86 机器,编译开 O2 优化。结果大致是:
| 实现方式 | 耗时(相对值) | 内存局部性 | 代码量 |
|---|---|---|---|
std::stack<int> | 1.0(基准) | 较好 | 0 |
std::stack<int, std::vector<int>> | 0.7 左右 | 最好 | 0 |
| 自写 MyStack(vector 底层) | 0.7 左右 | 最好 | 约 50 行 |
| 自写 CircularQueue | 0.8 左右 | 最好 | 约 100 行 |
deque相对较慢的原因在于它的分段结构:访问每个元素需要先算清楚"在第几段、段内偏移多少",多了一层映射;vector则是直接连续内存,下标访问就是一次加法。但注意,这只是极端全量 push/pop 的测试。实际工程里queue用deque的好处是头部出队无需搬移元素,而vector头部出队是 O(n)。
我的建议是分场景选型:
- 纯 LIFO 场景,元素数量大:用
std::stack<int, std::vector<int>>,或者干脆直接用vector自己管理size。 - FIFO 场景,频繁出队入队:用标准库默认
queue(deque 底层)最省心。除非你能证明热点在内存拷贝上,否则别轻易换底层。 - 教学练手:一定亲自写一遍循环队列。写完之后你再看 STL 的
deque源码,理解会完全不同。
7.1 模拟实现到底有什么意义:面试之外的价值
有人会问:标准库已经有现成的stack和queue,自己实现一遍不是重复造轮子吗?
我的观点是:这个"重复造轮子"值得造。首先,写一遍能让你彻底搞懂适配器模式,这是 STL 架构的核心思想之一,你后面看priority_queue、看迭代器适配器、看函数适配器都会顺很多。其次,面试考数据结构时,面试官经常让你手写栈、队列或循环队列,你提前写过,现场才能写得快、写得稳。最后,实际工作中你确实可能遇到标准库不满足需求的场景,比如队列需要支持批量清空、需要统计历史最大长度、需要线程安全版本。这时候你理解了底层结构,才能正确地"定制一个容器",而不是把std::queue包上一层锁就完事。
7.2 进一步扩展:priority_queue 和双端队列
写完stack和queue之后,很容易顺着这条线扩展出两个新容器。
priority_queue(优先队列)也是一个容器适配器,底层默认是vector,但通过堆算法保证每次top()取到的是"优先级最高"的元素。它的接口很像stack:push、pop、top。区别在于 push 之后内部要做堆的上浮调整,pop 之后要做下沉调整,时间复杂度都是 O(log n)。你完全可以用同样的模板化思路模拟实现:
template <typename T, typename Container = std::vector<T>> class MyPriorityQueue { public: void push(const T& x) { _c.push_back(x); std::push_heap(_c.begin(), _c.end()); } void pop() { std::pop_heap(_c.begin(), _c.end()); _c.pop_back(); } T& top() { return _c.front(); } // ... private: Container _c; };这里要用到<algorithm>里的push_heap和pop_heap。注意push_heap要求先把元素放到末尾,再调用它调整堆;pop_heap会先把堆顶换到末尾,然后你pop_back()删除它。算法配合容器的顺序有讲究,写错了堆序就乱了。
deque本身就是双端队列,但它太底层,属于容器而非适配器。如果业务逻辑需要"两端都能进出",直接用std::deque而不是组合stack和queue。标准库里还有一个std::deque的适配版本,但一般工程里很少需要自己再包一层。
7.3 最后分享一个实际项目中的小技巧
我们在一个消息缓冲模块里,最初用的是std::queue<std::string>。后来发现因为在多线程环境下要加锁,而锁的粒度太大导致吞吐上不去。改版时我没有直接用无锁队列(那个实现成本太高),而是先把队列底切换成std::queue<std::string, std::list<std::string>>,减少一些底层锁竞争时的内存分配压力;接着在队列外层做了一个"批量获取"接口——一次从队列里取出最多 N 条消息,然后统一释放锁。
这个批量接口如果是自己实现的循环队列,非常好做:定义一个新的数据结构Batch { std::vector<std::string> items; },在取数时一次性循环 N 次把元素搬到items里。而标准库std::queue做不到直接"切走底层缓冲区",你只能一个个 pop。所以如果你明确知道队列会被高并发消费,自己实现一个有批量出队、支持内存预分配的循环队列,会比套标准库更游刃有余。
我在实际开发中还有一个体会:不要在第一次写容器的时候就追求"完美适配所有场景"。先写一个能跑的最小实现,把接口定清楚,再把底层抽出来做成模板参数。这种"先窄后宽"的设计路径会让你做起扩展来非常舒服。比如一开始MyQueue写死用list,跑通之后再改成模板参数支持deque,整个过程几乎不需要返工。就像学习stack和queue本身一样:先用最直观的接口理解它们的行为,再深入看底层,最后自己动手重写一遍,你对 C++ 容器这层知识就算真正打通了。