我最早接触栈和队列,是在教科书里那个“限定性线性表”的章节。当时真心觉得这俩东西也叫数据结构?一个只能从一头进出的数组,一个排队打饭的模型,考前背背定义、抄抄代码也就过去了。直到后来在项目里被递归爆栈、被消息队列的重复消费、被线程池的阻塞队列选择轮番锤过,才意识到当年自以为懂了,其实连门都没摸到。
C++ 数据结构里,栈和队列属于那种“看着简单、用起来全是细节”的知识点。它们不只是面试题里的括号匹配和层序遍历,更是函数调用、表达式求值、任务调度、网络消息缓冲这些底层机制的地基。这篇东西不讲教科书上那套定义复读,我想换个方式,从一个实际开发者的视角,把栈和队列的底层逻辑、容器适配器的选择、手写实现的细节、并发场景里的进阶玩法,以及我踩过的一堆坑,一次性说透。适合正在学数据结构的同学,也适合准备 C++ 面试、或者写业务代码时想弄明白队列该怎么选型的开发。
1. 从一道面试题说起:栈和队列真正难在哪
1.1 “修改受限”反而让逻辑更清晰
很多人第一次接触栈和队列,默认把它们理解成数组和链表的特殊形态。这个理解没错,但容易让人忽略一件事:栈和队列最核心的价值,是它们强制规定了操作边界。数组和链表是“给你所有操作,你自己看着办”,栈和队列是“只给你这几个操作,你必须在约束里解决问题”。
这种限制恰恰是工程里最需要的。比如函数调用,每个线程的调用深度是有限的,一旦递归层数太深,栈空间耗尽,程序直接崩溃。这就是因为调用栈只允许你“后调用的先返回”,这个 LIFO 纪律保证了返回地址、局部变量、参数传递的恢复顺序永远不乱。如果函数调用的返回顺序可以随便乱跳,计算机就没法正常运行了。
队列也一样。生产者产生的任务,消费者去处理,要求“先进来的任务先处理”才合理。如果允许随意插队或者从中间取任务,整个系统的公平性和时序就会失控。
所以栈和队列表面上是“限制了操作的表”,本质上是一种通过约束换取确定性的设计。它们不是性能不够强,而是用更少的操作换来了更容易推理的行为。
1.2 栈和队列与线性表的本质区别
教科书上把这三种结构放一块讲,导致很多人觉得栈和队列就是线性表的子集,学了线性表就等于学了栈和队列。实操里这个认知很危险。
线性表的优势在于“灵活”,你可以在任意位置插入、删除、查找,也可以遍历整个表。但灵活意味着你需要自己去维护状态,比如链表断了、指针指错、越界访问,都是灵活付出的代价。
栈和队列则把操作收敛到了一个很小的集合里:
- 栈:push 压栈,pop 弹栈,top 看栈顶,empty 判空。就这四个核心操作。
- 队列:push 入队,pop 出队,front 看队首,back 看队尾,empty 判空。也是有限的几个。
正是因为操作少,出错的概率被大幅压缩。数据结构面试题里那些“括号匹配”“逆波兰表达式求值”“表达式转换”“滑动窗口最大值”,看起来花样百出,内核全是这四个操作的变化。
我自己的体会是:你不应该问“栈和队列到底是什么类型的数据结构”,而应该问“什么场景需要后进先出,什么场景需要先进先出”。想明白这个,做题和做工程都会顺很多。
2. C++里栈和队列的“官方答案”:容器适配器的设计哲学
2.1 stack 和 queue 根本不是容器
拿到 STL 的std::stack和std::queue,第一件事要搞清楚:它们不是像std::vector、std::list那样的独立容器,而是容器适配器。也就是说,它俩本身不存数据,底层是包了一个真正的容器,比如std::deque或std::vector,然后对外只暴露受限的接口。
打个比方,std::vector是一间带大门的仓库,你可以从任何位置搬货。std::stack是你在仓库外面装了一个只留一个窗口的传送带,只能从窗口放货、只能从窗口取货。仓库还是那个仓库,只是你对外只承诺这一个动作。
这个设计的意义在于:你不必为栈这种结构重新写一套内存管理,只需要对已有的容器进行能力裁剪。STL 选择默认底层是std::deque,是因为双端队列能同时在头尾进行 O(1) 的插入和删除,既能满足栈只在尾部操作的需求,也能满足队列在尾部入、头部出的需求。
常用接口很少,记熟就行:
| 操作 | std::stack | std::queue |
|---|---|---|
| 入栈/入队 | push | push |
| 出栈/出队 | pop | pop |
| 栈顶/队首 | top | front |
| 队尾 | - | back |
| 判空 | empty | empty |
| 元素个数 | size | size |
注意栈没有front和back的概念,它只有top;队列没有top,只有front和back。这个别混。
2.2 底层容器怎么选:vector、deque、list 的取舍
虽然默认底层是 deque,但你完全可以显式指定底层容器,比如:
std::stack<int, std::vector<int>> s; std::queue<int, std::list<int>> q;这里面的门道是不同容器的操作开销完全不一样。
vector 在尾部 push/pop 是均摊 O(1),内存连续,缓存友好,但一旦需要扩容,会整体搬移元素,迭代器全部失效。deque 则把内存分成多段缓冲区,头部尾部插入删除都是 O(1),扩容时不需要搬移已有元素,只是新增一段缓冲区,所以栈和队列默认选它很合理。list 的插入删除都是 O(1),但节点分散在内存各处,遍历时缓存不友好,而且每个节点有额外的指针开销。
工程选型时的经验:
- 如果数据量不大、只做尾部操作,
vector做栈的底层非常合适,内存占用比 deque 小,随机访问还快。 - 如果需要高效的头部弹出,deque 是队列的默认选择,优于 vector(vector 头部 erase 是 O(n))。
- list 一般只在需要频繁在中间插入删除时才考虑,对栈和队列这种场景并没什么优势。
我之前写过一个小型计算器,表达式求值需要一个数字栈,数据量小但操作频繁,直接把底层切成 vector,实测比默认 deque 快了接近一成,内存更紧凑。这种优化很小,但能让你理解“容器适配器”这个抽象的价值。
2.3 queue 里那个让人迷惑的 size_type 和 pop 行为
用 queue 的时候有个常见的困惑:为什么pop()没有返回值?很多人第一次写代码,想取队首元素再弹出,写成了auto x = q.pop();,编译直接报错。
原因是pop()负责删除元素,而front()负责读取元素。把两者分开,是为了规避返回引用后马上删除造成的悬垂引用问题。正确做法是:
int value = q.front(); q.pop();栈的pop()也是如此。这么设计不是 STL 故意刁难,而是 C++ 异常安全与所有权语义下的合理选择。Go 和 Python 的 pop 会有返回值,那是不同语言的设计取舍,在 C++ 里别纠结,按它的规矩来。
deque 还有一个小坑:虽然头尾操作都是 O(1),但如果你在 deque 中间做插入删除,那是 O(n),千万别拿 deque 当万能表用。
3. 两个高频实战场景:函数调用栈与消息队列的底层逻辑
3.1 函数调用栈:局部变量、栈帧与栈溢出
要说栈最经典的应用,非函数调用莫属。每次函数调用,系统会分配一块栈帧,里面存着返回地址、参数、局部变量、保存的寄存器状态。函数返回时,栈顶指针恢复,这块栈帧就被释放。
很多同学写过这样的递归导致崩溃:
long long fib(int n) { return n <= 1 ? n : fib(n - 1) + fib(n - 2); }n 稍微给大一点,比如 50,程序就卡死或者直接栈溢出。原因不是计算量太大,而是递归会持续把新的栈帧压进调用栈,每一层都占用栈空间,而线程栈默认只有 1MB 到 8MB(不同平台不一样)。n=50 的朴素递归调用深度会到 50 层吗?不止,因为展开是指数级的,调用栈深度其实等于最深的递归路径,也就是 50 层。真正让程序崩溃的往往是计算量爆炸加上每一层栈帧的累积消耗。
要测试你的栈到底多深,可以递归打印深度:
#include <iostream> void dive(int n) { char buffer[1024]; // 每层占 1KB 栈空间 std::cout << "depth: " << n << std::endl; dive(n + 1); } int main() { dive(0); return 0; }实测很快就崩溃。这就是为什么工程里对于深度不确定的递归,要么改写成循环,要么改用显式栈,比如用std::stack模拟系统调用栈,把递归转成迭代。
还有一点,局部大数组也是栈溢出的常客:
void process() { int matrix[1000][1000]; // 4MB // ... }1000 乘 1000 的 int 是 4MB,如果线程栈默认只有 1MB,函数还没执行就崩了。这种大数组要么放堆上,用std::vector,要么声明成 static,要么用智能指针管理。这是我踩过的很实在的坑之一,排查了很久才发现是局部变量太大直接把栈打爆。
3.2 本地方法栈、浮点栈与“栈空间”认知
JVM 讲栈的时候会分虚拟机栈和本地方法栈,C++ 里也有对应的概念,就是我们说的调用栈。而 x87 浮点运算单元里还有一个独立的浮点栈,寄存器是 ST0 到 ST7,专门处理浮点运算,用 push/pop 的方式往浮点栈里加载数据。
这些术语乍一听容易晕,其实本质都是一样的:一个后进先出的存储区域,配合运算指令完成数据暂存。理解了这个共性,你在看各种底层资料时就不会被“栈”这个名字吓住,它到底在哪个内存区域、由谁管理、能放多少数据,才是关键。
比如 MIPS 汇编里调整栈指针,常见的就是addiu $sp, $sp, -N来分配栈空间,然后sw存数据、lw取数据,函数结束再addiu $sp, $sp, N恢复。这套流程和 C++ 的调用栈原理完全一致,只是你平时观察不到这些指令,因为编译器替你生成了。
3.3 从队列到消息队列:解耦、削峰与异步
队列在系统设计里最大的应用就是消息队列。从 RabbitMQ、Kafka 到 Redis Stream,核心思想都是把生产者、消费者解耦。
一套订单系统,用户下单后要发短信、扣库存、更新积分。如果同步调用,一处服务卡了全链路卡。引入消息队列后,订单服务把“已下单”消息塞进去,短信服务、库存服务、积分服务自己去订阅消费。好处有三个:
- 解耦:下游服务挂了不会拖垮上游。
- 削峰:秒杀突发流量先堆在队列里,消费者按自己的处理能力拉取,不会把数据库打崩。
- 异步:用户请求快速返回,耗时操作后台慢慢做。
这里面最经典的坑是重复消费。消息队列为了保证不丢消息,往往提供“至少一次”的送达保证,意味着消费者可能收到重复消息。解决办法不是让队列不重复,而是消费者自己做幂等:记录已处理的消息 ID、利用唯一索引约束、或者用状态机先查后改。这个我在项目里体会很深,第一次处理重复消息时没做好幂等,结果用户收到两条重复的短信。
线程池里的任务队列也一样,本质是生产任务的线程和消费任务的线程之间的缓冲区。线程池选什么阻塞队列直接决定系统的行为,这个后面细说。
4. 手写栈和队列:从数组模拟到循环队列的完整实现
4.1 数组模拟栈:为什么它比 STL 更可控
工作里用 STL 没问题,但面试和竞赛里,手写栈是基本功。原因很简单:STL 的封装会隐藏掉底层的内存分配细节,而你需要展示的是对数据结构本身的理解。
最简单的数组栈:
class ArrayStack { private: int* data; int capacity; int topIndex; // 指向栈顶元素,空栈时为 -1 public: explicit ArrayStack(int cap) : capacity(cap), topIndex(-1) { data = new int[capacity]; } ~ArrayStack() { delete[] data; } bool push(int val) { if (topIndex + 1 >= capacity) return false; // 栈满 data[++topIndex] = val; return true; } bool pop(int& out) { if (topIndex < 0) return false; // 栈空 out = data[topIndex--]; return true; } bool top(int& out) const { if (topIndex < 0) return false; out = data[topIndex]; return true; } bool empty() const { return topIndex < 0; } };这里++topIndex和topIndex--的顺序很关键。push 时先把指针后移再写入,pop 时先取出当前元素再把指针前移。一旦搞反,就会出现覆盖或者取到脏数据。
数组栈扩容也可以做成动态的,满了就 double,类似 vector 的 grow。扩容时的整体拷贝是 O(n),但均摊下来还是 O(1),这就是为什么 vector 的 push_back 均摊 O(1)。
也可以基于链表实现栈,每次 push 在头部插入节点,pop 从头部删除。好处是不会栈满,坏处是每个节点有额外指针开销,且内存不连续。实际工程里数组栈更常见,因为缓存命中率高。
4.2 循环队列的判空判满:多留一个空位的学问
普通队列用数组模拟时,如果 front 出队后不移位,front 会一直后移,前面的空间就浪费了。循环队列把数组首尾相连,让 rear 能从尾部绕回头部。
常见的实现是预留一个空位来区分空和满:
class CircularQueue { private: int* data; int capacity; int front; // 队首下标 int rear; // 队尾下标的下一个位置 public: explicit CircularQueue(int cap) : capacity(cap), front(0), rear(0) { data = new int[capacity]; } ~CircularQueue() { delete[] data; } bool empty() const { return front == rear; } bool full() const { return (rear + 1) % capacity == front; } bool push(int val) { if (full()) return false; data[rear] = val; rear = (rear + 1) % capacity; return true; } bool pop(int& out) { if (empty()) return false; out = data[front]; front = (front + 1) % capacity; return true; } int size() const { return (rear - front + capacity) % capacity; } };满的条件是(rear + 1) % capacity == front,也就是说永远留一个空位不存数据。为什么要多留这一个?因为如果不留空位,空和满都是front == rear,条件判别就会冲突。
如果不舍得浪费一个空间,还有一个办法:单独用一个 bool 标记是否为空,或者用 size 计数器。但预留空位是最优美的经典做法,它用一个位置的代价换来了逻辑上的绝对清晰。
size()的计算(rear - front + capacity) % capacity也要注意。如果 rear 已经绕过了 front 一圈,直接相减是负数,加 capacity 再取模就能得到正确长度。这个公式我在竞赛里用过无数次,背熟了不如理解它为什么成立。
5. 高并发进阶:阻塞队列、线程池队列选择与无锁尝试
5.1 线程池的阻塞队列怎么选:有界还是无界
如果说消息队列解决的是分布式系统里的解耦,那阻塞队列解决的就是单进程内多线程之间的协调。
生产者和消费者之间如果直接用普通临界区,需要手工管理条件变量和互斥锁,很容易写出死锁或者忙等的代码。C++ 标准库的std::condition_variable就是干这个的。一个简化的生产者消费者模型:
#include <condition_variable> #include <deque> #include <mutex> template <typename T> class BlockingQueue { private: std::mutex mtx; std::condition_variable not_empty; std::deque<T> data; size_t limit; public: explicit BlockingQueue(size_t maxSize) : limit(maxSize) {} void push(const T& item) { std::unique_lock<std::mutex> lock(mtx); // 如果队列已满,等待消费者腾出空间 not_empty.wait(lock, [this]() { return data.size() < limit; }); data.push_back(item); not_empty.notify_one(); } T pop() { std::unique_lock<std::mutex> lock(mtx); // 如果队列为空,等待生产者放入数据 not_empty.wait(lock, [this]() { return !data.empty(); }); T item = data.front(); data.pop_front(); not_empty.notify_one(); return item; } };生产者在队列满时阻塞在wait上,消费者取走元素后唤醒它;消费者在队列空时阻塞,生产者放入数据后唤醒它。这样两个线程就不会互相抢到空队列或满队列。
线程池的任务队列选型,核心是选有界还是无界:
- 无界队列(比如
std::queue不限制大小,或者 Java 的LinkedBlockingQueue不设容量):任务永远能塞进去,不会被拒绝,但如果生产速度长期大于消费速度,队列会无限膨胀,最终内存耗尽。这在业务上表现为“系统没报错,但内存一点点被打满”,特别隐蔽。 - 有界队列(比如容量固定的
ArrayBlockingQueue):队列满了之后,新任务要么等待、要么被拒绝、要么由调用线程自己执行。有界队列等于给系统设了一个防洪闸,让过载问题尽早暴露。
我的经验是:生产环境优先选择有界队列,配合明确的拒绝策略和监控告警。无界队列只是把内存溢出的问题从“立即崩溃”改成“慢速崩溃”,并没有真正消除风险。
在高并发场景里,互斥锁的竞争代价是真实存在的,所以才有无锁队列的研究方向。简单说就是用 CAS 原子操作替代加锁,典型实现是 Michael-Scott 队列。但无锁队列很难写对,ABA 问题、内存回收、内存序都需要仔细处理。我建议业务代码先老老实实用锁,只有当 profiler 明确告诉你锁竞争是瓶颈时,再考虑无锁方案。
5.2 消息队列的三大作用和重复消费问题
把单进程的阻塞队列放大到分布式,就是消息队列。前面提到的解耦、削峰、异步三大作用,本质和阻塞队列没有区别,只不过队列从进程内变成了独立的中间件。
使用消息队列之后,数据流变成了:
生产者 -> 队列 -> 消费者这中间一旦消费者处理失败,消息要不要重新投递?如果重新投递,消费者可能再次收到同一条消息。所以业务上必须做幂等。
消息幂等常见做法:
- 唯一消息 ID 判重:消费前先查这个 ID 是不是处理过。
- 数据库唯一索引:同一个业务 ID 重复插入会直接失败,天然幂等。
- 状态机校验:处理前检查当前状态是否已经推进到目标状态。
我在实际项目中用的组合是:数据库唯一索引加状态机。消息 ID 本身可能因为重试产生新 ID,但业务 ID 是唯一的,数据库层面就把重复挡掉了。
6. 实战中的坑位复盘与排查链路
6.1 迭代器失效:queue.pop() 之后别再用 front()
用 STL 容器最容易踩的坑就是迭代器失效和引用失效。std::queue的front()返回的是队首元素的引用。一旦执行pop(),队首元素被销毁,之前保存的引用就悬空了,再访问就是未定义行为。
之前有个同事写的代码简化一下:
int& ref = q.front(); q.pop(); std::cout << ref << std::endl; // 危险这段代码在 release 模式下可能偶尔能跑出正确结果,因为内存在短期内没被覆盖,但在 debug 模式或者稍加压力就会出错,而且错误很随机。排查这种“随机崩溃”特别费时间。正确做法是先取值再弹出,或者确保引用在使用完后才执行 pop。
6.2 栈空间不足:一个局部数组引发的崩溃现场
有一次排查一个服务端的崩溃问题,程序运行一段时间后偶发 SIGSEGV,用 gdb 找定位时发现崩溃在了一个很简单的函数里。查来查去,原因是函数内部声明了一个大的局部数组,比如char buffer[2 * 1024 * 1024],这个函数又在某条业务链路上被递归调用,两层一叠加,线程栈就爆了。
排查链路可以复现一下:
- 先用
ulimit -s查看栈大小,很多系统默认是 8192KB 也就是 8MB,看似够大。 - 但如果每个线程都有自己的栈,线程数一多,虚拟内存压力也会上来。
- 用工具查线程栈使用情况,比如 Linux 下可以看
/proc/<pid>/task/下面的栈信息。 - 最终定位到那个 2MB 局部数组。
解决办法是把这个数组改成指针,用std::vector分配在堆上,或者改成 static。从那以后,我在代码审查里看到大局部数组都会专门提醒一句:这个可能爆栈。
6.3 期末和面试高频变形:单调栈、单调队列与双端队列
掌握了基础栈和队列之后,进阶考点基本就是单调栈、单调队列和双端队列。
单调栈维护栈内元素单调递增或单调递减,常用于找下一个更大元素、接雨水、柱状图最大矩形。核心思想是:当新元素破坏单调性时,栈顶元素就可以出栈并确定它的答案。比如经典的“每日温度”问题:
vector<int> dailyTemperatures(vector<int>& temperatures) { int n = temperatures.size(); vector<int> ans(n, 0); stack<int> st; for (int i = 0; i < n; ++i) { while (!st.empty() && temperatures[i] > temperatures[st.top()]) { int prev = st.top(); st.pop(); ans[prev] = i - prev; } st.push(i); } return ans; }单调队列常用于滑动窗口最大值。用双端队列std::deque维护一个候选下标集合,队头永远是当前窗口最大值,新元素入队时,从队尾弹出所有比它小的元素。
双端队列本身也是热搜词里的常客,它同时具备栈和队列的能力,头尾都能操作,C++ 的std::deque就是标准实现。但它不是万能的,中间插入删除是 O(n),也不适合做随机访问次数极其密集的场景,那种情况 vector 更合适。
我之前把这些高频变形整理过一个速查表,这次也分享出来:
| 场景 | 核心数据结构 | 关键思路 |
|---|---|---|
| 括号匹配 | 栈 | 遇到左括号压栈,右括号弹栈匹配 |
| 逆波兰表达式求值 | 栈 | 数字入栈,遇到运算符弹两个数计算 |
| 表达式中缀转后缀 | 栈 | 操作符优先级控制出入栈 |
| 递归转循环 | 显式栈 | 用 stack 模拟系统调用栈 |
| 层次遍历 | 队列 | 每轮记录 size,按层处理 |
| 滑动窗口最大值 | 双端队列/单调队列 | 队头淘汰过期下标,队尾维护单调递减 |
| 下一个更大元素 | 单调栈 | 破坏单调性时弹出并确定结果 |
| 生产者消费者 | 阻塞队列 | 条件变量控制满与空 |
| 消息队列幂等 | 队列+业务幂等 | 唯一 ID、唯一索引、状态机 |
6.4 一些小而有用的工具建议
很多初学者会在 VSCode 里配置 C++ 环境,这个没什么捷径,重点是把编译器和调试器装好,tasks.json 和 launch.json 配通。遇到配置问题不要死磕,优先看编译器输出信息。
学习阶段一定要亲手写一遍栈和队列的手写实现,不要只调 STL 接口。写完之后再自己写测试用例,压入几万条数据,验证判空判满、扩容、异常输入。这个过程比看十篇博客都有用。
如果做数据结构期末复习,建议按这个顺序:先理解数组和链表的实现基础,再理解栈和队列的操作约束,然后把容器适配器的底层容器选择过一遍,最后用单调栈和单调队列刷两三道题。这样从基础到应用是一个完整的回路,比零散刷题记得牢。
我个人在实际操作中的体会是,数据结构这块东西,难点从来不是“语法怎么写”或者“接口怎么调”,而是当你面对一个真实问题时,能不能意识到“这里应该用一个栈,那里应该用一个队列”。这种意识只能靠亲手做过、踩过坑练出来。等你哪天在写业务代码时,看到任务调度第一反应是队列,看到递归先想会不会爆栈,看到撤销功能想用栈来存历史状态,那才算真的把这两个“最简单”的数据结构用明白了。