C语言双向循环链表实现详解:哨兵位与循环结构

前言

双向链表是链表数据结构的重要变体,相比单向链表,每个节点都包含指向前驱和后继的指针,这使得双向链表在插入、删除操作上更加高效。本文将详细分析一个带哨兵位的双向循环链表实现,探讨其设计思想、核心操作和实际应用。

目录

前言

正文

链表结构设计

核心功能实现

[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;
}

操作流程:

  1. 新节点的prev指向原尾节点

  2. 新节点的next指向哨兵位

  3. 原尾节点的next指向新节点

  4. 哨兵位的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;
}

测试亮点:

  • 验证了插入、删除操作的顺序正确性

  • 测试了边界情况(查找不存在的元素)

  • 展示了链表的动态变化过程

  • 确保资源正确释放

总结

双向循环链表的优势

  1. 时间复杂度优化

    • 头插尾插:O(1)

    • 头删尾删:O(1)

    • 指定位置插入删除:O(1)

    • 相比单向链表有显著性能提升

  2. 哨兵位设计的优点

    • 统一了空链表和非空链表的处理逻辑

    • 简化了边界条件判断

    • 避免了头指针的特殊处理

  3. 循环结构的价值

    • 尾节点直接指向头节点,形成闭环

    • 便于实现循环遍历和环形数据结构

实现要点总结

  1. 指针操作的对称性:双向链表的插入删除操作具有完美的对称性,需要同时维护prev和next指针。

  2. 内存管理:每个malloc都需要对应的free,特别是在销毁链表时要遍历释放所有节点。

  3. 断言使用:合理使用assert进行参数校验,提高代码健壮性。

  4. 设计选择

    • 使用哨兵位简化操作

    • 采用循环结构提高效率

    • 提供多种初始化方式增加灵活性

实际应用场景

双向循环链表特别适用于:

  • 需要频繁在两端进行插入删除的场景

  • 实现双向队列(deque)

  • 浏览器历史记录管理

  • 撤销重做功能实现

  • LRU缓存淘汰算法

这个实现展示了高质量的数据结构编码实践,通过巧妙的设计大幅提升了链表操作的效率和代码的简洁性,是学习数据结构与算法的优秀范例。

相关推荐
wljun73937 分钟前
五、OrcaSlicer 切片
算法·切片软件 orcaslicer
小帅学编程38 分钟前
Java基础
java·开发语言
思密吗喽38 分钟前
如何完全清除Node.js环境重装 Node.js彻底卸载指南
java·开发语言·node.js·毕业设计·课程设计
bcbnb42 分钟前
Charles抓包怎么用 Charles抓包工具详细教程、网络调试方法、HTTPS配置与手机抓包实战
后端
咬_咬43 分钟前
C++仿muduo库高并发服务器项目:EventLoop模块
服务器·c++·muduo·eventloop
杨福瑞44 分钟前
数据结构:栈
c语言·开发语言·数据结构
Bona Sun1 小时前
单片机手搓掌上游戏机(十九)—pico运行doom之硬件连接
c语言·c++·单片机·游戏机
罗湖老棍子1 小时前
宠物小精灵之收服(信息学奥赛一本通- P1292)
算法·动态规划·01背包
傲文博一1 小时前
为什么我的产品尽量不用「外置」动态链接库
前端·后端