很多初学者学到 C++ 的栈和队列时,都会有一个共同的疑惑:vector、list、string 都能遍历,std::stack 和 std::queue 拿到手却连迭代器都不给你。我第一次用 std::stack 的时候,甚至怀疑是不是自己少写了头文件导致功能被阉割。后来才明白,这不是缺陷,而是标准库的设计选择——栈和队列的本质是对数据访问方式的一种约束,C++ 用“容器适配器”这个概念来承载它们。这篇文章就围绕三个关键词展开:栈、队列、容器适配器。无论你是刚学完 STL 想补全这块拼图,还是准备面试被问到“栈和队列的区别”,又或者想搞懂 LeetCode 里单调栈、单调队列到底在干什么,这篇初阶详解都会给你一个清晰完整的答案。
1. 初遇栈和队列:先搞清楚我们到底在学什么
1.1 stack 和 queue 为什么连迭代器都没有
接触过 STL 的人都知道,vector、list、map 都有 begin() 和 end(),可以随手写个 for 循环遍历。但 stack 和 queue 没有迭代器,也没有 begin()/end(),这让很多初学者一脸懵。
原因其实一句话就能说透:栈和队列并不关心“里面到底存了哪些元素”,只关心“你能从哪个口拿到元素”。
栈(stack)只允许在一端操作,这一端叫栈顶。你能做的只有三件事:把元素压进去(push)、看栈顶是什么(top)、把栈顶弹出去(pop)。先进去的元素在最底下,要等上面全部弹出后才能轮到它,这就是“后进先出”(LIFO,Last In First Out)。
队列(queue)则是两头分工:队尾负责进,队头负责出。先进去的元素先出来,就是“先进先出”(FIFO,First In First Out)。
如果给了迭代器,就意味着可以随意访问中间的任意元素。那上面的约束就被破坏了,栈不再是栈,队列也不再是队列。所以标准库干脆不提供迭代器,用接口上的“残缺”守住结构上的“纯粹”。
1.2 生活化类比帮你建立直觉
理解抽象结构最快的方式是找一个日常场景。
栈最经典的类比是一摞盘子。你往上面放盘子,最后一个放上去的盘子永远最先被拿走。你想拿最底下的盘子,得先把上面所有盘子移开。递归函数调用也是这么工作的:调用一个函数,系统就把它的返回地址和局部变量压进调用栈;函数返回时,最后压入的信息最先被弹出。所以递归过深会“栈溢出”,就是因为这块区域有上限。
队列的类比更简单:排队打饭。先来的人站前面,先拿到饭,后来的自动站到队尾。打印机任务队列、CPU 进程调度、网络数据包缓冲,底层都是这个模型。
把两个结构放在一起看,它们的核心区别根本不是“长什么样”,而是**“出元素的顺序由谁决定”**。栈由“最后进入的”决定,队列由“最先进入的”决定。这个区别会一路影响底层的容器选择和算法设计。
1.3 先记住一张对比表
| 特性 | std::stack | std::queue |
|---|---|---|
| 数据访问 | 只能通过栈顶 top | 队头 front、队尾 back |
| 入元素 | push 压栈 | push 入队 |
| 出元素 | pop 弹栈 | pop 出队 |
| 出元素顺序 | 后进先出(LIFO) | 先进先出(FIFO) |
| 允许遍历 | 否 | 否 |
| 默认底层容器 | deque | deque |
这里有个细节很多人第一次没注意:stack 和 queue 的默认底层容器都是 deque(双端队列),不是 vector 也不是 list。 这个选择背后的逻辑,就是下一章的重点。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 打碎模板:容器适配器才是标准库的真相
2.1 什么是“容器适配器”
翻开 cppreference,stack 的类模板声明长这样:
cpp复制template<
class T,
class Container = std::deque<T>
> class stack;
queue 也类似。注意 class Container = std::deque<T> 这个默认参数。它说明 stack 本身并不是一个从零实现的容器,而是内部持有另一个容器,然后把那个容器的接口“改造”成一个受限的栈形态。
class 模板的第一个参数是元素类型,第二个参数是底层容器类型。也就是说,stack 内部有一个私有的 Container 对象 c,所有操作都是转发给 c 的对应接口。用伪代码理解就是这样:
cpp复制template <class T, class Container = std::deque<T>>
class stack {
public:
bool empty() const { return c.empty(); }
size_t size() const { return c.size(); }
T& top() { return c.back(); }
void push(const T& value) { c.push_back(value); }
void pop() { c.pop_back(); }
protected:
Container c;
};
这套思路其实就是设计模式里的 Adapter(适配器)模式:把一个现成的组件包装成另一个接口,不重新发明轮子。 你可以把容器适配器理解成一个带传菜窗口的厨房:食物存在后厨的储物柜(底层容器)里,客人只能通过一个固定的小窗口(适配器接口)取菜。窗口开在哪、开多大,决定了你拿到的是一种怎样的体验。
2.2 默认底层容器为什么是 deque 而不是 vector 或 list
这是个面试常问的问题。三个候选容器各有短板:
- vector:支持尾部 O(1) 的 push_back / pop_back,所以做 stack 完全够用。但它不支持头部高效插入删除,做 queue 时缺少 pop_front,会编译报错。
- list:每个节点单独分配内存,插入删除确实灵活,但节点额外开销大,缓存不友好。做大量元素存取时性能通常不如连续内存。
- deque:双端开口,头尾插入删除都是 O(1),并且支持随机访问。
所以 deque 一个人就能同时满足 stack 和 queue 的底层要求:stack 需要尾部增删,deque 可以;queue 需要尾部入、头部出,deque 同样可以。而 vector 只能做 stack 不能做 queue,list 虽然都能做但综合效率不高。标准库选 deque 当默认值,属于“一个容器覆盖两种场景”的最优解。
deque 的底层并不是一整块连续内存,而是由一小块“中控”指针数组指向多个固定大小的缓冲区。头尾扩容时,只需要在两侧新增缓冲区、修改中控映射,不需要像 vector 那样整块搬迁。所以它头尾插入才是真正的 O(1)。代价是随机访问比 vector 多一次间接寻址,遍历性能稍微差一点点,但在栈和队列这种“只从两端进出”的场景里,这点差异完全可以忽略。
2.3 std::stack 的完整用法:top、push、pop、empty、size
下面代码演示栈最基础的用法。记住一个关键点:pop 不返回被弹出的元素,想拿到元素必须先调用 top。
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; // 3
std::cout << "top = " << st.top() << std::endl; // 3
while (!st.empty()) {
std::cout << st.top() << " "; // 输出:3 2 1
st.pop();
}
return 0;
}
成员函数数量不多,但接口语义要记牢:
| 函数 | 作用 | 复杂度 |
|---|---|---|
| push(x) | 压入元素 x | O(1) |
| pop() | 弹出栈顶元素,无返回值 | O(1) |
| top() | 返回栈顶元素的引用 | O(1) |
| empty() | 判断栈是否为空 | O(1) |
| size() | 返回元素个数 | O(1) |
| emplace(args...) | 原地构造元素,避免拷贝 | 取决于底层容器 |
看到 emplace 可能有人问:push 和 emplace 有什么区别?push 是“把已经存在的元素放进栈”,emplace 是“用参数直接在栈里构造元素”。如果入栈对象的构造函数比较复杂,emplace 能省掉一次临时对象的拷贝构造。C++11 之后我基本习惯用 emplace 替代 push 的场景,但两者最终效果是一样的。
2.4 std::queue 的用法:front、back、push、pop
queue 的接口和 stack 略有不同,它没有 top,取而代之的是 front(队头)和 back(队尾):
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; // 1
std::cout << "back = " << q.back() << std::endl; // 3
while (!q.empty()) {
std::cout << q.front() << " "; // 输出:1 2 3
q.pop();
}
return 0;
}
注意 queue 的使用要求:底层容器必须支持 front、back、push_back、pop_front。vector 没有 pop_front,所以直接拿 vector 当 queue 的底层容器是会编译失败的;deque 和 list 都可以。这里也能看出为什么 queue 默认用 deque,而不是用更常见的 vector。
日常开发里,队列最常见的用途是“排队处理任务”:从网络收包、日志写入、消息投递,统统可以用一个 queue 把事情按到达顺序串起来,避免并发写冲突,也保证处理顺序不乱。
2.5 自定义底层容器:什么时候手动指定第二个模板参数
虽然默认是 deque,但有些场景需要手动指定底层容器:
cpp复制#include <stack>
#include <queue>
#include <vector>
#include <list>
// 用 vector 做栈的底层:内存连续、缓存友好,适合对栈顶访问频繁的场景
std::stack<int, std::vector<int>> st;
// 用 list 做队列的底层:元素多且频繁出入队时,节点增删更灵活
std::queue<int, std::list<int>> q;
什么时候值得改?说实话,初阶阶段 90% 的情况默认就够用,不用刻意改。真正需要自定义的典型场景是:
- 明确要求底层内存连续,方便和 C 接口交互;
- 想要 list 那样“插入不会使已有迭代器失效”的保证;
- 面试题或竞赛里用 vector 模拟栈,想统一类型别名。
stack 对底层容器的要求是有 back() / push_back() / pop_back(),vector、deque、list 都能满足;queue 额外要求 front() / pop_front(),vector 不行。写模板代码之前先想清楚这两个约束,能省不少编译报错的时间。
3. 从标准库到算法题:双端队列、优先队列、单调栈与单调队列
3.1 std::deque:既是底层,也是独立的容器
上一章提到 deque 是 stack 和 queue 的默认底层,但 deque 本身也是一个可以直接使用的容器,叫双端队列:两端都能插入和删除,还能随机访问。
cpp复制#include <deque>
#include <iostream>
int main() {
std::deque<int> dq;
dq.push_back(1); // 尾部插入
dq.push_front(0); // 头部插入
dq.push_back(2);
std::cout << dq[1] << std::endl; // 随机访问,输出 1
dq.pop_front(); // 头部删除
dq.pop_back(); // 尾部删除
return 0;
}
它非常适合“两边都可能进、两边都可能出”的算法场景,比如滑动窗口、双端限制的任务队列、撤销重做记录。但也要记住一个性能细节:deque 的随机访问虽然支持,但比 vector 慢一点点,因为要先经过中控跳转;如果你要频繁按下标遍历所有元素,vector 依然是更优选择。
3.2 std::priority_queue:带优先级的队列
默认的 queue 是“先到先服务”,priority_queue 却是“谁优先级高谁先走”。它内部是一个堆(默认最大堆),底层容器默认是 vector。
cpp复制#include <iostream>
#include <queue>
#include <vector>
int main() {
// 默认是大顶堆:top() 返回最大值
std::priority_queue<int> pq;
pq.push(10);
pq.push(30);
pq.push(20);
std::cout << pq.top() << std::endl; // 30
// 小顶堆:需要显式给出比较器
std::priority_queue<int, std::vector<int>, std::greater<int>> small;
small.push(10);
small.push(30);
small.push(20);
std::cout << small.top() << std::endl; // 10
return 0;
}
这里有个很多人写错的地方:priority_queue 的模板参数顺序是固定的:元素类型、底层容器、比较器。 想写小顶堆不能只写 std::priority_queue<int, std::greater<int>>,缺了底层容器参数会报错。比较器也要注意,std::greater<int> 让你拿到的是最小元素,因为堆的“排在最上面”的判断逻辑被反转了。
priority_queue 在什么时候用?任务调度、Top-K 问题、Dijkstra 最短路。刷题时看到“每次取最大/最小”的需求,第一反应就应该是它。
3.3 单调栈:下一个更大元素的经典模板
单调栈不是一个新容器,而是“用 stack 保存一个单调序列”的技巧。它解决的一类典型问题是:对每个元素,找它右边第一个比它大的元素。
例题是 LeetCode 496 / 739。暴力法是双重循环 O(n²),单调栈可以把时间复杂度压到 O(n):
cpp复制#include <vector>
#include <stack>
std::vector<int> nextGreaterElement(std::vector<int>& nums) {
int n = nums.size();
std::vector<int> res(n, -1);
std::stack<int> st; // 栈里存下标,栈底到栈顶对应元素单调递减
for (int i = 0; i < n; ++i) {
// 当前元素比栈顶元素大,说明栈顶的下一个更大元素出现了
while (!st.empty() && nums[i] > nums[st.top()]) {
res[st.top()] = nums[i];
st.pop();
}
st.push(i);
}
return res;
}
核心思想是:栈里保留的是“还没找到下一个更大元素”的候选下标。当新元素更大时,它会把栈顶“孵化”出来,自己入栈继续当候选。每个元素入栈一次、出栈一次,所以总复杂度 O(n)。
理解单调栈的关键,是意识到栈天然适合处理“追溯之前元素”的需求。因为后进先出的特性,你总能快速拿到“最近的未决元素”。
3.4 单调队列:滑动窗口最大值
单调队列对应的问题是:给定数组和一个窗口大小 k,求每个窗口的最大值。经典题是 LeetCode 239。
如果每次暴力扫描窗口,复杂度 O(n×k);用单调队列可以做到 O(n):
cpp复制#include <deque>
#include <vector>
std::vector<int> maxSlidingWindow(std::vector<int>& nums, int k) {
std::deque<int> dq; // 存下标,队头永远是当前窗口最大值的下标
std::vector<int> res;
for (int i = 0; i < (int)nums.size(); ++i) {
// 新元素入队前,先弹出队尾所有比它小的元素
// 这些元素既不如新元素大,也不如新元素“新”,不可能再成为最大值
while (!dq.empty() && nums[dq.back()] <= nums[i]) {
dq.pop_back();
}
dq.push_back(i);
// 队头出窗口
if (dq.front() <= i - k) {
dq.pop_front();
}
// 窗口形成后才收集结果
if (i >= k - 1) {
res.push_back(nums[dq.front()]);
}
}
return res;
}
这里为什么赶走“队尾所有比自己小的元素”?因为它们已经输给了当前元素:当前元素更大,而且位置更靠后。一个更大、更新的元素存在,旧且小的元素在后续所有窗口里都不可能翻身。单调队列本质上是在维护一个“按实力排序”的候选池,淘汰掉永远不可能胜出的对手。
单调栈和单调队列很像,但一个用的是 stack,一个用的是 deque。区别在于:单调栈往往只跟“当前元素”比对,解决“找最近/下一个”;单调队列还需要考虑“过期元素移出窗口”,所以要用双端队列同时支持两端操作。
4. 初阶最容易踩的坑:接口细节、性能损耗与数组循环队列
4.1 pop 为什么不返回元素:先 top 再 pop
这是新手最容易写错的代码:
cpp复制// 错误示范,编译不过
// int x = st.pop();
pop() 返回的是 void。很多初学者不理解,为什么这么设计?主要原因是异常安全。
如果 pop 返回被弹出的元素,那就必须先拷贝(或移动)这个元素,再调整内部结构。如果拷贝过程抛异常,元素已经被移出容器,数据就丢了,状态也说不清楚。拆成 top 和 pop 两步,就可以做到“要么拿到值再安全弹出,要么弹出不涉及拷贝,绝不半途而废”。所以标准库宁可让你多写一行:
cpp复制int x = st.top(); // 拿到值
st.pop(); // 再弹出
queue 也一样:q.front() 拿队头,q.pop() 出队。这个“两步走”的接口习惯,从老版 STL 一直保留到今天,是设计者刻意为之,不是疏漏。
4.2 判空用 empty() 而不是 size() == 0
很多老代码习惯写 if (st.size() == 0),这不算大错,但不如 if (st.empty()) 规范。原因有两个:一是语义上 empty 直指“是否为空”,读代码的人一眼懂;二是在某些容器实现里,empty 的语义保证比 size 更轻量,虽然对 stack / queue 来说复杂度都是 O(1),但约定俗成地统一风格能减少代码噪音。
面试时如果被问“stack 和 queue 怎么判空”,答 empty() 基本就是标准答案,别在 size() == 0 上纠缠。
4.3 别把 front/back 和 top 背反
stack 只有 top,queue 有 front 和 back。这个看似简单,但真到写代码时,很多人因为惯性把 queue 的队头写成 top。
一个很实用的记忆方法:stack 操作的是同一端,所以只有一个“顶”;queue 有进有出,才需要区分头和尾。刷题和项目里,凡是报出 “no member named 'top' in 'std::queue'” 这类错误,十有八九就是这里混了。
4.4 手写循环队列:数组实现与 rear + length 的变体
STL 的 queue 很好用,但有些面试和底层开发场景会让你手写循环队列,为的是不依赖动态分配、提前定好容量。核心难点是:环形数组如何区分“队空”和“队满”。
常见方案是用一个 cnt 记录当前元素个数:
cpp复制class MyCircularQueue {
private:
std::vector<int> data;
int head;
int tail; // tail 指向下一个可写入位置
int cnt; // 当前元素个数
int cap; // 容量
public:
MyCircularQueue(int k) : data(std::vector<int>(k, 0)),
head(0), tail(0), cnt(0), cap(k) {}
bool enQueue(int value) {
if (isFull()) return false;
data[tail] = value;
tail = (tail + 1) % cap;
++cnt;
return true;
}
bool deQueue() {
if (isEmpty()) return false;
head = (head + 1) % cap;
--cnt;
return true;
}
int Front() {
return isEmpty() ? -1 : data[head];
}
int Rear() {
return isEmpty() ? -1 : data[(tail - 1 + cap) % cap];
}
bool isEmpty() {
return cnt == 0;
}
bool isFull() {
return cnt == cap;
}
};
这里有两个细节值得多说一句。
第一,tail = (tail + 1) % cap 是环形数组的常规操作。取模的目的是让下标在到达 cap 时绕回 0。只要保证 cap 在循环过程中不变,这个写法是安全的。
第二,也有题目不用 head,而是用 rear 和 length 两个变量指示队列状态。这种情况下,队头下标等价于 (rear - length + cap) % cap。想象 rear 指向最后一个元素的下一个位置,length 是当前元素个数,那么向前倒序找 length 个位置,正好就是队头。这个变体和上面的 head 版本本质完全一样,区别只是用“长度”替代了“头指针”,写题时不要被题目描述绕晕。
4.5 对象入栈/入队时的拷贝与 emplace
如果栈或队列里存的是复杂对象,每次 push 都可能触发拷贝构造,甚至多次拷贝。C++11 之后可以用 emplace 在容器内直接构造:
cpp复制#include <string>
#include <queue>
std::queue<std::pair<std::string, int>> q;
// 先构造临时对象再拷贝入队,多一次拷贝
q.push(std::make_pair("hello", 42));
// 直接在容器内构造,避免临时对象
q.emplace("hello", 42);
对初阶来说,记住一个建议:入栈/入队的是复杂对象时,优先用 emplace。 而对那些本身就很轻量的内置类型,push 和 emplace 差距不大,按习惯选一个就行。
另外要注意,stack / queue 本身是可以整体拷贝的。拷贝一个 queue 等于拷贝它内部的整个底层容器,元素多的时候开销不小。如果你只想要“复用队列结构而不要数据”,记得在拷贝后马上 clear,或者一开始就考虑传引用。
5. 从单线程到多线程:阻塞队列、无锁队列与学习路线
5.1 单线程队列越用越顺手,可多线程就变味了
前面的内容全部建立在单线程前提下。实际项目中,queue 经常是多个线程共享的:一个线程往队列里塞任务,另一些线程从队列里取任务执行。比如线程池的任务队列,就是这个模型。
问题来了:如果多个线程同时调用 push 和 pop,内部的数据竞争会让程序出现不可预测的行为。所以多线程环境下需要“线程安全队列”。最常见的实现是“阻塞队列”(Blocking Queue),它额外提供了两个语义:
- 队列为空时,消费者尝试取元素会被阻塞,直到有元素入队;
- 队列为满时,生产者尝试放元素会被阻塞,直到有位置释放。
这就是典型的生产者-消费者模型。在 Java 里你直接有 BlockingQueue 接口,C++ 标准库没有现成实现,但可以用 std::condition_variable + std::mutex + std::queue 自己封装。初阶阶段不急着写,但要理解:阻塞队列解决的是“线程之间如何安全地传递数据”,它仍然是队列,只是多了同步控制。
5.2 无锁队列和 CAS 原子操作是什么
锁会带来线程阻塞和切换开销,某些极高并发场景会用无锁队列。无锁队列的核心不是“不用锁”,而是用原子操作保证数据同步。最典型的是 CAS(Compare-And-Swap):
cpp复制#include <atomic>
std::atomic<int> counter{0};
void add(int delta) {
int old = counter.load();
// 如果 counter 还是 old,就把它换成 old + delta;否则重新尝试
while (!counter.compare_exchange_weak(old, old + delta)) {
// compare_exchange_weak 失败时,old 会被更新为当前值
}
}
CAS 的意思一句话总结:“如果内存值还是我以为的那个值,我就直接替换;如果不是,说明别人改过了,重新读取再试一次。” 无锁队列就是用这种机制在多个线程同时修改头尾指针时保持一致性。
初阶阶段,我不建议你一上来就背无锁队列模板。这玩意调试起来非常折磨人,ABA 问题、内存序、伪共享这些概念叠加在一起,远超入门范畴。正确的路线是先掌握互斥锁版本的线程安全队列,理解生产者和消费者的同步逻辑,再碰无锁。而且真实项目中,90% 的队列场景用互斥锁就已经足够高效了。
5.3 消息队列:栈和队列在系统层面上的放大版
把视角再放大一层:微服务之间的数据传递,用的也是“队列”思想,只是它跑在独立的进程甚至不同的机器上,通常叫消息队列。
消息队列的核心理念和 std::queue 几乎一模一样:生产者发消息到队列,消费者按顺序取消息处理。额外增加的是持久化、ACK 确认、重试机制这些可靠性设计。
关于热词里的“消息队列重复消费问题”,简单说一句:消费者处理完业务后,还没来得及给中间件发送 ACK,系统就宕机了;重启后消息会被重新投递,于是同一笔业务被处理了两次。业界常见的解决思路是幂等设计——把“重复执行也不出错”作为业务底线。这对 C++ 初阶来说属于视野扩展,了解一个大概的因果链条就好,不用太深入。
5.4 给初学者的学习路线:从理论到实战的四步走
如果你想把栈和队列从“会用接口”变成“真的理解”,可以按这个顺序来:
- 用数组手写一个 stack、一个循环队列,理解下标控制和空间复用。这个阶段帮你看懂底层一切行为的来源。
- 回到 STL,熟悉 std::stack / std::queue / std::deque / std::priority_queue 的成员函数和它们之间的“适配器关系”。
- 去 LeetCode 刷单调栈和单调队列题目,重点不是背模板,而是体会“什么时候需要栈顶的回溯能力”和“什么时候需要队尾淘汰机制”。
- 如果还有余力,去看线程池的阻塞队列源码,或者自己用 mutex + condition_variable 封装一个线程安全队列。
这套路线最大的好处是每一层都建立在上一层之上:先懂裸数据结构,再懂标准库封装,再懂算法用法,最后懂并发安全。跳级学很容易出现“API 会写、原理一问三不知”的空心状态。
最后再分享一点我个人的体会
我入行前几年,总觉得栈和队列不过是两个“玩具型容器”,实际项目里直接使用 std::deque 的情况都不多。直到后来在某个模块里看到同事用 std::stack 做表达式求值、用 std::queue 串任务、用单调队列做限流窗口统计,才真正意识到:越基础的数据结构越能撑起复杂系统的骨架。
栈和队列的核心价值,在于它们用接口的“克制”换来了行为的“确定性”。约束不是为了限制你,而是为了在正确的场景里替你挡住错误。掌握这部分内容之后,你再看 vector、deque、queue、priority_queue 之间的底层容器关系,就会像看一张地图一样清晰。等你能在心里把这张图随手画出来,C++ 的 STL 才算真正入门了一半。
