☰
栈与队列:从算法题到消息队列与线程池的工程实践
2026/9/28 12:58:08 网站建设 项目流程

栈和队列,是算法训练里最容易被轻视的两个数据结构。Day10这天我重新把它们梳理了一遍,从基本实现到高频题,再到工程里的阻塞队列和消息队列,突然发现它们原来是同一套思维在不同尺度下的变体。这篇内容不是我讲课,而是把我踩过的坑和梳理过的逻辑完整记录一遍,希望能给正在刷题或者准备面试的朋友一点参考。

1. 后进先出不是规则,是一种天然的回溯方式

1.1 数组实现栈:栈顶指针为什么是灵魂

我们一开始学栈,总喜欢背“后进先出、先进后出”。背完就忘,因为没理解为什么需要这个结构。直到我刷题时遇到递归回溯、括号匹配、表达式求值,才意识到栈的本质是“记录历史,然后有序地撤销历史”。它像一个只允许从顶端进出的弹夹,压进去的子弹最后才发射出去。

用数组实现栈非常简单,一个一维数组加一个栈顶指针top就够了。初始化top = -1,push就是arr[++top] = x,pop就是top--,取栈顶就是arr[top]。这三个操作都是O(1)。但真正关键的是,top指针永远指向“最后一个有效元素”,它天然记录了我们操作的顺序。很多同学写栈的题容易错,不是因为不了解操作,而是写循环时搞错了top的边界。比如括号匹配里,if (top == -1) return false和if (top < 0) return false是等价的,但有人用top == -1去匹配“空栈”,却在pop后忘了top--,导致越界。

还有一个容易混淆的点:算法里说的“栈”和内存里的“堆栈”。内存中的调用栈就是栈帧的集合,而“堆”是动态分配内存的区域,和数据结构里的堆(优先队列)完全是两回事。我在学习时也踩过这个坑,面试被问“栈和堆的区别”,结果我答成了数据结构里的栈和堆,面试官一脸问号。实际上,他问的是内存布局。所以建议把“数据结构栈”和“内存栈区”分清楚,避免答非所问。

1.2 栈帧形成过程与backtrace栈回溯

说到内存栈区,Day10我重新回顾了函数调用的过程。每次调用函数,系统会在栈上压入一个“栈帧”(stack frame),里面保存了局部变量、参数、返回地址等信息。栈帧的形成有固定的步骤:先压入返回地址,再压入局部变量空间,函数返回时栈帧被弹出。这就是为什么递归层级太深会导致栈溢出——栈帧一个个叠上去,内存栈区的空间是有限的。

C语言里局部变量越少,所占栈空间越小,这句话是对的。因为局部变量就存在当前函数的栈帧里,每多一个变量,栈帧就大一点。但要注意,现代编译器可能优化,不一定严格按照代码顺序留内存,但总体趋势没错。

工程里经常用“backtrace栈回溯”来排查问题。当程序崩溃时,backtrace会打印出一串调用链,看起来就像栈帧的“时间轴”。我一直觉得,背不住递归不要紧,只要你理解栈帧的压入和弹出,递归其实就是同一种函数的栈帧反复堆叠而已。

1.3 经典错题:pop两次还是peek一次?

配合栈的操作,有一道很经典的错题:实现MinStack(最小栈),要求O(1)取最小值。很多人上来就开一个辅助栈,虽然也对,但容易在处理同步弹出时出错。我见过最典型的错误是:取最小值时peek了辅助栈顶,但主栈pop之后忘了检查辅助栈也要pop。或者反过来,在辅助栈里只存最小值,但如果有重复元素,pop时会把唯一的最小值也弹没。所以正确做法是,辅助栈每次入栈时存入“当前全局最小值”,主栈pop时,辅助栈也必须pop,这样就保持了同步。这个题不算难,但特别适合检验对栈“历史记录”的理解。

2. 用栈解题:括号、表达式、单调栈

2.1 括号匹配:为什么用栈而不是计数器

括号匹配是栈的入门题,也是面试高频。题目很简单:给一个只包含()[]{}的字符串,判断括号是否合法。我看到有人用三个计数器分别统计三种括号的数量,然后发现([)]这种交叉嵌套的情况过不了。计数器只能记录数量,无法记录“顺序”,而括号合法性恰恰依赖顺序。用栈来表达顺序再自然不过:遇到左括号就入栈,遇到右括号就检查栈顶是否匹配,匹配就弹出,不匹配就失败。

