引言
双向循环链表是一种重要的数据结构,它结合了双向链表和循环链表的特点,每个节点包含指向前驱和后继节点的指针,并且尾节点的后继指向头节点,形成一个闭环。在实际开发中,带哨兵位(头节点)的双向循环链表因其操作的统一性和便捷性而被广泛应用。
本文将从零开始,详细解析双向循环链表的实现原理、核心操作及测试过程,并总结相关知识点和易错点,帮助读者深入理解这一数据结构。
目录结构设计
一个良好的项目结构有助于代码的管理和维护,我们将链表实现分为以下几个文件:
double_linked_list/
├── include/
│ └── List.h // 链表结构体定义和函数声明
├── src/
│ └── List.c // 链表函数实现
└── test/
└── test.c // 测试代码
代码实现与解析
1. 头文件 List.h
头文件主要包含链表节点的定义和所有操作函数的声明,是整个链表实现的接口。
#pragma once
#include <stdio.h>
#include <stdlib.h>
#include <assert.h>
// 定义链表存储的数据类型,方便后续修改存储类型
typedef int LTDataType;
// 定义双链表节点结构
typedef struct ListNode
{
LTDataType data; // 节点存储的数据
struct ListNode* prev; // 指向前驱节点的指针
struct ListNode* next; // 指向后继节点的指针
}LT;
// 节点创建
LT* LTBuyNode(LTDataType num);
// 哨兵位创建及初始化
LT* LTInit();
// 打印链表
void LTPrint(LT* phead);
// 头插与尾插
void LTPushFront(LT* phead, LTDataType num);
void LTPushBack(LT* phead, LTDataType num);
// 头删与尾删
void LTPopFront(LT* phead);
void LTPopBack(LT* phead);
// 查找元素
LT* LTFind(LT* phead, LTDataType num);
// 在pos位置之后插入数据
void LTInsert(LT* pos, LTDataType num);
// 删除pos节点
void LTErase(LT* pos);
// 销毁链表
void LTDestroy(LT* phead);
代码解析:
- #pragma once 用于防止头文件被重复包含
- typedef int LTDataType 为数据类型创建别名,便于后续修改存储的数据类型(如改为char、float等)
- 节点结构体包含三个成员:数据域和两个指针域(prev和next)
- 函数声明涵盖了链表的所有基本操作
2. 源文件 List.c
该文件实现了头文件中声明的所有函数,是链表功能的核心实现。
2.1 节点创建函数
#include "List.h"
// 创建新节点
LT* LTBuyNode(LTDataType num)
{
// 为新节点分配内存
LT* newnode = (LT*)malloc(sizeof(LT));
if(newnode == NULL)
{
perror("malloc fail !"); // 内存分配失败时打印错误信息
exit(1); // 退出程序
}
newnode->data = num; // 初始化数据域
newnode->next = newnode->prev = newnode; // 新节点的前后指针都指向自身
return newnode;
}
代码解析:
- LTBuyNode 函数负责创建一个新的链表节点
- 首先使用malloc分配内存,必须检查内存分配是否成功
- 新节点初始化时,prev和next都指向自身,这为后续操作提供了便利
- 函数返回新创建的节点指针
2.2 链表初始化函数
// 哨兵位创建及初始化
LT* LTInit()
{
LT* phead = LTBuyNode(–1); // 创建哨兵位节点,数据域值无实际意义
return phead;
}
代码解析:
- 初始化函数创建一个哨兵位节点(头节点)并返回
- 哨兵位节点的数据域值可以是任意的,因为它不存储实际数据
- 带哨兵位的链表可以统一空链表和非空链表的操作,简化代码逻辑
2.3 打印函数
// 打印链表内容
void LTPrint(LT* phead)
{
assert(phead); // 确保哨兵位节点不为空
printf("链表: ");
LT* pcur = phead->next; // 从第一个有效节点开始遍历
while(pcur != phead) // 当pcur回到哨兵位时,遍历结束
{
printf("%d->", pcur->data);
pcur = pcur->next; // 移动到下一个节点
}
printf("phead\\n"); // 表示链表结束,回到哨兵位
}
代码解析:
- 使用assert(phead)确保传入的哨兵位节点不为空
- 遍历从哨兵位的下一个节点(phead->next)开始
- 循环结束条件是pcur != phead,体现了循环链表的特性
- 打印格式清晰地展示了链表的结构
2.4 插入操作
2.4.1 头插操作
// 头插:在哨兵位之后插入新节点
void LTPushFront(LT* phead, LTDataType num)
{
assert(phead); // 确保哨兵位节点有效
LT* newnode = LTBuyNode(num); // 创建新节点
// 建立新节点与前后节点的链接
newnode->prev = phead;
newnode->next = phead->next;
// 更新原头节点后继的前驱指针和头节点的后继指针
phead->next->prev = newnode;
phead->next = newnode;
}
图解:
插入前:phead <-> A <-> …
插入后:phead <-> newnode <-> A <-> …
代码解析:
- 头插操作是在哨兵位节点之后插入新节点
- 操作步骤分为两步:先设置新节点的prev和next指针,再更新原有节点的指针
- 这种操作顺序避免了指针丢失
2.4.2 尾插操作
// 尾插:在链表末尾插入新节点
void LTPushBack(LT* phead, LTDataType num)
{
assert(phead); // 确保哨兵位节点有效
LT* newnode = LTBuyNode(num); // 创建新节点
// 新节点的前驱指向原尾节点,后继指向哨兵位
newnode->prev = phead->prev;
newnode->next = phead;
// 更新原尾节点的后继和哨兵位的前驱
phead->prev->next = newnode;
phead->prev = newnode;
}
图解:
插入前:… <-> B <-> phead
插入后:… <-> B <-> newnode <-> phead
代码解析:
- 尾插操作是在链表末尾(哨兵位之前)插入新节点
- 利用哨兵位的prev指针可以直接找到尾节点,无需遍历
- 时间复杂度为O(1),效率很高
2.5 删除操作
2.5.1 头删操作
// 头删:删除哨兵位之后的第一个节点
void LTPopFront(LT* phead)
{
// 确保哨兵位有效且链表不为空
assert(phead && phead->next != phead);
LT* del = phead->next; // 记录要删除的节点
// 建立删除节点前后节点的直接链接
del->next->prev = phead;
phead->next = del->next;
free(del); // 释放删除节点的内存
// del = NULL; 此处可以省略,因为del是局部变量
}
代码解析:
- 断言条件phead->next != phead确保链表不为空
- 先记录要删除的节点,再更新指针关系,最后释放内存
- 避免了内存泄漏
2.5.2 尾删操作
// 尾删:删除链表末尾的节点
void LTPopBack(LT* phead)
{
// 确保哨兵位有效且链表不为空
assert(phead && phead->next != phead);
LT* del = phead->prev; // 记录要删除的尾节点
// 建立删除节点前后节点的直接链接
phead->prev = del->prev;
del->prev->next = phead;
free(del); // 释放内存
}
代码解析:
- 利用哨兵位的prev指针直接找到尾节点
- 同样先更新指针关系,再释放内存
- 时间复杂度为O(1)
2.6 查找操作
// 查找元素,返回第一个匹配节点的指针
LT* LTFind(LT* phead, LTDataType num)
{
LT* pcur = phead->next; // 从第一个有效节点开始查找
while(pcur != phead) // 遍历整个链表
{
if(pcur->data == num) // 找到匹配节点
{
return pcur;
}
pcur = pcur->next; // 移动到下一个节点
}
return NULL; // 未找到匹配节点
}
代码解析:
- 遍历整个链表查找指定元素
- 找到则返回节点指针,未找到则返回NULL
- 时间复杂度为O(n),n为链表长度
2.7 指定位置操作
2.7.1 在指定位置后插入
// 在pos位置之后插入数据
void LTInsert(LT* pos, LTDataType num)
{
assert(pos); // 确保pos位置有效
LT* newnode = LTBuyNode(num); // 创建新节点
// 设置新节点的指针
newnode->next = pos->next;
newnode->prev = pos;
// 更新原有节点的指针
pos->next->prev = newnode;
pos->next = newnode;
}
代码解析:
- 这是一个通用的插入函数,可以在任意有效位置后插入新节点
- 头插操作LTPushFront其实可以通过LTInsert(phead, num)实现
- 体现了代码复用的思想
2.7.2 删除指定位置节点
// 删除pos节点(禁止删除哨兵位)
void LTErase(LT* pos)
{
assert(pos);
// 断言确保不是删除哨兵位(哨兵位的prev和next都指向自身)
assert(pos->prev != pos);
LT* next = pos->next; // 记录pos的后继节点
// 建立pos前后节点的直接链接
next->prev = pos->prev;
pos->prev->next = next;
free(pos); // 释放内存
pos = NULL; // 避免野指针
}
代码解析:
- 可以删除任意有效节点(除了哨兵位)
- 头删操作LTPopFront可以通过LTErase(phead->next)实现
- 尾删操作LTPopBack可以通过LTErase(phead->prev)实现
2.8 销毁链表
// 销毁链表:释放所有节点内存
void LTDestroy(LT* phead)
{
assert(phead);
LT* pcur = phead->next;
while(pcur != phead) // 遍历所有节点
{
LT* next = pcur->next; // 先记录下一个节点
free(pcur); // 释放当前节点
pcur = next; // 移动到下一个节点
}
free(phead); // 释放哨兵位节点
// phead = NULL; 此处无效,因为是形参,需要外部手动置空
}
代码解析:
- 遍历所有节点并释放内存,包括哨兵位节点
- 注意需要先记录下一个节点,再释放当前节点
- 函数内部对phead置空无效,需要调用者在外部手动置空
3. 测试文件 test.c
测试是保证代码正确性的重要环节,我们编写测试用例验证各个功能。
#include "List.h"
// 测试头插和头删功能
void ListTest01()
{
LT* phead = LTInit(); // 初始化链表
// 头插测试
LTPushFront(phead, 1);
LTPrint(phead); // 预期:链表:1->phead
LTPushFront(phead, 2);
LTPrint(phead); // 预期:链表:2->1->phead
LTPushFront(phead, 3);
LTPrint(phead); // 预期:链表:3->2->1->phead
LTPushFront(phead, 4);
LTPrint(phead); // 预期:链表:4->3->2->1->phead
// 头删测试
LTPopFront(phead);
LTPopFront(phead);
LTPrint(phead); // 预期:链表:2->1->phead
LTPopFront(phead);
LTPopFront(phead);
// 此时链表为空
LTDestroy(phead); // 销毁链表
phead = NULL; // 外部手动置空
}
// 测试尾插、尾删、查找、插入和删除指定节点功能
void ListTest02()
{
LT* phead = LTInit(); // 初始化链表
// 尾插测试
LTPushBack(phead, 1);
LTPushBack(phead, 2);
LTPushBack(phead, 3);
LTPushBack(phead, 4);
LTPrint(phead); // 预期:链表:1->2->3->4->phead
// 尾删测试(暂时注释,测试其他功能)
// LTPopBack(phead);
// LTPopBack(phead);
// LTPrint(phead); // 预期:链表:1->2->phead
// LTPopBack(phead);
// LTPopBack(phead);
// 查找测试
LT* find1 = LTFind(phead, 3);
if(find1 == NULL)
{
printf("查找失败 !\\n");
}
else
{
printf("查找成功\\n"); // 预期:查找成功
}
// 在找到的位置后插入
LTInsert(find1, 11);
LTPrint(phead); // 预期:链表:1->2->3->11->4->phead
// 在第一个节点后插入
LT* find2 = LTFind(phead, 1);
LTInsert(find2, 11);
LTPrint(phead); // 预期:链表:1->11->2->3->11->4->phead
// 删除指定节点测试
LT* find3 = LTFind(phead, 1);
LTErase(find3);
LTPrint(phead); // 预期:链表:11->2->3->11->4->phead
LTDestroy(phead); // 销毁链表
phead = NULL; // 外部手动置空
}
int main()
{
// ListTest01();
// printf("\\n");
ListTest02();
return 0;
}
代码解析:
- 测试代码采用模块化设计,每个测试函数专注于测试特定功能
- ListTest01主要测试头插和头删操作
- ListTest02主要测试尾插、查找、指定位置插入和删除等操作
- 测试时可以通过注释切换不同的测试用例
- 每个操作后都打印链表状态,便于观察结果是否符合预期
重要知识点总结
1. 哨兵位的作用
统一操作 | 使空链表和非空链表的操作保持一致,无需额外判断 |
简化边界条件 | 插入和删除操作不需要特殊处理头节点和尾节点 |
提高代码可读性 | 使代码逻辑更清晰,减少条件判断 |
2. 双向循环链表的特点
- 每个节点有两个指针,分别指向直接前驱和直接后继
- 尾节点的next指针指向头节点(哨兵位)
- 头节点(哨兵位)的prev指针指向尾节点
- 可以从任意节点开始遍历整个链表
- 插入和删除操作效率高,时间复杂度为O(1)(已知位置情况下)
3. 与单向链表的对比
查找前驱节点 | O(1) | O(n) |
插入操作 | O(1)(已知位置) | O(1)(已知前驱位置) |
删除操作 | O(1)(已知位置) | O(n)(需先找到前驱) |
内存占用 | 较高(多一个指针) | 较低 |
实现复杂度 | 较高 | 较低 |
易错点分析
指针操作顺序错误
- 插入节点时,应先设置新节点的指针,再更新原有节点的指针
- 错误的操作顺序可能导致节点丢失
内存泄漏
- 删除节点后必须使用free释放内存
- 销毁链表时要确保所有节点(包括哨兵位)都被释放
对空链表的操作
- 进行删除操作前必须确保链表不为空
- 使用assert(phead->next != phead)进行检查
哨兵位的特殊处理
- 禁止删除哨兵位节点
- 哨兵位的数据域无实际意义,不应被访问或修改
销毁链表后未置空指针
- LTDestroy函数内部无法将外部指针置空
- 调用者必须在外部手动将指针置空,避免野指针
循环遍历的结束条件
- 正确的结束条件是pcur != phead
- 错误使用pcur != NULL会导致无限循环
总结
双向循环链表(带哨兵位)是一种功能强大的数据结构,通过合理的设计可以实现高效的插入、删除和查找操作。本文详细解析了其实现原理,包括节点结构、初始化、插入、删除、查找和销毁等操作,并通过测试用例验证了代码的正确性。
掌握双向循环链表不仅有助于理解其他更复杂的数据结构,也能在实际开发中灵活运用,提高程序效率。希望本文能帮助读者深入理解这一重要的数据结构,并在实践中正确应用。
扩展思考
这些问题可以帮助读者进一步深化对双向链表的理解和应用。
评论前必须登录!
注册