21、动态队列:用动态数组实现循环队列、入队与出队操作

队列这东西,说白了就是「先来后到」。你想想看,食堂打饭、打印机排队、网络数据包处理,哪个不是先进先出?在C语言里实现队列,我个人的习惯是优先用动态数组做循环队列。为什么?因为静态数组太死板,链表又太重,动态数组加循环逻辑,刚好卡在中间——既灵活又高效。

队列的核心思想

队列只有两个正经操作:入队(从尾巴塞数据)和出队(从脑袋取数据)。不允许插队,不允许从中间掏。嗯,这就是纪律。

用数组实现时,你会遇到一个尴尬的问题:出队后,前面的位置就空出来了。如果一直往后加,数组很快就用完了,但前面明明还有空位。怎么办?循环队列就是答案——把数组头尾连起来,形成一个环。

核心要点:循环队列通过取模运算(%)让数组下标循环,从而复用已出队的位置。

数据结构设计

我习惯这样定义循环队列的结构体:

typedef struct {
    int *data;      // 动态数组指针
    int capacity;   // 数组总容量
    int front;      // 队头下标
    int rear;       // 队尾下标
    int size;       // 当前元素个数
} CircularQueue;

这里有个小细节:front指向队头元素,rear指向队尾元素的下一个位置。为什么要多留一个空位?这是为了区分「队列空」和「队列满」的状态。如果front == rear,队列为空;如果(rear + 1) % capacity == front,队列为满。

我的经验:我在项目中遇到过有人用front == rear同时表示空和满,结果调试了一下午才发现。后来我干脆加了一个size字段,用size == 0判空,size == capacity判满,简单粗暴,再也没出过问题。

初始化与销毁

既然是动态数组,内存就得我们自己管。初始化时分配空间,销毁时释放空间,这是铁律。

CircularQueue* createQueue(int capacity) {
    CircularQueue *q = (CircularQueue*)malloc(sizeof(CircularQueue));
    q->data = (int*)malloc(sizeof(int) * (capacity + 1)); // 多留一个位置
    q->capacity = capacity + 1;
    q->front = 0;
    q->rear = 0;
    q->size = 0;
    return q;
}

void destroyQueue(CircularQueue *q) {
    if (q) {
        free(q->data);
        free(q);
    }
}

注意这里capacity + 1的用意。我故意多分配了一个元素的空间,这样rear永远指向一个空位,方便判断队满。你想想看,如果不留这个空位,front == rear就没办法区分空和满了。

入队操作

入队就是把数据放到rear位置,然后rear往后挪一步。如果到了数组末尾,就绕回开头。

int enqueue(CircularQueue *q, int value) {
    if ((q->rear + 1) % q->capacity == q->front) {
        printf("队列已满,无法入队\n");
        return -1;
    }
    q->data[q->rear] = value;
    q->rear = (q->rear + 1) % q->capacity;
    q->size++;
    return 0;
}

这里(q->rear + 1) % q->capacity就是循环的精髓。取模运算让下标在0capacity-1之间循环。我曾经在嵌入式项目里用这个逻辑做串口数据缓冲,跑了几百万次都没出过问题。

出队操作

出队就是从front位置取数据,然后front也往后挪。同样要处理循环。

int dequeue(CircularQueue *q, int *value) {
    if (q->front == q->rear) {
        printf("队列为空,无法出队\n");
        return -1;
    }
    *value = q->data[q->front];
    q->front = (q->front + 1) % q->capacity;
    q->size--;
    return 0;
}

注意:出队时一定要先判空。我曾经在调试网络协议栈时,忘了判空就直接出队,结果读到了脏数据,整个协议解析都乱了。从那以后,我写队列操作的第一行代码永远是判空判满。

完整示例

把上面这些拼起来,就是一个能用的循环队列了。我写个简单的测试:

int main() {
    CircularQueue *q = createQueue(5);
    
    enqueue(q, 10);
    enqueue(q, 20);
    enqueue(q, 30);
    
    int val;
    dequeue(q, &val);
    printf("出队: %d\n", val);  // 10
    
    enqueue(q, 40);
    enqueue(q, 50);
    enqueue(q, 60);  // 这里会触发队满吗?不会,因为前面出队了一个
    
    while (dequeue(q, &val) == 0) {
        printf("出队: %d\n", val);
    }
    
    destroyQueue(q);
    return 0;
}

运行结果:

出队: 10
出队: 20
出队: 30
出队: 40
出队: 50

看到了吗?虽然我们只创建了容量为5的队列,但通过循环利用,实际上可以连续入队出队很多次。这就是循环队列的魅力。

核心逻辑流程图

下面这张图展示了循环队列的入队和出队流程,以及循环下标的变化:

循环队列核心逻辑 动态数组 data[0..capacity-1] data[0] data[1] data[2] data[3] data[4] 空位 front 指向 rear 指向 入队流程 判满: (rear+1)%cap == front? data[rear] = value rear = (rear+1) % cap 出队流程 判空: front == rear? *value = data[front] front = (front+1) % cap

性能分析

循环队列的入队和出队操作,时间复杂度都是O(1)。这一点比链表实现的队列还要好——链表虽然也是O(1),但每次操作都要分配或释放节点,有额外开销。

操作 时间复杂度 空间复杂度 说明
入队 O(1) O(1) 直接赋值,下标循环
出队 O(1) O(1) 直接取值,下标循环
判空 O(1) O(1) 比较 front 和 rear
判满 O(1) O(1) 比较 (rear+1)%cap 和 front

我的建议:如果你不确定队列的最大长度,可以用动态扩容的循环队列。当队列满时,重新分配一个更大的数组,把数据拷贝过去。不过要注意,扩容时frontrear的映射关系要重新计算,我一般会先把数据按顺序整理到新数组的开头,再重置frontrear

避坑指南

  • 我曾经在嵌入式项目里用循环队列做串口接收缓冲,结果忘了处理frontrear的循环,数据一多就数组越界。从那以后,我每次更新下标都会用取模运算,绝不偷懒。
  • 注意:动态数组的容量是固定的,如果入队速度大于出队速度,队列迟早会满。实际项目中要监控队列使用率,必要时报警或扩容。
  • 还有一点:多线程环境下,入队和出队操作需要加锁。我一般用互斥锁保护frontrearsize的读写,否则会出现数据竞争。

好了,循环队列就讲到这里。动态数组加循环逻辑,这个组合在C语言里非常实用。你可以在自己的项目里试试,比如用它做任务队列、数据缓冲或者消息管道。嗯,动手写一遍,比看十遍都管用。

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