
前言
上一篇讲了单链表,单链表虽然解决了顺序表头部插入删除效率低的问题,但它自身也有痛点:不支持逆向遍历,尾插尾删效率是O(N),很多操作都要先找前驱节点。为了解决这些问题,带头双向循环链表 登场了------它是链表结构中最复杂,但实际工程中最常用的一种,C++ STL中的list底层就是它。本文将系统讲解它的结构与实现。

一、什么是双链表
双链表 (Doubly Linked List)的每个节点除了数据域 ,还有两个指针域 :一个指向前一个节点(prev),一个指向后一个节点(next)。
本文实现的是带头双向循环链表,它有三个关键特征:
- 带头:有一个不存储有效数据的哨兵头节点(哨兵位),头节点永远存在,即使链表为空;
- 双向:每个节点既能找到前驱,也能找到后继;
- 循环 :最后一个节点的
next指向头节点,头节点的prev指向最后一个节点,形成一个环。

带头双向循环链表看起来结构复杂,但正因为头节点永远存在,所以插入删除时不需要对"链表是否为空"做特殊判断,代码反而比单链表更简单统一,这是它的一大优势。
二、双链表的结构定义
cpp
typedef int LTDataType;
typedef struct ListNode {
LTDataType data; // 数据域
struct ListNode* prev; // 指向前一个节点
struct ListNode* next; // 指向后一个节点
} ListNode;
由于是带头循环结构,整个链表只需要一个头节点指针 即可代表,不再需要像单链表那样用二级指针传参。
三、双链表的基本操作
3.1 创建新节点
cpp
ListNode* BuyListNode(LTDataType x) {
ListNode* newNode = (ListNode*)malloc(sizeof(ListNode));
if (newNode == NULL) {
perror("malloc fail");
exit(-1);
}
newNode->data = x;
newNode->prev = NULL;
newNode->next = NULL;
return newNode;
}
3.2 初始化(创建带头节点的空链表)
cpp
ListNode* ListInit() {
ListNode* head = BuyListNode(0); // 哨兵位,data值无意义
head->next = head;
head->prev = head;
return head;
}
3.3 头插

cpp
void ListPushFront(ListNode* phead, LTDataType x) {
assert(phead != NULL);
ListNode* newNode = BuyListNode(x);
ListNode* first = phead->next;
// 新节点插入头节点和原第一个节点之间
phead->next = newNode;
newNode->prev = phead;
newNode->next = first;
first->prev = newNode;
}
3.4 头删

cpp
void ListPopFront(ListNode* phead) {
assert(phead != NULL);
assert(phead->next != phead); // 链表不能为空
ListNode* first = phead->next;
ListNode* second = first->next;
phead->next = second;
second->prev = phead;
free(first);
}
3.5 尾插

得益于循环结构,phead->prev永远指向最后一个节点 ,所以尾插不需要遍历,直接是O(1)------这是双链表相比单链表最大的效率提升。
cpp
void ListPushBack(ListNode* phead, LTDataType x) {
assert(phead != NULL);
ListNode* newNode = BuyListNode(x);
ListNode* last = phead->prev;
last->next = newNode;
newNode->prev = last;
newNode->next = phead;
phead->prev = newNode;
}
3.6 尾删

