← 首页·文章列表

循环队列与普通队列:我是怎么把「假溢出」这个问题彻底想明白的

你去商场里吃饭,门口有个牌子写着「当前叫到 23 号」。你拿的是 24 号。这意味着在你前面还有一个人。这就是一个队列。

先排队的人,先被叫到,先入座。后来的人乖乖等着。

这个逻辑太简单了,简单到我们从小就知道。但当我真正去看数据结构里的队列实现时,才发现这个「简单」的背面藏着一个困扰了我很久的问题——普通队列会「假溢出」,而循环队列就是来解决这个问题的。

说实话,这个问题我折腾了两天才彻底想明白。所以我把这篇写成自己被自己教一遍的样子,如果你也在这个地方卡住,希望对你有用。


队列是什么

先说清楚队列这个东西。

队列,Queue,就是一种「先进先出」的数据结构。你可以把它理解成一排人排队买东西,第一个人买完走了,第二个人补上去。

它支持两个核心操作:

还有一个辅助操作叫判空,判断队列里还有没有东西。

这大概就是队列的全部了。三个操作,别想复杂。

在实际场景里,队列用得非常多。比如 CPU 调度任务、打印机任务排队、宽度优先搜索(BFS)遍历图、消息队列解耦系统——这些背后都是队列。但理解这些应用场景是之后的事,先把队列本身搞清楚。


普通顺序队列的问题

好,现在我用数组来实现一个队列。

数组是连续的内存空间。假设我开一个 MAXSIZE = 5 的数组,用两个指针来标记位置:

一开始,front = 0,rear = 0,队列是空的。

入队三个元素 A、B、C 之后,数组里是这样的:

下标:   0    1    2    3    4
数组: [ A ] [ B ] [ C ] [空] [空]
               ↑         ↑
             front      rear

现在出队一次,A 被取走了。通常的实现会怎么做呢?

不迁移数据,只是把 front 往前挪一位:

下标:   0    1    2    3    4
数组: [ A ] [ B ] [ C ] [空] [空]
          ↑         ↑
        front      rear

现在 B 和 C 在数组前面了,index=0 是空的,但 rear 已经指到 3 了。

继续入队 D、E:

下标:   0    1    2    3    4
数组: [空] [ B ] [ C ] [ D ] [ E ]
               ↑              ↑
             front           rear

再入队一个 F 呢?rear 要变成 5,但数组最大下标是 4——

溢出报警。但数组里明明还有位置(index=0 是空的)。

这就是「假溢出」。队列逻辑上还有空间,但物理上已经没有位置可用了,因为 rear 已经走到了数组尽头。这种情况在频繁入队出队的场景下会不断发生。

你可能会说,那就把所有数据往前搬移一下不就好了?

确实可以,但这就意味着每次出队都要 O(n) 的数据迁移。队列本来想做到 O(1) 的入队出队,这么一搞效率直接掉下去。不可行。


循环队列:首尾相接的思路

假溢出的根本原因是 rear 只能往前走,不能「回头」。

那如果我把数组的最后一个位置和第一个位置接起来呢?

就像一个甜甜圈,数据沿着环形走,走到末尾就绕回开头。这就是循环队列的核心思想。

怎么实现?用模运算。

rear = (rear + 1) % MAXSIZE;

(rear + 1) % MAXSIZE 这个式子保证了 rear 走到末尾之后,会绕回 0。

比如 MAXSIZE = 5,rear = 4 时执行这个操作:

(4 + 1) % 5 = 0,rear 就回到了开头位置。

同样 front 的移动也是这个公式:

front = (front + 1) % MAXSIZE;

循环队列里,数组变成了一个环。rear 可以一直往前走,走完了就从头再来。这样只要数组没真正满,就不会有假溢出。

但问题来了——


空和满,这是最烧脑的地方

循环队列里有一个设计难点,正是因为这个「环形 wrap around」的特性而产生的。

