先别急着背接口,先搞清楚一个问题:C++ STL里的stack和queue,跟你在数据结构课本上写的那个栈和队列,是同一回事,但实现方式完全不一样。很多初学者一上来就翻文档,把push、pop、top背得贼熟练,结果真要自己写一个栈去做括号匹配,还是卡住。这篇文章就专门把这两个容器适配器掰开揉碎,讲清楚它们是什么、怎么用、底层为什么默认是deque、以及平时最容易踩的坑。适合刚学完vector和list、准备进入STL进阶的你,也适合那些刷题时对“为什么用stack不用vector”有点迷糊的老朋友。
1. 先弄清楚身份:stack和queue是容器适配器,不是容器
1.1 为什么叫“适配器”而不是“容器”
STL里的术语很容易把人绕晕。vector、list、deque这些是我们常说的“顺序容器”,它们真正管着数据存储,有自己的迭代器,能干很多事。但stack和queue不一样,它们不直接存储数据,而是内部套一个容器,把那个容器的接口“改装”成栈或队列的样子。
什么叫改装?就是重新包装。比如说底层用deque,deque本身提供了push_back、push_front、pop_back、pop_front等各种操作,太灵活了。但现在我要一个栈,只需要“从尾部压入”“从尾部弹出”“看尾部元素”这三个动作,其他功能都多余。于是stack就做了一层壳,把deque的push_back重命名成push,把pop_back重命名成pop,把back重命名成top。对外暴露的只有栈语义,内部具体是谁在干活,调用者不关心。
用生活类比就是电源转换插头。美标插头不能直接插国标插座,中间加一个转换头,接口变了,但电流还是那个电流。stack就是转换头,deque就是插座本体。这种设计的好处很直接:你写代码时面对的是栈的逻辑,不用操心容器的底层差异;底层容器想换就换,只要满足要求,不影响上层逻辑。
1.2 底层容器的选择:默认deque,vector和list也能当备胎
既然是适配器,底层容器当然可以指定。stack的模板声明长这样:
cpp复制template<class T, class Container = std::deque<T>>
class stack;
第二个模板参数就是底层容器。C++标准要求stack的底层容器必须支持back、push_back、pop_back这几个操作,所以vector、deque、list都能用。想换的话,声明时直接写:
cpp复制std::stack<int, std::vector<int>> st1;
std::stack<int, std::list<int>> st2;
queue的模板声明类似,但要求更严格一点,必须支持front、back、push_back、pop_front。注意这里有pop_front,vector不提供pop_front,所以vector不能作为queue的底层容器。这一点很多人会记混,以为queue和stack一样什么都能套,实际上queue默认用deque,或者换成list。
| 底层容器 | 能作为stack底层 | 能作为queue底层 | 原因 |
|---|---|---|---|
| vector | 可以 | 不行 | 没有pop_front,头删做不到O(1) |
| deque | 可以 | 可以 | 头尾插入删除都是O(1) |
| list | 可以 | 可以 | 双向链表,头尾操作都O(1) |
所以初学阶段你只要记住一件事:默认就是deque,除非有特殊性能需求,否则别自己瞎改。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 接口梳理:stack和queue的常用操作就这样
2.1 stack:后进先出,top不是front
stack的接口少得可怜,总共就这几个:
| 接口 | 作用 |
|---|---|
| push(value) | 入栈,压入一个元素 |
| pop() | 出栈,弹出栈顶元素 |
| top() | 返回栈顶元素的引用 |
| empty() | 判断栈是否为空 |
| size() | 返回栈中元素个数 |
| emplace(args...) | 原地构造元素,C++11起支持 |
| swap(other) | 交换两个栈的内容 |
一个很容易被忽略的点:stack的接口里没有front,只有top。想想也合理,栈只能从顶部进出,顶部就是它唯一的门。top返回的是引用,所以你可以直接改栈顶元素,比如:
cpp复制std::stack<int> st;
st.push(10);
st.top() = 20; // 栈顶从10变成20
然后看一个完整示例:
cpp复制#include <iostream>
#include <stack>
int main() {
std::stack<int> st;
st.push(1);
st.push(2);
st.push(3);
std::cout << "size = " << st.size() << std::endl;
while (!st.empty()) {
std::cout << st.top() << " ";
st.pop();
}
std::cout << std::endl;
return 0;
}
输出是3 2 1。注意循环条件是!st.empty(),千万不要写成while (st.size())然后不pop,那样会死循环。
2.2 queue:先进先出,front和back两个出口
queue的接口类似,但多了一个front和一个back:
| 接口 | 作用 |
|---|---|
| push(value) | 入队,从队尾插入 |
| pop() | 出队,弹出队头元素 |
| front() | 返回队头元素的引用 |
| back() | 返回队尾元素的引用 |
| empty() | 判断队列是否为空 |
| size() | 返回队列中元素个数 |
| emplace(args...) | 原地构造元素 |
| swap(other) | 交换两个队列 |
queue没有top,它有两个“观测口”:front看队头,back看队尾。而且queue没有提供clear方法,想清空队列,标准做法是拿一个空queue去swap,后面专门讲。
看示例:
cpp复制#include <iostream>
#include <queue>
int main() {
std::queue<int> q;
q.push(1);
q.push(2);
q.push(3);
std::cout << "front = " << q.front() << std::endl;
std::cout << "back = " << q.back() << std::endl;
q.pop();
std::cout << "after pop, front = " << q.front() << std::endl;
return 0;
}
输出是front = 1、back = 3、after pop, front = 2。注意pop弹出的元素不会返回,想读队头必须先用front拿到值再pop,这一点和stack的pop一样,都是“不返回被删元素”。一开始不习惯很正常,我自己刚用的时候也总想int x = q.pop(),编译器直接报错,后来才明白这是为了兼顾异常安全和性能习惯。
3. 底层机制:为什么默认底层容器是deque
3.1 deque的“分段连续”结构
既然stack和queue都默认用deque当底层容器,那deque到底特殊在哪?这就要说到deque的内部结构了。
deque的全称是double-ended queue,双端队列。它跟vector最大的区别是:vector的内存是一整块连续空间,头部插入要搬移所有元素;deque的逻辑上看似连续,实际上由一段段固定大小的缓冲区块构成,这些区块的地址由一个中控器(本质上是一个指针数组或者叫map)管理。
向头尾两边扩展时,deque并不需要搬移已有元素,只需要在中控器里维护新的区块指针。所以deque的头尾插入删除操作都是O(1)复杂度。这一点正好喂饱了stack和queue:stack只用尾,queue既用头又用尾,deque两边都对付得了。
那为什么不直接用vector当stack的默认底层?vector尾插尾删本来就是O(1),扩容时还能搬移数据,也不是不能用。但问题是如果stack将来不老实,或者写代码的人手滑调用了底层容器的某些接口,vector自己做不了头插,而deque都能做。更重要的是,queue必须用pop_front,vector做不到,STL想统一一个默认容器让stack和queue都舒服,deque自然成了最大公约数。而list虽然头尾操作也O(1),但每个元素单独分配节点,内存碎片多,遍历时缓存命中率远不如deque,所以默认首选还是deque。
3.2 自己动手实现一个极简stack:看懂适配器本质
如果你只看不写,可能还是觉得“适配器”三个字很虚。那咱手写一个极简版本,就十行出头:
cpp复制#include <deque>
template <typename T, typename Container = std::deque<T>>
class MyStack {
public:
void push(const T& value) {
c_.push_back(value);
}
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_;
};
这个MyStack没有任何数据存储能力,存储全靠Container对象c_。push调的是底层容器的push_back,pop调的是pop_back,top调的是back。你看,这就是适配器:我没有发明新的数据结构,只是把别人的接口重新包装成了栈的语义。
如果我们把MyStack的Container换成vector,一样能跑;换成list,也能跑。这就是模板参数的威力。标准库里的stack实现比这个复杂得多,但核心思路一模一样。看懂这个例子之后,再去翻std::stack的源码,你会觉得特别亲切。
4. 实战演练:用stack和queue解决三个经典场景
4.1 括号匹配:栈最典型的应用
括号匹配是栈的看家题,几乎所有数据结构教材都会拿它开刀。思路很简单:遇到左括号就入栈,遇到右括号就检查栈顶是不是对应的左括号,如果是就弹出,不是就说明匹配失败。最后如果栈为空,说明所有括号都匹配上了。
cpp复制#include <iostream>
#include <stack>
#include <string>
bool isValid(const std::string& s) {
std::stack<char> st;
for (char ch : s) {
if (ch == '(' || ch == '[' || ch == '{') {
st.push(ch);
} else if (ch == ')' || ch == ']' || ch == '}') {
if (st.empty()) {
return false;
}
char left = st.top();
st.pop();
if ((ch == ')' && left != '(') ||
(ch == ']' && left != '[') ||
(ch == '}' && left != '{')) {
return false;
}
}
}
return st.empty();
}
int main() {
std::cout << isValid("()[]{}") << std::endl;
std::cout << isValid("([)]") << std::endl;
return 0;
}
输出是1和0。这里有个细节值得停下来想一下:为什么要先st.top()拿到left,然后才st.pop()?因为pop不返回被删元素,你想知道栈顶是不是匹配的,必须先把top存下来。还有,右括号到来时栈为空,说明右括号没有对应左括号,直接返回false。这个分支很容易漏,漏掉之后遇到"()]"这种用例就会出错。实际刷题时,你可以在力扣20号题直接验证这段代码,稳得很。
4.2 层序遍历:队列在BFS里的位置
队列最经典的应用场景是广度优先搜索,BFS。以二叉树层序遍历为例,核心流程是:根节点先入队,然后循环处理队列,每次从队头取出一个节点,访问它,再把它不为空的左右孩子依次入队。因为队列先进先出,所以每一层的节点会按顺序被处理完,天然完成“逐层遍历”。
cpp复制#include <iostream>
#include <queue>
struct TreeNode {
int val;
TreeNode* left;
TreeNode* right;
explicit TreeNode(int x) : val(x), left(nullptr), right(nullptr) {}
};
void levelOrder(TreeNode* root) {
if (root == nullptr) {
return;
}
std::queue<TreeNode*> q;
q.push(root);
while (!q.empty()) {
TreeNode* cur = q.front();
q.pop();
std::cout << cur->val << " ";
if (cur->left != nullptr) {
q.push(cur->left);
}
if (cur->right != nullptr) {
q.push(cur->right);
}
}
}
int main() {
TreeNode* root = new TreeNode(1);
root->left = new TreeNode(2);
root->right = new TreeNode(3);
root->left->left = new TreeNode(4);
root->left->right = new TreeNode(5);
levelOrder(root);
// 输出: 1 2 3 4 5
return 0;
}
这段代码里有个小设计:q里存的是TreeNode*,而不是TreeNode。为什么?因为队列里放指针不会产生对象拷贝,要是直接放TreeNode,每次push都要拷贝一个完整的树节点,浪费内存和时间。但存裸指针就要小心内存释放,这里为了演示没写清理逻辑,实际工程里建议直接用unique_ptr或者shared_ptr。队列在BFS里是不可替代的,因为DFS用栈或递归,BFS就必须用队列维持“先来先处理”的顺序。
4.3 用两个栈实现队列:从底层理解两种结构
栈和队列虽然语义相反,但神奇的是,用两个栈可以拼出一个队列。思路是把两个栈分成inStack和outStack:入队时直接往inStack压;出队时,如果outStack为空,就把inStack的所有元素倒进outStack,再从outStack弹出。因为栈是后进先出,倒两次之后顺序正好反过来了,变成先进先出。
cpp复制#include <stack>
class MyQueue {
public:
void push(int x) {
inStack_.push(x);
}
int pop() {
if (outStack_.empty()) {
while (!inStack_.empty()) {
outStack_.push(inStack_.top());
inStack_.pop();
}
}
int front = outStack_.top();
outStack_.pop();
return front;
}
int peek() {
if (outStack_.empty()) {
while (!inStack_.empty()) {
outStack_.push(inStack_.top());
inStack_.pop();
}
}
return outStack_.top();
}
bool empty() const {
return inStack_.empty() && outStack_.empty();
}
private:
std::stack<int> inStack_;
std::stack<int> outStack_;
};
这段代码的思路理解之后,你再也不会觉得stack和queue只是“背背接口”而已了。它俩的语义差异本质上就是“倒序”与“原序”的切换,而这种切换可以用容器组合来实现。类似的还有逆波兰表达式求值,也是用stack,思路是把数字入栈,遇到运算符就弹出两个数字计算再压回。表达式求值、函数调用栈、浏览器的回退按钮,底层逻辑都是栈;任务调度、消息队列、网络请求缓冲区,背后都是队列。看得多了就会明白,数据结构不是死概念,是解决问题的工具。
5. 常见问题与排查技巧:这些坑我基本都踩过
5.1 空容器访问top/pop:未定义行为
这是新手最容易踩的坑,没有之一。stack为空时调用top,或者queue为空时调用front,都是未定义行为——程序可能直接崩溃,也可能返回一堆垃圾数据,然后在另一个地方莫名其妙崩掉。更可恶的是,有时debug版能跑,release版就炸。
cpp复制std::stack<int> st;
st.pop(); // 未定义行为,pop前必须判断empty
正确的姿势是先判断再操作:
cpp复制if (!st.empty()) {
st.pop();
}
每次从容器里取数据前,养成先问一句“里面有没有东西”的习惯。这个习惯在写循环时尤其重要,while (!q.empty())这种写法永远比“先size再递减”安全,因为size在循环过程中可能变化,空判断不会骗你。
5.2 queue没有clear,stack没有迭代器
有次我同事想把queue清空,直接调用q.clear(),编译都没过,翻文档才发现queue根本没有clear。标准库容器里vector和deque都有clear,但queue和stack作为适配器,故意不开放clear接口。为什么?因为适配器的哲学是“只暴露严格必要的接口”,栈不需要批量清空,队列也不需要。那要清空怎么办?标准做法是用一个空对象swap过来:
cpp复制std::queue<int> q;
std::queue<int> empty;
q.swap(empty);
或者更简洁一点,C++11风格的匿名对象:
cpp复制std::queue<int>().swap(q);
同理,stack和queue都没有迭代器。你没法遍历一个栈,也没法用范围for循环输出队列内容。这也不是偷懒,而是因为“允许遍历”会打破栈和队列的语义:栈要是能随便遍历,它还叫栈吗?真需要遍历的时候,就把元素挨个pop出来,或者换成其他容器。
5.3 存指针时的内存管理
放int这种普通类型,stack和queue都不存在内存泄漏问题,因为析构时容器会逐个销毁元素。但如果你存放的是裸指针,比如std::queue<Node*> q,容器析构只会回收指针变量本身的内存,不会delete指针指向的对象。这是C++老生常谈的内存所有权问题。
处理方案就两个:一是用智能指针,把std::shared_ptr或std::unique_ptr放进容器,让智能指针接管生命周期;二是自己负责手动释放,每次pop出来后记得delete,很麻烦但有时必须做。千万不要混:既裸指针又到处new,然后忘记delete,QPS一高内存直接涨爆。我见过生产环境因为队列里存裸指针不释放,跑一天内存占用98%的情况,排查起来想死的心都有。
5.4 常见疑问速查表
| 问题 | 答案 |
|---|---|
| stack为空时能调用top吗? | 不行,未定义行为,先判断empty |
| queue怎么清空? | 没有clear,用空queue对象swap |
| vector能做queue的底层容器吗? | 不能,缺少pop_front |
| stack能遍历吗? | 不能,没有迭代器接口 |
| 无参的priority_queue是什么? | 优先级队列,默认底层vector,是另一个适配器 |
| emplace和push有什么区别? | emplace原地构造元素,减少一次拷贝/移动 |
| deque是连续内存吗? | 逻辑连续,物理上分块,头尾插入O(1) |
还有一个细节值得提:size()返回的是size_t,无符号类型,用if (st.size() >= 0)这种判断永远是true,因为size()永远不会小于0。这个看起来低级,但我在真实code review里真的见过不下一次。
另外,stack和queue的swap也支持成员函数,且和std::swap一样是O(1)的,因为它俩都只是交换内部底层容器指针,不搬元素。所以交换两个栈,放心用swap,不用担心性能。
说到最后,我自己在实际项目里用得最多的反而是queue和BFS的组合,不管是业务上的异步任务队列,还是遍历图结构,queue从来都是那个“先来先处理”的守护者。而stack更多出现在表达式解析、函数递归转非递归、浏览器后退按钮这类“回溯”场景里。如果你想继续深挖,下一步可以看priority_queue,它同样是适配器,但底层用了堆算法,默认容器是vector,和今天讲的这两个完全不是一个套路。掌握stack和queue之后,你再看STL里其他适配器,会发现都是同一套“包装底层容器”的逻辑,理解成本会低很多。
