数据结构链表:带头双向循环链表

一、什么是带头双向循环链表?

1.1 核心概念拆解

我们把名字拆成三部分理解:

  1. 双向 :每个节点有prev(前驱指针)和next(后继指针),支持正反双向遍历
  2. 带头 :链表自带一个哨兵头节点,它不存储有效数据,仅作为链表的 "锚点" 存在
  3. 循环 :尾节点的next指向哨兵头节点,哨兵头节点的prev指向尾节点,首尾相连形成闭环

1.2 结构示意图

空链表状态(只有哨兵头节点):

有有效节点的状态:

1.3 相比普通双向链表的优势

操作 普通双向链表 带头双向循环链表
尾插 / 尾删 需要遍历找尾,O (n) 通过头节点 prev 直接找尾,O (1)
空链表处理 需要特殊判断头指针 哨兵位始终存在,逻辑统一
头插 / 头删 需要更新头指针 头指针固定不变,无需修改
边界情况 头、尾、空链表三种特殊情况 所有位置操作逻辑完全一致

简单说:它用一个额外的哨兵节点,换来了代码逻辑的极度简洁,极大降低了出错概率。

二、结构体定义与基础函数

2.1 节点结构体定义

和普通双向链表的节点结构完全一致,区别仅在于使用方式。

cs 复制代码
#include <stdio.h>
#include <stdlib.h>
#include <assert.h>

// 数据类型,可按需修改为char、结构体等
typedef int DataType;

// 双向链表节点结构体
typedef struct DListNode
{
    DataType data;               // 数据域
    struct DListNode* prev;      // 指向前一个节点
    struct DListNode* next;      // 指向后一个节点
} DListNode;

2.2 创建新节点

所有插入操作的基础:申请内存、赋值、初始化指针为NULL

cs 复制代码
// 创建一个新的数据节点,返回节点指针
DListNode* CreateNode(DataType x)
{
    DListNode* newNode = (DListNode*)malloc(sizeof(DListNode));
    if (newNode == NULL)
    {
        perror("malloc failed"); // 打印内存分配失败原因
        exit(-1);
    }
    newNode->data = x;
    newNode->prev = NULL;
    newNode->next = NULL;
    return newNode;
}

2.3 初始化链表(创建哨兵头节点)

这是带头链表特有的操作:创建哨兵头节点,让它的prevnext都指向自己,形成初始的空循环链表。

cs 复制代码
// 初始化带头双向循环链表,返回哨兵头节点指针
DListNode* DListInit()
{
    // 创建哨兵头节点,数据域无意义,随便赋值
    DListNode* phead = CreateNode(0);
    // 空循环链表:头节点自己指向自己
    phead->prev = phead;
    phead->next = phead;
    return phead;
}

初始化后,phead->next == pheadphead->prev == phead,这就是空链表的判断条件。

三、核心操作逐行实现

3.1 打印链表(正向遍历)

遍历终止条件不再是NULL,而是回到哨兵头节点

cs 复制代码
// 正向打印链表
void DListPrint(DListNode* phead)
{
    assert(phead); // 断言:头节点不能为空
    DListNode* cur = phead->next; // 从第一个有效节点开始
    
    printf("链表内容:");
    while (cur != phead) // 回到头节点说明遍历完成
    {
        printf("%d ", cur->data);
        cur = cur->next;
    }
    printf("\n");
}

3.2 尾插法(末尾插入节点)

这是带头循环链表最爽的地方:不用遍历找尾phead->prev直接就是尾节点。

cs 复制代码
// 尾插:在链表末尾插入数据x
void DListPushBack(DListNode* phead, DataType x)
{
    assert(phead);
    DListNode* newNode = CreateNode(x);
    DListNode* tail = phead->prev; // 直接拿到尾节点

    // 建立双向连接:尾节点 <-> 新节点 <-> 头节点
    tail->next = newNode;
    newNode->prev = tail;
    newNode->next = phead;
    phead->prev = newNode;
}

无论链表是空还是有数据,这段代码完全通用,不需要任何 if 判断。

3.3 头插法(头部插入节点)

在哨兵头节点和第一个有效节点之间插入,逻辑同样统一。

cs 复制代码
// 头插:在链表开头插入数据x
void DListPushFront(DListNode* phead, DataType x)
{
    assert(phead);
    DListNode* newNode = CreateNode(x);
    DListNode* first = phead->next; // 第一个有效节点

    // 建立双向连接:头节点 <-> 新节点 <-> 原第一个节点
    phead->next = newNode;
    newNode->prev = phead;
    newNode->next = first;
    first->prev = newNode;
}

