队列这个东西,只要写过几年代码的人,基本天天都在跟它打交道。线程池的任务排队、操作系统的消息缓冲、网络请求的流量削峰,背后全是队列的变形应用。但很多朋友跟我说,顺序队列、链式队列这种基础概念,上课听的时候觉得懂了,真到用的时候又模模糊糊,尤其是碰上“假溢出”这种词,直接一头雾水。这篇就把这两种最经典的队列实现掰开揉碎讲清楚,从原理到代码,从选型到避坑,一篇到位。
不管你是刚学数据结构的学生,还是工作几年想回头补基础的后端开发,或者做嵌入式、游戏逻辑时要自己管理任务队列的工程师,这篇都值得你花十来分钟看完。理解了队列的本质,后面看阻塞队列、消息中间件这些上层建筑,会轻松很多。
1. 队列到底解决什么问题
队列的核心思想一句话就能讲完:先进先出(FIFO,First In First Out)。就像去银行柜台办事,先取号的人先被叫到,后到的老老实实排在后面。这个在生活里天经地义的规则,放到计算机世界里,恰恰是很多系统能正常运转的基石。
1.1 三种典型的“屁股决定脑袋”场景
排队等待类场景是所有队列应用里最直观的。打印机任务、CPU的任务调度、外卖平台的订单分配,本质上都是把请求丢进一个队列,然后按照到达顺序逐个处理。这类场景的核心诉求是公平,先来后到不能乱。如果哪天打印机突然先打了后提交的文件,你一定会觉得这系统有bug。
生产者消费者解耦是另一个经典场景。系统的某一部分负责产生数据(生产者),另一部分负责处理数据(消费者)。两边的速度天然就不匹配,可能是生产者瞬间爆发产生大量数据,也可能是消费者处理一条数据需要耗时很久。没有队列做缓冲,生产者就必须等消费者处理完才能产生下一条,整个系统的吞吐量会被死死按住。有了队列,生产者只管往里面塞,消费者按自己的节奏取,两边谁也不用等谁。
广度优先遍历(BFS)是算法领域最依赖队列的场景。不管是走迷宫求最短路径、社交网络找几度人脉关系,还是搜索引擎的网页爬取,都需要先把当前层的所有节点处理完,再去处理下一层。这种逐层扩散的顺序,只有队列能天然契合。我用一个生活化的类比:你往平静的水面丢一颗石子,波纹是一圈一圈往外扩散的,每一圈都完整地展开后才轮到下一圈,这就是BFS,而实现这个扩散过程的存储结构就是队列。
1.2 队列的三个核心认知
第一,队列是一种操作受限的线性表。这意味着它不像数组那样支持按下标随机访问,你不能说“帮我把队列里第3个元素改成5”,它就是只能从尾部加入、从头部取出。这个限制不是缺陷,而是队列存在的意义。
第二,队列的“长度”不是固定不变的。随着入队和出队的交替进行,数据不断流入流出,队列始终处于动态变化之中。这也是为什么后面讲实现时,顺序队列会面临“假溢出”,链式队列会更灵活的原因。
第三,队列只有两个主要操作:入队(enqueue)和出队(dequeue),外加几个辅助操作(判空、取队头但不删除)。这世上不存在“队列的排序算法”、“队列的查找算法”,因为队列压根不关心内部元素谁大谁小,它只负责维护“顺序”这件事本身。
2. 顺序队列:基于数组的直观实现
顺序队列就是用一块连续的内存空间(数组)来存储队列元素。前端时间面试不少候选人,一说顺序队列就是“用数组存一下嘛”,但真让他写一个能用的实现,一半人会在“假溢出”这个坎上翻车。这块是重头戏。
2.1 基本结构与头尾指针
顺序队列需要三个关键字段:存储元素的数组、指向队头的索引 front、指向队尾的索引 rear。初始状态下,front 和 rear 都指向数组的起始位置(通常为0)。入队时,把元素放入 rear 指向的位置,然后 rear 加1;出队时,取出 front 指向的元素,然后 front 加1。
用代码写出来就是这个样子,我习惯用 C 语言讲数据结构,因为内存布局看得清清楚楚:
#define MAX_SIZE 100 typedef struct { int data[MAX_SIZE]; int front; // 队头下标 int rear; // 队尾下标 } SeqQueue; // 初始化 void initQueue(SeqQueue *q) { q->front = 0; q->rear = 0; }到此为止一切都挺顺理成章的。front 和 rear 都往一个方向移动,元素存进来取出去,逻辑上完全没问题。但这里藏着一个经典的坑。
2.2 假溢出:顺序队列最经典的设计缺陷
假设数组长度为5,你依次入队5个元素,此时 rear = 5,数组满了。然后你出队3个元素,front = 3。现在的情况是:数组的前3个位置空着,rear 却已经指向了数组末尾。再进行入队操作时,rear + 1 就超过数组下标范围了,会直接数组越界。
但问题是,明明数组前面还有3个空位啊!数组没有真正满,只是 rear 走到了尽头。这个现象就叫“假溢出”。
我当年第一次学到这的时候,觉得这个设计也太蠢了——空间明明没用完,却因为指针只能单向移动导致无法继续使用。但仔细一想才发现,这恰恰是顺序存储在“操作受限”这条路上的必然结果。数组的物理空间是固定的,出队操作把头部空间释放出来了,但 rear 指针并不知道“前面空了”,它只知道自己的位置。
解决假溢出的方案有两个方向。第一个是一旦发现 rear 到顶了,就把所有元素整体前移,把空位腾到后面来。这个方案简单粗暴,但每触发一次就要搬动所有还在队列里的元素,时间复杂度是 O(n),出队操作均摊下来就很慢了。
第二个方案就是下面要讲的循环队列,也是实际工作中真正会用的方案。
2.3 循环队列:用取模运算盘活整块内存
循环队列的思路特别朴素:把数组想象成一个首尾相接的圆环。rear 到顶之后,如果数组头部有空位,就让 rear 绕回下标0继续用。实现只需要一行关键代码:
rear = (rear + 1) % MAX_SIZE; front = (front + 1) % MAX_SIZE;不管 front 和 rear 走到哪,只要超过数组边界就取模绕回起点。这样一来,数组的每一个位置都可能被反复使用,假溢出的问题就消解于无形了。
但循环队列引入了一个新的问题:怎么判断队列是空还是满?因为空队列满足 front == rear,满队列也满足 front == rear(当数组完全被占满时,rear 绕了一圈又等于 front)。空和满成了一个条件,这是不能接受的。
业界有两种解法。
第一种是牺牲一个存储单元。规定 rear 的下一个位置是 front 时(即 (rear + 1) % MAX_SIZE == front),就认为队列满,也就是说数组中永远有一个位置是空闲的,最多存 MAX_SIZE - 1 个元素。这是最常见的做法,C++ STL 的 deque 底层用环形缓冲区时就采用了类似思路。判空条件仍然是 front == rear,判满条件是 (rear + 1) % MAX_SIZE == front。
第二种是加一个 size 字段记录当前元素个数。入队时 size++,出队时 size--,利用 size 区分空和满。这样能完整利用所有存储空间,但多维护了一个变量,逻辑稍多一点。
我个人建议自己实现时用牺牲一个存储单元的方式,逻辑干净,也不容易出错。完整的循环队列入队出队代码长这样:
int enQueue(SeqQueue *q, int val) { if ((q->rear + 1) % MAX_SIZE == q->front) { printf("队列已满\n"); return 0; } q->data[q->rear] = val; q->rear = (q->rear + 1) % MAX_SIZE; return 1; } int deQueue(SeqQueue *q, int *val) { if (q->front == q->rear) { printf("队列为空\n"); return 0; } *val = q->data[q->front]; q->front = (q->front + 1) % MAX_SIZE; return 1; }注意一个细节:循环队列里 front 和 rear 的初始值不一定非得是0,只要两者相等,队列就是空的。如果你后续要做“队列元素个数”的计算,公式是(rear - front + MAX_SIZE) % MAX_SIZE。这里一定要加上 MAX_SIZE 再取模,因为 rear 可能已经绕过一圈比 front 小,直接相减会是负数。
2.4 顺序队列的扩容问题
固定大小的循环队列有一个天然短板:容量写死了。如果 MAX_SIZE 设小了,高峰期数据直接丢弃或拒绝入队;如果设大了,平时又白占内存。所以真正在工程里用顺序队列时,往往需要支持动态扩容。
扩容的做法是,当队列满时,申请一块更大的新数组(比如原来的2倍),然后把旧数组里的元素按正确的队列顺序搬到新数组里。这里有个必须注意的细节:不能简单地把旧数组下标对应拷贝,因为循环队列的元素物理位置可能是“断开的”,front 到数组末尾是一段,下标0到 rear 又是一段,要把这两段按先后顺序拼起来放到新数组的头部。
搬移代码的大致思路:
int *newData = malloc(sizeof(int) * newCapacity); int count = (q->rear - q->front + oldCapacity) % oldCapacity; for (int i = 0; i < count; i++) { newData[i] = q->data[(q->front + i) % oldCapacity]; } // 更新 front = 0, rear = count, 替换底层数组这段逻辑不难,但很容易写错,属于面试里基础中的基础但又能筛掉一批人的考点。
3. 链式队列:基于链表的动态实现
顺序队列受限于固定容量,链式队列就是为了解决这个问题而存在的。链式队列的逻辑和顺序队列非常相似,但存储结构完全不同,每个元素是一个节点,通过指针串起来。
3.1 节点设计与指针操作
链式队列的每个节点包含数据域和指向下一个节点的指针 next。整个队列需要两个指针:front 指向队头节点,rear 指向队尾节点。入队在 rear 后面挂新节点,出队从 front 取节点。
节点定义:
typedef struct Node { int data; struct Node *next; } Node; typedef struct { Node *front; Node *rear; } LinkQueue;这里有一个非常重要的设计细节:链式队列通常设置一个头结点。头结点不存储数据,只是作为哨兵,它的 next 才指向队列的第一个真实元素。为什么不直接让 front 指向第一个元素呢?
因为如果不带头结点,当队列为空时,front 和 rear 都要指向 NULL;插入第一个元素后,front 和 rear 都要指向这个新节点;删除最后一个元素后,又要把两者置为 NULL。每一种操作都要多一个分支判断“是不是空队列”,而且出队删到最后一个节点时还要记得把 rear 也置空,否则 rear 就成了悬空指针。
带头结点后,情况就统一了:空队列时 front 指向头结点,rear 也指向头结点;入队永远在 rear 后面插入;出队永远删除 front->next。所有操作的逻辑一模一样,不需要额外判断边界分支。这种“用哨兵统一边界条件”的品味,在 C 语言这种手写数据结构的语境下非常关键,面试时能体现你的工程意识。
3.2 链式队列的入队与出队实现
入队操作,三步走:
int enQueue(LinkQueue *q, int val) { Node *newNode = (Node *)malloc(sizeof(Node)); if (!newNode) return 0; // 内存申请失败 newNode->data = val; newNode->next = NULL; q->rear->next = newNode; // 挂到队尾后面 q->rear = newNode; // 更新队尾指针 return 1; }出队操作,注意删的是头结点后面的那个节点:
int deQueue(LinkQueue *q, int *val) { if (q->front->next == NULL) { printf("队列为空\n"); return 0; } Node *tmp = q->front->next; *val = tmp->data; q->front->next = tmp->next; if (q->rear == tmp) { q->rear = q->front; // 删除的是最后一个元素,同步更新 rear } free(tmp); return 1; }这里有个容易被忽略的细节:如果队里只有一个元素,出队后 front->next 为空,同时 rear 还指着这个已经被删除的节点。如果不把 rear 重新指向头结点,下一次入队时就会通过一个悬空指针去挂新节点,程序崩溃都算轻的,悄悄写坏内存才叫要命。这个边界场景,我见过好几个人掉坑里。
3.3 链式队列的空间特征与劣势
链式队列最大的优势是容量不受限,理论上只要内存足够就能一直入队,不会出现“假溢出”问题。但它的代价也很明显:
每个节点都要额外开销一个指针的内存。如果你的队列里存的是小结构体(比如一个 int),指针的开销可能比数据本身还大,内存利用率并不高。频繁的 malloc/free 会造成内存碎片,在小内存的嵌入式环境里甚至可能导致内存分配失败。
还有一点容易被忽略:链式队列的随机性差。它是通过指针逐个跳转的,如果你需要遍历队列找某个元素,只能从头走到尾,CPU 缓存预取的效果也远不如数组连续内存好。在需要高性能、频繁遍历的场景下,链式队列不是首选。
4. 顺序队列和链式队列的选型:到底什么时候用哪个
很多朋友学完两种实现,脑子里只有一个模糊的印象“数组有上限,链表没上限”,但这远远不够。选型的本质是在费率、容量、复杂度之间找平衡,下面拆解一下。
4.1 核心维度对比
我整理了一个对比表,基本覆盖了两种实现的所有差异点:
| 对比维度 | 顺序队列(循环队列) | 链式队列 |
|---|---|---|
| 存储结构 | 数组,连续内存 | 链表节点,分散内存 |
| 容量上限 | 固定或需扩容 | 理论仅受内存限制 |
| 入队/出队时间复杂度 | O(1) | O(1) |
| 访问第二个元素 | 取模计算即可,快 | 要 next 跳一次,稍慢 |
| 内存利用率 | 高,无额外指针开销 | 较低,每个节点多一个指针 |
| CPU 缓存友好性 | 连续内存,好 | 分散内存,差 |
| 扩容/缩容 | 需要搬移数据,O(n) | 天然支持,无需搬移 |
| 实现复杂度 | 取模边界需小心 | 指针操作容易出错 |
4.2 实战选型建议
选顺序队列的场景:队列的最大长度可以提前估算,且对性能敏感。典型例子是嵌入式系统的串口环形缓冲区——数据量不大(几百字节),频率很高,用数组加头尾指针的循环队列是最标准的做法。FreeRTOS 底层的消息队列实现,本质上就是一个基于静态数组的环形缓冲区,因为嵌入式环境里内存极其宝贵,动态分配是被严格限制的。再比如操作系统的任务就绪队列、网络驱动的收包缓冲区,长度都有上限,且要求极低的延迟,这类场景顺序队列完胜。
选链式队列的场景:队列长度不可预知,或者波动极大。典型例子是某个后台服务的任务队列——平时每分钟几千个任务,但大促时可能一秒钟就涌进来几十万个,这时候如果预先分配固定容量,要么浪费空间要么丢任务。用链式队列,内存按需分配,即便消息中间件这种场景,底层通常也是用可增长的链表结构加锁来实现。另外在 Java 的 LinkedList 同时实现了 Deque 接口,用来做队列的时候,本质上就是一个链式队列,适合需要频繁增删且长度不固定的场景。
还有一个比较反直觉的建议:如果你用 Java/C++/Go 这类高级语言,底层容器已经帮你处理好了扩容逻辑(比如 Java 的 ArrayDeque 会自动扩容),那么即使“逻辑上是顺序队列”,你也不用操心手动扩容的复杂度。这种情况下优先选择基于数组的实现,性能和缓存友好性都更好。只有在下层没有动态扩容能力、且容量确实无法预估时,才应该选链式实现。
4.3 延伸:线程池里的阻塞队列该怎么选
热词里提到了“线程池的阻塞队列选择”,这其实是顺序队列和链式队列思想在上层工程里的延展。Java 的 ThreadPoolExecutor 支持多种阻塞队列,最常用的三个,你理解了底层存储结构就不难选了。
ArrayBlockingQueue底层是数组,必须指定容量。它的特点是“定长”,线程池满了之后新任务只能被拒绝或者由调用方处理,适合对任务堆积量有严格上限、不希望内存无限膨胀的场景。
LinkedBlockingQueue底层是链表,默认容量是 Integer.MAX_VALUE(约21亿)。它不指定容量时,“似乎”可以无限堆积任务,但这恰恰是生产事故的温床——线程池处理不过来时,任务全堆在队列里,内存一点一点耗尽,直到 OOM。我第一次排查这种事故时,看到监控里堆内存呈一条直线上升,最后容器直接被杀掉,印象太深了。所以用 LinkedBlockingQueue 务必显式指定容量上限。
SynchronousQueue更特殊,它本身不存储任务,每个入队的操作必须等待一个出队的操作同时发生,所以它相当于一个零容量的队列。适合追求低延迟、任务来了立即交给线程执行、不要排队缓冲的场景。
你看,选线程池队列这件事,本质上就是在问:任务堆积是可接受的吗?堆积量能不能估出来?想清楚这两个问题,选哪个队列就不需要背了。
5. 把队列放到更大的版图里看
顺序队列和链式队列只是队列家族的基石,往上还有更多变形应用。这块不算必考,但理解了会让你的知识体系完整很多。
5.1 从数据结构队列到分布式消息队列
热词里反复出现 Kafka、RabbitMQ、RocketMQ 的消息队列选型对比,很多初学朋友容易混淆:数据结构里的队列和消息中间件是一回事吗?
完全不是一回事,但思想一脉相承。“分布式消息队列”本质上是把“先进先出”这个内核,放在一个分布式系统里重新实现。Kafka 的每个分区(Partition)内部就是一条严格有序的消息日志,消费者按顺序读取;RabbitMQ 的队列支持多消费者时还能做轮询分发;RocketMQ 则强调消息事务和延迟消息,底层也是队列模型的扩展。它们要解决的是“跨进程”甚至“跨机器”的消息传递,涉及网络通信、持久化、高可用、消费端负载均衡,这比内存里一个队列复杂得多。
理解基础队列模型的最大价值在于:你面对 Kafka 消息乱序问题、重复消费问题、堆积预警调优时,第一反应是回到“先进先出”和“缓冲”的本源去看问题,而不是一头扎进配置参数里。
5.2 阻塞队列与生产者消费者模式
阻塞队列是队列在并发编程里的标准形态。它在入队和出队操作上加上了线程安全控制,并提供两种特殊行为:队列满时入队线程阻塞等待,队列空时出队线程阻塞等待。这种设计让生产者和消费者不需要互相通知、轮询判断,谁空了谁等着就行,代码极简。
Java 的BlockingQueue、Go 的channel、C++ 里各种并发库的concurrent_queue,本质都是在这个模型上封装出来的。写多线程代码时,如果你发现总是不由自主地去写while(true) { check(); sleep(10); }这样的轮询逻辑,试着换成阻塞队列,代码会清爽一个量级。
5.3 单调队列:队列的“算法形态”
热词里还有“单调队列优化DP”,这也是队列的高级应用之一。单调队列是指队列内部元素保持严格单调递增或递减,它的核心用途是在滑动窗口问题里维护窗口内的极值。比如给你一个数组和一个窗口大小 k,要你输出每个窗口的最大值,用普通做法每次扫一遍窗口时间复杂度是 O(n*k),用单调队列优化后是 O(n)。
单调队列的特殊之处在于,它不但要维护“进出顺序”,还要在入队时把队尾不满足单调性的元素全部弹出。这已经超出了普通出队入队的语义,属于队列思想的拓展应用。建议先把基础队列写熟练,再接触单调队列,否则两件事混在一起容易懵。
6. 高频踩坑现场与排查技巧
队列的实现虽然短,但工程里用起来坑不少。我把自己实际调试中遇到过的问题整理成了一份清单,按“症状—原因—解法”的格式列出来,你以后遇到类似问题能少走弯路。
6.1 顺序队列的假溢出
症状:队列明明还有空间,入队却报错或越界。
原因:rear 指针到了数组尾部,前面的空间没有利用。
解法:用循环队列,入队出队都强制取模。如果用的是非循环实现,务必在每次入队前检查 rear 是否触底,触底就整体搬移数据。这块属于必须理解到骨子里的知识点,面试官只要看你代码里有没有取模运算,就知道你是不是真的懂了循环队列,而不是背了个示意图。
6.2 链式队列删除最后一个元素后的悬空指针
症状:队列删除到空后,再入队发生段错误,或者内存被莫名写坏。
原因:出队删除了最后一个节点,rear 还指向已被释放的内存区域。
解法:出队时判断q->rear == tmp,如果成立,立即把 rear 指回头结点。写代码时这个分支看起来多余,但少写了就是这样的小事故。
6.3 循环队列的判空判满条件写反
症状:队列满了还能继续入队、覆盖掉旧数据;或者队列为空时出队拿到脏数据。
原因:混淆了判空(front == rear)和判满((rear+1)%MAX_SIZE == front)两个条件。
解法:自己动手画一个容量为4的循环队列手推一遍入队出队过程。我强烈建议每个学这块的朋友都花十分钟做这件事:画正方形表示数组,用两个小箭头表示 front 和 rear,手动模拟入队、出队、再入队直到满、再出队直到空。推完一遍,这两个条件永远都不会忘。
6.4 动态扩容后的元素顺序错乱
症状:扩容后遍历队列,元素顺序变成乱序或者元素丢失。
原因:从旧数组拷贝元素时,没有把两段环形排列的数据拼成正确的顺序。
解法:按(front + i) % oldCapacity的方式遍历旧队列,把元素依次塞到新数组的 0、1、2... 位置,然后重新设置 front=0、rear=count。除非你彻底不打算扩容,否则这个细节绕不开。
6.5 多线程环境下直接用普通队列
症状:并发入队出队时偶发数据不一致、死锁或者程序崩溃。
原因:head 和 tail 指针的读写不是原子的。
解法:并发场景不要手写裸队列。优先用现成的并发队列库(Java BlockingQueue、Go channel),如果必须手写,用 CAS 无锁队列或者加锁保护,并且测试时用高并发压测工具验证。手写并发队列的难度比手写单线程队列高一个档次,除非是学习目的,否则别在线上造这个轮子。
6.6 队列元素上限导致的业务静默丢弃
症状:系统高峰期,部分用户请求消失了,日志没有任何报错。
原因:队列满了,入队失败但业务代码没处理返回值。
解法:入队一定要检查返回值。队列满时的策略(阻塞等待、丢弃、拒绝并提示、记录日志)需要业务方明确约定。那种“入队失败就静默忽略”的代码,迟早会把线上问题变成玄学问题。这条不管对内存队列还是消息队列都适用。
7. 一个完整的链式队列示例
上面讲了这么多,最后给一个可以直接编译运行的完整示例,把前面所有细节落进代码里。我比较推荐用链式队列示例作为模板,因为它的指针操作比顺序队列更容易出错,跑通了会有更深的体感。
#include <stdio.h> #include <stdlib.h> #include <stdbool.h> typedef struct Node { int data; struct Node *next; } Node; typedef struct { Node *front; Node *rear; } LinkQueue; void initQueue(LinkQueue *q) { Node *head = (Node *)malloc(sizeof(Node)); head->next = NULL; q->front = head; q->rear = head; } bool isEmpty(LinkQueue *q) { return q->front->next == NULL; } bool enQueue(LinkQueue *q, int val) { Node *newNode = (Node *)malloc(sizeof(Node)); if (!newNode) return false; newNode->data = val; newNode->next = NULL; q->rear->next = newNode; q->rear = newNode; return true; } bool deQueue(LinkQueue *q, int *val) { if (isEmpty(q)) return false; Node *tmp = q->front->next; *val = tmp->data; q->front->next = tmp->next; if (q->rear == tmp) { q->rear = q->front; } free(tmp); return true; } void destroyQueue(LinkQueue *q) { while (q->front != NULL) { q->rear = q->front->next; free(q->front); q->front = q->rear; } } int main() { LinkQueue q; initQueue(&q); enQueue(&q, 1); enQueue(&q, 2); enQueue(&q, 3); int val; while (deQueue(&q, &val)) { printf("%d ", val); } printf("\n"); destroyQueue(&q); return 0; }这段代码输出1 2 3,逻辑和上面讲的一致。你可以在本地跑一下,再试试删除到空之后继续入队,观察是否正常。我个人的建议是在此基础上自己再写一个循环队列版本,跑通之后把两者放到一起对比,你收获的会比单纯看完这篇文章大得多。
回到文章开头说的:队列是那种看起来简单、但值得反复琢磨的基础结构。我从顺序队列的假溢出讲到循环队列的取模技巧,再到链式队列的头结点设计,然后又把这套基础放到了线程池、消息中间件这些上层工程里看了一圈。这些内容不是割裂的知识点,而是同一个“先进先出”内核在不同约束条件下的自然演化。理解了这个内核,你在任何语言、任何框架里遇到队列这个问题,都能秒速想清楚它底层大概是怎么实现的,有哪些边界需要注意,也就能在工作里更从容地做取舍了。