当我们把数组首尾相接之后,front 和 rear 都用「下一个可用的位置」来定义。这意味着初始化时,front == rear 表示空队列没有任何问题。

但当队列满了之后呢?

想象一下,数组长度 5,满员时存了 4 个元素(后面会解释为什么是 4 个不是 5 个)。此时 front 和 rear 会指向同一个位置——因为 rear 绕回来了,刚好和 front 重合。

所以在循环队列里,front == rear 既表示空,也表示满。

这就是循环队列最绕的地方。一种状态,两种含义,靠什么区分?

有三种主流方案。

方案一:牺牲一个存储空间

这是教科书最常见的做法。

核心思路:数组有 n 个位置,但实际只用 n-1 个。那个空出来的位置就是「满」的标志。

满的判断条件是:

(rear + 1) % n == front

空的判断条件还是:

front == rear

为什么这样设计?

数学上,n 个存储位置只能区分 n 种状态(每个位置可以是空或被占用,这是组合数问题)。但队列有 n+1 种状态(0 个元素、1 个元素、……、n 个元素)。n 种状态塞不下 n+1 种情况,所以必须牺牲一种。

牺牲哪一种呢?牺牲「存满 n 个元素」这个状态,只存最多 n-1 个。这样空和满就能明确区分了。

方案二:用一个计数器记录当前元素个数

这个更直观,我更推荐在实际代码里用。

结构体里加一个 size 字段:

typedef struct {
    int data[MAXSIZE];
    int front;
    int rear;
    int size;    // 当前元素个数
} CircleQueue;

判空:size == 0

判满:size == MAXSIZE

这样 front 和 rear 相等时到底表示空还是满,完全由 size 来判断,不会混淆。而且数组的每个位置都能用上,容量是满的 n。

方案三:标志位 tag

每次入队把 tag 设成 1,每次出队把 tag 设成 0。

然后通过 front == rear 时的 tag 值来区分——如果 tag==0 说明刚出队导致的 front==rear,所以是空;tag==1 说明刚入队导致的,所以是满。

这个方案理论上可行,但用额外的变量增加了复杂度,面试提一下就行,实际代码不推荐。


完整代码实现

说了这么多,不如直接看代码。我用「方案二」(计数器法)写一个完整的循环队列,代码可以直接编译运行。

结构体定义

#include <stdio.h>
#include <stdlib.h>

#define MAXSIZE 5

typedef struct {
    int data[MAXSIZE];
    int front;    // 队头下标,指向第一个元素
    int rear;     // 队尾下标,指向最后一个元素的下一位置
    int size;     // 当前元素个数
} CircleQueue;

注意 rear 的语义——它指向「下一个可以入队的位置」,而不是最后一个元素本身。这个设计让入队操作 data[rear] = x; rear = (rear + 1) % n; 变得很自然。

初始化

CircleQueue* init() {
    CircleQueue* q = (CircleQueue*)malloc(sizeof(CircleQueue));
    q->front = 0;
    q->rear = 0;
    q->size = 0;
    return q;
}

入队

int enqueue(CircleQueue* q, int x) {
    if (q->size == MAXSIZE) {
        printf("队满,无法入队 %d\n", x);
        return -1;
    }
    q->data[q->rear] = x;
    q->rear = (q->rear + 1) % MAXSIZE;  // 循环移动
    q->size++;
    return 0;
}

出队

int dequeue(CircleQueue* q, int* x) {
    if (q->size == 0) {
        printf("队空,无法出队\n");
        return -1;
    }
    *x = q->data[q->front];
    q->front = (q->front + 1) % MAXSIZE;  // 循环移动
    q->size--;
    return 0;
}

打印队列状态(调试用)

void printQueue(CircleQueue* q) {
    printf("front=%d, rear=%d, size=%d | ", q->front, q->rear, q->size);
    if (q->size == 0) {
        printf("[空队列]\n");
        return;
    }
    printf("内容: [");
    int count = q->size;
    int idx = q->front;
    while (count > 0) {
        printf("%d", q->data[idx]);
        count--;
        if (count > 0) printf(" -> ");
        idx = (idx + 1) % MAXSIZE;
    }
    printf("]\n");
}

