📚1、 引言:链表的进化之路
在掌握单向链表之后,我们迎来了链表的进阶形态------双向链表和双向循环链表。这三种数据结构虽然血脉相连,但在实现细节和应用场景上各有千秋。本文将带你深入探索这些数据结构的精髓,通过对比分析,让你不仅知其然,更知其所以然。
核心价值:双向结构带来的不仅是技术上的优化,更是思维方式的转变。从前向后遍历已成常态,从后向前遍历则是新的可能。
2. 双向链表
2.1 结构体节点定义
cs
typedef int DataType;
typedef struct ListNode {
DataType val; // 节点数据
struct ListNode *prev; // 指向前驱节点
struct ListNode *next; // 指向后继节点
} ListNode;
设计哲学:
-
对称性:每个节点都有两个方向的连接,这是双向链表的核心特征
-
双向导航:既可以从头到尾遍历,也可以从尾到头回溯
-
空间换时间:多一个指针的开销,换来操作的灵活性
应用思考:在需要频繁前后查找的场景中,双向链表的优势尤为明显。比如浏览器的前进后退功能,音乐播放器的上一曲下一曲等。
2.2 哑节点(头节点)创建
cs
ListNode *CreateDummyNode(void) {
ListNode *dummy = (ListNode *)malloc(sizeof(ListNode));
if (dummy == NULL) {
perror("malloc dummy failed");
return NULL;
}
dummy->val = 0; // 头节点数据域通常不使用或用于存储长度
dummy->prev = NULL; // 前驱指向空
dummy->next = NULL; // 后继指向空
return dummy;
}
设计要点:
-
哨兵节点:头节点不存储实际数据,简化边界处理
-
清晰边界 :
prev和next都初始化为NULL,明确标识链表的空状态 -
内存安全:严格的错误检查,防止内存分配失败导致的程序崩溃
编程思想:好的数据结构设计从清晰的初始化开始。一个明确的起点,为后续所有操作奠定基础。
2.3 头插法
cs
int InsertHeadNode(ListNode *dummy, DataType val) {
ListNode *newNode = (ListNode *)malloc(sizeof(ListNode));
if (newNode == NULL) {
perror("malloc newNode failed");
return -1;
}
newNode->val = val;
newNode->next = dummy->next;
newNode->prev = dummy;
// 如果原链表非空,需更新后继节点的前驱指针
if (dummy->next != NULL) {
dummy->next->prev = newNode;
}
dummy->next = newNode;
return 0;
}
插入过程解析:
初始状态: dummy ⇄ NULL (空链表)
插入节点A:dummy ⇄ A ⇄ NULL
↑______↑
插入节点B:dummy ⇄ B ⇄ A ⇄ NULL
↑______↑ ↑____↑
关键操作解析:
-
顺序的重要性:先设置新节点的指针,再修改已有节点的指针
-
边界判断:只有在原链表非空时才需要更新原头部的前驱指针
-
原子性思维:每个指针操作都要考虑其对整体结构的影响
时间复杂度:O(1),无论链表多长,头插法都只需要常数时间
2.4 指定值删除(删除首个匹配节点)
cs
int DeleteNode(ListNode *dummy, DataType val) {
ListNode *cur = dummy->next;
// 查找目标节点
while (cur != NULL && cur->val != val) {
cur = cur->next;
}
if (cur == NULL) {
printf("未找到值为 %d 的节点\n", val);
return -1;
}
// 调整前后节点的指针
cur->prev->next = cur->next;
if (cur->next != NULL) {
cur->next->prev = cur->prev;
}
free(cur);
return 0;
}
⚠️ 重要注意事项
在双向链表的插入和删除操作中,必须特别注意边界节点的处理:
当操作涉及链表尾部的节点时,next 指针可能为 NULL
若直接访问 NULL 指针的成员,将导致段错误
因此,在执行 cur->next->prev 或类似操作前,务必检查指针是否有效
3. 双向循环链表
3.1 与双向链表的区别
双向循环链表的尾节点后继指向头节点,头节点的前驱指向尾节点,形成一个闭环。
3.2 哑节点创建
cs
ListNode *CreateDummyNode(void) {
ListNode *dummy = (ListNode *)malloc(sizeof(ListNode));
if (dummy == NULL) {
perror("malloc dummy failed");
return NULL;
}
dummy->val = 0;
dummy->prev = dummy; // 指向自身
dummy->next = dummy; // 指向自身
return dummy;
}
**关键区别:**初始时 prev 和 next 均指向自身,形成自环。
3.3 头插法
cs
int InsertHeadNode(ListNode *dummy, DataType val) {
ListNode *newNode = (ListNode *)malloc(sizeof(ListNode));
if (newNode == NULL) {
perror("malloc newNode failed");
return -1;
}
newNode->val = val;
newNode->next = dummy->next;
newNode->prev = dummy;
// 由于是循环链表,dummy->next 永远不会为 NULL
newNode->next->prev = newNode;
newNode->prev->next = newNode;
return 0;
}
3.4 尾插法
cs
int InsertTailNode(ListNode *dummy, DataType val) {
ListNode *newNode = (ListNode *)malloc(sizeof(ListNode));
if (newNode == NULL) {
perror("malloc newNode failed");
return -1;
}
newNode->val = val;
newNode->next = dummy;
newNode->prev = dummy->prev;
// 对称更新相邻节点的指针
newNode->next->prev = newNode;
newNode->prev->next = newNode;
return 0;
}
3.5 遍历与结束条件
循环链表的遍历结束条件不再是判断 NULL,而是判断是否回到头节点:
cs
ListNode *cur = dummy->next;
while (cur != dummy) { // 回到起点时结束
// 处理当前节点
printf("%d ", cur->val);
cur = cur->next;
}
这一原则同样适用于删除、销毁等操作。
实际应用:双向循环链表因其操作的对称性和遍历的便利性,在实现缓存、轮询调度等场景中应用广泛。
4. 数组与链表的对比
| 特性 | 数组 | 链表 |
|---|---|---|
| 存储空间 | 连续 | 可以不连续 |
| 容量限制 | 大小固定 | 动态扩展,理论上无上限 |
| 插入/删除效率 | 较低(需移动元素) | 较高(仅修改指针) |
| 访问效率 | O(1) 随机访问 | O(n) 顺序访问 |
| 内存利用率 | 可能浪费或不足 | 按需分配,但需额外指针空间 |
选择建议:
-
需要频繁随机访问 → 数组
-
需要频繁插入/删除 → 链表
-
数据规模变化大 → 链表
-
内存空间紧张 → 数组(指针开销更小)
5. 总结
本文详细介绍了双向链表和双向循环链表的实现原理与关键操作,并对比了数组与链表的优缺点。掌握这些基础数据结构对于后续学习栈、队列等更复杂的抽象数据类型至关重要。
下篇预告 :我们将进入数据结构之栈的学习,探讨其LIFO(后进先出)特性及实际应用。
如有疑问或发现错误,欢迎在评论区留言讨论!