☰
顺序队列与链式队列:从数据结构基础到消息队列实战
2026/9/26 5:47:03 网站建设 项目流程

队列这种数据结构,很多人第一次接触是在《数据结构》课上,觉得它无非就是"先进先出"四个字,考试背一背就过去了。但等你真正开始写工程代码,或者去研究消息中间件、线程池、操作系统的任务调度,你会发现队列无处不在,而且"顺序队列"和"链式队列"这两个基础实现,恰恰是理解那些看似高深系统的钥匙。

这篇文章我想把顺序队列和链式队列从头到尾拆一遍,包括它们的结构设计、入队出队的细节、循环队列为什么存在、链式队列的指针操作坑在哪里,最后再聊聊从基础队列衍生出的阻塞队列、消息队列、单调队列等真实场景。无论你是正在学数据结构的在校生,还是工作几年想补一补内功的开发者,这篇都值得认真看完。

1. 先搞清楚队列到底解决了什么问题

1.1 队列的本质:先进先出

队列是一种操作受限的线性表,限制体现在两端:数据只能从一端进入,从另一端离开。进入的一端叫队尾,离开的一端叫队头。这个模型和我们生活中排队买奶茶一模一样——先来的人先买到,后来的人排在后面。

把这句话翻译成计算机术语就是 FIFO(First In First Out,先进先出)。这个特性看起来简单,但它在系统设计里意义重大。无论是网络请求的处理顺序、CPU 对任务的调度,还是生产者往缓冲区写入数据、消费者从缓冲区读取数据,都需要一种结构来保证"先产生的数据先被处理"。没有队列,这些系统会陷入混乱。

1.2 队列的几个标准操作

任何一个队列,无论底层用什么实现,都必须提供这几组基本操作:

  • 入队(enqueue):把元素追加到队尾。
  • 出队(dequeue):把队头元素取出并从队列中删除。
  • 取队头(front / peek):查看队头元素,但不删除。
  • 判空(isEmpty):判断队列是否为空。
  • 取长度(size):返回队列中当前元素个数。

这些操作的时间复杂度,在理想实现下都应该是 O(1)。也就是说,无论队列里有一百个元素还是一百万个元素,入队和出队所花的时间都不变。这个"理想"并不容易达到,后面你会看到顺序队列如果不做特殊处理,出队成本会被拖到 O(n)。

1.3 计算机里的队列和生活中的排队有什么区别

生活中的排队,人走了队伍就往前挪;但计算机里的队列,数据并不会物理移动。我们通常靠"指针"或者"索引"来标记队头和队尾的位置,元素在内存里的位置可以保持不变。这个差异是理解队列实现的关键——我们操作的是"头尾标记"而不是元素本身。

举例来说,数组实现的队列中,出队时我只需要把队头索引往后移动一位,那个被"出队"的元素其实还躺在数组里,只是不再被当作有效数据。这个设计节省了移动元素的开销,但也带来了一个经典的问题——假溢出,后面专门讲。

2. 顺序队列:用数组模拟排队

2.1 数组队列的结构设计

顺序队列就是用一段连续的内存空间(数组)来存储队列元素。C 语言里最朴素的结构大概是这样的:

#define MAXSIZE 100 typedef struct { int data[MAXSIZE]; // 存储队列元素的数组 int front; // 队头下标 int rear; // 队尾下标 } SqQueue;

初始化的时候,让 front 和 rear 都指向 0。每入队一个元素,就把它放到 data[rear] 的位置,然后 rear 加 1;每出队一个元素,就取出 data[front],然后 front 加 1。

朋友,如果你真的去写了一版,很快就会发现一个问题:队列还没满呢,front 前面空出来了一大片位置,但 rear 已经指到数组末尾了。这时候想继续入队会提示"队列已满",可实际可用的空间明明还有很多。这就是教科书上说的"假溢出"。

2.2 为什么不能把出队元素真的删掉

有同学会问:出队的时候,直接把后面所有元素往前挪一位不就行了吗?那样 front 永远指向数组头部,空间就不会浪费了。

这个方法确实能解决问题,但代价极大。每次出队都要把队尾方向的 n-1 个元素整体前移,时间复杂度从 O(1) 变成了 O(n)。想象一下一个有十万个元素的队列,每出队一个元素就要搬移十万个数据,这性能没法看。