完整运行示例

int main() {
    CircleQueue* q = init();

    enqueue(q, 10); printQueue(q);  // 入队 10
    enqueue(q, 20); printQueue(q);  // 入队 20
    enqueue(q, 30); printQueue(q);  // 入队 30

    int x;
    dequeue(q, &x); printQueue(q);  // 出队一个
    printf("出队的元素是: %d\n", x);

    enqueue(q, 40); printQueue(q);  // 入队 40
    enqueue(q, 50); printQueue(q);  // 入队 50
    enqueue(q, 60); printQueue(q);  // 入队 60(会失败,因为队满)

    // 再继续入队出队,看循环效果
    dequeue(q, &x);
    enqueue(q, 70); printQueue(q);
    dequeue(q, &x);
    enqueue(q, 80); printQueue(q);

    free(q);
    return 0;
}

输出:

front=0, rear=1, size=1 | 内容: [10]
front=0, rear=2, size=2 | 内容: [10 -> 20]
front=0, rear=3, size=3 | 内容: [10 -> 20 -> 30]
front=1, rear=3, size=2 | 内容: [20 -> 30]
出队的元素是: 10
front=1, rear=4, size=3 | 内容: [20 -> 30 -> 40]
front=1, rear=0, size=4 | 内容: [20 -> 30 -> 40 -> 50]
队满,无法入队 60
front=2, rear=0, size=3 | 内容: [30 -> 40 -> 50]
front=2, rear=1, size=4 | 内容: [30 -> 40 -> 50 -> 70]
front=3, rear=1, size=3 | 内容: [40 -> 50 -> 70]
front=3, rear=2, size=4 | 内容: [40 -> 50 -> 70 -> 80]

注意看 rear 的移动。当 rear 从 4 再入队一个元素时,它绕回了 0,而不是溢出。这就是循环队列的核心价值——空间可以复用。


链表实现:另一种思路

数组实现的队列有容量限制。如果你需要动态扩容、不确定容量上限的场景,可以用链表来实现队列。

思路很简单:每个节点存数据和指向下一个节点的指针,维护 head 和 tail 两个指针。

入队就是在链表尾部插入,出队就是从链表头部删除。两个操作都是 O(1),不需要循环移位。

但链表实现的代价是每个节点要额外存储一个指针,空间开销比数组大,而且每次入队要 malloc、出队要 free,有额外的系统调用开销。如果队列大小可以预估,循环队列是更高效的选择。


什么时候用什么

说了这么多,最后来一个简单的选择指南。

场景 推荐 原因
大小固定,数据量可预估 循环队列(数组) 无需 malloc/free,运行效率高
大小不确定,可能很大 链式队列 按需分配,不浪费空间
频繁入队出队,实时系统 循环队列(数组) 入队出队都是 O(1),无内存碎片
需要遍历查看队列内容 循环队列(数组) 随机访问,调试方便
需要严格的最大容量 循环队列(数组) 容量固定,不会意外失控

其实大多数场景下,循环队列(数组实现)就是最优选。代码简单、效率高、没有内存碎片。只有在真正需要动态扩容时才考虑链表版本。


回到开头的场景

现在你再去商场吃饭,门口那个叫号牌是怎么工作的?

叫号系统维护一个「当前叫到的号码」和一个「下一个可分配的号码」,每次有人入座,当前号码就往前挪一位——这就是出队操作。系统里叫号系统和你的号码之间的排队关系就是一个队列。每新来一个人,就排到当前最大号的后面。

数据结构从来不是凭空发明的东西,它就是把生活中已经存在的逻辑,用精确的语言描述出来。

循环队列就是这样——把排队这个动作里「有人走了位置空出来,后面的人可以补上」的朴素想法,变成了一个可以跑在计算机里的数据结构。

理解了这一点,「假溢出」这个问题就再也困不住你了。