C语言实现循环队列:从原理到代码,攻克判空判满难题
2026/9/2 2:40:16 网站建设 项目流程

这次我们来看数据结构中的队列实现,特别是循环队列。队列是“先进先出”的线性表,在操作系统调度、消息队列、网络包缓冲等场景中应用广泛。很多初学者在实现队列,尤其是循环队列时,常被结构体设计、判空判满的逻辑绕晕,导致程序出现难以排查的bug。

本文的目标很直接:带你从零设计一个队列的结构体,完成初始化、入队、出队等基本操作,并重点攻克循环队列的判空与判满这一经典难题。我们会用C语言实现,给出可运行的代码示例,并分析每种实现方式的优缺点和适用场景。无论你是正在准备数据结构考试,还是需要在嵌入式或后台开发中使用队列,这篇文章都能提供清晰的实现思路和避坑指南。

1. 核心能力速览

在深入代码之前,我们先快速了解本文将涵盖的队列实现要点:

能力项说明
数据结构基础基于顺序存储结构(数组)实现队列。
核心操作结构体设计、初始化、判空、判满、入队、出队、遍历。
重点难点循环队列的判空与判满条件设计与实现。
代码语言C语言(代码可直接在支持C99及以上的编译环境中运行)。
硬件/环境门槛无特殊要求,任何能运行C编译器的设备(PC、开发板)均可。
适合场景数据结构学习、算法题练习、嵌入式系统开发、需要轻量级缓冲区的后台服务。
前置知识基础的C语言语法(结构体、指针、数组)、对线性表有基本了解。

本文将实现两种队列:一种是普通顺序队列(有“假溢出”问题),另一种是重点讲解的循环队列(解决空间利用率问题)。

2. 队列的基本概念与适用场景

队列是一种操作受限的线性表,它只允许在表的一端(队尾)进行插入操作,在另一端(队头)进行删除操作。这种“先进先出”的特性使其非常适合模拟现实生活中的排队场景。

适用场景包括:

  • CPU进程调度:操作系统使用就绪队列来管理等待CPU的进程。
  • 消息队列:在分布式系统或异步编程中,用于解耦生产者和消费者,如Kafka、RabbitMQ的基础思想。
  • 数据缓冲:在网络通信中,临时存储接收或待发送的数据包。
  • 广度优先搜索:在图论算法中,队列用于存储待访问的节点。
  • 打印机任务管理:多个打印任务按提交顺序排队执行。

使用边界:

  • 队列不支持随机访问,无法直接获取中间位置的元素。
  • 当需要频繁在任意位置插入或删除元素时,应选择链表等其他数据结构。
  • 本文实现的顺序队列(数组实现)长度固定,需提前预估最大容量。动态扩容需要更复杂的逻辑。

3. 环境准备与前置条件

实现和测试本文的队列代码,只需要最基本的C语言开发环境。

  1. 操作系统:Windows, Linux 或 macOS 均可。
  2. 编译器:支持C99标准的C编译器。
    • Windows: 推荐使用 MinGW-w64 或 Visual Studio (选择控制台C++项目,使用C编译)。
    • Linux/macOS: 系统通常自带gccclang
  3. 开发工具:任一文本编辑器(如VS Code, Sublime Text, Vim)或IDE(如CLion, Code::Blocks)。
  4. 验证方式:通过编写main函数,调用队列操作接口,打印结果来验证逻辑正确性。

无需任何第三方库。核心工作在于逻辑理解与代码实现。

4. 队列结构体设计与初始化

队列的核心是维护一个存储区以及两个指针(或下标)。我们使用结构体来封装这些信息。

4.1 顺序队列的结构体设计

对于顺序队列(非循环),我们通常这样设计:

