一、队列的基本概念
1.1 什么是队列
队列是一种特殊的线性表,它的限制是:只允许在一端进行插入操作,在另一端进行删除操作。
- 执行插入操作的一端,叫做队尾
- 执行删除操作的一端,叫做队头
生活中最贴切的例子就是排队买奶茶:先来的人站在队伍最前面,先拿到奶茶离开;新来的人只能排在队伍最后面,不能插队。整个过程严格遵守「先来先服务」的规则。
1.2 核心规则:先进先出
队列中的数据元素严格遵守 FIFO(First In First Out,先进先出) 原则:
- 最先进入队列的元素,会最先被取出
- 最后进入队列的元素,会最后才被取出
两个核心操作有专门的名称:
- 入队:往队尾插入元素的操作
- 出队:从队头删除元素的操作
二、队列的底层结构选型
和栈一样,队列也可以用数组或链表实现,但两者的效率差异很大,链表结构实现队列更优,原因非常直观:
- 如果用数组实现:入队是尾插,效率很高;但出队是头删,需要把后面所有元素依次向前挪动,时间复杂度是 O (n),数据量大时效率很低。即便用下标移动避免数据挪动,也会出现「前面空间空了却用不了」的假溢出问题。
- 如果用链表实现:出队对应头删、入队对应尾插,只要我们同时维护队头、队尾两个指针,两种操作都能在 O (1) 时间内完成,而且内存按需分配,没有空间浪费。
因此我们用带头尾指针的单链表来实现队列。
三、队列的完整 C 语言实现
我们按照工程规范拆分代码:头文件存放结构体定义和函数声明,源文件存放具体实现。
3.1 头文件 Queue.h:定义结构与接口
我们先定义链表结点的结构,再把队头指针、队尾指针、元素个数封装成一个队列结构体。这样做的好处是:不用每次传两个指针,操作头尾都很方便,也符合封装的思想。
// Queue.h
#pragma once // 防止头文件重复包含
#include <stdio.h>
#include <stdlib.h>
#include <stdbool.h>
#include <assert.h>
// 队列中存储的数据类型,修改类型只需改动这一行
typedef int QDataType;
// 链表结点结构:存储数据 + 指向下一个结点的指针
typedef struct QueueNode
{
QDataType val;
struct QueueNode* next;
} QNode;
// 队列结构:封装头尾指针与元素个数
typedef struct Queue
{
QNode* phead; // 队头指针,指向第一个结点
QNode* ptail; // 队尾指针,指向最后一个结点
int size; // 记录队列中有效元素的个数
} Queue;
// 初始化队列
void QueueInit(Queue* pq);
// 销毁队列,释放所有结点内存
void QueueDestroy(Queue* pq);
// 入队:队尾插入元素 x
void QueuePush(Queue* pq, QDataType x);
// 出队:删除队头元素
void QueuePop(Queue* pq);
// 获取队头元素
QDataType QueueFront(Queue* pq);
// 获取队尾元素
QDataType QueueBack(Queue* pq);
// 判断队列是否为空
bool QueueEmpty(Queue* pq);
// 获取队列中有效元素的个数
int QueueSize(Queue* pq);
3.2 源文件 Queue.c:接口逐一实现
下面我们逐个实现每个函数,重点标注新手容易踩坑的边界情况。
① 初始化队列
空队列状态下,头尾指针都为 NULL,元素个数为 0。
void QueueInit(Queue* pq)
{
assert(pq); // 断言:防止传入空指针
pq->phead = NULL;
pq->ptail = NULL;
pq->size = 0;
}
② 销毁队列
队列的每个结点都是动态申请的,销毁时必须逐个遍历释放,不能只释放队列结构体,否则会造成严重的内存泄漏。
void QueueDestroy(Queue* pq)
{
assert(pq);
QNode* cur = pq->phead;
while (cur)
{
QNode* next = cur->next; // 先保存下一个结点的地址
free(cur); // 释放当前结点
cur = next; // 继续处理下一个
}
// 释放完所有结点后,指针置空,计数归零
pq->phead = NULL;
pq->ptail = NULL;
pq->size = 0;
}
③ 入队(队尾插入)
入队需要分两种情况处理:队列为空时、队列已有元素时。
- 空队列:新结点既是队头也是队尾
- 非空队列:让当前尾结点的 next 指向新结点,再把尾指针更新为新结点
void QueuePush(Queue* pq, QDataType x)
{
assert(pq);
// 1. 创建新结点并赋值
QNode* newNode = (QNode*)malloc(sizeof(QNode));
if (newNode == NULL)
{
perror("malloc fail");
exit(-1);
}
newNode->val = x;
newNode->next = NULL;
// 2. 插入到队尾
if (pq->ptail == NULL) // 队列为空
{
pq->phead = newNode;
pq->ptail = newNode;
}
else // 队列非空
{
pq->ptail->next = newNode;
pq->ptail = newNode;
}
pq->size++;
}
④ 出队(队头删除)
要处理「只剩一个结点」的特殊情况,否则尾指针会变成野指针。
- 只剩一个结点:释放后队头、队尾指针都要置空
- 多个结点:保存第二个结点,释放头结点,再更新队头指针
void QueuePop(Queue* pq)
{
assert(pq);
assert(!QueueEmpty(pq)); // 空队列禁止出队
if (pq->phead->next == NULL) // 只剩一个结点
{
free(pq->phead);
pq->phead = NULL;
pq->ptail = NULL; // 必须置空,否则ptail会变成野指针
}
else // 多个结点
{
QNode* next = pq->phead->next;
free(pq->phead);
pq->phead = next;
}
pq->size--;
}
⑤ 获取队头元素
直接返回队头结点的数据,空队列禁止取值。
QDataType QueueFront(Queue* pq)
{
assert(pq);
assert(!QueueEmpty(pq));
return pq->phead->val;
}
⑥ 获取队尾元素
直接返回队尾结点的数据。
QDataType QueueBack(Queue* pq)
{
assert(pq);
assert(!QueueEmpty(pq));
return pq->ptail->val;
}
⑦ 判断队列是否为空
队头指针为 NULL 时,队列为空;也可以用 size == 0 判断,两者等价。
bool QueueEmpty(Queue* pq)
{
assert(pq);
return pq->phead == NULL;
}
⑧ 获取元素个数
直接返回我们维护的 size 即可,不用遍历计数。
int QueueSize(Queue* pq)
{
assert(pq);
return pq->size;
}
3.3 功能测试示例
写一个主函数,验证我们实现的队列是否符合先进先出的规则:
int main()
{
Queue q;
QueueInit(&q);
// 依次入队 1、2、3、4
QueuePush(&q, 1);
QueuePush(&q, 2);
QueuePush(&q, 3);
QueuePush(&q, 4);
printf("队列大小:%d\n", QueueSize(&q)); // 输出 4
printf("队头元素:%d\n", QueueFront(&q)); // 输出 1(最先入队的在队头)
printf("队尾元素:%d\n", QueueBack(&q)); // 输出 4(最后入队的在队尾)
// 执行一次出队
QueuePop(&q);
printf("出队后队头:%d\n", QueueFront(&q)); // 输出 2
// 用完销毁,释放所有结点内存
QueueDestroy(&q);
return 0;
}
运行结果完全符合先进先出的特性:最先入队的 1 位于队头,出队后队头变为第二个入队的 2。
四、总结
- 队列的核心特征是先进先出(FIFO),队尾入队、队头出队,所有操作都只在两端进行。
- 通用实现首选带头尾指针的单链表,入队、出队时间复杂度都是 O (1)。
- 实现时的关键注意点:
- 入队要处理空队列的特殊情况,同时更新头尾指针
- 出队要处理只剩一个结点的情况,避免尾指针变成野指针
- 销毁时必须逐个遍历释放结点,防止内存泄漏
- 固定容量场景下可以使用数组实现循环队列,通过牺牲一个位置区分空满,空间复用效率更高。

1万+

被折叠的 条评论
为什么被折叠?



