队列:先进先出的艺术

队列这东西,说白了就是「先来后到」。你想想看,生活中排队买奶茶,第一个排队的先拿到,最后一个排队的只能等着——这就是队列最朴素的逻辑。我在早年做嵌入式系统时,经常用队列来处理串口数据,那时候才真正体会到,一个简单的数据结构,用对了地方能省多少事。

队列的定义与核心操作

队列是一种先进先出(FIFO, First In First Out)的线性表。它只允许在一端插入(称为队尾),在另一端删除(称为队头)。

嗯,这里要注意:队列和栈正好相反。栈是后进先出,队列是先进先出。我见过不少初学者把这两个搞混,其实你只要记住「排队」这个生活场景就行了。

队列的核心操作就这几个:

  • 入队(Enqueue):在队尾插入一个元素
  • 出队(Dequeue):删除队头元素
  • 判空(IsEmpty):检查队列是否为空
  • 取队头(Front):获取队头元素的值,但不删除
  • 取队尾(Rear):获取队尾元素的值
  • 获取长度(Size):返回队列中元素个数

重要概念:队列的插入和删除操作分别发生在两端,时间复杂度均为 O(1)。这是队列高效性的核心保证。

循环队列:解决「假溢出」问题

用数组实现队列时,会遇到一个尴尬的问题。我举个例子:你有一个长度为5的数组,先入队3个元素,再出队3个元素。此时队头指针已经跑到数组末尾了,但数组前面空着的位置却用不了。这就是「假溢出」。

怎么解决?把数组首尾相连,形成一个环。这就是循环队列

我的经验:循环队列在嵌入式开发中非常实用。我曾经在一个数据采集项目中,用循环队列做环形缓冲区,完美解决了数据流的中断处理问题。内存占用固定,没有动态分配,非常适合资源受限的环境。

循环队列的关键在于取模运算。队头指针 front 和队尾指针 rear 在数组范围内循环移动:

// 入队操作
rear = (rear + 1) % MAX_SIZE;
queue[rear] = value;

// 出队操作
front = (front + 1) % MAX_SIZE;
value = queue[front];

这里有个坑:如何区分队列「空」和「满」?当 front == rear 时,队列可能是空的,也可能是满的。常用的解决方案有两种:

  • 方案一:牺牲一个存储单元。当 (rear + 1) % MAX_SIZE == front 时,认为队列已满。此时队列最多存储 MAX_SIZE - 1 个元素。
  • 方案二:增加一个 size 变量记录元素个数。这样 front == rear 时,通过 size 就能判断是空还是满。

我个人习惯用方案一,因为不需要额外维护一个变量,代码更简洁。

我曾经踩过的坑:有一次写循环队列,忘记处理取模运算的边界情况。当 rear 等于 MAX_SIZE - 1 时,直接加 1 就数组越界了。后来我养成了一个习惯——所有涉及循环队列指针移动的地方,一律用取模运算,绝不偷懒。

下面是循环队列的完整实现:

#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;
}

队列的链式存储结构

数组实现的队列有个硬伤:大小固定。你想想看,如果事先不知道数据量,数组要么浪费空间,要么不够用。这时候就需要链式队列了。

链式队列本质上是一个带头结点的单链表,队头指针指向头结点,队尾指针指向最后一个结点。入队操作在链表尾部插入,出队操作在链表头部删除。

为什么用带头结点的链表?因为这样出队操作时,不需要特殊处理空队列的情况。头结点永远存在,代码更统一。

typedef struct Node {
    int data;
    struct Node *next;
} Node;

typedef struct {
    Node *front;  // 队头指针,指向头结点
    Node *rear;   // 队尾指针,指向最后一个结点
} LinkedQueue;

// 初始化
void initQueue(LinkedQueue *q) {
    q->front = (Node*)malloc(sizeof(Node));
    q->front->next = NULL;
    q->rear = q->front;
}

// 入队
void enqueue(LinkedQueue *q, int value) {
    Node *newNode = (Node*)malloc(sizeof(Node));
    newNode->data = value;
    newNode->next = NULL;
    q->rear->next = newNode;
    q->rear = newNode;
}

// 出队
int dequeue(LinkedQueue *q, int *value) {
    if (q->front == q->rear) {
        return -1;  // 队列为空
    }
    Node *temp = q->front->next;
    *value = temp->data;
    q->front->next = temp->next;
    if (q->rear == temp) {
        q->rear = q->front;  // 删除最后一个元素时,更新队尾指针
    }
    free(temp);
    return 0;
}

注意:链式队列的入队和出队操作时间复杂度仍然是 O(1)。但每个结点需要额外的指针空间,内存开销比数组大。在内存紧张的场景下,我倾向于用循环队列。

两种实现的对比

对比维度 循环队列(数组) 链式队列
空间大小 固定,事先确定 动态,按需分配
内存开销 小,只有数据本身 大,每个结点多一个指针
入队/出队 O(1) O(1)
适用场景 数据量已知,内存受限 数据量未知,频繁插入删除
实现复杂度 中等,需处理取模 简单,但需注意内存管理

队列知识体系总览

下面这张图展示了队列的核心知识结构,帮你理清思路:

队列 定义与特性 先进先出(FIFO) 队头插入,队尾删除 循环队列 数组实现,取模运算 解决假溢出问题 判空/判满技巧 链式队列 带头结点的单链表 动态内存分配 无大小限制 核心操作 入队 / 出队 判空 / 判满 取队头 / 取队尾 应用场景 任务调度 缓冲区管理 BFS 广度优先搜索

队列的应用其实很广。比如操作系统的任务调度、打印机的任务队列、网络数据包的缓冲处理,底层都是队列。我记得有一次做网络协议栈,用队列来管理接收到的数据包,配合中断处理,整个系统的吞吐量提升了不少。

队列的两种实现各有优劣。循环队列适合数据量确定的场景,链式队列则更灵活。实际开发中,我建议你根据具体需求来选择——没有最好的数据结构,只有最合适的。


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