数据结构:用链表实现队列(Implementing Queue Using List)
目录
第一性原理:为什么需要链表?
设计链表的节点和队列结构
1. 积木:节点 (Node)
2. 蓝图:队列的结构 (Queue)
第2步:队列的诞生与状态检查
创建一个空队列
判断队列是否为空
第3步:核心操作 (入队与出队)
入队 (Enqueue)
出队 (Dequeue)
第4步:善后工作 (销毁队列)
我们再次从第一性原理出发,承接上文,这次我们来探讨如何用链表来实现一个队列。我们同样会一步步分析,发现问题,并逐步完善代码。
数据结构:队列(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. 初始化,空队列的头尾指针都为 NULLq->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
两个指针来满足性能要求,并细致地处理了入队和出队时的各种边界情况,最终构建了一个功能完整、健壮的链式队列。