2.3双向链表
2.3.1 双向链表定义
双向链表(Doubly Linked List)是一种更复杂的链式数据结构,它的每个节点都包含两个指针,分别指向直接前驱节点和直接后继节点。这种双向连接的特性使得链表可以双向遍历,解决了单链表只能单向遍历的限制。
核心 结构对比
特性对比 | 单向链表 | 双向链表 |
---|---|---|
指针 数量 | 1个(next) | 2个(prev + next) |
遍历 方向 | 只能从头到尾 | 双向遍历(前向+后向) |
前驱 访问 | O(n)时间复杂度 | O(1)时间复杂度 |
空间 开销 | 较小 | 较大(多一个指针) |
操作 复杂度 | 相对简单 | 稍复杂(需维护两个指针) |
2.3.2 双向链表的设计与实现
节点结构定义:
typedef int ELEM_TYPE;
typedef struct ListNode {
ELEM_TYPE val;
ListNode* prior;
ListNode* next;
} ListNode;
typedef struct LinkList { //头结点
ListNode* head;
int cursize;
} LinkList;
代码 解读:
- 每个节点包含三个部分:数据域、前驱指针(prior)、后继指针(next)
- Li nkList 结构体封装头指针和大小信息,便于管理
- 循环 特性:尾节点的next指向头节点,头节点的prior指向尾节点
- 带头 节点:头节点不存储有效数据,作为哨兵节点简化操作
-
初始化函数 ( In itList )
void InitList(LinkList* plist) {
assert(plist != NULL);
ListNode* p = buynode();
if (p == NULL) return;
p->val = 0;
p->prior = p; // 前驱指向自己
p->next = p; // 后继指向自己
plist->head = p;
plist->cursize = 0;
}
关键 点:
- 创建头节点并建立自环 结构: p- >prior = p; p->next = p;
- 空的双向循环链表就是头节点自己指向自己
- 这种设计使得插入和删除操作的边界条件处理统一
-
节点创建函数 ( bu ynode )
ListNode* buynode(ELEM_TYPE val) {
ListNode* p = (ListNode*)malloc(sizeof(ListNode));
if (p == NULL) return NULL;
p->val = val;
p->prior = NULL;
p->next = NULL;
return p;
}
-
按位置查找节点 ( Fi ndPos )
ListNode* FindPos(const LinkList* plist, int pos) {
assert(plist != NULL);
if (pos < 0 || pos > plist->cursize || Is_Empty(plist)) {
printf("位置不符或链表为空\n");
return NULL;
}
ListNode* p = plist->head;
while (pos--) {
p = p->next;
}
return p;
}
关键 点:
- 位置约定:pos=0返回头节点,pos=1返回第一个数据节点
- 循环遍历直到找到目标位置
- 时间复杂度O(n)
- 插入操作函数群
在指定节点后插入 ( In sertNext )
bool InsertNext(LinkList* plist, ListNode* ptr, ELEM_TYPE val) {
assert(plist != NULL);
if (ptr == NULL) { return false; }
ListNode* p = buynode();
if (p == NULL) return false;
p->val = val;
// 关键指针操作
p->prior = ptr;
p->next = ptr->next;
ptr->next = p;
p->next->prior = p; // 原ptr->next节点的前驱指向新节点
// 如果插入在尾节点后,需要更新头节点的前驱指向
if (Is_Empty(plist) || (ptr == plist->head->prior)) {
plist->head->prior = p;
}
plist->cursize++;
return true;
}
图解 插入过程:
插入前: A <--> C
在A后插入B: A <--> B <--> C
步骤:
1. B->prior = A
2. B->next = A->next (即C)
3. A->next = B
4. C->prior = B (即B->next->prior = B)
在指定节点前插入 ( In sertPrev )
bool InsertPrev(LinkList* plist, ListNode* ptr, ELEM_TYPE val) {
assert(plist != NULL);
ListNode* newNode = buynode();
newNode->val = val;
newNode->next = ptr;
newNode->prior = ptr->prior;
ptr->prior = newNode;
newNode->prior->next = newNode; // 原ptr->prior节点的后继指向新节点
plist->cursize++;
return true;
}
关键 点:双向链表的优势体现,前插操作也是O(1)时间复杂度
头插法和尾插法
// 头插法:在头节点后插入
bool Push_Front(LinkList* plist, ELEM_TYPE val) {
assert(plist != NULL);
return InsertNext(plist, plist->head, val);
}
// 尾插法:在尾节点后插入
bool Push_Back(LinkList* plist, ELEM_TYPE val) {
assert(plist != NULL);
ListNode* tail = plist->head->prior; // 直接获取尾节点
return InsertNext(plist, tail, val);
}
关键 点:
- 头插法和尾插法的时间复杂度都是O( 1)
- 双向循环链表的尾节点可以通过 he ad->prior 直接获得,无需遍历
- 删除操作函数群
删除指定节点的后继节点 ( De lNext )
bool DelNext(LinkList* plist, ListNode* ptr) {
assert(plist != NULL);
if (plist->cursize <= 0) return false;
if (plist == NULL || ptr == NULL) return false;
ListNode* p = ptr->next;
ptr->next = p->next;
p->next->prior = ptr;
// 如果删除的是尾节点,需要更新头节点的前驱指向
if (p->next == plist->head) {
plist->head->prior = ptr;
}
free(p);
p = NULL;
plist->cursize--;
return true;
}
图解 删除过程:
删除前: A <--> B <--> C
删除B: A <--> C
步骤:
1. A->next = B->next (即C)
2. C->prior = B->prior (即A)
3. free(B)
头删法和尾删法
// 头删法:删除头节点后的第一个节点
bool Pop_Front(LinkList* plist) {
return DelNext(plist, plist->head);
}
// 尾删法:删除尾节点
bool Pop_Back(LinkList* plist) {
assert(plist != NULL);
// 删除尾节点等价于删除尾节点的前驱节点的后继
return DelNext(plist, plist->head->prior->prior);
}
关键 点:
- 头删法和尾删法的时间复杂度都是O( 1)
- 尾删法通过 he ad->prior->prior 直接找到倒数第二个节点
- 查找函数
按值查找 ( Fi ndValue )
ListNode* FindValue(const LinkList* plist, ELEM_TYPE val) {
assert(plist != NULL);
ListNode* p = plist->head->next;
// 循环遍历,遇到头节点说明遍历完成
while (p != plist->head) {
if (p->val == val) return p;
p = p->next;
}
return NULL;
}
关键 点:
- 遍历的终止条件是 p != plist->head (不是NULL)
- 时间复杂度O(n)
- 清空与销毁函数
清空链表 ( Cl earList )
void ClearList(LinkList* plist) {
assert(plist != NULL);
ListNode* p = plist->head->next;
while (p != plist->head) {
ListNode* n = p;
p = p->next;
free(n);
n = NULL;
}
// 恢复头节点的自环状态
plist->head->prior = plist->head;
plist->head->next = plist->head;
plist->cursize = 0;
}
销毁链表 ( De stroyList )
void DestroyList(LinkList* plist) {
assert(plist != NULL);
ListNode* p = plist->head->next;
// 先释放所有数据节点
while (p != plist->head) {
ListNode* n = p;
p = p->next;
free(n);
n = NULL;
}
// 再释放头节点
free(plist->head);
plist->head = NULL;
plist->cursize = 0;
}
2.3.3 双向循环链表的优势总结
操作 | 时间复杂度 | 关键要点 |
---|---|---|
初始化 | O(1) | 创建头节点并建立自环 |
插入操作 | O(1) | 需要维护两个方向的指针 |
删除操作 | O(1) | 需要维护两个方向的指针 |
按值查找 | O(n) | 需要遍历整个链表 |
头尾操作 | O(1) | 双向循环链表的优势体现 |
核心 优势
- 双向 遍历能力:支持前向和后向遍历,灵活性极高
- 操作 效率高:插入、删除、头尾操作都是O(1)时间复杂度
- 边界 统一:循环结构使得头尾操作逻辑统一,代码简洁
- 空间 利用率:相对于性能提升,额外的指针开销是可接受的
适用 场景
- 需要频繁在链表两端进行插入删除的操作
- 需要双向遍历的应用程序(如浏览器历史记录)
- 实现双向队列(Deque)等高级数据结构
- 需要循环缓冲区的场景