我还试过另一种写法:遇到右括号时,栈顶必须是对应的左括号,否则直接返回。循环结束后,栈必须是空的。这个流程很清晰,但有几个陷阱。比如"{[]}"这种,入栈顺序是{ [,遇到]弹出[,遇到}弹出{,完全没问题。但有人为了简化代码,会在入栈时压入对应的右括号,遇到右括号时直接pop比较是否相等。这种写法更直观,我更推荐。

2.2 逆波兰表达式求值:操作数顺序是魔鬼

逆波兰表达式(后缀表达式)是一道很好的栈应用题。题目给一个表达式如["2","1","+","3","*"],求值。规则是:遇到数字就入栈,遇到运算符就弹出两个数,先弹出的是右操作数,后弹出的是左操作数,算完再入栈。这里最容易出错的就是操作数顺序。比如3-2,先弹出2(右),再弹出3(左),然后3-2算出1,如果写反了就会得到-1。为什么顺序重要?因为减法和除法不是交换运算,先入栈的数字实际上是左边的操作数,但栈顶其实是右边的,所以要先把栈顶弹出作为right,再弹出作为left。

我在LeetCode上提交了一次错误,正是写成了a - b而不是b - a,而且加法乘法不影响,因为是交换的,所以这类bug特别隐蔽。建议写一个辅助函数,明确标注“先弹出的是b,后弹出的是a,运算时用a op b”。

2.3 单调栈:从“下一个更大元素”到“每日温度”

讲完基础的栈,必须提单调栈,因为它是栈算法里的“天花板”之一。核心思想是:维护一个栈内元素单调递增或递减的栈,用来解决“左右两边第一个比当前元素大/小”的问题。

经典题是“下一个更大元素”和“每日温度”。例如nums = [73, 74, 75, 71, 69, 72, 76, 73],要求输出每个温度之后需要等几天才能等到更高的温度。暴力解法是双重循环O(n^2),数据多就会超时。单调栈的做法是:遍历数组,栈里存下标,保证栈里的元素对应的温度是单调递减的。当前温度如果大于栈顶对应的温度,说明栈顶遇到了“下一个更高温度”,此时可以出栈并计算结果;否则把当前下标入栈。整个过程每个元素最多入栈出栈一次,总共O(n)。

单调栈的精髓在于“延迟结算”。在暴力解法中,我们聚焦“当前元素往右看”,而单调栈让我们想一想“谁在做左边界”。先入栈的元素在等待一个更大的值,一旦等到,就可以从栈里弹出。这就像一个排队机制,后来的人如果更强,前面的人就可以走了。理解了这个,单调栈就不难写。我自己的记忆口诀是:找右边更大,栈内递减;找右边更小,栈内递增。

除了解题,单调栈还能优化DP。比如“最大子矩阵”、“直方图最大矩形”,本质都是找“左右第一个小于/大于自己的位置”,用单调栈可以把O(n^2)降到O(n)。这也是算法训练里“一题多解”的乐趣。

3. 用队列解题:BFS、循环队列与滑动窗口

3.1 队列的朴素实现与循环队列:front和rear怎么转圈

说完成本再说队列。队列的思想更简单:先进先出。但实现上比栈多一个“环”的问题。用数组实现队列时,如果直接push和pop,front一直后移,很快前面的空间就浪费了。解决办法是循环队列:让数组逻辑上首尾相接,rear在队尾入队,front在队头出队,当rear到达数组末尾时,跳回开头。

循环队列有两个注意点:一是判空和判满的条件容易混淆。常见做法是浪费一个数组位置,当(rear + 1) % capacity == front时队满,front == rear时队空。二是下标移动要用取模运算,例如入队后rear = (rear + 1) % capacity。很多新手会写rear++,然后数组越界。我刷题时看到很多版本的循环队列,其中一个高频题是设计MyCircularQueue,要求实现enQueue、deQueue、Front、Rear、isEmpty、isFull。这种题其实就是在考边界条件。

链式队列就好理解多了。入队就是在链表尾部插入,出队就是删除链表头节点。时间复杂度都是O(1),但每个节点有额外的指针开销。一般刷题时用数组模拟更快,但在C++ STL里直接用queue即可,而Java中用LinkedList实现队列。链式队列的图解很容易搜到,明白“队首出,队尾入”即可。

3.2 BFS层级遍历:为什么必须用队列

队列最经典的应用是广度优先搜索(BFS)。比如二叉树的层序遍历,需要按层打印节点。为什么用队列?因为BFS的访问顺序就是逐层扩散,先访问到的节点要先被扩展,这恰好是先进先出的逻辑。用栈替代行不行?不行,栈会改变遍历顺序,变成深度优先。

实现时有几个细节。一是要提前把根节点入队,然后循环内先记录当前队列大小size,因为在遍历当前层的节点时,子节点会不断入队,如果不先记录size,就会把下一层的节点也当成当前层处理。二是size要在进入循环前取,不能每次动态查。这是层序遍历最容易错的点。

如果题目要求“之字形打印”或者“分层统计”,只需要在size那一层里做特殊处理。我曾经在一次面试里被问“不用队列能否实现BFS”,我说可以用vector存每一层的节点,但其实那还是隐式队列。本质上,只要你需要“保持待访问节点按时间顺序”,就必须用队列或能模拟队列的数据结构。

3.3 单调队列:滑动窗口最大值的O(n)解法

和栈有单调栈对应,队列也有高级用法:单调队列。最经典的是滑动窗口最大值,题目给数组和窗口大小k,要求输出每个窗口内的最大值。暴力法是每个窗口扫一遍O(nk),窗口大了就超时。

单调队列的思路是:维护一个双端队列deque,队列里存下标,且对应的值在窗口内单调递减。每次滑动窗口时,先移除队头已经不在窗口内的下标(下标小于i-k+1的),然后从队尾开始,把所有小于等于当前值的元素弹出,因为它们在当前以及未来窗口中都不可能成为最大值,最后把当前下标入队。这样队头永远是当前窗口最大值的下标。每个元素入队出队各一次,总复杂度O(n)。

这个算法很考验“虽然当前元素小,但可能未来被留下”的思考。举个例子:[1, 3, -1],窗口k=3。当处理-1时,虽然它比3小,但窗口还没满,它还有机会成为最大?其实在窗口内它永远比不过3,所以可以弹,但是单调队列的做法是保留它,因为当3被滑出窗口后,它可能成为最大值。所以弹出条件应当是“队尾元素小于等于当前元素”,而不是小于。如果写成小于等于,会丢掉相等值的顺序。这里“等于”的问题也是常见坑,我踩过。

单调队列还能优化DP,比如解决“跳石头问题”或“多重背包优化”。理解了单调队列本质是“维护一个动态集合的最值”,就抓住了要害。

4. 队列思想出圈:线程池阻塞队列与消息队列避坑

4.1 线程池为什么选阻塞队列,以及怎么选

当队列思想进入并发编程,就变成了阻塞队列。线程池的核心就是一堆线程从一个共享队列里取任务执行。这个队列不简单,因为当队列满时,提交任务的线程必须被阻塞,等待有空位;当队列空时,工作线程要阻塞,等待新任务。所以Java里提供了一组BlockingQueue实现。

怎么选?ArrayBlockingQueue底层是数组,有界,适合控制并发数;LinkedBlockingQueue底层是链表,默认无界,但如果初始化指定容量可以变为有界;SynchronousQueue不存储元素,每个插入必须等待另一个线程取走,直接传递任务;PriorityBlockingQueue支持优先级。我在做线程池配置时踩过坑:刚开始用无界队列LinkedBlockingQueue,当任务大量涌入时,线程数到达最大值后,新任务不会继续创建线程,而是全堆在队列里,结果内存被撑爆,而且响应延迟越来越大。后来改成有界队列ArrayBlockingQueue配合CallerRunsPolicy拒绝策略,才稳定下来。

所以选型关键是看拒绝策略和队列容量。有界队列能限制积压,但太快被填满又会导致大量任务被拒绝;无界队列会导致任务无限排队,失去“削峰填谷”的意义。建议根据实际提交速率和消费能力来设计队列长度,并监控队列积压量。

4.2 Kafka、RabbitMQ、RocketMQ选型实战对比,以及我踩过的坑

再往分布式走一步,消息队列其实就是“分布式系统里的队列”。我在项目选型时对比过Kafka、RabbitMQ和RocketMQ。这里简单分享一下我的看法,仅供参考。

Kafka吞吐量最高,适合大数据流、日志采集。它是基于追加日志的存储模型,消费后不删除消息,靠offset维护进度。我一次线上排查发现,Kafka重复消费很常见,因为消费者处理完消息后还没提交offset就挂了,恢复后会重新消费旧数据。

RabbitMQ是消息中间件里的“老好人”,胜在路由灵活,支持多种交换机类型,适合复杂路由和低延迟场景。但它默认不适合堆积海量消息,如果积压太多,性能下降明显。

RocketMQ是阿里开源,介于两者之间,吞吐量比RabbitMQ高,事务消息、定时消息等支持较好,适合电商等业务场景。我们当时选RocketMQ主要是因为事务消息做得比较完善。

选择上没有绝对的好坏,关键看你的业务场景。如果追求极致的吞吐和顺序性,选Kafka;如果要求灵活路由和快速响应,选RabbitMQ;如果既要吞吐又要事务等高级特性,RocketMQ是不错的选择。

4.3 重复消费:队列的at-least-once特性与幂等设计

提到消息队列,绕不开重复消费问题。几乎所有主流的消息队列都提供at-least-once投递保证:消息不会丢失,但可能重复。解决办法就是消费端做幂等。我常用的方案有几种:

  • 唯一ID去重:消息携带唯一业务ID,消费端先查数据库有没有这个ID,存在就跳过。
  • 数据库唯一索引:插入时利用唯一索引,重复插入会报错,catch住即可。
  • 状态机:依赖业务状态,只有“待支付”才能变为“已支付”,重复消息进来后状态不匹配直接被忽略。

这些思路本质上都是“让重复消息的处理结果与第一次一样”。我在一个订单项目里用唯一索引去重,实测可以挡住绝大多数重复消息,但要注意数据库性能瓶颈,必要时加分布式锁。

从数据结构的角度看,消息队列的重复消费问题是什么?它源于队列的“至少一次”投递语义,而不是“队列只能被读一次”。我们在算法题里操作队列时,默认出队就是删除,但在分布式环境里,出队和确认是分离的,这才是重复的根源。明白这个差异,再去设计幂等就会很有方向感。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询