1. 先说结论:为什么栈和队列永远值得再聊一遍
栈和队列这两个词,刷过题的人闭着眼都能写出几个操作,背八股的人张口就是"后进先出、先进先出"。但真到项目落地的时候,能把它们用得漂亮的人其实没那么多。我看了一圈最近的数据结构相关热搜,"栈和堆"、"循环队列"、"消息队列重复消费"、"线程池的阻塞队列选择"这些词反复出现,说明大家不只是想背概念,而是想知道这东西在真实代码里到底怎么玩。这篇就把我这些年实际写过的、踩过的、给别人讲过的栈和队列,一次性梳理清楚。
这篇文章适合三类人:一是刚学数据结构的在校生,需要一个能把"概念-实现-应用"串起来的主线;二是准备面试的开发者,需要把高频考点和实际工程场景对上路;三是在做架构设计的朋友,消息队列、任务调度、调用栈优化这些场景里,栈和队列的思想无处不在。我会从底层实现讲到工程应用,代码以 C++ 为主,关键场景会补一些 Java/Python 的视角,尽量做到每个结论都能落地。
2. 栈:一台严格按"后进先出"运转的回溯机器
2.1 核心概念与基本操作
栈的本质就是一个线性表,但它的插入和删除被限制在同一端进行。这一端叫栈顶,另一端叫栈底。你可以把它想象成一摞盘子,后放上去的盘子一定先被拿走,这就是"后进先出"(LIFO,Last In First Out)。这个朴素的限制听起来很蠢,但它恰恰是计算机系统里最重要的一条约束——函数调用、递归回溯、表达式求值,全都在靠它撑场子。
栈的核心操作就五个:入栈(push)、出栈(pop)、取栈顶(top/peek)、判断是否为空(empty)、获取大小(size)。特别注意,pop 和 top 在多数实现里是分开的:top 只读不移除,pop 只移除不返回。很多新手踩过这个坑,用 top 取完值以为元素没了,实际上还在栈里,导致逻辑错乱。C++ 里 pop 不返回值,Java 的 pop 会返回并移除,Python 的 list 直接用 pop() 返回并移除——语言差异就在这,写代码时一定要清楚自己用的是哪种语义。
栈的空间利用上也分两块,一个是栈本身作为数据结构占用的内存,另一个是系统为每个线程分配的调用栈空间。这两个"栈"概念经常被混在一起聊,热搜里那个"c++ 栈空间"和"栈和堆"指向的就是后者。实际开发中,递归过深导致的栈溢出,本质就是系统调用栈被打满了。
2.2 顺序栈实现(数组版)
数组实现栈是最直观的方案。核心思路是维护一个数组和一个栈顶指针,push 时把数据写到指针位置,指针加一;pop 时指针减一。这里有一个关键点:pop 之后数组里的旧数据并没有被真正清除,只是通过缩小逻辑范围把它"屏蔽"了。如果数组里存的是指针或对象引用,释放前最好把对应槽位置空,避免内存无法被回收。
class ArrayStack { private: int* data; int capacity; int topIndex; // 指向当前栈顶元素的下一个位置 public: ArrayStack(int cap) : capacity(cap), topIndex(0) { data = new int[capacity]; } ~ArrayStack() { delete[] data; } void push(int val) { if (topIndex >= capacity) { // 实际工程中这里应做扩容,下面有说明 throw "stack overflow"; } data[topIndex++] = val; } int pop() { if (topIndex <= 0) throw "stack empty"; return data[--topIndex]; } int top() const { if (topIndex <= 0) throw "stack empty"; return data[topIndex - 1]; } bool empty() const { return topIndex == 0; } int size() const { return topIndex; } };上面这份代码是定长数组版本,适合容量预先可知的场景。工程里更常见的是动态扩容:当 topIndex 等于 capacity 时,申请一个两倍大小的新数组,把老数据搬过去,再释放旧空间。均摊时间复杂度仍然是 O(1),这一点和 Java 的 ArrayList、C++ 的 vector 扩容思路完全一致。不过扩容搬数据是一次性 O(n) 的操作,对实时性要求高的系统,要谨慎处理。
数组栈的优点是缓存友好,数据在内存中连续分布,访问和写入都非常快。缺点是容量受限于连续内存块的大小,如果单个栈需要存很大的数据量,数组扩容时会有一次明显的卡顿。
2.3 链式栈实现(链表版)
链表实现栈的思路是把链表头当作栈顶,每次 push 就是在头部插入一个新节点,每次 pop 就是摘掉头节点。因为只在头部操作,天然就是 O(1) 复杂度,完全不需要考虑扩容问题。
struct Node { int val; Node* next; Node(int v) : val(v), next(nullptr) {} }; class LinkedStack { private: Node* head; int count; public: LinkedStack() : head(nullptr), count(0) {} ~LinkedStack() { while (head) { Node* tmp = head; head = head->next; delete tmp; } } void push(int val) { Node* node = new Node(val); node->next = head; head = node; count++; } int pop() { if (!head) throw "stack empty"; Node* tmp = head; int val = tmp->val; head = head->next; delete tmp; count--; return val; } int top() const { if (!head) throw "stack empty"; return head->val; } };链式栈的缺点是每个节点要多存一个指针,内存开销比数组大,而且节点是散落分配在堆上的,访问时不连续,缓存命中率差。所以同一个栈,在数据量小、操作频繁的场景,数组版几乎总是赢;链表版只有在数据量无法预估、或者需要频繁动态创建销毁多个栈实例时才更有优势。我在项目里还见过一种混合方案:用链式结构存"大块数组",每个块内部连续,块与块之间用指针相连,兼顾了两者的优点,但实现复杂度也上去了,一般用不到这么重。
3. 队列:一条按"先进先出"运转的流水线
3.1 核心概念与基本操作
队列和栈正好相反,插入在队尾(tail)进行,删除在队头(head)进行,也就是"先进先出"(FIFO,First In First Out)。它像超市收银台排队,先来的先结账走人。核心操作是入队(enqueue/push/offer)和出队(dequeue/pop/poll),以及查看队头(front/peek)。
如果你用数组直接实现队列,会立刻遇到一个问题:队头出队后,数组前面的空间就空出来了,但如果只把 head 指针往后移,后面入队总会有到头的时候。这时候就需要循环队列出场:逻辑上把数组首尾相接,当 tail 走到数组末尾时,如果数组开头还有空位,就绕回去继续用。
3.2 循环队列:数组实现的灵魂
循环队列的实现有这么几个关键点,每个都是面试官爱挖的细节:
第一,判空和判满的区分。如果你只用一个 head 指针对应队头、一个 tail 指针对应队尾后面的位置,那么空队列时 head == tail,满队列时 tail 绕一圈也会追到 head,两者状态一样,就分不清了。常见的解法有三:一是牺牲一个存储单元,规定"tail + 1 == head"才算满,这样最多只能用 capacity - 1 个位置;二是加一个 size 变量记录元素个数;三是加一个 flag 标记最后一次操作是入队还是出队。工程上我最推荐维护 size,逻辑直白,几乎不会出边界 bug。
第二,取模运算的效率。循环队列的指针移动不能简单地 ++,而是要用(tail + 1) % capacity完成回绕。如果 capacity 是 2 的幂,可以把取模优化成位运算(tail + 1) & (capacity - 1),性能会好一些。这也是为什么很多高性能环形缓冲区的容量都设计成 2 的幂。
class CircularQueue { private: int* data; int capacity; int head; // 队头索引 int tail; // 队尾下一个位置索引 int size; public: CircularQueue(int cap) : capacity(cap), head(0), tail(0), size(0) { data = new int[capacity]; } ~CircularQueue() { delete[] data; } bool enqueue(int val) { if (size == capacity) return false; // 队列已满 data[tail] = val; tail = (tail + 1) % capacity; size++; return true; } bool dequeue(int& out) { if (size == 0) return false; // 队列为空 out = data[head]; head = (head + 1) % capacity; size--; return true; } int front() const { if (size == 0) throw "queue empty"; return data[head]; } bool empty() const { return size == 0; } bool full() const { return size == capacity; } };这段代码我实际在好几个项目里用过,包括串口数据缓冲、日志异步写入、音视频帧缓存等等。循环队列最打动人的地方是:它可以在不移动任何元素的情况下完成入队出队,时间复杂度稳定在 O(1),而且不需要频繁申请释放内存。对于嵌入式开发和实时系统,这几乎是"零成本"的缓冲方案。
3.3 链式队列与阻塞队列
链表实现的队列(链式队列)思路和链式栈相似,但需要同时维护头指针和尾指针。入队时在尾部接新节点,出队时摘头节点。相比循环队列,它不受容量限制,但每个节点有指针开销。
真正值得展开的是阻塞队列(BlockingQueue)。这是 Java 并发包里的一等公民。它的特殊之处在于:当队列为空时,消费者线程执行 take() 会被挂起,直到有数据入队;当队列满了的时候,生产者线程执行 put() 也会被挂起,直到有空位。这个机制避免了忙等待空转,是线程池、生产者-消费者模型的核心发动机。
Java 里常见的阻塞队列实现有这几种,我简单对比一下:
| 实现类 | 底层结构 | 特性 | 适用场景 |
|---|---|---|---|
| ArrayBlockingQueue | 循环数组 | 有界、公平性可配 | 线程池队列、限流缓冲 |
| LinkedBlockingQueue | 链表 | 默认可无界,也可指定容量 | 任务队列、生产者消费者 |
| SynchronousQueue | 无存储槽 | 每个 put 必须等一个 take | 直接交接、无缓冲场景 |
| PriorityBlockingQueue | 堆 | 按优先级出队 | 定时任务、优先级调度 |
| DelayQueue | 优先队列 | 元素到期才能取出 | 延时消息、订单超时处理 |
这里插一句热搜里的"java中的延时队列"。DelayQueue 的本质是优先队列 + 延迟时间比较器,出队时会检查队首元素的延迟时间是否已到,没到就阻塞等待。很多人对它的理解停留在"定时任务"层面,其实它最经典的场景是订单超时关闭:每个订单入队时带上超时时间,系统后排一个线程不断从 DelayQueue 里 poll,取到哪个就说明哪个订单到期了。比每分钟扫一遍数据库高效太多。
4. 典型应用:从源码到架构的真实战场
4.1 栈的经典应用:函数调用、表达式求值和单调栈
先说函数调用栈。每次函数调用,系统都会在调用栈上压入一个栈帧,里面保存了局部变量、参数、返回地址等信息。函数返回时,栈帧弹出,控制权回到调用方。递归能工作、断点调试能看到调用链、异常抛出能一层层往上抛,全都依赖这个机制。理解了这一点,你就能明白为什么递归太深会栈溢出——每个栈帧都占空间,栈帧累积多了,系统栈撑不住。所以我在实际开发里,凡是递归深度可能上千的场景,都会优先改成显式栈 + 循环。比如树的遍历,用栈模拟递归,既能控制内存,又能随时中断,调试还更直观。
再说表达式求值。中缀表达式转后缀表达式(逆波兰表达式),以及后缀表达式的计算,是栈的经典考题。我自己写过不下五遍,核心套路就一句话:遇到数字就进栈,遇到运算符就弹出两个操作数计算,结果再压回栈。中缀转后缀的核心则是维护一个运算符栈,通过比较运算符优先级决定是压栈还是输出。这类题掌握套路后就是一马平川,但重点不是背,而是理解栈在这里充当了"记忆最近上下文"的角色。
单调栈是栈的一个进阶玩法。它维护栈内元素单调递增或单调递减,用来解决一类"找左边/右边第一个比当前元素大/小"的问题。经典题目包括柱状图中最大的矩形、每日温度、接雨水等。单调栈的威力在于它能把暴力解法 O(n^2) 的时间复杂度降到 O(n),而且代码不长。我面试别人时,只要对方能自己推出单调栈的维护逻辑,基本就认可了他的数据结构功底。
4.2 队列的经典应用:BFS、消息队列与全栈场景
树的层序遍历、图的广度优先搜索(BFS),标准做法就是维护一个普通队列。每一层先入队,出队一个就把它下一层的子节点入队,直到队列为空。队列在这里保证了一个非常重要的性质:按"距离源点远近"的顺序访问节点。最短路径、连通块计数、拓扑排序,全都是 BFS 队列思想的不同变体。
再往工程层面走,消息队列(MQ)是队列思想的集大成者。热搜里反复出现的"消息队列的三大作用",我理解下来就是这三点:解耦、异步、削峰。解耦,是 A 系统不需要关心下游谁要数据;异步,是调用方发完消息立刻返回,不用干等下游处理完;削峰,是突发流量先在队列里排队,消费者按自己的处理能力慢慢消费。这三个作用能解决分布式系统里的很多痛点,但代价是引入了一致性问题和运维复杂度。
顺带说一个工程上极容易被问到的点:消息队列的重复消费问题。为什么会出现重复消费?因为消费者处理完消息后,还没来得及上报确认,就宕机了;消息被重新投递,就会再处理一次。解决方案就是消费侧做幂等——用业务唯一标识去重,比如订单号、消息 ID,处理之前查一下有没有处理过。这个问题不是 MQ 独有的,Kafka、RocketMQ、RabbitMQ 都会遇到,核心思想都是"消费者必须幂等"。
我还注意到热搜里有"小程序页面栈大于10怎么处理"。小程序页面栈本质上就是一个页面栈结构,栈顶是当前页面,路由跳转是入栈,返回是出栈。小程序限制页面栈最多 10 层,超过之后 navigateTo 会失效。解决办法无非是换用 redirectTo 重定向(替换当前页)或者 reLaunch 重新启动(清空栈再进新页),关键是要理解页面栈的语义,不要在深层链路里一直往里叠页。
4.3 线程池阻塞队列的选择逻辑
线程池的阻塞队列选择,是队列知识在并发编程里的典型应用。先说结论,再展开解释:
- 追求任务不丢失、内存充足,选 LinkedBlockingQueue(无界队列),缺点是极端情况下内存可能被打爆。
- 追求资源有上限、快速失败,选 ArrayBlockingQueue(有界队列),配合拒绝策略使用。
- 希望任务按紧急程度处理,选 PriorityBlockingQueue。
- 希望任务能延迟执行,选 DelayQueue。
这里有一个常见的误区:很多人以为线程池核心线程数满了,任务就会进队列,队列满了才会开非核心线程。实际上要看你用的是哪个线程池构造方法。Java 的 ThreadPoolExecutor 里,当核心线程忙碌时新任务先尝试进队列而不是开新线程;如果用的是 SynchronousQueue,它根本不会缓存任务,而是直接尝试创建非核心线程来执行。这个差别直接影响系统的吞吐和线程数量,选型前一定要想清楚任务的特征:是 CPU 密集还是 IO 密集,是可积压还是必须即时处理。
5. 常见问题与排查技巧实录
5.1 栈溢出:递归陷阱与"隐式栈"改造
我在实际项目中遇到过的最典型的栈溢出场景,就是递归处理树形结构。比如组织架构树、菜单树、评论回复树,业务深度一上来,递归就爆了。排查方法也很直接:先看异常栈,如果深度超过几万层,基本可以断定是无限递归或数据成环;如果深度在几千层就崩,那就是系统栈空间设置太小。
解决方向有三个:第一,检查递归终止条件,避免成环和无限循环,这是最基础也最重要的一步。第二,把递归改成显式栈迭代。第三,如果递归一定要保留,可以用"尾递归优化"或手动调整线程栈大小,比如 Java 启动参数 -Xss 可以加大线程栈,但这只是把问题延后,不是根治。
这里我想多说一句显式栈的写法。很多人觉得用栈模拟递归很难,其实核心就两步:把递归的参数打包成栈帧结构体,把递归调用换成"压入子任务的栈帧"。以二叉树前序遍历为例,用栈实现的逻辑非常清爽:先压根节点,循环里弹出一个节点处理,然后把右子树压栈、再压左子树,因为栈是后进先出,右子树先压就能保证左子树先处理。这样写出来的代码不怕深度爆栈,还方便做剪枝和分支限界。
5.2 循环队列判空判满:边界到底怎么算
循环队列最容易被问崩的就是"空和满怎么区分"。如果你用的是"牺牲一个空间"的方案,判空条件是 head == tail,判满条件是(tail + 1) % capacity == head;如果你用的是 size 方案,判空是 size == 0,判满是 size == capacity。两种方案我都写过,实际使用中强烈建议选 size 方案,理由有三个:
第一,判空判满逻辑对称,不容易把条件写反。第二,size 可以直接用于遍历、统计剩余空间,省得每次现算。第三,调试的时候,打印 head、tail、size 三个值,很容易定位问题。牺牲一个空间的方案在内存极度紧张时有用,但现代开发环境下这个优化意义不大,反而多了个"最多只能存 capacity - 1 个元素"的隐藏约束,容易埋雷。
还有一个小细节:循环队列遍历时,不能像普通数组一样用 for 循环从 head 到 head + size,因为越过数组末尾时要回绕。正确写法是for (int i = 0; i < size; i++) { visit(data[(head + i) % capacity]); }。这个知识点对刷题和写底层缓冲都很重要。
5.3 消息队列重复消费与堆积:两大高频故障
重复消费的问题前面提到了,核心是消费者幂等。但我再补充一个工程细节:幂等不能只在业务代码里"查一下再写",要用事务或唯一索引锁住,否则并发情况下两条相同消息同时处理,还是会重复写入。比如数据库表加一个 message_id 唯一索引,重复插入会直接失败,这才叫真正的幂等兜底。
消息堆积是另一个高频问题。队列积压越来越多,消费者来不及消费。排查思路可以按这个顺序来:先看消费者是否有人挂了。比如几个消费实例里一个实例宕机,剩余实例接不住全部流量,就会堆积。再看消费逻辑是否变慢。比如数据库慢查询、下游接口超时。最后看有没有"毒丸消息",即某条消息每次都被消费失败并重试,卡在队列头部,导致后面的消息全部堵车。
还有一个容易被忽略的坑:消费者消费失败后,如果直接返回重试,而没有做重试次数限制,那这条失败消息会在队列里反复打转,把消费者线程耗死。正确做法是设置失败重试上限,超过就投递到死信队列,等人排查。死信队列这个概念非常实用,算是对普通队列的一个必要补充,大家在设计消息系统时一定要预留这个"兜底下水道"。
5.4 双端队列和优先队列:变体选型要看清场景
栈和队列还有一些近亲结构,选型时常被混淆。双端队列(deque)允许两端都插入和删除,Java 里的 ArrayDeque、C++ STL 里的 deque 都是这个结构。它能当栈用,也能当队列用,还能实现"滑动窗口最大值"这类需要两头维护的算法题。
优先队列(priority queue)内部实现通常是一棵二叉堆,入队出队复杂度都是 O(log n),但它并不保证严格 FIFO,而是按优先级排序。很多人在面试时会把"队列"和"优先队列"搞混:普通队列先进先出,优先队列"优先级高者先出"。一个典型的例子是操作系统进程调度,就绪队列如果按优先级维护,其实用的是优先队列而非普通队列。在设计任务调度系统时,一定要明确自己的需求到底是严格的时序保证,还是优先级保证,选错结构会带来完全不同的行为。
单调队列则是解决滑动窗口类问题的利器,它和单调栈是一对,思路相似但维护的是队列。核心技巧是:入队前把队尾所有比当前元素“更差”的元素弹出,还要处理窗口边界把过期的队头弹出。这样每次窗口滑动后,队头就是当前窗口的最值。这一类结构虽然名字里带"单调",但它不是某个标准库类,而是你基于双端队列自己维护的一套逻辑,掌握它对刷算法题和理解"用数据结构维护状态集合"的思路都很有帮助。
6. 一点个人体会
栈和队列之所以值得反复琢磨,不是因为它们是考纲里必背的两个名词,而是因为它们背后代表了两种最基本的调度秩序:栈是回溯,队列是缓冲。回溯让我们能在迷宫里走回头路,缓冲让我们能在大流量和慢消费之间找到一个平衡点。我在自己做的每一个稍微像样的系统里,几乎都能找出这两样东西的影子——线程池是队列,递归遍历是栈,调用链是栈,异步削峰是队列。
最后分享一个小技巧:当我需要快速判断一个场景该用栈还是队列时,我会问自己一个问题——“先来的要优先处理,还是后到的要优先处理?”先来先服务就是队列,后到先覆盖就是栈。想清楚了再动手写代码,基本不会选错。这个习惯帮我少走了很多弯路,希望你也能用上。