首页 / 资讯中心 / 文章详情

队列精讲:从FIFO基础到单调队列、阻塞队列与无锁队列

队列精讲:从FIFO基础到单调队列、阻塞队列与无锁队列 ★ FEATURED ARTICLE
算法题刷到一定阶段你会发现有个数据结构特别微妙——队列。它表面简单到只有入队、出队两个操作但真正用起来既能解滑动窗口这类高频面试题又能牵扯出消息队列、线程池、无锁队列这些工程难题。这篇文章想站在刷题老手的角度把队列从数据结构基础、算法实战、并发工程几个层面串起来讲透。适合正在备战面试的开发者、写业务代码时纠结队列选型的工程师也适合刚学数据结构、想搞懂“循环队列为什么要浪费一个位置”“单调队列为什么是O(n)”这类问题的学生。看完之后你至少能明白队列不是只能排队它还是一整套解决“顺序处理”问题的思想工具。1. 队列的本质先搞懂FIFO和两种基础写法1.1 数组实现和链表实现边界情况的差异队列的核心性质只有一个先进先出也就是FIFO。你可以把它理解成食堂打饭的窗口——先来的先打到饭后来的排后面。这个逻辑放到代码里就是两个基本操作入队push把元素放到队尾。出队pop把元素从队头取出。很多初学者觉得队列太简单随手就用数组写int q[1000]; int head 0, tail 0; // push q[tail] x; // pop x q[head];这种写法在数据量小、只做一次性模拟的时候没问题。但有个致命弱点出队后head前面的空间就永远空着了。哪怕队列里只有一两个元素只要不断入队出队tail就会一直往后走直到数组越界。这就是“假溢出”——数组空间明明还有但尾巴已经到头了。遇到这种情况新手第一反应是“那我出队的时候把所有元素往前挪一位”。能解决但每次出队都是O(n)的搬移成本在算法题里基本必挂。另一种方案是用链表实现删除头结点只要改一下指针struct Node { int val; Node* next; };链表队列没有假溢出问题也不需要扩容代价是每个节点多存一个指针而且大量反复new/delete会有性能开销。实际写算法题时我一般直接用C的std::queue或者Python的collections.deque很少手写链表。但面试官如果追问底层你至少得能说出来数组队列适合容量可预估、操作频繁的场景链表队列适合容量动态变化、不要求连续内存的场景。这里还有一个容易被忽略的点数组实现里head和tail的语义并不是“元素位置”而是“当前队头位置”和“下一个入队位置”。很多边界错误都来自把tail当成“最后一个元素的位置”。养成习惯队列判空就是head tail入队赋值在q[tail]然后tail出队取q[head]然后head。逻辑理清楚后面循环队列才不会乱。1.2 循环队列为什么浪费一个位置也要这么做假溢出的正解是把数组首尾接起来变成逻辑上的环这就是循环队列。假设数组长度为maxtail到达末尾后下一次入队下标是 (tail1) % max取模运算实现了“绕回开头”的效果。判空和判满就成了循环队列最容易写错的地方。经典的写法是牺牲一个存储单元当 tail head 时队列为空当 (tail1) % max head 时队列为满。也就是说数组里明明有max个格子但最多只存max-1个元素。为什么非要浪费一个因为“空”和“满”在指针相等的情况下会产生歧义——如果允许存满tail追上head那你没法区分到底是空还是满。有的教材会给出另一种更直观的解法也是很多热词里反复出现的那种用rear和length分别指示队尾和元素个数。队头下标可以这样算front (rear - length max) % max这里的rear指向“下一个入队的位置”length表示当前元素个数。判空就是 length 0判满就是 length max。举个小例子max 5先入队a、b、crear 3length 3front (3 - 3 5) % 5 0正确。出队一个元素length变成2rear不变还是3front (3 - 2 5) % 5 1也正确。核心巧妙之处在于用length弥补了“空满同态”的信息缺口取模加上max再取模是为了防止负数下标的出现——这一点很多人在写代码时会漏掉。两种方案各有各的好处。浪费一个格子代码更好理解面试写起来快记录length空间利用率满但多维护一个变量的同步成本。个人建议是刷题时除非题目明确要求循环队列实现否则直接用内置队列把精力放在算法逻辑上但面试如果手写循环队列用浪费格子法最稳几行代码就能说清楚。2. 算法题里的队列滑动窗口、BFS和队列模拟2.1 单调队列滑动窗口最大值的O(n)解法队列在算法题里最值钱的应用之一是单调队列。最经典的题是“滑动窗口最大值”给定数组和窗口大小k求每个窗口内的最大值。暴力做法是每个窗口扫一遍时间复杂度O(nk)数据一大必然超时。单调队列的思路很巧妙维护一个双端队列里面存的是数组下标但从队头到队尾下标对应的元素值严格递减。也就是说队头永远是当前窗口的最大值候选。维护逻辑分三步入窗前先把队尾所有“比新元素小”的元素弹出因为它们在新元素进窗口后永远没机会成为最大值。把新元素下标加入队尾。出窗时如果队头下标已经滑出窗口弹出队头。from collections import deque def maxSlidingWindow(nums, k): q deque() res [] for i, v in enumerate(nums): # 维护单调递减 while q and nums[q[-1]] v: q.pop() q.append(i) # 队头滑出窗口 if q[0] i - k: q.popleft() # 窗口形成 if i k - 1: res.append(nums[q[0]]) return res为什么每个元素最多进出队列一次因为每个元素入队时会把前面比它小的元素全部淘汰出局每个元素最多被弹出一次所以整体复杂度是O(n)。这是单调队列最迷人的地方——它用“淘汰”代替“重复比较”把看起来必须枚举的操作压缩成了线性扫描。和它同族的还有单调栈用来解决“找下一个更高的人”“柱状图最大矩形”这类问题。用哪个的关键在于单调栈通常处理一端的最近关系单调队列面向带有“过期窗口”的连续区间最值问题。刷题时看到“固定窗口内最大值/最小值”第一反应就应该是单调队列。2.2 BFS与拓扑排序队列不只是“排队”队列在算法中更广为人知的角色是广度优先搜索BFS的载体。BFS的核心思想是按层扩展每一层的节点先进队列处理完一层再处理下一层天然匹配FIFO特性。树的层次遍历、走迷宫的最短路径、社交网络的好友推荐、Word Ladder这种单词接龙题统统是BFS的天下。一道典型题二叉树的层序遍历。要求按层输出节点每层一个数组。实现时用一个队列每次先把当前队列里的节点全部处理完同时把它们的子节点加入队列。关键点是在处理前先记录queue.size()把“当前层”和“下一层”隔开否则队列一直增长层与层的边界就全乱了。拓扑排序则是队列另一个杀手级应用。给定一组依赖关系要找到一个合法的执行顺序最经典的做法是Kahn算法统计每个节点的入度把所有入度为0的节点入队每次出队一个节点把它指向的节点的入度减1如果某个节点入度变成0再入队。这个过程本身就是队列的流水线作业。我当初学拓扑排序时最大的误区是总想用递归去处理依赖关系结果绕得晕头转向。后来想明白了Kahn算法里队列的作用就是“当前没有任何前置依赖、可以立刻处理的节点集合”。这和乱序无关纯粹是依赖消除的进度管理。如果你面试被问到“用队列实现一个任务调度要求依赖先执行”本质就是拓扑排序。2.3 约瑟夫环与队列模拟用模拟替代数学推导有些题看起来是数学问题但你如果不想推公式用队列可以直接模拟出结果。最典型的是约瑟夫环n个人围成一圈每次数到m就淘汰一人问最后剩下谁。数学递推法有O(n)的简洁解法但理解门槛高用队列模拟就非常暴力直接每次把前m-1个人从队头弹出再放到队尾第m个人直接丢弃重复到队列只剩一个人。queueint q; for (int i 0; i n; i) q.push(i); while (q.size() 1) { for (int i 0; i m - 1; i) { q.push(q.front()); q.pop(); } q.pop(); } return q.front();这种解法的复杂度是O(nm)不算最优但胜在思路简单、写出来的代码几乎不会错。面试时如果你能先说出暴力模拟的思路再补充数学优化的方向其实比直接甩公式更容易让面试官认可。类似的还有“用队列模拟电影院排队时间”“用队列模拟银行叫号系统”这类题目本质都是把现实排队逻辑映射成操作序列。我的建议是凡是题目里出现“按照顺序依次处理”“最近最先处理一批对象”这种描述先画一个队列看看能不能套上。3. 从算法题到工程阻塞队列、消息队列与无锁队列3.1 线程池的阻塞队列选型为什么选错会卡死算法题里的队列是单线程的但到了工程里队列几乎必然要面对多线程同时入队、出队的并发场景。Java里最常见的就是线程池的阻塞队列选型。你要搞清楚线程池中的任务队列承载的是“核心线程忙不过来时任务先排队等待”的缓冲功能。选错队列轻则任务堆积重则线程池直接拒绝任务。Java的BlockingQueue家族里几个主要成员区别很大我整理了一个对照表队列类型是否有界核心行为适用场景ArrayBlockingQueue有界基于数组容量固定系统资源严格受限任务突发量大LinkedBlockingQueue可选有界/无界基于链表默认容量Integer.MAX_VALUE一般业务线程池常用允许任务排队SynchronousQueue不存储元素每个入队操作必须等待出队操作不想堆积任务直接转交给线程执行PriorityBlockingQueue无界按优先级出队任务有优先级差异时DelayQueue无界延迟到期才能取出定时任务、延迟重试最容易踩坑的误区是无界队列看着很安全认为“反正不会满”可一旦生产速度长期大于消费速度队列里就会堆上千万个任务内存直接爆掉。所以如果线上队列是new LinkedBlockingQueue()没给容量你就相当于给自己埋了一颗内存炸弹。相反如果你用了有界队列又没配合合理的拒绝策略任务满载时可能直接被丢弃业务受损。SynchronousQueue又是另一个极端。它内部不保存数据put一个任务必须等到有人take才能返回。好处是线程池永远不会堆积任务坏处是如果池里没有空闲线程任务就决绝地等待很容易触发创建新线程的逻辑。它的名字里带Queue但语义更像“交接”而不是“排队”。选它必须是你的业务场景真的不需要缓冲。从我自己的经验看一般业务型线程池首选ArrayBlockingQueue或指定容量的LinkedBlockingQueue容量根据峰值请求量和线程处理耗时来估算。任务可以短时间排队但不能无限排队。这个选择背后其实是三件事你能接受多大的延迟、你能接受多大的内存占用、任务溢出时必须拒绝还是必须保留。答案清晰了队列选型也就跟着清晰了。3.2 消息队列的重复消费问题幂等设计才是王道工程里的“队列”不只有内存队列还有跨进程的消息队列比如Kafka、RocketMQ、RabbitMQ。消息队列最常见的痛点之一就是重复消费。用户下单后发送一条“创建订单”消息消费者处理了一半突然宕机消息重投后消费者重新处理一次订单就可能被创建两次。这不是bug级别的偶发问题而是消息队列可靠交付机制下的必然产物。为什么消息会产生重复核心原因是分布式系统里不存在“恰好一次”这种完美的语义。大部分消息队列为了保证不丢消息至少实现“最少一次”投递。消费者消费成功后还没来得及提交offset就挂了这条消息就会被重新投递。所以“防止重复消费”这件事纯粹在队列层面是堵不住的只能在消费端做幂等。幂等设计有几种常见手段。最可靠的是“唯一业务键 去重表”每条消息携带一个全局唯一的消息ID消费者处理前先去查一下数据库或者Redis如果这个ID已经处理过就直接跳过。下单类业务更推荐用业务主键做幂等比如订单号。再来就是状态机比如一条消息要把订单从“待支付”改成“已支付”操作前先判断当前状态如果已经是“已支付”就忽略。这个方案比去重表少一次外部查询但要求业务状态流转有明确的边界。我强烈建议不要指望消息队列自带的“消费端去重”一劳永逸。真要彻底解决问题就设计一套通用幂等组件以“消费时间消息唯一键”一起做存储判断。平时看着多耗费一次查询关键时刻能挡住不知道多少线上故障。这也是为什么很多中大型团队把“幂等”列为消息队列使用的必修课。3.3 C无锁队列原子操作与ABA聊到队列的工程实现还有一个绕不开的高性能话题无锁队列。热词里提到的“C原子操作与无锁队列”是很多后台开发面试的加分项。无锁队列的核心是用原子操作CASCompare-And-Swap替代互斥锁让多个线程可以同时往队列里入队、出队避免锁竞争导致的线程切换开销。听起来不复杂写起来全是坑。经典的单生产者单消费者无锁队列相对简单一个头指针、一个尾指针消费者用原子操作把头指针往后移生产者把数据写好后用原子操作更新尾指针。难点在于多生产者多消费者场景多个线程同时CAS同一个head或tail可能一个线程成功、其他线程失败后需要重试这个重试逻辑要处理得极其小心。更麻烦的是ABA问题。某个线程读取head A准备CAS(A - B)但另一个线程已经把头结点从A改成B又改回A。这时候第一个线程的CAS会成功但它以为的“A节点还在队列里”可能已经被复用了甚至指向了失效内存。解决ABA问题的常用方法是给指针绑一个版本号每次CAS不只比较指针还比较版本号比如用std::atomicuint64_t把指针和计数器打包在一起比较。我自己的经验是无锁队列的正确性分析难度远超普通锁队列日常业务开发完全没必要上来就用无锁。真到了吞吐量要求极高的场景比如高频交易、游戏服务器消息转发才值得考虑。而且就算要上最好先基于现成的高性能库比如boost::lockfree::queue不要自己造轮子。很多人最终会发现自己写一个错误百出的无锁队列性能还不如一把互斥锁来得稳定。4. 队列实战中的典型坑与排查思路4.1 循环队列取模的边界负数下标循环队列的写法里最常见的bug就是取模出现负数。比如按front (rear - length) % max去算队头在rear小于length的时候会得到负数下标。很多教科书公式省略了“ max”这一步是建立在rear永远大于length的假设下但实际入队出队交错进行后这个假设根本不能保证。正确的写法是 (rear - length max) % max其中 max保证被减数始终非负。排查这类问题老实测试几个出队入队交错的用例比肉眼盯代码快得多。4.2 单调队列的入队时机先淘汰还是先入队滑动窗口的单调队列代码经常有人纠结“先处理队头过期”还是“先维护单调性入队”。这两步顺序错不乱的话会导致窗口边界判断出错。我的习惯是先淘汰队尾劣元素并完成入队再做队头过期判断最后输出结果。新元素入队后队头才真正代表“当前窗口没加入新元素前”的合法最大值如果先弹队头再入队就可能把窗口外的过时元素误当成当前最大值输出。刷题建议把这三步顺序固化下来形成肌肉记忆。4.3 阻塞队列在低并发下的假死有界阻塞队列最阴险的问题是“假死”队列没满线程池却不再处理任务。常见的隐藏原因有两个第一消费者线程异常退出但生产端不知道任务永远堆在队列里第二超时设置不合理比如poll(timeout)的timeout太短线程频繁空转导致CPU飙升但实际处理效率反而下降。排查时先看线程dump里消费者线程是否还存在再看GC日志和消息积压曲线基本能快速定位。千万别一上来就怀疑队列实现本身有问题99%的情况是使用姿势的问题。4.4 消息队列消费慢的排查三板斧消息积压是消息队列最普遍的线上问题。我遇到过最典型的场景业务高峰期消费者处理速度跟不上生产速度积压越来越大。排查思路按顺序来先监控消费并发度和每条消息的平均处理耗时确认是单个消息处理太慢还是消费者实例数不够再查看消息是否有“毒丸”——某条消息永远消费失败反复进入重试队列把后面的消息全部堵死最后看是否存在消费线程阻塞比如消费逻辑里同步调了数据库或远程服务导致线程池被打满。这三步走完大部分积压问题的根因都能浮出水面。我个人在处理队列相关问题的过程中最大的体会是队列这个数据结构看上去平凡但它横跨了算法、并发、分布式三个层级。刷题时用单调队列、BFS练思维工程上用阻塞队列、消息队列解决实际问题每一层都有各自的规则和坑。如果能把队列从数组实现到无锁实现这条链路真正理解透很多框架底层的设计逻辑再看也就不难了。希望这篇精讲能帮你把队列入门到进阶的路踩实遇到队列题不慌选型不纠结。
阅读完成 · 觉得有帮助?
咨询建站