在 C++ 标准模板库(STL)中,stack(栈)和queue(队列)是开发者最常用的数据结构之一,但很多人仅停留在调用push/pop的表层,对它们的设计思想、底层依赖的deque双端队列,以及背后的数据结构原理知之甚少。
本文将结合手写模拟源码、底层结构原理图与经典算法场景,从使用方法、设计模式、底层数据结构、性能对比、面试考点 五个维度,带你彻底吃透stack、queue和deque的所有技术细节。
一、栈(stack):后进先出的线性表
1.1 核心特性与本质
栈是一种 LIFO(Last In First Out,后进先出) 的线性数据结构:元素只能从**栈顶(top)**一端插入,也只能从栈顶一端删除,就像弹夹压子弹 ------ 最后压入的子弹最先被射出。
需要特别注意:std::stack 并不是独立的容器,而是一个「容器适配器」。它不自己管理内存、存储数据,而是封装另一个底层容器,对外只暴露符合 "栈" 语义的统一接口。
1.2 std::stack 常用接口与使用
STL 中 stack 的核心接口如下表:
| 成员函数 | 功能说明 |
|---|---|
stack() |
构造一个空栈 |
empty() |
检测栈是否为空,返回 bool |
size() |
返回栈中元素个数,类型为 size_t |
top() |
返回栈顶元素的引用(提供 const 和非 const 两个版本) |
push(const T& val) |
将元素压入栈顶,底层调用容器的 push_back |
pop() |
弹出栈顶元素,无返回值,底层调用容器的 pop_back |
补充知识 : C++11 之后新增了
emplace()成员函数,可以直接在栈顶原地构造 元素,避免了临时对象的拷贝构造开销。当栈中存储自定义类对象时,emplace(args...)直接调用构造函数,性能优于push(T(args))。此外
stack不提供迭代器,也不支持遍历 ------ 这是设计上的刻意封装:栈的语义就是只允许操作栈顶,不允许访问中间元素。
基础使用示例:
cpp
#include <stack>
#include <iostream>
int main() {
std::stack<int> st;
st.push(1);
st.push(2);
st.push(3);
std::cout << st.top(); // 输出 3
st.pop();
std::cout << st.top(); // 输出 2
return 0;
}
1.3 栈的经典算法应用
结合原理图,我们讲解三个栈的高频面试题,这也是图片资料中的核心考点。
(1)最小栈(MinStack)
题目 :设计一个支持 push、pop、top 操作,并能在O (1) 时间内检索到最小元素的栈。
实现思路:使用双栈
_elem栈:存储所有元素,负责正常的入栈出栈_min栈:存储每一步的当前最小值,栈顶始终是当前栈的最小值
操作规则:
- 压入元素时:如果元素小于等于
_min栈顶,就同时压入_min栈;否则只压入_elem - 弹出元素时:如果弹出的元素等于
_min栈顶,_min也同步弹出
画图可以直观看到:依次压入 6、5、3 时,_min 栈会同步压入 6、5、3;弹出 3 时,_min 也弹出 3,此时栈顶最小值变为 5。
拓展优化:还可以用「差值法」只用一个栈 + 一个变量存最小值,进一步节省空间。
cpp
class MinStack {
public:
MinStack() {
}
void push(int value) {
_elem.push(value);
if(_min.empty() || value <= _min.top())
{
_min.push(value);
}
}
void pop() {
if(_elem.top() == _min.top())
{
_min.pop();
}
_elem.pop();
}
int top() {
return _elem.top();
}
int getMin() {
return _min.top();
}
private:
stack<int> _elem;
stack<int> _min;
};
(2)栈的压入、弹出序列
题目:输入两个整数序列,第一个表示压入顺序,判断第二个是否为该栈的合法弹出顺序。
模拟法思路:
- 用辅助栈模拟入栈过程,按入栈序列逐个压入
- 每次压入后,循环检查栈顶是否等于出栈序列的当前元素
- 如果相等就弹出栈顶,出栈索引后移;直到栈空或不相等
- 最终如果入栈序列走完但栈不为空,则不是合法序列
画图可以清晰展示:入栈 [1,2,3,4,5]、出栈 [4,5,3,2,1] 的完整匹配过程。
cpp
class Solution {
public:
bool IsPopOrder(vector<int>& pushV, vector<int>& popV) {
stack<int> st;
size_t pushi = 0 , popi = 0;
while(pushi < pushV.size())
{
st.push(pushV[pushi++]);
while((!st.empty())&&st.top()==popV[popi])
{
popi++;
st.pop();
}
}
return st.empty();
}
};
(3)逆波兰表达式求值(RPN)
逆波兰表达式又称后缀表达式 ,运算符位于操作数之后,比如 2 1 + 3 * 对应中缀表达式 (2+1)*3。
解法思路:
- 遇到数字:直接入栈
- 遇到运算符:连续弹出两个数(先弹出的是右操作数,后弹出的是左操作数),计算后将结果压回栈中
- 遍历结束后,栈顶元素就是最终结果
易错点:因为栈是后进先出,后缀表达式中左操作数先出现、先入栈,所以计算时要注意左右操作数的顺序,减法和除法顺序不能反。
cpp
class Solution {
public:
int evalRPN(vector<string>& tokens) {
stack<int> st;
for(auto & str : tokens)
{
if(str == "+" || str == "-" || str == "*" || str == "/" )
{
int right = st.top();
st.pop();
int left = st.top();
st.pop();
switch(str[0])
{
case '+':
st.push(left + right);
break;
case '-':
st.push(left - right);
break;
case '*':
st.push(left * right);
break;
case '/':
st.push(left / right);
break;
default:
break;
}
}
else
{
st.push(stoi(str));
}
}
return st.top();
}
};
1.4 stack 模拟实现源码解析
我们结合 stack.h 中的手写实现,逐行拆解适配器的本质:
cpp
namespace a {
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(); }
size_t size() const { return _con.size(); }
bool empty() const { return _con.empty(); }
const T& top() const { return _con.back(); }
T& top() { return _con.back(); }
private:
Container _con; // 底层容器
};
}
代码细节全解析:
- 双模板参数 :第一个
T是元素类型;第二个Container是底层容器类型,默认使用std::deque<T>。 - 纯封装逻辑 :所有成员函数没有任何数据结构逻辑,全部是调用底层容器的对应接口 ------
push调push_back、pop调pop_back、top调back。这就是「适配器」的核心:换接口,不换实现。 - const 重载 :提供了
const和非const两个版本的top(),保证const栈对象也能调用,符合 C++ const 成员函数的设计规范。
标准库的
stack还实现了swap、emplace、移动构造、关系运算符重载等功能,我们的模拟实现是核心简化版。
二、队列(queue):先进先出的线性表
2.1 核心特性与本质
队列是 FIFO(First In First Out,先进先出) 的线性数据结构:元素从队尾(back) 入队,从**队头(front)**出队,和现实中排队的逻辑完全一致。
和 stack 一样,std::queue 同样是容器适配器 ,默认底层容器也是 deque。
2.2 std::queue 常用接口与使用
核心接口如下表:
| 成员函数 | 功能说明 |
|---|---|
queue() |
构造空队列 |
empty() |
检测队列是否为空 |
size() |
返回队列中元素个数 |
front() |
返回队头元素的引用 |
back() |
返回队尾元素的引用 |
push(const T& val) |
队尾入队 |
pop() |
队头出队 |
和栈一样,队列也不提供迭代器,不支持遍历;同样支持
emplace原地构造优化。
2.3 队列的经典应用:二叉树层序遍历(BFS)
队列最典型的应用就是广度优先搜索(BFS),最基础的就是二叉树的层序遍历 ------ 按层次从上到下、从左到右访问所有节点。
算法步骤:
- 根节点先入队
- 每次记录当前层的节点数
size,循环size次:依次出队节点、访问节点、将节点的左右孩子入队 - 队列为空时遍历结束
画图清晰展示了一棵三层二叉树的层序遍历过程,每一层的节点都会批量入队、批量出队,保证按层访问。
cpp
class Solution {
public:
vector<vector<int>> levelOrder(TreeNode* root) {
vector<vector<int>> res;
if(root == nullptr) return res; // 空树直接返回
queue<TreeNode*> q;
q.push(root);
while(!q.empty())
{
int size = q.size(); // 当前这一层一共有多少个节点
vector<int> curLevel; // 保存本层结果
// 遍历当前层所有节点
for(int i = 0; i < size; ++i)
{
TreeNode* node = q.front();
q.pop();
curLevel.push_back(node->val);
// 把左右孩子入队(下一层)
if(node->left != nullptr)
q.push(node->left);
if(node->right != nullptr)
q.push(node->right);
}
res.push_back(curLevel); // 将本层加入总结果
}
return res;
}
};
2.4 queue 模拟实现源码解析
结合 queue.h 的代码:
cpp
namespace b {
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(); }
size_t size() const { return _con.size(); }
bool empty() const { return _con.empty(); }
const T& front() const { return _con.front(); }
T& front() { return _con.front(); }
const T& back() const { return _con.back(); }
T& back() { return _con.back(); }
private:
Container _con;
};
}
与 stack 的核心区别:
pop()调用的是底层的pop_front()(头删),而stack是pop_back()(尾删)------ 这正是 FIFO 和 LIFO 的本质差异。- 多了
front()和back()两个接口,分别访问队头和队尾;而栈只有top()一个访问接口。
思考题:为什么
vector不能做 queue 的底层容器?答:因为
vector没有高效的头删操作,头部删除需要搬移所有元素,时间复杂度 O (n);而deque和list的头删都是 O (1)。
三、拓展:优先级队列(priority_queue)
它是 STL 重要的容器适配器。
3.1 本质与特性
priority_queue 本质是堆(heap) ,默认是大顶堆 (堆顶始终是最大元素)。它也是容器适配器,默认底层容器是 vector------ 因为堆结构需要随机访问来快速计算父子节点位置。
3.2 大小堆控制
- 默认使用
less<T>比较器,构造大顶堆 - 如需小顶堆,需要手动指定第三个模板参数为
greater<T>
cpp
std::priority_queue<int> big_heap; // 默认大顶堆
std::priority_queue<int, std::vector<int>, std::greater<int>> small_heap; // 小顶堆
自定义类型放入优先级队列,需要重载
<或>运算符,或者自定义仿函数作为比较器。
3.3 经典应用:TopK 问题
求数组中第 K 大的元素是堆的经典场景:
- 方案 1:全部元素放入大顶堆,弹出 K-1 次,堆顶即为第 K 大,时间 O (n log n)
- 方案 2:维护 K 个元素的小顶堆,遍历数组,比堆顶大就替换,最终堆顶就是第 K 大,时间 O (n log k),适合海量数据
cpp
class Solution {
public:
int findKthLargest(vector<int>& nums, int k) {
priority_queue<int> pq(nums.begin(),nums.end());
while(--k)
{
pq.pop();
}
return pq.top();
}
};
四、容器适配器与适配器设计模式
4.1 什么是适配器模式
适配器模式是一种结构型设计模式 ,核心作用是:将一个类的接口转换成客户希望的另一个接口,让原本接口不兼容的类可以协同工作。
生活中最典型的例子就是电源适配器:欧洲插座和美国插头规格不兼容,通过适配器转换接口就能正常使用。STL 的容器适配器也是同理 ------ 把 deque/vector/list 的通用接口,转换成栈、队列、优先级队列的特定语义接口。
4.2 三个适配器的底层容器要求
不是所有容器都能作为适配器的底层,必须满足对应的接口要求:
| 适配器 | 要求的底层接口 | 可用底层容器 | 默认底层容器 |
|---|---|---|---|
stack |
push_back、pop_back、back |
vector、list、deque | deque |
queue |
push_back、pop_front、front、back |
list、deque | deque |
priority_queue |
随机访问、push_back、pop_back |
vector、deque | vector |
4.3 为什么 stack 和 queue 默认选择 deque
这是高频面试题,核心原因有四点:
- 不需要遍历:栈和队列本身不提供迭代器,完美避开了 deque 遍历效率低的缺点。
- 扩容开销小:deque 扩容只需要新增缓冲区、修改中控指针,不需要像 vector 那样搬移全部元素。
- 两端操作高效:队列需要头删尾插,deque 两端操作都是 O (1),远优于 vector 的头删 O (n)。
- 内存利用率高:对比 list,deque 是分段连续存储,不需要额外存储前后指针,空间浪费更少。
一句话总结:deque 刚好结合了 vector 和 list 的优点,又完美避开了它们的缺点,是栈和队列底层的最优解。
五、deque 深层原理:分段连续的双端队列
deque(double-ended queue,双端队列)是 STL 中最复杂的容器之一,也是理解栈和队列的关键。
5.1 整体结构:分段连续 + 中控器
deque 对外表现得像 "连续数组",但底层实际上是分段连续的,由两部分组成:
- 缓冲区(buffer):真正存储数据的连续内存块,每个缓冲区大小固定。
- 中控器(map) :一个指针数组(二级指针
T**),每个元素指向一个缓冲区的首地址,负责管理所有缓冲区。
当缓冲区数量增多、map 空间不足时,中控器会扩容 ------ 重新分配更大的指针数组,拷贝原有指针,不需要搬移任何数据,这是 deque 扩容效率高于 vector 的核心原因。
5.2 迭代器:模拟连续空间的核心
deque 能支持随机访问、让用户感觉 "空间连续",全部秘密都在它的迭代器设计上。deque 的迭代器是一个类,包含 4 个核心成员:
cpp
struct __deque_iterator {
T* cur; // 当前指向的元素
T* first; // 当前缓冲区的起始地址
T* last; // 当前缓冲区的末尾地址(超尾)
map_pointer node;// 指向中控器中当前缓冲区的指针
};
迭代器工作原理:
operator++时:先cur++,如果cur == last(走到当前缓冲区末尾),就node++切换到下一个缓冲区,再把cur置为下一个缓冲区的first。- 随机访问
operator+=时:先计算偏移量,如果在当前缓冲区内就直接移动;如果跨缓冲区,就计算跨越的缓冲区数量,移动node指针,再定位到对应位置。
正是因为迭代器封装了跨缓冲区的复杂逻辑,用户才能像操作连续数组一样使用 deque。但也因为每次移动都要做边界检查,deque 迭代器的遍历效率远低于 vector 的原生指针。
5.3 核心操作的底层逻辑
- 尾插 push_back:末尾缓冲区有空间就直接放;没空间就申请新缓冲区,加到 map 末尾。
- 头插 push_front:开头缓冲区有前置空间就直接放;没空间就申请新缓冲区,加到 map 开头。
这就是 deque 头尾插入都是 O (1) 的原因 ------ 不需要搬移现有元素,只需要新增缓冲区。
注意:deque 中间位置的插入删除是 O (n) 的,因为需要搬移元素,且分段结构让搬移更复杂,所以 deque 不适合频繁中间操作。
5.4 性能测试深度分析
测试 1:vector 与 deque 的 sort 效率对比
test_op1 对 100 万个元素的 vector 和 deque 分别排序,结果是 vector 排序远快于 deque。
根本原因:
- CPU 缓存与空间局部性:vector 是完全连续内存,遍历时 CPU 缓存可以预取后续元素,缓存命中率极高;deque 分段存储,跨缓冲区时缓存失效,需要从内存重新加载,速度大幅下降。
- 迭代器开销:vector 迭代器就是原生指针,零开销;deque 迭代器每次移动都要做边界判断,额外增加了很多指令。
测试 2:deque 直接排序 vs 拷贝到 vector 再排序
test_op2 对比了两种方案:直接对 deque 排序;把 deque 拷贝到 vector → 排序 → 拷回 deque。 结果反而是第二种方案更快。
这验证了上面的结论:虽然多了两次拷贝,但 vector 排序的效率提升远远超过了拷贝的开销。这也是实际开发中的优化技巧:如果需要对 deque 做大量排序 / 遍历,可以先转成 vector 处理再转回。
cpp
void test_op1()
{
srand(time(0));
const int N = 1000000;
deque<int> dq;
vector<int> v;
for (int i = 0; i < N; ++i)
{
auto e = rand() + i;
v.push_back(e);
dq.push_back(e);
}
int begin1 = clock();
sort(v.begin(), v.end());
int end1 = clock();
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);
}
int begin1 = clock();
sort(dq1.begin(), dq1.end(), less<int>());
int end1 = clock();
int begin2 = clock();
// vector
vector<int> v(dq2.begin(), dq2.end());
sort(v.begin(), v.end());
dq2.assign(v.begin(), v.end());
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;
}
5.5 deque 优缺点总结
| 维度 | 优点 | 缺点 |
|---|---|---|
| 头尾操作 | 两端插入删除都是 O (1) | 中间插入删除 O (n),效率低 |
| 随机访问 | 支持 [] 随机访问 |
效率低于 vector,需要计算缓冲区跳转 |
| 扩容开销 | 扩容仅需新增缓冲区、拷贝中控指针,开销小 | 中控器和缓冲区存在一定空间浪费 |
| 遍历效率 | --- | 遍历效率低,缓存命中率差,迭代器边界检查开销大 |
| 内存布局 | 分段连续,内存碎片少 | 非真正连续,不适合传给要求连续数组的 C 接口 |
六、高频面试题总结
- stack 和 queue 是容器吗?默认底层是什么? 答:不是容器,是容器适配器。stack 和 queue 默认底层是 deque,priority_queue 默认是 vector。
- 为什么 stack/queue 默认用 deque 不用 vector? 答:queue 需要头删,vector 头删效率极低;deque 扩容不需要搬移全部元素,开销更小;两端 O (1) 操作完美适配栈和队列的语义。
- deque 的底层原理是什么? 答:由多段固定大小的缓冲区和一个中控器(指针数组)组成。迭代器封装了跨缓冲区逻辑,对外模拟连续空间。支持两端 O (1) 插入删除,随机访问和遍历效率低于 vector。
- 用两个栈实现队列 / 两个队列实现栈
- 栈实现队列:一个入栈负责入队,一个出栈负责出队;出栈空时把入栈所有元素倒过来。
- 队列实现栈:入栈时倒腾队列,让新元素在队头。
cpp
//栈实现队列
class MyQueue {
public:
MyQueue() {
}
void push(int x) {
instack.push(x);
}
void transfer()
{
if(outstack.empty())
{
while(!instack.empty())
{
outstack.push(instack.top());
instack.pop();
}
}
}
int pop() {
transfer();
int i = outstack.top();
outstack.pop();
return i;
}
int peek() {
transfer();
return outstack.top();
}
bool empty() {
return instack.empty() && outstack.empty();
}
private:
stack<int> instack;
stack<int> outstack;
};
cpp
//队列实现栈
class MyStack {
public:
MyStack() {
}
void push(int x) {
q.push(x);
for(int i = 0; i < q.size() - 1; ++i )
{
q.push(q.front());
q.pop();
}
}
int pop() {
int top = q.front();
q.pop();
return top;
}
int top() {
return q.front();
}
bool empty() {
return q.empty();
}
private:
queue<int> q;
};
最后
stack 和 queue 看似简单,背后却藏着适配器设计模式的思想,以及 deque 复杂的分段存储结构。理解这些底层原理,不仅能让我们更精准地选择和使用 STL 容器,也能在面试中建立更深的技术优势。