cpp
void ListPopBack(ListNode* phead) {
assert(phead != NULL);
assert(phead->next != phead);
ListNode* last = phead->prev;
ListNode* newLast = last->prev;
newLast->next = phead;
phead->prev = newLast;
free(last);
}
3.7 查找
cpp
ListNode* ListFind(ListNode* phead, LTDataType x) {
assert(phead != NULL);
ListNode* cur = phead->next;
while (cur != phead) {
if (cur->data == x) {
return cur;
}
cur = cur->next;
}
return NULL; // 没找到
}
3.8 在指定位置之前/之后插入
有了prev指针,双链表可以在O(1)时间内完成任意已知位置的插入,不再需要像单链表那样遍历找前驱。
cpp
// 在pos之前插入x(O(1))
void ListInsert(ListNode* pos, LTDataType x) {
assert(pos != NULL);
ListNode* newNode = BuyListNode(x);
ListNode* prev = pos->prev;
prev->next = newNode;
newNode->prev = prev;
newNode->next = pos;
pos->prev = newNode;
}
// 在pos之后插入x(O(1))
void ListInsertAfter(ListNode* pos, LTDataType x) {
assert(pos != NULL);
ListNode* newNode = BuyListNode(x);
ListNode* next = pos->next;
pos->next = newNode;
newNode->prev = pos;
newNode->next = next;
next->prev = newNode;
}
3.9 删除指定位置节点
cpp
void ListErase(ListNode* pos) {
assert(pos != NULL);
ListNode* prev = pos->prev;
ListNode* next = pos->next;
prev->next = next;
next->prev = prev;
free(pos);
}
3.10 打印链表
cpp
void ListPrint(ListNode* phead) {
assert(phead != NULL);
printf("head <-> ");
ListNode* cur = phead->next;
while (cur != phead) {
printf("%d <-> ", cur->data);
cur = cur->next;
}
printf("head\n");
}
3.11 销毁链表
cpp
void ListDestroy(ListNode* phead) {
assert(phead != NULL);
ListNode* cur = phead->next;
while (cur != phead) {
ListNode* next = cur->next;
free(cur);
cur = next;
}
free(phead); // 别忘了释放头节点自己
}
四、完整测试代码
cpp
int main() {
ListNode* plist = ListInit();
ListPushBack(plist, 1);
ListPushBack(plist, 2);
ListPushBack(plist, 3);
ListPrint(plist); // head <-> 1 <-> 2 <-> 3 <-> head
ListPushFront(plist, 0);
ListPrint(plist); // head <-> 0 <-> 1 <-> 2 <-> 3 <-> head
ListNode* pos = ListFind(plist, 2);
if (pos) {
ListInsert(pos, 100);
}
ListPrint(plist); // head <-> 0 <-> 1 <-> 100 <-> 2 <-> 3 <-> head
ListPopFront(plist);
ListPopBack(plist);
ListPrint(plist); // head <-> 1 <-> 100 <-> 2 <-> head
ListDestroy(plist);
return 0;
}
五、时间复杂度分析
| 操作 | 时间复杂度 | 说明 |
|---|---|---|
| 头插/头删 | O(1) | 直接操作头节点 |
| 尾插/尾删 | O(1) | 有prev指针,无需遍历 |
| 指定位置插入/删除 | O(1) | 已知位置即可直接操作 |
| 查找 | O(N) | 仍需遍历 |
| 随机访问(下标) | O(N) | 不支持真正的随机访问 |
可以看到,除了查找和随机访问,双链表的增删操作全部是O(1),这是它相比单链表和顺序表最大的优势。
六、双链表 vs 单链表 vs 顺序表
| 特性 | 顺序表 | 单链表 | 双链表(带头循环) |
|---|---|---|---|
| 存储方式 | 物理地址连续 | 物理地址不连续 | 物理地址不连续 |
| 随机访问 | O(1) | O(N) | O(N) |
| 头部插入删除 | O(N) | O(1) | O(1) |
| 尾部插入删除 | O(1)(均摊) | O(N) | O(1) |
| 任意位置插入删除 | O(N) | O(N)(需找前驱) | O(1)(已知位置) |
| 逆向遍历 | 支持 | 不支持 | 支持 |
| 空指针判断 | 不需要 | 需要频繁判断 | 头节点恒存在,几乎不需要 |
| 空间开销 | 可能有扩容冗余 | 一个指针 | 两个指针 |
可以看出,双链表几乎在所有增删操作上都做到了O(1) ,代价是每个节点多了一个prev指针的空间开销(空间换时间) ,以及实现相对更复杂。这也是为什么STL选择用带头双向循环链表实现list------用少量的额外空间换取了全方位的高效增删。
七、总结
双链表,尤其是带头双向循环链表,是链表结构的"完全体":
- 因为带头,插入删除不用特判链表是否为空;
- 因为双向,可以O(1)找到任意节点的前驱,也支持逆向遍历;
- 因为循环 ,
phead->prev天然就是尾节点,尾插尾删也能做到O(1)。
理解了双链表的实现原理,再回头看STL的list、unordered_map的哈希桶等结构会更加得心应手。
链表和顺序表是线性表的两种典型实现方式,二者各有优劣,没有绝对的孰优孰劣,需要根据实际的业务场景(是否频繁随机访问、是否频繁增删、数据规模是否已知)来做选择。理解透单链表的指针操作,尤其是二级指针的使用、边界条件的处理,是后续学习双向链表、栈、队列乃至STL中list容器的重要基础。
如果这篇文章对你有帮助,欢迎点赞收藏,后续会继续更新栈、队列、二叉树等数据结构内容!