目录
我们再次从第一性原理出发,承接上文,这次我们来探讨如何用链表来实现一个队列。我们同样会一步步分析,发现问题,并逐步完善代码。
数据结构:队列(Queue)与循环队列(Circular Queue)-CSDN博客
第一性原理:为什么需要链表?
我们已经知道,基于数组的循环队列有一个天生的“缺陷”:容量固定。
在创建队列时,我们必须预先指定其最大容量。如果业务需求变化,元素数量可能远超预期,队列就会“溢出”;如果预估过大,则会浪费内存。
如何解决这个问题?我们需要一个“用多少,拿多少”的动态结构。这正是链表的拿手好戏。
我们需要一个数据结构,能够高效地(时间复杂度 O(1))在头部移除元素,并在尾部添加元素。
设计链表的节点和队列结构
链表是由一个个“节点”串联起来的。所以,我们首先需要设计这个最基本的“积木”。
1. 积木:节点 (Node)
一个节点需要包含什么信息?
数据本身 (
int data
)指向下一个节点的指针 (
struct Node* next
),这是把它“链接”起来的关键。
【代码实现 1:节点结构体定义】
#include <stdio.h>
#include <stdlib.h>
// 链表节点,是构成我们队列的基本单元
typedef struct Node {
int data;
struct Node* next;
} Node;
2. 蓝图:队列的结构 (Queue)
如何用链表来组织,才能满足 O(1) 的头删和尾增?
如果我们有一个指针指向链表的头节点,我们称之为 front
。
那么删除头节点的操作就是把 front
指向 front->next
,这本身是 O(1) 的。
从头部删除 (Dequeue):
在尾部添加 (Enqueue):
如果我们只有一个 front
指针,为了找到尾部,我们必须从头遍历整个链表,时间复杂度是 O(N),这太慢了,不符合我们的要求。
为了实现 O(1) 的尾部添加,我们必须始终保留一个直接指向尾节点的指针。我们称之为 rear
。
因此,我们的队列结构需要两个指针:一个指向头,一个指向尾。
【代码实现 2:链式队列结构体定义】
// 链式队列的蓝图
typedef struct {
Node* front; // 指向队头节点
Node* rear; // 指向队尾节点
} LinkedQueue;
这个结构比数组实现更简洁,因为它不需要关心容量和下标。
第2步:队列的诞生与状态检查
创建一个空队列
一个空的链式队列是什么样的?它没有任何节点。因此,它的 front
和 rear
指针都应该指向 NULL
。
【代码实现 3:创建队列函数】
// 功能:创建一个空的链式队列
LinkedQueue* createLinkedQueue() {
// 1. 为队列结构体本身分配内存
LinkedQueue* q = (LinkedQueue*)malloc(sizeof(LinkedQueue));
if (!q) {
perror("Failed to allocate memory for LinkedQueue");
return NULL;
}
// 2. 初始化,空队列的头尾指针都为 NULL
q->front = NULL;
q->rear = NULL;
return q;
}
判断队列是否为空
这比数组实现简单得多。只要 front
指针是 NULL
,队列就是空的。
【代码实现 4:判空函数】
// 功能:判断队列是否为空
int isLinkedQueueEmpty(LinkedQueue* q) {
// 如果 front 为 NULL,队列必为空
return q->front == NULL;
}
注意:链式队列理论上没有“满”的状态(除非整个计算机的内存耗尽),所以我们不需要
isFull
函数。
第3步:核心操作 (入队与出队)
这是链式队列最关键的部分,包含了一些必须小心处理的“边界情况”。
入队 (Enqueue)
向队尾添加一个元素 value
。
创建新节点:无论如何,我们都需要先根据
value
创建一个新的节点。处理边界情况:如果当前队列是空的 (
front
是NULL
),那么这个新节点既是队头也是队尾。所以front
和rear
都应该指向这个新节点。处理通用情况:如果队列不为空,我们只需做两件事:
a. 让当前的队尾节点(q->rear
)的 next
指针指向新节点。
b. 更新队尾指针 q->rear
,让它指向这个新节点。
【代码实现 5:入队函数】
// 功能:将元素 value 加入队尾
void enqueueLinked(LinkedQueue* q, int value) {
// 1. 无论如何,都先创建新节点
Node* newNode = (Node*)malloc(sizeof(Node));
if (!newNode) {
perror("Failed to allocate memory for new node");
return;
}
newNode->data = value;
newNode->next = NULL; // 新节点总是被加在最后,所以它的 next 永远是 NULL
// 2. 处理队列为空的特殊情况
if (isLinkedQueueEmpty(q)) {
q->front = newNode;
q->rear = newNode;
} else {
// 3. 通用情况:将新节点链接到队尾,并更新队尾指针
q->rear->next = newNode;
q->rear = newNode;
}
}
出队 (Dequeue)
从队头取出一个元素。
检查:队列是否为空?空则无法出队。
获取数据:要返回的数据在
front
指针指向的节点里。移动指针:将
front
指针移动到下一个节点 (q->front = q->front->next
)。释放内存:原来的头节点已经“游离”出队列了,必须用
free
释放它的内存。处理最关键的边界情况:
如果出队的是队列中最后一个元素,那么在移动 front
指针后,front
会变为 NULL
。
此时队列变空,但 rear
指针还指向刚刚被释放的那个无效内存地址!
这非常危险。我们必须在这种情况下,同时将 rear
指针也设置为 NULL
。
【代码实现 6:出队函数】
// 功能:从队头取出一个元素,并通过指针 pValue 返回
int dequeueLinked(LinkedQueue* q, int* pValue) {
// 1. 检查是否为空
if (isLinkedQueueEmpty(q)) {
printf("出队失败:队列为空。\n");
return 0; // 失败
}
// 为了稍后释放,临时保存旧的头节点
Node* temp = q->front;
// 2. 获取数据
*pValue = temp->data;
// 3. 移动 front 指针到下一个节点
q->front = q->front->next;
// 4. 处理最关键的边界:如果出队后队列变空了
if (q->front == NULL) {
// 必须同时更新 rear 指针,否则 rear 会成为一个危险的“野指针”
q->rear = NULL;
}
// 5. 释放旧的头节点内存
free(temp);
return 1; // 成功
}
第4步:善后工作 (销毁队列)
销毁整个队列意味着要释放所有节点的内存,以及队列结构体本身的内存。
最简单的方法是利用我们已经写好的 dequeueLinked
函数。
【代码实现 7:销毁函数】
// 功能:销毁整个队列,释放所有内存
void destroyLinkedQueue(LinkedQueue* q) {
int dummyValue; // 用于接收出队元素,我们不关心它的值
// 不断地从队列中取出元素,直到队列为空
// dequeueLinked 函数会自动释放每个节点的内存
while (!isLinkedQueueEmpty(q)) {
dequeueLinked(q, &dummyValue);
}
// 最后,释放队列结构体本身的内存
free(q);
}
通过以上步骤,我们从链表的基本特性出发,推导出了需要 front
和 rear
两个指针来满足性能要求,并细致地处理了入队和出队时的各种边界情况,最终构建了一个功能完整、健壮的链式队列。