一、什么是带头双向循环链表?
1.1 核心概念拆解
我们把名字拆成三部分理解:
- 双向 :每个节点有
prev(前驱指针)和next(后继指针),支持正反双向遍历 - 带头 :链表自带一个哨兵头节点,它不存储有效数据,仅作为链表的 "锚点" 存在
- 循环 :尾节点的
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 初始化链表(创建哨兵头节点)
这是带头链表特有的操作:创建哨兵头节点,让它的prev和next都指向自己,形成初始的空循环链表。
cs
// 初始化带头双向循环链表,返回哨兵头节点指针
DListNode* DListInit()
{
// 创建哨兵头节点,数据域无意义,随便赋值
DListNode* phead = CreateNode(0);
// 空循环链表:头节点自己指向自己
phead->prev = phead;
phead->next = phead;
return phead;
}
初始化后,
phead->next == phead且phead->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");
}