双向链表专题:结构、实现与对比分析
双向链表是链表中结构最复杂但使用最广泛的一种。本文将深入讲解带头双向循环链表的结构特点,带您从零实现双向链表的核心接口(增删改查),并与顺序表进行全方位对比,分析各自的适用场景。
目录
- 一、双向链表的结构
- [1. 带头双向循环链表的定义](#1. 带头双向循环链表的定义)
- [2. 哨兵位的作用](#2. 哨兵位的作用)
- 二、实现双向链表
- [1. 节点结构与接口声明](#1. 节点结构与接口声明)
- [2. 核心接口实现](#2. 核心接口实现)
- 三、顺序表和双向链表的对比分析
一、双向链表的结构
1. 带头双向循环链表的定义
带头双向循环链表是链表中最复杂也最常用的一种结构。它具备三个特征:
- 带头:存在一个哨兵位节点(不存储有效数据)
- 双向 :每个节点既有指向下一个节点的指针
next,也有指向上一个节点的指针prev - 循环 :尾节点的
next指向哨兵位,哨兵位的prev指向尾节点
c
typedef struct ListNode {
struct ListNode* next; // 指向下一个节点
struct ListNode* prev; // 指向上一个节点
int data; // 数据域
} LTNode;
2. 哨兵位的作用
哨兵位节点不保存有效数据,只作为链表头尾的标志。它的存在使得:
- 空链表不再是
NULL,而是仅有一个哨兵位节点(其next和prev都指向自己) - 遍历链表时不会死循环,因为遇到哨兵位即表示遍历结束
- 插入删除操作无需考虑头尾特殊处理,逻辑统一
二、实现双向链表
1. 节点结构与接口声明
c
typedef int LTDataType;
typedef struct ListNode {
struct ListNode* next;
struct ListNode* prev;
LTDataType data;
} LTNode;
// 创建哨兵位(初始化链表)
LTNode* LTInit();
// 销毁链表
void LTDestroy(LTNode* phead);
// 打印链表
void LTPrint(LTNode* phead);
// 判断链表是否为空(仅含哨兵位)
bool LTEmpty(LTNode* phead);
// 尾插 / 尾删
void LTPushBack(LTNode* phead, LTDataType x);
void LTPopBack(LTNode* phead);
// 头插 / 头删
void LTPushFront(LTNode* phead, LTDataType x);
void LTPopFront(LTNode* phead);
// 在 pos 位置之后插入
void LTInsert(LTNode* pos, LTDataType x);
// 删除 pos 位置节点
void LTErase(LTNode* pos);
// 查找值为 x 的节点
LTNode* LTFind(LTNode* phead, LTDataType x);
注意 :所有接口参数均为哨兵位指针 phead,无需二级指针,因为哨兵位永远固定不变。
2. 核心接口实现
创建哨兵位(初始化)
c
LTNode* LTInit() {
LTNode* phead = (LTNode*)malloc(sizeof(LTNode));
if (phead == NULL) exit(-1);
phead->next = phead;
phead->prev = phead;
return phead;
}
创建新节点
c
LTNode* BuyLTNode(LTDataType x) {
LTNode* node = (LTNode*)malloc(sizeof(LTNode));
if (node == NULL) exit(-1);
node->data = x;
node->next = NULL;
node->prev = NULL;
return node;
}
尾插
c
void LTPushBack(LTNode* phead, LTDataType x) {
LTNode* newNode = BuyLTNode(x);
LTNode* tail = phead->prev; // 哨兵位的 prev 即尾节点
tail->next = newNode;
newNode->prev = tail;
newNode->next = phead;
phead->prev = newNode;
}
头插
c
void LTPushFront(LTNode* phead, LTDataType x) {
LTNode* newNode = BuyLTNode(x);
LTNode* first = phead->next;
phead->next = newNode;
newNode->prev = phead;
newNode->next = first;
first->prev = newNode;
}
尾删
c
void LTPopBack(LTNode* phead) {
assert(!LTEmpty(phead)); // 不能删除哨兵位
LTNode* tail = phead->prev;
LTNode* prev = tail->prev;
prev->next = phead;
phead->prev = prev;
free(tail);
}
头删
c
void LTPopFront(LTNode* phead) {
assert(!LTEmpty(phead));
LTNode* first = phead->next;
LTNode* second = first->next;
phead->next = second;
second->prev = phead;
free(first);
}
在 pos 之后插入
c
void LTInsert(LTNode* pos, LTDataType x) {
assert(pos);
LTNode* newNode = BuyLTNode(x);
LTNode* next = pos->next;
pos->next = newNode;
newNode->prev = pos;
newNode->next = next;
next->prev = newNode;
}
删除 pos 节点
c
void LTErase(LTNode* pos) {
assert(pos);
LTNode* prev = pos->prev;
LTNode* next = pos->next;
prev->next = next;
next->prev = prev;
free(pos);
}
打印链表
c
void LTPrint(LTNode* phead) {
LTNode* cur = phead->next;
printf("哨兵位 <-> ");
while (cur != phead) {
printf("%d <-> ", cur->data);
cur = cur->next;
}
printf("哨兵位\n");
}
判断空链表
c
bool LTEmpty(LTNode* phead) {
return phead->next == phead;
}
销毁链表
c
void LTDestroy(LTNode* phead) {
LTNode* cur = phead->next;
while (cur != phead) {
LTNode* next = cur->next;
free(cur);
cur = next;
}
free(phead);
}

三、顺序表和双向链表的对比分析
| 不同点 | 顺序表 | 双向链表(单链表类似) |
|---|---|---|
| 存储空间 | 物理上一定连续 | 逻辑上连续,物理上不一定连续 |
| 随机访问 | 支持 O(1) | 不支持,O(N) |
| 任意位置插入删除 | 需要搬移元素,效率低 O(N) | 只需修改指针指向 O(1) |
| 容量管理 | 需要扩容,可能浪费空间或扩容耗时 | 无容量概念,按需分配节点 |
| 缓存友好性 | 好(空间局部性强) | 差(节点分散,缓存命中率低) |
| 适用场景 | 元素高效存储 + 频繁随机访问 | 任意位置频繁插入删除 |
总结:没有绝对优劣,只有是否适合场景。顺序表适合静态数据、频繁查找;双向链表适合动态数据、频繁增删。实际开发中两者常配合使用。
总结:双向链表通过哨兵位和双向指针,实现了优雅的循环结构和统一的操作逻辑,解决了单链表在删除、反向遍历上的不便。与顺序表相比,双向链表在任意位置插入删除上具有天然优势,但牺牲了随机访问和缓存性能。掌握双向链表是理解更复杂数据结构(如LRU缓存、双向队列)的基础,建议读者动手实现所有接口,并与单链表、顺序表进行对比测试。