第六章:队列与循环队列——从排队买票到环形缓冲区

队列这东西,说白了就是「先来后到」。我刚开始学数据结构时,觉得队列不就是个排队吗?有啥好讲的。直到我在一个嵌入式项目里,因为队列的假溢出问题导致系统死锁,才真正明白——队列的水,比你想象的要深。

6.1 队列的顺序存储与假溢出问题

队列的顺序存储,就是用数组来实现。两个指针:front 指向队头,rear 指向队尾。入队时 rear++,出队时 front++。听起来很简单对吧?

但问题来了。你想想看,如果 front 一直往后移,前面的空间就空出来了。rear 到了数组末尾,明明前面还有空位,却不能再入队了。这就是经典的「假溢出」。

假溢出的本质:存储空间没有被真正用完,但指针已经无法移动了。

我曾经在一个数据采集系统中遇到过这个问题。采集卡不断往队列里塞数据,处理线程从队头取。跑了几个小时,突然发现队列满了——但实际存储的数据量只有数组容量的一半。排查了半天,就是假溢出在作怪。

避坑指南:我曾经以为只要把数组开大就能解决问题。但假溢出不是空间不够,而是指针管理的问题。单纯加大数组,只是把问题推迟,并没有解决。

6.2 循环队列——解决假溢出的经典方案

循环队列的思路很巧妙:把数组首尾相连,形成一个环。当 rear 到达数组末尾时,下一个位置回到数组开头。

嗯,这里要注意:循环队列的判空和判满,是个经典陷阱。

6.2.1 判空与判满

循环队列中,front 和 rear 的初始值通常都是 0。判空很简单:front == rear

但判满呢?如果也用 front == rear,那就和判空冲突了。怎么办?

我个人习惯用「牺牲一个存储单元」的方法:

  • 判空:front == rear
  • 判满:(rear + 1) % maxSize == front

也就是说,队列最多只能存 maxSize - 1 个元素。那个空出来的位置,就是用来区分空和满的。

我的经验:还有一种方法是加一个 size 变量记录元素个数。这样判空判满都直接比较 size 就行,不用浪费一个存储单元。但代价是多维护一个变量,在多线程环境下还要考虑同步问题。

// 循环队列核心操作
#define MAX_SIZE 100

typedef struct {
    int data[MAX_SIZE];
    int front;  // 队头指针
    int rear;   // 队尾指针
} CircularQueue;

// 初始化
void initQueue(CircularQueue *q) {
    q->front = 0;
    q->rear = 0;
}

// 判空
int isEmpty(CircularQueue *q) {
    return q->front == q->rear;
}

// 判满
int isFull(CircularQueue *q) {
    return (q->rear + 1) % MAX_SIZE == q->front;
}

// 入队
int enQueue(CircularQueue *q, int value) {
    if (isFull(q)) {
        return -1;  // 队列已满
    }
    q->data[q->rear] = value;
    q->rear = (q->rear + 1) % MAX_SIZE;
    return 0;
}

// 出队
int deQueue(CircularQueue *q, int *value) {
    if (isEmpty(q)) {
        return -1;  // 队列为空
    }
    *value = q->data[q->front];
    q->front = (q->front + 1) % MAX_SIZE;
    return 0;
}

6.3 链式队列——动态扩容的另一种选择

循环队列虽然解决了假溢出,但数组大小是固定的。如果你不知道数据量有多大,或者数据量波动很大,链式队列就更合适。

链式队列本质上是一个单链表,但只允许在队尾插入、队头删除。我习惯用两个指针:front 指向头结点,rear 指向尾结点。

链式队列的优势:理论上没有容量限制(只要内存够),入队出队都是 O(1) 时间复杂度。

// 链式队列结点
typedef struct QNode {
    int data;
    struct QNode *next;
} QNode;

// 链式队列
typedef struct {
    QNode *front;  // 队头指针
    QNode *rear;   // 队尾指针
} LinkedQueue;