那不用数组,改用链表呢?链表确实可以避免这个问题,因为节点删除后空间就释放了,这是链式队列的优势之一。但如果题目明确要求你使用顺序存储,那就要用循环队列来解决假溢出。

2.3 循环队列:顺序队列的正解

循环队列的思路非常巧妙:把数组头尾在逻辑上接成一个环。当 rear 或 front 走到数组末尾时,下一步就绕回数组开头。这样 front 前面空出的空间就能被重新利用。

实现上只需要在指针移动时加一个取模操作:

rear = (rear + 1) % MAXSIZE; front = (front + 1) % MAXSIZE;

当 rear 到达 MAXSIZE - 1 时,再入队一个元素,rear 就变成 0,回到数组开头。数组空间被循环使用,不再有假溢出问题。

这里有一个非常经典的考研/面试考点:循环队列怎么判断空和满。因为空的时候 front == rear,满的时候 front 也等于 rear,两者区分不开。

常用的解决方法有三个:

  • 牺牲一个存储单元。当 (rear + 1) % MAXSIZE == front 时判定为满,也就是说数组里最多存 MAXSIZE - 1 个元素。这样满和空就不会冲突了。
  • 增加一个 size 变量记录元素个数。入队 size++,出队 size--,size == 0 为空,size == MAXSIZE 为满。
  • 增加一个 flag 标记。入队时置 1,出队时置 0,配合 front == rear 判断是空还是满。

我平时写代码最推荐第二种方案,加一个 size 字段。虽然多占了一点内存,但判断逻辑最直观,不容易出 bug。考试的时候如果没有特别说明,默认用牺牲一个存储单元的方案。

2.4 循环队列入队出队的完整实现