3.4 尾删法(删除最后一个节点)

同样直接通过phead->prev拿到尾节点,O (1) 时间复杂度。

cs 复制代码
// 尾删:删除链表最后一个节点
void DListPopBack(DListNode* phead)
{
    assert(phead);
    assert(phead->next != phead); // 断言:链表不能为空(不能删哨兵位)

    DListNode* tail = phead->prev;       // 要删除的尾节点
    DListNode* tailPrev = tail->prev;    // 尾节点的前一个节点

    // 重新连接:新尾节点 <-> 头节点
    tailPrev->next = phead;
    phead->prev = tailPrev;
    
    free(tail); // 释放原尾节点内存
}

3.5 头删法(删除第一个有效节点)

cs 复制代码
// 头删:删除链表第一个有效节点
void DListPopFront(DListNode* phead)
{
    assert(phead);
    assert(phead->next != phead); // 链表不能为空

    DListNode* first = phead->next;       // 要删除的第一个节点
    DListNode* second = first->next;      // 第二个节点

    // 重新连接:头节点 <-> 第二个节点
    phead->next = second;
    second->prev = phead;
    
    free(first);
}

3.6 查找节点(辅助函数)

按值查找节点,返回节点指针;通常配合插入、删除操作使用。

cs 复制代码
// 查找值为x的节点,找到返回节点指针,找不到返回NULL
DListNode* DListFind(DListNode* phead, DataType x)
{
    assert(phead);
    DListNode* cur = phead->next;
    
    while (cur != phead)
    {
        if (cur->data == x)
        {
            return cur;
        }
        cur = cur->next;
    }
    return NULL;
}

3.7 在 pos 位置之后插入数据

这里的pos节点指针(通常由 DListFind 或按位置查找得到)。这是双向链表的通用插入逻辑,在任意位置插入都用同一套代码。

cs 复制代码
// 在pos节点之后插入数据x
void DListInsertAfter(DListNode* pos, DataType x)
{
    assert(pos);
    DListNode* newNode = CreateNode(x);
    DListNode* posNext = pos->next; // 保存pos的下一个节点

    // 建立连接:pos <-> newNode <-> posNext
    pos->next = newNode;
    newNode->prev = pos;
    newNode->next = posNext;
    posNext->prev = newNode;
}

有了这个通用插入函数,头插和尾插都可以复用它:

  • 头插 = 在头节点之后插入
  • 尾插 = 在头节点的 prev(尾节点)之后插入

3.8 删除 pos 位置的节点

删除指定节点,不需要找前驱,直接通过pos->prev就能拿到。

cs 复制代码
// 删除pos位置的节点
void DListErase(DListNode* pos)
{
    assert(pos);
    assert(pos->prev != pos); // 不能删除哨兵头节点

    DListNode* prevNode = pos->prev;
    DListNode* nextNode = pos->next;

    // 前后节点直接相连
    prevNode->next = nextNode;
    nextNode->prev = prevNode;
    
    free(pos);
}

同理,头删和尾删也都可以复用这个函数:

  • 头删 = 删除头节点的 next
  • 尾删 = 删除头节点的 prev

3.9 销毁整个链表

释放所有节点内存,包括哨兵头节点。

cs 复制代码
// 销毁整个链表
void DListDestroy(DListNode* phead)
{
    assert(phead);
    DListNode* cur = phead->next;

    // 遍历释放所有有效节点
    while (cur != phead)
    {
        DListNode* next = cur->next; // 先保存下一个节点
        free(cur);
        cur = next;
    }
    
    free(phead); // 最后释放哨兵头节点
    printf("链表已销毁\n");
}
相关推荐
不灭的黄金瞳1231 小时前
编译与链接
c语言
我变成萤火虫7 小时前
河南萌新联赛2026第(四)场:南阳理工学院
数据结构·c++·算法·贪心算法·stl·动态规划
Shaoxi Zhang11 小时前
JAVA学习笔记035——对象和JSON格式
java·笔记·学习
陈皮波比茶12 小时前
Redis学习
数据库·redis·学习
minglie114 小时前
espidf的esp32版的webClient
学习
Nil20814 小时前
leetcode 138随机链表的复制
算法·leetcode·链表
学运维的Kysan14 小时前
暑假运维学习打卡第二十五天8.16
学习
键盘飞行员15 小时前
Flutter App 全套实战学习计划:从零搭建到 APK 部署
学习·flutter
疯狂打码的少年16 小时前
【数据结构】图的遍历:深度优先搜索(DFS)
数据结构·笔记·算法·深度优先