// 入队
void enQueue(LinkedQueue *q, int value) {
    QNode *newNode = (QNode *)malloc(sizeof(QNode));
    newNode->data = value;
    newNode->next = NULL;
    
    if (q->rear == NULL) {
        // 空队列
        q->front = newNode;
        q->rear = newNode;
    } else {
        q->rear->next = newNode;
        q->rear = newNode;
    }
}

// 出队
int deQueue(LinkedQueue *q, int *value) {
    if (q->front == NULL) {
        return -1;  // 空队列
    }
    QNode *temp = q->front;
    *value = temp->data;
    q->front = temp->next;
    
    if (q->front == NULL) {
        q->rear = NULL;  // 队列变空
    }
    free(temp);
    return 0;
}

注意:链式队列每次入队都要 malloc,出队都要 free。在嵌入式系统中,频繁的动态内存分配可能导致内存碎片。我曾在某个 RTOS 项目里吃过这个亏——系统跑了几天后,malloc 开始返回 NULL,排查发现就是链式队列导致的内存碎片。

6.4 舞伴配对问题——队列的经典应用

舞伴配对问题,说白了就是:一群男生和一群女生跳舞,每个人按顺序排队。每次从男队和女队各取一个人配对。如果某一队没人了,剩下的人继续等待。

这个问题用队列来解决,简直天造地设。两个队列,一个男队,一个女队。每次从两个队列各出队一个人,配对成功。如果某个队列空了,就等下一轮。

// 舞伴配对
typedef struct {
    char name[20];
    char sex;  // 'M' 或 'F'
} Dancer;

void matchDancers(Dancer dancers[], int n) {
    LinkedQueue maleQueue, femaleQueue;
    initQueue(&maleQueue);
    initQueue(&femaleQueue);
    
    // 按性别分别入队
    for (int i = 0; i < n; i++) {
        if (dancers[i].sex == 'M') {
            enQueue(&maleQueue, dancers[i]);
        } else {
            enQueue(&femaleQueue, dancers[i]);
        }
    }
    
    // 配对
    while (!isEmpty(&maleQueue) && !isEmpty(&femaleQueue)) {
        Dancer male, female;
        deQueue(&maleQueue, &male);
        deQueue(&femaleQueue, &female);
        printf("%s 和 %s 配对\n", male.name, female.name);
    }
    
    // 输出等待的人
    if (!isEmpty(&maleQueue)) {
        printf("还有 %d 位男士在等待\n", queueSize(&maleQueue));
    }
    if (!isEmpty(&femaleQueue)) {
        printf("还有 %d 位女士在等待\n", queueSize(&femaleQueue));
    }
}

实际应用:舞伴配对问题看起来简单,但它的思想在很多地方都能用。比如打印机任务调度、CPU 进程调度、网络数据包处理——本质上都是「多个输入队列,一个处理单元」的模式。

6.5 本章知识体系

下面这张图,是我自己总结的队列知识体系。你看一遍,应该就能把整个章节串起来。

队列知识体系 队列 顺序存储(数组) 链式存储(链表) 假溢出问题 循环队列(解决假溢出) 动态扩容(无容量限制) 注意:内存碎片问题 舞伴配对问题

这张图把队列的两种存储方式、各自的问题、以及解决方案都串起来了。你顺着箭头看,就能理解整个知识脉络。

6.6 总结

队列这东西,看着简单,用起来坑不少。我总结几个要点:

  • 顺序队列:实现简单,但要注意假溢出。用循环队列解决。
  • 循环队列:判空判满要小心。我习惯用牺牲一个单元的方法,简单可靠。
  • 链式队列:灵活,但注意内存管理。嵌入式系统里慎用。
  • 舞伴配对:队列的典型应用,理解了这个,其他类似场景都能举一反三。

最后说一句:队列是很多复杂系统的基础。消息队列、任务队列、缓冲区——底层都是队列。把队列搞透了,后面学这些东西会轻松很多。


公众号:蓝海资料掘金营,微信 deep3321