typedef struct { int data[MAXSIZE]; int front; int rear; int size; // 当前元素个数 } SqQueue; // 初始化 void initQueue(SqQueue *q) { q->front = 0; q->rear = 0; q->size = 0; } // 判空 int isEmpty(SqQueue *q) { return q->size == 0; } // 判满 int isFull(SqQueue *q) { return q->size == MAXSIZE; } // 入队 int enQueue(SqQueue *q, int value) { if (isFull(q)) return 0; // 队列已满 q->data[q->rear] = value; q->rear = (q->rear + 1) % MAXSIZE; q->size++; return 1; } // 出队 int deQueue(SqQueue *q, int *value) { if (isEmpty(q)) return 0; // 队列为空 *value = q->data[q->front]; q->front = (q->front + 1) % MAXSIZE; q->size--; return 1; }

这段代码有几个细节值得注意。入队时先判断满,出队时先判断空,这是所有队列实现都不能忽略的前提。取模运算保证了指针在数组范围内循环,但取模操作本身有一定的 CPU 开销,所以在性能极其敏感的场景里,有人会用位运算优化——把数组大小设置为 2 的幂,然后用 (rear + 1) & (MAXSIZE - 1) 代替取模。

2.5 顺序队列的优缺点

顺序队列的优点在于空间紧凑。数组元素在物理上是连续的,CPU 缓存友好,遍历或者批量处理时性能好。而且因为没有动态分配内存的操作,入队出队的速度非常稳定。

但它的缺点也很明显:队列大小固定,一旦容量不够就要扩容。扩容需要重新分配一块更大的内存,然后把旧数据复制过去,这个过程是 O(n) 的。如果你无法预估队列的峰值长度,顺序队列很容易出现"空间浪费严重"或"扩容频繁"两种尴尬局面。

3. 链式队列:动态扩容的队列实现

3.1 链式队列的结构设计

链式队列以链表作为底层存储,每个节点包含数据域和指针域。相比顺序队列,它最大的优势就是"想存多少存多少",只要有内存,队列就可以无限增长。

链式队列一般需要两个指针:队头指针 front 和队尾指针 rear。注意,这里的 front 和 rear 不是下标,而是指向链表节点的指针。为了方便操作,通常会加一个头节点(哨兵节点),让空队列和非空队列的处理逻辑统一。

typedef struct QNode { int data; struct QNode *next; } QNode; typedef struct { QNode *front; // 队头指针,指向头节点 QNode *rear; // 队尾指针,指向最后一个节点 } LinkQueue;

头节点不存储数据,front 永远指向它。初始化时,front 和 rear 都指向头节点。这样设计的妙处在于:出队时即使队列变空,front 依然指向头节点,不需要特殊处理空队列的情况。

3.2 链式队列入队操作

入队操作在队尾进行,核心步骤是:

  1. 创建一个新节点,把数据放进去,next 置为 NULL。
  2. 将当前队尾节点的 next 指向新节点。
  3. 更新 rear 指向新节点。

写成 C 代码:

int enQueue(LinkQueue *q, int value) { QNode *node = (QNode *)malloc(sizeof(QNode)); if (node == NULL) return 0; // 内存分配失败 node->data = value; node->next = NULL; q->rear->next = node; q->rear = node; return 1; }

注意第 3 步的顺序。一定要先把 rear 原本指向的最后一个节点的 next 接上新节点,然后再更新 rear 本身。如果反过来,先改了 rear,就找不到原来的尾节点了,新节点就断了链接。这个"先接线、再移指针"的顺序,在链表操作中几乎处处适用。

3.3 链式队列出队操作

出队操作在队头进行,核心步骤是:

  1. 找到头节点后面第一个有效节点,也就是真正的队头节点。
  2. 取出它的数据。
  3. 让头节点的 next 指向队头节点的下一个节点。
  4. 如果出队的是最后一个节点,需要把 rear 也指向头节点,避免尾指针悬空。
  5. 释放原队头节点的内存。
int deQueue(LinkQueue *q, int *value) { if (q->front->next == NULL) return 0; // 队列为空 QNode *tmp = q->front->next; *value = tmp->data; q->front->next = tmp->next; // 如果出队的是最后一个节点,rear 要跟着调整 if (q->rear == tmp) { q->rear = q->front; } free(tmp); return 1; }

这一步最容易踩的坑就是尾指针的调整。假设队列中只有一个有效节点,你把它出队了,如果没有第 4 步,rear 还指向那个已经被 free 掉的节点,下次入队时就会访问野指针,程序直接崩掉。这种问题在调试时不一定会立即暴露,因为被释放的内存可能还没被改写,但随着程序运行,崩溃迟早会出现。

3.4 链式队列的销毁与内存管理

链式队列用到了动态内存分配,所以销毁队列时必须把所有节点逐个 free,防止内存泄漏。很多人在初始化时记得 malloc,却忘了销毁时释放,跑一个长时间运行的服务,内存慢慢涨,最后 OOM,这种教训在真实项目里我见过太多次。

void destroyQueue(LinkQueue *q) { while (q->front != NULL) { q->rear = q->front->next; free(q->front); q->front = q->rear; } }

这里从队头开始,用 rear 临时保存下一个节点,然后 free 当前节点。思路和出队类似,但要做完整链表的遍历释放。

补充一个点:如果你用的是高级语言(Java、Python、Go),内存回收由语言运行时管理,不需要手动 free。但理解底层的内存分配逻辑依然重要,因为在 C/C++ 这类语言里,内存泄漏是真实存在的威胁。

4. 顺序队列与链式队列:选型要这么看

4.1 两种实现的核心差异对比

对比维度顺序队列(循环队列)链式队列
底层存储数组,连续内存链表节点,离散内存
容量固定,需要扩容则成本高动态增长,只要有内存
入队时间复杂度O(1)O(1)
出队时间复杂度O(1)O(1)
空间开销较小,数组本身即可每个节点额外存 next 指针
CPU 缓存友好性高,数据连续低,节点分散
扩容成本需要复制旧数据不需要复制,直接分配新节点
内存释放一次性释放每个节点单独释放

从时间复杂度上看,两种实现不相上下,主要差异体现在空间利用和实际运行时的缓存表现。

4.2 顺序队列更适合什么场景

如果你能提前预估队列最大长度,且这个长度变化不大,顺序队列是最好的选择。原因很简单:内存占用少,访问速度快。比如在嵌入式系统中,一个缓冲区需要存 100 条传感器数据,用数组预先分配好,避免了频繁 malloc 和 free,性能和稳定性都有保障。

再比如网络协议栈里的数据包缓冲,很多内核实现使用固定大小的环形缓冲区。这种场景不允许动态分配内存,因为中断上下文里无法安全地调用分配器,循环队列就成了唯一选择。

4.3 链式队列更适合什么场景

如果你的队列长度无法预估,或者波动非常大,链式队列更合适。典型的例子是任务队列:系统启动时你不知道下一秒会有多少任务进来,可能高峰期每秒几十万个请求,低峰期一个都没有。用动态链表实现,空闲时不占内存,繁忙时自动扩容,不会因为预设容量不足而拒绝请求。

链式队列的缺点也不能忽视。每个节点都要额外存一个 next 指针,在 64 位系统上这个指针占 8 字节。如果你存的是小数据(比如一个 int),内存开销实际上翻倍了。而且链表节点在堆上分散分配,CPU 缓存的命中率不如数组,在数据量大时整体吞吐可能比顺序队列低 20% 到 50%,这也是为什么有些高性能框架宁愿用动态数组实现队列,也不直接用链表。

4.4 动态数组是第三种选择

聊到这里值得提一句,真实工程里还有第三种实现:动态数组(也叫可扩容顺序队列)。它结合了两者的优点:底层是一个自动扩容的数组,满了就申请一块更大的内存,把数据搬过去。这种实现在 Java 的 ArrayDeque、C++ 的 deque、Python 的 list 里都能看到影子。

动态数组扩容时虽然有一次 O(n) 的复制开销,但如果按照"容量翻倍"的策略扩容,平摊到每次入队操作的时间复杂度依然是 O(1)。我个人的建议是:在大多数业务系统里,动态数组是一个比纯链表更均衡的选择。

5. 队列在真实系统里长什么样

5.1 阻塞队列与线程池

基础队列加上"阻塞"语义,就变成了阻塞队列。它会在线程取不到元素时自动睡眠等待,或者在队列满时让生产者线程睡眠。Java 的 ThreadPoolExecutor 就依赖阻塞队列来管理待执行的任务,常见的实现有 ArrayBlockingQueue、LinkedBlockingQueue、SynchronousQueue。线程池里的 worker 线程从队列里取任务执行,没有任务时就阻塞等待,这套机制让线程的使用率得到最大化的优化。

你在面试时被问到"线程池的阻塞队列怎么选",本质上就是在考察你对顺序队列和链式队列差异的理解。ArrayBlockingQueue 是基于数组的循环队列,容量固定;LinkedBlockingQueue 是链表队列,容量可配置,默认可以非常大。选择哪一个,取决于你对任务数量的预估和对内存的控制需求。

5.2 消息队列:从基础队列到分布式系统

消息队列是队列思想在分布式系统里的高级应用。Kafka、RabbitMQ、RocketMQ 这些大家熟知的中间件,底层都离不开"先进先出"这个核心逻辑。虽然它们要处理分区、副本、持久化、网络传输等复杂问题,但最基础的语义依然是:生产者把消息放入队列,消费者按顺序取出。

你可能会问:Kafka 为什么吞吐量那么高?一个很关键的设计是它在内存中维护了连续的消息批次,使用类似顺序队列的机制批量读写,充分利用了顺序磁盘 IO 和页缓存。而 RabbitMQ 更强调灵活的路由和灵活的消息模型,单机吞吐不如 Kafka,但功能丰富。

我见过的很多团队在消息中间件选型时纠结不已,其实可以先回到队列的本质:你要求的是高吞吐还是高可靠?是削峰填谷还是复杂路由?搞清楚这些再从 Kafka、RabbitMQ、RocketMQ 里做选择,思路会清晰很多。

5.3 单调队列优化 DP

队列不仅做"先进先出",还能做一些更有趣的事。单调队列就是一种特殊的队列,队列里的元素保持单调性(单调递增或递减),常用来解决滑动窗口的最值问题。

比如给你一个数组和一个大小为 k 的滑动窗口,让你求每个窗口的最大值。朴素做法是每移动一次就扫描整个窗口,复杂度 O(nk);用单调队列,可以做到 O(n)。它的核心思路是:维护一个双端队列,队头是当前窗口最大值,每次新元素入队时,把队尾所有比它小的元素全部弹出,因为这些"弱者"永远不会再成为后续窗口的最大值。

单调队列优化 DP 也是竞赛里非常常见的套路。状态转移方程里如果出现了类似 dp[i] = max(dp[j]) + cost 的形式,其中 j 落在某个固定长度的区间里,那么就可以用单调队列把 O(n) 的转移优化掉。我刚学这个技巧的时候感觉很神奇,后来想明白了:它在本质上就是用一个维护了"候选最优解"的队列,把重复的区间扫描消除了。

5.4 嵌入式与操作系统中的队列

在嵌入式领域,FreeRTOS 提供了一种机制也叫队列,但它是任务间通信的核心手段。任务 A 往队列里发数据,任务 B 从队列里取数据。这种队列虽然不是用数组或者链表简单实现的,但底层的"先进先出"思想和循环缓冲区设计,和顺序队列有千丝万缕的联系。

操作系统里的打印任务队列、IO 请求队列、网络包队列也都是队列思想的直接应用。日常用到的"打印队列被策略阻塞"这类问题,本质上就是队列权限和排队机制的异常表现。理解底层队列结构,对你排查这些系统级问题非常有帮助。

6. 常见问题与避坑指南

6.1 循环队列判空判满的沙雕问题

这是面试中出现频率最高的弱智问题之一。很多人在纸上推导时头头是道,一旦上手写代码,就忘掉队尾后移要取模。比如 MAXSIZE = 5,rear 在 4,入队一个元素后,rear 应该变成 0。如果你忘了取模,rear 变成 5,下一次访问 data[5] 就越界了。

我建议你在实现循环队列时,把 front 和 rear 的每一次更新都写成带取模的形式,并在入队前后打印一遍 front、rear 的值做自测。这种边界问题靠肉眼检查很难发现,写几个测试用例跑一遍最靠谱。

6.2 链式队列的内存泄漏

链式队列的内存泄漏有两个常见来源。第一个是出队之后没有 free 节点,第二个是销毁队列时只销毁了数据节点,却漏掉了头节点。有些同学把出队和销毁的逻辑分开写,结果出队时释放了节点、销毁时又想释放一遍,导致 double free。

我的习惯是:在每次 free 之后,把指针置为 NULL。虽然不是严格必须,但能大大减少悬空指针带来的调试痛苦。在真实项目里,内存问题往往不是当场崩溃,而是运行一段时间后性能越来越差,最后被 OOM killer 干掉。

6.3 队列积压的监控

在消息队列和线程池场景里,队列的长度是必须监控的指标。队列长度持续上涨,说明生产者速度大于消费者速度,系统正在积压。这时候有两种处理思路:要么增加消费者,要么对生产者做限流。最怕的就是对队列长度毫无感知,等到消费者被拖垮、消息大量超时,才到处排查。

我经手过的项目中,有一条规则几乎通用:队列长度超过某个阈值时,必须产生告警。阈值怎么定?一般是峰值消费能力的 100 到 200 倍,留出足够的缓冲时间让人工干预。

6.4 实战选型的快速决策清单

如果你现在要在一个新项目里用队列,我建议按下面这几步来判断:

  • 预估队列最大长度。能预估且波动小,优先顺序队列或动态数组队列。
  • 无法预估或者峰值极高,考虑链式队列或基于堆的内存队列。
  • 涉及多线程生产消费,优先使用语言内置的阻塞队列,而不是自己封装。
  • 涉及跨进程或跨节点,消息中间件才是正确选择,不要自己造轮子。
  • 低延迟高频场景,关注 CPU 缓存友好性,优先顺序存储。

另外想说一句:在你的业务里,很多问题用基础队列就能解决,并不需要引入重量级的消息中间件。我在实际项目中见过一个团队,只是为了把服务 A 的数据传到服务 B,硬是引入了 Kafka,结果运维成本、网络开销全上来了,最后又改回简单的内存队列。选型的核心是匹配场景,不是越复杂越好。

7. 结束前再说点实际体会

如果你正在面试或者准备考研,我建议把顺序队列和链式队列的代码亲手写一遍,不只是看。写循环队列时,故意把取模去掉,观察会出现什么问题;写链式队列时,故意漏掉队尾节点的调整,再跑一次测试。这些错误搞一遍,你对队列的理解会超过大多数人。

队列不仅仅是数据结构课里的一道题。它背后代表了一种系统设计哲学:解耦生产者和消费者,让不同速度的组件协同工作。理解了这一点,你再看消息队列、线程池、操作系统调度,会发现它们全都是同一个思想的不同尺度而已。希望你读完这篇之后,能把这个基础功真正练扎实。

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

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

立即咨询