#define MAX_SIZE 100 // 定义队列的最大容量 typedef struct { int data[MAX_SIZE]; // 静态数组存储队列元素 int front; // 队头指针(下标) int rear; // 队尾指针(下标) } SeqQueue;
  • front:指向队列第一个元素的前一个位置(初始为-1),或者指向第一个元素(初始为0)。不同的初始化方式会影响后续判空判满的公式。本文采用frontrear初始都为-1的常见写法。
  • rear:指向队列的最后一个元素。

4.2 队列的初始化

初始化操作是将队列置为空状态。

void InitQueue(SeqQueue *q) { if (q == NULL) { return; // 或进行错误处理 } q->front = -1; q->rear = -1; // 如果需要,也可以清空data数组,但逻辑上front和rear为-1即代表空队列 }

初始化后,frontrear都等于-1,这是一个重要的“空队列”标志。

5. 基本操作:判空、判满、入队、出队

在实现循环队列之前,我们先实现普通顺序队列的操作,理解基本流程和其中存在的问题。

5.1 判断队列是否为空

int IsEmpty(SeqQueue *q) { // 当front和rear都等于初始值-1时,队列为空 return (q->front == -1 && q->rear == -1); // 另一种常见写法(当front == rear时为空),但要求初始化时front=rear=0 }

5.2 判断队列是否已满

对于普通顺序队列,“满”意味着队尾指针rear已经到达了数组的最后一个下标。

int IsFull(SeqQueue *q) { // 判断rear是否指向了数组的最后一个位置 return (q->rear == MAX_SIZE - 1); }

5.3 入队操作

入队操作在队尾添加一个元素。

int EnQueue(SeqQueue *q, int value) { if (IsFull(q)) { printf("队列已满,无法入队!\n"); return 0; // 入队失败 } if (IsEmpty(q)) { // 如果队列为空,插入第一个元素时,front和rear都需要移动 q->front = 0; } q->rear++; // 队尾指针后移 q->data[q->rear] = value; // 放入元素 return 1; // 入队成功 }

注意:当队列为空时插入第一个元素,需要同时移动frontrear

5.4 出队操作

出队操作移除并返回队头元素。

int DeQueue(SeqQueue *q, int *value) { if (IsEmpty(q)) { printf("队列为空,无法出队!\n"); return 0; // 出队失败 } *value = q->data[q->front]; // 获取队头元素 if (q->front == q->rear) { // 如果出队后队列变为空,重置front和rear q->front = -1; q->rear = -1; } else { q->front++; // 队头指针后移 } return 1; // 出队成功 }

注意:当出队的是最后一个元素时,队列变空,需要将frontrear重置为初始状态-1

5.5 普通顺序队列的问题:“假溢出”

运行上述代码你会发现一个严重问题:进行一系列入队和出队操作后,即使data数组前面有空位(因为元素出队了),rear指针也可能已经到达MAX_SIZE-1,导致IsFull返回真,无法再入队新元素。这种现象称为“假溢出”。

数组空间并未真正用完,但因为队列的“单向移动”特性,导致可用空间被浪费。循环队列就是为了解决这个问题而生的。

6. 循环队列的设计与实现

循环队列将数组在逻辑上首尾相连,形成一个环。当指针移动到数组末尾时,再前进一位就回到数组开头。

6.1 循环队列的结构体设计

结构体定义与顺序队列类似,但指针移动的逻辑完全不同。

#define MAX_SIZE 5 // 为了便于演示,设置一个小容量 typedef struct { int data[MAX_SIZE]; int front; // 指向队头元素 int rear; // 指向队尾元素的下一个位置(这是关键!) } CircularQueue;

关键点:在循环队列中,我们常约定rear指向队尾元素的下一个位置(即下一个可插入的位置)。front指向队头元素。

6.2 循环队列的初始化

void InitCircularQueue(CircularQueue *q) { q->front = 0; q->rear = 0; // front和rear初始都指向0 }

初始化后,队列为空。front == rear是循环队列判空的条件之一。

6.3 循环队列的判空与判满(核心难点)

这是循环队列最易混淆的地方。因为frontrear在环上移动,当它们相遇时,既可能表示队列空,也可能表示队列满。我们必须区分这两种情况。

常见解决方案有三种:

  1. 牺牲一个存储单元:这是最经典和常用的方法。约定当(rear + 1) % MAX_SIZE == front时,认为队列已满。这样rear指向的位置始终是空的(牺牲掉),用于区分空和满的状态。

    • 判空front == rear
    • 判满(rear + 1) % MAX_SIZE == front
  2. 增加一个数据成员size:在结构体中增加一个计数器,记录当前队列中的元素个数。

    • 判空size == 0
    • 判满size == MAX_SIZE
    • 这种方法逻辑清晰,但需要维护额外的变量。
  3. 增加一个标志位tag:用一个标志位记录最近一次操作是入队(tag=1)还是出队(tag=0)。

    • front == rear时:
      • 如果tag == 1,说明刚执行了入队导致相遇,队列为满。
      • 如果tag == 0,说明刚执行了出队导致相遇,队列为空。

本文采用第一种(牺牲一个单元)方法进行实现,因为它最考验对循环队列本质的理解,也是面试和考试中的重点。

6.4 循环队列的入队与出队

基于“牺牲一个单元”的方案,我们实现入队和出队。

// 判断循环队列是否为空 int IsCircularQueueEmpty(CircularQueue *q) { return (q->front == q->rear); } // 判断循环队列是否已满 int IsCircularQueueFull(CircularQueue *q) { return ((q->rear + 1) % MAX_SIZE == q->front); } // 循环队列入队 int EnCircularQueue(CircularQueue *q, int value) { if (IsCircularQueueFull(q)) { printf("循环队列已满,无法入队!\n"); return 0; } q->data[q->rear] = value; // 将元素放入rear指向的位置 q->rear = (q->rear + 1) % MAX_SIZE; // rear指针循环后移 return 1; } // 循环队列出队 int DeCircularQueue(CircularQueue *q, int *value) { if (IsCircularQueueEmpty(q)) { printf("循环队列为空,无法出队!\n"); return 0; } *value = q->data[q->front]; // 取出front指向的元素 q->front = (q->front + 1) % MAX_SIZE; // front指针循环后移 return 1; }

代码解析

  • (q->rear + 1) % MAX_SIZE:这是实现“循环”的关键。取模运算使得指针在到达数组末尾后能回到起点。
  • 入队时,先放元素,再移动rear
  • 出队时,先取元素,再移动front

6.5 功能测试与效果验证

让我们编写一个main函数来测试循环队列,并观察其如何解决“假溢出”问题。

#include <stdio.h> // 此处包含上述所有结构体和函数定义 int main() { CircularQueue cq; int value; InitCircularQueue(&cq); printf("初始化后,队列是否空? %s\n", IsCircularQueueEmpty(&cq) ? "是" : "否"); // 测试入队,直到队满 printf("\n--- 开始入队测试 ---\n"); for (int i = 1; i <= 5; i++) { // MAX_SIZE=5,但只能存4个元素 if (EnCircularQueue(&cq, i * 10)) { printf("入队元素: %d\n", i * 10); } else { printf("入队 %d 失败(预期中,队列应已满)\n", i * 10); } } printf("\n队列是否满? %s\n", IsCircularQueueFull(&cq) ? "是" : "否"); // 测试出队两个元素 printf("\n--- 开始出队测试 ---\n"); for (int i = 0; i < 2; i++) { if (DeCircularQueue(&cq, &value)) { printf("出队元素: %d\n", value); } } // 再入队两个元素,测试“循环”特性 printf("\n--- 再次入队测试(利用出队空出的空间) ---\n"); for (int i = 5; i <= 6; i++) { if (EnCircularQueue(&cq, i * 10)) { printf("入队元素: %d\n", i * 10); } } // 遍历并清空队列 printf("\n--- 遍历并清空队列 ---\n"); while (!IsCircularQueueEmpty(&cq)) { DeCircularQueue(&cq, &value); printf("出队: %d\n", value); } printf("队列是否空? %s\n", IsCircularQueueEmpty(&cq) ? "是" : "否"); return 0; }

预期输出

初始化后,队列是否空? 是 --- 开始入队测试 --- 入队元素: 10 入队元素: 20 入队元素: 30 入队元素: 40 入队 50 失败(预期中,队列应已满) 队列是否满? 是 --- 开始出队测试 --- 出队元素: 10 出队元素: 20 --- 再次入队测试(利用出队空出的空间) --- 入队元素: 50 入队元素: 60 --- 遍历并清空队列 --- 出队: 30 出队: 40 出队: 50 出队: 60 队列是否空? 是

测试成功的关键

  1. MAX_SIZE=5,但只成功入队了4个元素(10,20,30,40),验证了“牺牲一个单元”的判满条件。
  2. 出队两个元素(10,20)后,队头位置空出。
  3. 再次入队时,元素50和60被成功加入,并且rear指针从数组末尾“循环”回到了开头(如果空间连续的话),这解决了“假溢出”问题。
  4. 最终出队顺序为30,40,50,60,符合“先进先出”原则。

7. 队列的遍历与辅助函数

有时我们需要查看队列中的所有元素而不出队。

7.1 循环队列的遍历

遍历需要从front开始,到rear的前一个位置结束,注意处理循环。

void TraverseCircularQueue(CircularQueue *q) { if (IsCircularQueueEmpty(q)) { printf("队列为空,无法遍历。\n"); return; } printf("当前队列元素(从队头到队尾): "); int i = q->front; while (i != q->rear) { printf("%d ", q->data[i]); i = (i + 1) % MAX_SIZE; // 循环移动 } printf("\n"); }

7.2 获取队列长度

int GetCircularQueueLength(CircularQueue *q) { // 计算从front到rear(不含)的元素个数,需处理循环 return (q->rear - q->front + MAX_SIZE) % MAX_SIZE; }

公式(rear - front + MAX_SIZE) % MAX_SIZE是计算循环队列元素个数的标准方法。

8. 常见问题与排查方法

在实现和使用队列时,你可能会遇到以下问题:

问题现象可能原因排查方式解决方案
入队失败,提示队列已满1. 队列真满。
2. (普通队列)“假溢出”。
3. (循环队列)判满条件写错。
1. 检查MAX_SIZE
2. 打印frontrear的值。
3. 单步调试,观察指针移动。
1. 增大容量或改用链队列。
2. 改用循环队列。
3. 核对判满公式(rear+1)%MAX_SIZE == front
出队失败,提示队列为空1. 队列真空。
2. 初始化不正确,frontrear未置为正确初始值。
3. 出队逻辑错误,在最后一个元素出队后未重置指针。
1. 检查入队操作是否成功。
2. 检查InitQueue函数。
3. 检查出队函数中if (front == rear)后的重置逻辑。
1. 确保有元素入队后再出队。
2. 统一初始化标准(如都设为0或都设为-1)。
3. 修正出队逻辑。
出队元素顺序错误或值不对1.front指针移动逻辑错误。
2. 入队时元素放错了位置(rear指针移动前/后赋值)。
3. 循环队列取模运算错误。
1. 在每次入队和出队后打印整个数组和frontrear的值。
2. 使用小容量(如5)进行手动推演。
1. 牢记:入队先赋值再移动rear;出队先取值再移动front
2. 检查所有% MAX_SIZE运算是否正确。
遍历队列时死循环或漏元素遍历的循环终止条件i != rear在队列满或空时可能不成立,或i的更新未取模。在遍历函数中加入计数器,防止无限循环。先判断队列是否为空。使用GetCircularQueueLength函数控制循环次数,或使用do...while结构并妥善处理边界。
程序编译通过但运行崩溃1. 未给队列结构体指针分配内存就使用。
2. 数组下标越界(frontrear值异常)。
1. 检查是否对局部变量SeqQueue q取了地址&q,还是错误使用了未初始化的指针。
2. 在每次指针移动后断言其值在[0, MAX_SIZE-1]范围内。
1. 使用栈变量(SeqQueue q; InitQueue(&q);)或动态分配后初始化。
2. 确保所有指针移动都正确取模。

9. 最佳实践与使用建议

  1. 明确约定:在项目或团队中,统一队列的实现规范。例如,明确frontrear的初始值、rear指向的含义(当前元素还是下一个空位)、判空判满的条件。这能避免协作时的混淆。
  2. 防御性编程:在所有队列操作函数(入队、出队、取队头)的开始,都检查队列指针是否为NULL以及队列是否为空/满。
  3. 小容量测试:在开发阶段,将MAX_SIZE设置为一个很小的数(如3或5),便于打印所有状态,快速验证循环逻辑和边界条件。
  4. 封装与接口:将队列的结构体和操作函数放在独立的头文件(.h)和源文件(.c)中。只对外暴露初始化、入队、出队、判空等接口,隐藏内部数据表示。这提高了代码的模块化和可维护性。
  5. 选择合适实现
    • 如果元素数量上限已知且不大,优先使用循环顺序队列,性能好。
    • 如果元素数量变化很大或难以预估,使用链式队列(用链表实现),可以动态扩容,但每个节点有额外指针开销。
    • 如果需要在多线程环境下使用,需要考虑线程安全,使用锁或原子操作来保护队列状态,或者直接使用线程安全的队列库。
  6. 内存管理:对于顺序队列,确保容量足够。对于链式队列,记得在出队时释放节点内存,在销毁队列时释放所有内存,防止内存泄漏。

10. 总结与下一步

队列作为一种基础且重要的数据结构,其核心在于理解“先进先出”的规则以及如何在物理存储上高效地实现这一规则。循环队列通过取模运算将线性数组转化为逻辑上的环形,是解决顺序队列“假溢出”问题的优雅方案,其判空判满的多种策略也体现了程序设计的灵活性。

最值得掌握的点

  • 循环队列判空判满的“牺牲一个单元”法:理解(rear + 1) % MAX_SIZE == front为何表示队列已满,以及为何要牺牲一个空间。这是面试高频考点。
  • 指针的循环移动:所有frontrear的向前移动都必须伴随% MAX_SIZE操作。
  • 边界条件处理:队列空和队列满时的入队出队操作,特别是最后一个元素出队后的状态重置。

最先应该验证的功能: 自己动手,将本文的代码敲一遍,并使用MAX_SIZE=5进行测试。尝试以下操作序列,并在每一步后打印队列状态(front,rear, 数组内容):

  1. 初始化。
  2. 连续入队4个元素。
  3. 尝试入队第5个元素(应失败)。
  4. 出队2个元素。
  5. 再入队2个元素。
  6. 连续出队直到队列为空。

最容易踩的坑

  • 混淆rear指向的是“最后一个元素”还是“下一个空位置”。本文采用后者,这是实现循环队列时更常见的约定。
  • 忘记在移动指针时进行取模运算。
  • 在判满条件中,错误地使用rear % MAX_SIZE == front而不是(rear + 1) % MAX_SIZE == front

后续扩展方向

  1. 实现链式队列:使用链表节点动态分配内存,实现一个无容量限制(受限于内存)的队列。
  2. 实现双端队列:允许从队头和队尾两端进行插入和删除操作。
  3. 实现优先级队列:元素出队顺序由优先级决定,而非入队顺序,通常使用堆来实现。
  4. 集成到实际项目:例如,用一个队列来管理串口接收到的数据字节,或用循环队列实现一个简单的日志缓冲器。

理解并熟练实现队列,是构建更复杂系统(如任务调度器、通信缓冲区)的基石。建议将本文的代码作为模板收藏,在需要时快速复用和调整。

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

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

立即咨询