前言
双向链表是链表数据结构的重要变体,相比单向链表,每个节点都包含指向前驱和后继的指针,这使得双向链表在插入、删除操作上更加高效。本文将详细分析一个带哨兵位的双向循环链表实现,探讨其设计思想、核心操作和实际应用。
目录
[1. 节点创建与初始化](#1. 节点创建与初始化)
[2. 两种初始化方式](#2. 两种初始化方式)
[3. 尾插操作](#3. 尾插操作)
[4. 头插操作](#4. 头插操作)
[5. 尾删操作](#5. 尾删操作)
[6. 查找操作](#6. 查找操作)
[7. 通用插入操作](#7. 通用插入操作)
[8. 通用删除操作](#8. 通用删除操作)
[9. 链表销毁](#9. 链表销毁)
正文
链表结构设计
在头文件List.h中定义了双向链表的基本结构:
typedef int LTDataType;
typedef struct ListNode
{
LTDataType data;
struct ListNode* next;
struct ListNode* prev;
}LTNode;
结构特点:
-
包含数据域
data、前驱指针prev和后继指针next -
使用哨兵位(dummy node)作为链表头,简化边界处理
-
采用循环结构,最后一个节点指向哨兵位
核心功能实现
1. 节点创建与初始化
LTNode* LTBuyNode(LTDataType x)
{
LTNode* node = (LTNode*)malloc(sizeof(LTNode));
if (node == NULL)
{
perror("malloc fail!");
exit(1);
}
node->data = x;
node->next = node->prev = node;
return node;
}
关键点: 新创建的节点初始化为自循环,这是循环链表的基础。
2. 两种初始化方式
// 方式一:通过二级指针初始化
void LTInit_1(LTNode** pphead)
{
*pphead = LTBuyNode(-1);
}
// 方式二:通过返回值初始化
LTNode* LTInit_2()
{
LTNode* phead = LTBuyNode(-1);
return phead;
}
设计对比:
-
LTInit_1:通过二级指针修改外部指针,类似单向链表做法 -
LTInit_2:返回哨兵位指针,代码更简洁,推荐使用
3. 尾插操作
void LTPushBack(LTNode* phead, LTDataType x)
{
assert(phead);
LTNode* newnode = LTBuyNode(x);
newnode->prev = phead->prev;
newnode->next = phead;
phead->prev->next = newnode;
phead->prev = newnode;
}
操作流程:
-
新节点的prev指向原尾节点
-
新节点的next指向哨兵位
-
原尾节点的next指向新节点
-
哨兵位的prev指向新节点
时间复杂度: O(1),相比单向链表的O(n)有显著优势
4. 头插操作
void LTPushFront(LTNode* phead, LTDataType x)
{
assert(phead);
LTNode* newnode = LTBuyNode(x);
newnode->prev = phead;
newnode->next = phead->next;
phead->next->prev = newnode;
phead->next = newnode;
}
对称性分析: 头插和尾插在双向循环链表中具有完美的对称性,都只需要O(1)时间。
5. 尾删操作
void LTPopBack(LTNode* phead)
{
assert(phead && phead->next != phead);
LTNode* del = phead->prev;
del->prev->next = phead;
phead->prev = del->prev;
free(del);
del = NULL;
}
边界检查: phead->next != phead确保链表不为空(只有哨兵位)
6. 查找操作
LTNode* LTFind(LTNode* phead, LTDataType x)
{
assert(phead);
LTNode* pcur = phead->next;
while (pcur != phead)
{
if (pcur->data == x)
{
return pcur;
}
pcur = pcur->next;
}
return NULL;
}
循环条件: pcur != phead是循环链表遍历的关键条件
7. 通用插入操作
void LTInsert(LTNode* pos, LTDataType x)
{
assert(pos);
LTNode* newnode = LTBuyNode(x);
newnode->prev = pos;
newnode->next = pos->next;
pos->next->prev = newnode;
pos->next = newnode;
}
通用性: 此函数可以在任意位置后插入,包括头插和尾插都可以复用
8. 通用删除操作
void LTErase(LTNode* pos)
{
assert(pos);
pos->prev->next = pos->next;
pos->next->prev = pos->prev;
free(pos);
pos = NULL;
}
安全性考虑: 代码注释提到无法校验是否删除哨兵位,这是设计上的一个小缺陷
9. 链表销毁
void LTDestroy(LTNode* phead)
{
assert(phead);
LTNode* pcur = phead->next;
while (pcur != phead)
{
LTNode* next = pcur->next;
free(pcur);
pcur = next;
}
free(phead);
phead = NULL;
}
内存管理: 需要逐个释放所有节点,包括哨兵位
测试代码深度分析
测试函数全面验证了双向链表的各种操作:
int main()
{
// 初始化链表
LTNode* plist = LTInit_2();
// 尾插构建基础链表
LTPushBack(plist, 1); // 链表:1->
LTPushBack(plist, 2); // 链表:1->2->
LTPushBack(plist, 3); // 链表:1->2->3->
LTPushBack(plist, 4); // 链表:1->2->3->4->
// 头插验证
LTPushFront(plist, 5); // 链表:5->1->2->3->4->
LTPushFront(plist, 6); // 链表:6->5->1->2->3->4->
LTPushFront(plist, 7); // 链表:7->6->5->1->2->3->4->
LTPushFront(plist, 8); // 链表:8->7->6->5->1->2->3->4->
// 删除操作验证
LTPopBack(plist); // 删除4,链表:8->7->6->5->1->2->3->
LTPopFront(plist); // 删除8,链表:7->6->5->1->2->3->
// 查找功能测试
LTNode* find = LTFind(plist, 8);
if (find == NULL)
{
printf("没找到!\n"); // 预期输出,因为8已被删除
}
// 指定位置插入
LTNode* find1 = LTFind(plist, 7);
LTInsert(find1, 99); // 在7后插入99,链表:7->99->6->5->1->2->3->
LTNode* find2 = LTFind(plist, 3);
LTInsert(find2, 100); // 在3后插入100,链表:7->99->6->5->1->2->3->100->
// 指定位置删除
LTNode* find3 = LTFind(plist, 1);
LTErase(find3); // 删除1,链表:7->99->6->5->2->3->100->
// 最终销毁
LTDestroy(plist);
plist = NULL;
return 0;
}
测试亮点:
-
验证了插入、删除操作的顺序正确性
-
测试了边界情况(查找不存在的元素)
-
展示了链表的动态变化过程
-
确保资源正确释放
总结
双向循环链表的优势
-
时间复杂度优化:
-
头插尾插:O(1)
-
头删尾删:O(1)
-
指定位置插入删除:O(1)
-
相比单向链表有显著性能提升
-
-
哨兵位设计的优点:
-
统一了空链表和非空链表的处理逻辑
-
简化了边界条件判断
-
避免了头指针的特殊处理
-
-
循环结构的价值:
-
尾节点直接指向头节点,形成闭环
-
便于实现循环遍历和环形数据结构
-
实现要点总结
-
指针操作的对称性:双向链表的插入删除操作具有完美的对称性,需要同时维护prev和next指针。
-
内存管理:每个malloc都需要对应的free,特别是在销毁链表时要遍历释放所有节点。
-
断言使用:合理使用assert进行参数校验,提高代码健壮性。
-
设计选择:
-
使用哨兵位简化操作
-
采用循环结构提高效率
-
提供多种初始化方式增加灵活性
-
实际应用场景
双向循环链表特别适用于:
-
需要频繁在两端进行插入删除的场景
-
实现双向队列(deque)
-
浏览器历史记录管理
-
撤销重做功能实现
-
LRU缓存淘汰算法
这个实现展示了高质量的数据结构编码实践,通过巧妙的设计大幅提升了链表操作的效率和代码的简洁性,是学习数据结构与算法的优秀范例。