目录
-
- 一、导言:初窥队列的端倪
- 二、队列的底层实现策略大比拼
- 三、核心实战:基于链表的队列实现
-
- [3.1 结构体设计](#3.1 结构体设计)
- [3.2 初始化与销毁](#3.2 初始化与销毁)
- [3.3 入队操作 --- 尾巴上的安全追加](#3.3 入队操作 — 尾巴上的安全追加)
- [3.4 出队操作 --- 当队列只剩最后一个元素时](#3.4 出队操作 — 当队列只剩最后一个元素时)
- [3.5 数据获取与状态检查](#3.5 数据获取与状态检查)
- [四、进阶升华:环形队列 (Circular Queue) 的奥秘](#四、进阶升华:环形队列 (Circular Queue) 的奥秘)
-
- [4.1 环形队列如何解决"假溢出"](#4.1 环形队列如何解决“假溢出”)
- [4.2 判空与判满的经典博弈](#4.2 判空与判满的经典博弈)
- [4.3 计算有效元素个数](#4.3 计算有效元素个数)
- 五、经典面试题与易错题突击
-
- [5.1 概念选择题热身](#5.1 概念选择题热身)
- [5.2 高频算法题思路](#5.2 高频算法题思路)
- 六、总结
- 附录:完整代码 (`Queue.hpp`)
一、导言:初窥队列的端倪
在日常生活中,我们几乎每天都会遇到排队------食堂打饭要排队、售票窗口要排队、甚至奶茶店等号也叫排队。这些真实场景都有一个共同的准则:先到的人先得到服务,后来的人必须排在末尾等待。把这个"先来后到"的直观经验抽象成计算机科学概念,就是队列 (Queue)。
队列是一种特殊的线性表。它的操作被限制在两端:一端只能进行插入操作,另一端只能进行删除操作。我们将允许插入的一端称为队尾 (Rear),允许删除的一端称为队头 (Front)。向队列中插入元素的行为叫作入队 (Enqueue),从队列中删除元素的行为叫作出队 (Dequeue)。
队列最核心的特性只有一个词:先进先出 (FIFO, First In First Out)。先存入队列的数据会先被取出,就像排队时先到的人先办完事离开一样。这条看似简单的规则,恰恰是队列在操作系统中任务调度、网络数据缓冲、广度优先搜索等各种领域能够大展身手的根本原因。
下面,我们将从底层实现入手,细拆每个接口的设计与潜在坑点,再延伸到环形队列、经典面试题等进阶内容,为你梳理出一条队列学习的清晰脉络。
二、队列的底层实现策略大比拼
常见的数据结构无非两种:数组(顺序存储) 与链表(链式存储)。栈因为只在一端操作,用数组或链表实现都足够简单高效;但换成队列,选择就不再随意了。
如果采用普通数组实现队列,我们可能会立即遇到一个效率灾难:
- 入队时,在数组尾部追加一个元素,时间复杂度 O ( 1 ) O(1) O(1),毫无问题。
- 出队时,却需要删除数组头部的元素。在 C/C++ 中,这通常意味着将后面所有元素整体前移一位,时间复杂度直接飙升到 O ( N ) O(N) O(N)。当队列很长时,频繁出队将导致大量无谓的数据搬运。
就算用一个指针标记"假队头",让头部元素留在原处不动,也只会造成空间的"假溢出"------队尾指针移到数组末尾后,即便前面还有空位,也无法继续入队,白白浪费了内存。
那么链表呢?单链表在头部删除、尾部插入的场景下恰好表现得游刃有余:
- 链表头部删除只需调整头指针,并释放旧节点,时间 O ( 1 ) O(1) O(1)。
- 链表尾部插入若维护一个尾指针,同样可以瞬间完成,时间 O ( 1 ) O(1) O(1)。
两者一结合,用带有头指针 (phead) 和尾指针 (ptail) 的单链表来实现普通队列,就成了最优选。它不仅避免了数据的大量移动,还能动态分配节点,无需担心容量不够用。这就是我们接下来要精析的实现方案。
三、核心实战:基于链表的队列实现
本文所提供的完整队列实现位于 Queue.hpp 中,该文件涵盖了队列的所有基本操作。让我们从结构体设计开始,一步步拆解每个函数的底层逻辑与边界条件。
3.1 结构体设计
队列需要两个核心结构:
c
typedef int QDataType;
typedef struct QueueNode
{
QDataType x;
struct QueueNode* next;
} QueueNode;
typedef struct Queue
{
QueueNode* phead;
QueueNode* ptail;
int size;
} Queue;
-
QueueNode:普通单链表节点,包含一个数据域x和一个指向下一节点的指针next。 -
Queue:队列的控制块。这个结构的设计值得特别留意:
phead指向链表头(队头),出队时从此处删除节点。ptail指向链表尾(队尾),入队时在此处追加新节点。size记录当前队列中的元素个数。有了它,获取队列长度的时间复杂度为 O ( 1 ) O(1) O(1),不必每次遍历链表。判空也可以直接通过size == 0完成,简洁高效。
为什么一定要同时保留 phead 和 ptail?如果只有 phead,插入时就需要从队头一直遍历到尾,复杂度退化为 O ( N ) O(N) O(N);如果只有 ptail,出队时将寸步难行,因为单链表从尾向前回退极其困难。双指针的引入,是链表队列保证 O ( 1 ) O(1) O(1) 操作时间的精髓。
3.2 初始化与销毁
c
void QueueInit(Queue* pq)
{
assert(pq);
pq->phead = pq->ptail = NULL;
pq->size = 0;
}
初始化极其简单:将两个指针置为 NULL,计数置零。assert 用于防御传参错误,防止后续操作访问非法内存。
c
void QueueDestroy(Queue* pq)
{
assert(pq);
QueueNode* cur = pq->phead;
while (cur)
{
QueueNode* next = cur->next;
free(cur);
cur = next;
}
pq->phead = pq->ptail = NULL;
pq->size = 0;
}
销毁必须彻底回收每一块动态分配的内存。遍历链表,逐个 free 节点,最后将指针全部归零。注意使用临时变量 next 保护后续节点,避免 free 后无法继续。完成后将 phead 和 ptail 同时置空,防止用户误用已销毁队列,导致"野指针"访问。
3.3 入队操作 --- 尾巴上的安全追加
c
void QueuePush(Queue* pq, QDataType x)
{
assert(pq);
QueueNode* newnode = QueueBuyNode(x);
if (pq->ptail == NULL) // 队列为空时
{
pq->ptail = pq->phead = newnode;
}
else // 队列不为空时
{
pq->ptail->next = newnode;
pq->ptail = newnode;
}
pq->size++;
}
这是一个典型的单链表尾插操作,但包含了关键的分支逻辑:
- 队列为空:头指针和尾指针都为空。这时新节点既是队头也是队尾,直接将
phead和ptail指向newnode。 - 队列非空:将原尾节点的
next指向新节点,再将尾指针ptail后移到新节点上。
这两条路径缺一不可。如果统一用非空逻辑,空队列时 pq->ptail->next 就会解引用空指针;如果统一用空逻辑,非空时 phead 不会被正确更新,链表就会断链。
此外,每入队一个元素 size++,为后续获取长度、判空提供实时数据。
3.4 出队操作 --- 当队列只剩最后一个元素时
出队是链表头部删除的操作,代码实现为:
c
void QueuePop(Queue* pq)
{
assert(pq);
assert(pq->size > 0);
if (pq->phead->next == NULL) // 一个节点
{
free(pq->phead);
pq->phead = pq->ptail = NULL;
}
else // 多个节点
{
QueueNode* node = pq->phead;
pq->phead = node->next;
free(node);
node = NULL;
}
pq->size--;
}
这里存在一个极易引发bug的陷阱:当队列中仅剩一个节点时,phead 和 ptail 指向同一个节点。如果只简单地 free(pq->phead),并让 phead 指向下一个(即 NULL),却不更新 ptail,那么 ptail 将变成一个悬空的野指针。后续若再次入队,程序会以为 ptail 仍然有效,而实际上它指向的内存早已被释放,紧接着的访问就会导致未定义行为。
正确处理方式是:检测 phead->next == NULL,当成立时意味着队列只有一个节点。此时释放它,然后将 phead 和 ptail 同时置 NULL。多个节点时,只需更新 phead 到下一节点,释放旧头节点即可。
这个细节是链表队列实现中最常考察的面试点,务必将"单节点出队必须重置尾指针"刻入肌肉记忆。
3.5 数据获取与状态检查
以下四个函数复杂度均为 O ( 1 ) O(1) O(1),代码简单明了:
c
bool QueueEmpty(Queue* pq)
{
assert(pq);
return pq->size == 0;
}
int QueueSize(Queue* pq)
{
assert(pq);
return pq->size;
}
QDataType QueueFront(Queue* pq)
{
assert(pq);
assert(pq->size > 0);
return pq->phead->x;
}
QDataType QueueBack(Queue* pq)
{
assert(pq);
assert(pq->size > 0);
return pq->ptail->x;
}
QueueEmpty直接利用size判空,无需检查指针。QueueSize同样直接返回size。QueueFront和QueueBack在调用前必须确保队列非空,否则对空指针解引用会崩溃,因此使用assert兜底。
四、进阶升华:环形队列 (Circular Queue) 的奥秘
链表实现的队列虽然灵活,但在某些需要预分配固定大小缓冲区的场景下,数组实现依然无法替代。典型例子是操作系统的生产者-消费者模型、网络数据环形缓冲区、音频流处理等。这时,环形队列(循环队列) 就粉墨登场了。
4.1 环形队列如何解决"假溢出"
普通数组入队到末尾后,即使前面有空位(因出队腾出),也无法继续插入,这就是"假溢出"。环形队列通过逻辑上把数组的头尾连接起来,形成一个环,彻底解决了这一问题。
假设数组容量为 max_size,我们维护两个指针:
front:指向队头元素的下标。rear:指向队尾元素下一个位置的下标(或队尾下标,视约定而定)。
我们可以用数组 data[max_size] 来模拟环形队列。入队的核心逻辑其实就两行:
C
// 假设已经检查过队列未满
data[rear] = value;
rear = (rear + 1) % max_size; // 取模是环形运转的灵魂
入队时,将数据放在 rear 位置,然后 rear = (rear + 1) % max_size。出队时,取出 front 位置的数据,然后 front = (front + 1) % max_size。取模运算保证了指针在越过数组末尾后能回到开头,从而使有限的存储空间得到重复利用。
4.2 判空与判满的经典博弈
环形队列实现的最大难点,是如何区分"空"和"满"。因为无论空还是满,front 和 rear 都可能相等(空时相等;当队尾指针绕圈一周追上队头时也会相等)。
一个广为使用的解决方案是:故意浪费一个存储单元,即队列实际可存元素个数为 max_size - 1。此时:
- 队空条件:
front == rear - 队满条件:
(rear + 1) % max_size == front
这种思路虽然牺牲了一个空间,但判断逻辑非常简洁,被多数教材和工程采用。
4.3 计算有效元素个数
即便空了一个位置,队列中有效元素的数目仍然需要公式计算,而不是简单地用 rear - front(因为可能存在绕圈)。通用公式为:
有效元素个数 = (rear - front + max_size) % max_size
这个公式在许多选择题和面试题中出现,值得牢记并理解其取模的含义。
五、经典面试题与易错题突击
5.1 概念选择题热身
-
下列哪一项不属于队列的基本运算?
A 从队尾插入一个新元素
B 从队列中删除第 i 个元素
C 判断一个队列是否为空
D 读取队头元素的值
解析:队列仅允许在队尾插入、队头删除。删除第 i 个元素破坏了"只能两端操作"的限制,因此B不是队列的基本运算。
-
循环队列存储在数组
Q[1..100]中,初始状态front=rear=100。经一系列操作后front=rear=99,则队列中的元素个数为( )A 1
B 2
C 99
D 0 或 100
解析:循环队列约定不同,
front==rear可能是空也可能是满。题干没有明确区分方式,故可能是 0 个元素(空)或 100 个元素(满),答案为 D。 -
假设队头不存放数据,环形队列长度为 N,有效长度为( )
A
(rear - front + N) % N + 1B
(rear - front + N) % NC
(rear - front) % (N + 1)D
(rear - front + N) % (N - 1)解析:不计队头位置本身是个约定,有效长度公式为
(rear - front + N) % N,答案 B。
5.2 高频算法题思路
- 用队列实现栈 (LeetCode 225)
思路:使用两个队列(或单个队列)。核心策略是每次压入元素后,将这个元素之前的所有元素依次弹出并重新入队,使得新元素始终位于队头,模拟栈顶。这样pop()直接出队即可。 - 用栈实现队列 (LeetCode 232)
思路:使用两个栈:一个输入栈、一个输出栈。入队时压入输入栈;出队时若输出栈为空,就将输入栈的元素全部弹出并压入输出栈,然后从输出栈弹出栈顶。这样实现了 FIFO。 - 设计循环队列 (LeetCode 622)
思路:按照上文"浪费一个空间"的方案实现。需实现enqueue、dequeue、Front、Rear、isEmpty、isFull等接口,重点处理好取模运算和边界条件。
这三道题目几乎覆盖了队列相关的所有核心考察点,建议亲自手撕一遍。
六、总结
队列是数据结构殿堂中与栈并立的另一根基。它以 FIFO 的秩序约束,换来了众多场景下的清晰逻辑。本文从现实排队映射到抽象数据类型,再到链表实现的精讲与环形队列的扩展,逐步搭建起关于队列的知识体系。
- 链表队列用头尾指针实现了 O ( 1 ) O(1) O(1) 的入队出队,但必须小心翼翼地在出队单节点时同步重置尾指针,以防野指针。
- 环形队列以取模运算克服数组弊端,但需要在判空判满上做出设计取舍。
- 面试中,除了上述实现细节,用栈模拟队列、用队列模拟栈等翻转思路也频频出现,建议作为巩固练习。
在计算机科学的宏大建筑中,队列虽只占一角,却联结着操作系统、网络协议、算法设计等众多重要领域。深刻理解队列,意味着你掌握了构建高效、可靠系统的一块关键基石。
附录:完整代码 (Queue.hpp)
c
#pragma once
#include<stdio.h>
#include<stdlib.h>
#include<assert.h>
#include<stdbool.h>
typedef int QDataType;
typedef struct QueueNode
{
QDataType x;
struct QueueNode* next;
}QueueNode;
typedef struct Queue
{
QueueNode* phead;
QueueNode* ptail;
int size;
}Queue;
//初始化
void QueueInit(Queue* pq)
{
assert(pq);
pq->phead = pq->ptail = NULL;
pq->size = 0;
}
//销毁
void QueueDestroy(Queue* pq)
{
assert(pq);
QueueNode* cur = pq->phead;
while (cur)
{
QueueNode* next = cur->next;
free(cur);
cur = next;
}
pq->phead = pq->ptail = NULL;
pq->size = 0;
}
//创建新节点
QueueNode* QueueBuyNode(QDataType x)
{
QueueNode* newNode = (QueueNode*)malloc(sizeof(QueueNode));
if (newNode == NULL)
{
printf("malloc error");
exit(1);
}
newNode->x = x;
newNode->next = NULL;
return newNode;
}
//入队
void QueuePush(Queue* pq, QDataType x)
{
assert(pq);
QueueNode* newnode = QueueBuyNode(x);
if (pq->ptail == NULL) //队列为空时
{
pq->ptail = pq->phead = newnode;
}
else //队列不为空时
{
pq->ptail->next = newnode;
pq->ptail = newnode;
}
pq->size++;
}
//出队
void QueuePop(Queue* pq)
{
assert(pq);
assert(pq->size > 0);
if (pq->phead->next == NULL) //一个节点
{
free(pq->phead);
pq->phead = pq->ptail = NULL;
}
else //多个节点
{
QueueNode* node = pq->phead;
pq->phead = node->next;
free(node);
node = NULL;
}
pq->size--;
}
//判对空
bool QueueEmpty(Queue* pq)
{
assert(pq);
return pq->size == 0;
}
//获取队中元素个数
int QueueSize(Queue* pq)
{
assert(pq);
return pq->size;
}
//获取队头元素
QDataType QueueFront(Queue* pq)
{
assert(pq);
assert(pq->size > 0);
return pq->phead->x;
}
//获取队尾元素
QDataType QueueBack(Queue* pq)
{
assert(pq);
assert(pq->size > 0);
return pq->ptail->x;
}