上一篇我们掌握了单向链表——每个结点只有一个 next 指针,指向后继。这种简洁带来一个痛点:只能向前走,不能回头。删尾部结点要遍历整条链;想在某个结点前插入,也得找到它的前驱。
如果能给每个结点加一个“回头的钩子”,指向它的前驱,双向穿梭就自由了。如果再让头尾相连,就形成了环。这就是今天的主角——双向链表与循环链表。
一、双向链表的结点定义
双向链表每个结点有两个指针:
next:指向后继(下一个结点)prev:指向先驱(上一个结点)
typedef struct DNode {
int data;
struct DNode *next;
struct DNode *prev;
} DNode;
形象记忆:每个车厢前后各有一个钩子。
NULL ← [prev | data | next] ↔ [prev | data | next] → NULL
头结点的 prev 是 NULL,尾结点的 next 是 NULL。两个方向的 NULL 是边界哨兵。
二、创建结点与插入操作
创建结点
DNode *dnode_create(int data) {
DNode *node = (DNode*)malloc(sizeof(DNode));
if (node) {
node->data = data;
node->next = NULL;
node->prev = NULL;
}
return node;
}
头插法
新结点放在最前面,成为新的头。需要处理旧头结点的 prev 指针。
void dlist_insert_head(DNode **head, int data) {
DNode *node = dnode_create(data);
if (!node) return;
node->next = *head;
if (*head != NULL) {
(*head)->prev = node;
}
*head = node;
}
注意:当链表为空(*head == NULL)时,没有 prev 需要设置。
尾插法
若维护了尾指针 tail,尾插是 O(1)。如果没有尾指针,需要遍历找到尾部(O(n))。为了最佳性能,实际项目常同时维护 head 和 tail。
void dlist_insert_tail(DNode **head, int data) {
DNode *node = dnode_create(data);
if (!node) return;
if (*head == NULL) {
*head = node;
return;
}
DNode *cur = *head;
while (cur->next != NULL) {
cur = cur->next;
}
cur->next = node;
node->prev = cur;
}
在指定结点后面插入
有了双向指针,插入操作异常简洁——不需要遍历找前驱。
void dlist_insert_after(DNode *target, int data) {
if (!target) return;
DNode *node = dnode_create(data);
if (!node) return;
node->next = target->next;
node->prev = target;
if (target->next != NULL) {
target->next->prev = node;
}
target->next = node;
}
在指定结点前面插入逻辑对称,可自行练习。
三、删除结点
双向链表的删除非常舒服:当前结点 target 的前驱和后继可以直接通过 prev 和 next 找到,无需从头发起遍历。
void dlist_delete(DNode **head, DNode *target) {
if (!target || !*head) return;
// 调整前驱的 next
if (target->prev != NULL) {
target->prev->next = target->next;
} else {
// target 是头结点
*head = target->next;
}
// 调整后继的 prev
if (target->next != NULL) {
target->next->prev = target->prev;
}
free(target);
}
配合查找函数(遍历比较),就能实现按值删除:
void dlist_delete_by_value(DNode **head, int value) {
DNode *cur = *head;
while (cur) {
if (cur->data == value) {
DNode *to_delete = cur;
cur = cur->next; // 先移到下一个,避免悬空
dlist_delete(head, to_delete);
// 注意:删除后继续查找(链表中可能还有同值结点)
continue;
}
cur = cur->next;
}
}
四、遍历双向链表
双向遍历可以正序也可以逆序。正序和单向链表完全一样;逆序需要先找到尾结点,然后沿 prev 向前走。
正序:
void dlist_print_forward(DNode *head) {
DNode *cur = head;
while (cur) {
printf("%d ↔ ", cur->data);
cur = cur->next;
}
printf("NULL\n");
}
逆序:先走到尾,再倒回来。
void dlist_print_backward(DNode *head) {
if (!head) return;
DNode *cur = head;
while (cur->next) {
cur = cur->next;
}
// cur 现在是尾结点
while (cur) {
printf("%d ↔ ", cur->data);
cur = cur->prev;
}
printf("NULL\n");
}
五、释放整个双向链表
释放逻辑和单向链表一样,逐个结点 free。双向链表的 next 已经够用。
void dlist_free(DNode *head) {
DNode *cur = head;
while (cur) {
DNode *tmp = cur;
cur = cur->next;
free(tmp);
}
}
双向链表的完整操作示例和单向链表整合在一起,放在后面的代码块里供你运行体会。
六、循环链表:让首尾相连
如果把单向链表尾结点的 next 从 NULL 改成指向头结点,就变成了循环单向链表。如果双向链表的尾 next 指向头,头的 prev 指向尾,就是循环双向链表。
循环链表的本质特点:没有绝对的起点和终点,从任一结点出发都能遍历整个链表。
1. 循环单向链表
头结点 → [1] → [2] → [3] → [4]
↑ |
└───────────────────┘ (尾结点的 next 指向头)
我们通常用尾指针(而不是头指针)来管理循环单向链表,因为从尾部获取头部只需 tail->next。
typedef struct CNode {
int data;
struct CNode *next;
} CNode;
// 在循环链表末尾插入(返回新尾)
CNode *clist_insert_tail(CNode *tail, int data) {
CNode *node = (CNode*)malloc(sizeof(CNode));
if (!node) return tail;
node->data = data;
if (tail == NULL) {
// 第一个结点:自己指向自己
node->next = node;
return node;
}
node->next = tail->next; // 新结点的 next 指向头
tail->next = node; // 旧尾指向新结点
return node; // 新尾
}
void clist_print(CNode *tail) {
if (!tail) return;
CNode *head = tail->next;
CNode *cur = head;
do {
printf("%d -> ", cur->data);
cur = cur->next;
} while (cur != head);
printf("(回到头)\n");
}
判断循环链表的结束不再是 NULL,而是回到了起点:cur != head。
2. 循环双向链表
在循环双向链表中,头结点的 prev 指向尾,尾结点的 next 指向头。判空即检查 head->next == head(或 head->prev == head)。
这种结构常用于操作系统中管理进程、内存页的调度队列。在 C 语言的 sys/queue.h 中就提供了名为 TAILQ 和 CIRCLEQ 的宏实现。
七、一个小应用:简单任务队列(循环单向链表)
循环链表天然适合“轮转调度”:比如一个简单的定时任务队列,每次执行一个任务后把它放到队尾,然后取下一个。
#include <stdio.h>
#include <stdlib.h>
typedef struct TaskNode {
int id;
struct TaskNode *next;
} TaskNode;
TaskNode *add_task(TaskNode *tail, int id) {
TaskNode *node = (TaskNode*)malloc(sizeof(TaskNode));
if (!node) return tail;
node->id = id;
if (tail == NULL) {
node->next = node;
return node;
}
node->next = tail->next;
tail->next = node;
return node;
}
void round_robin(TaskNode **tail, int rounds) {
if (!*tail) return;
TaskNode *cur = (*tail)->next; // 从头开始
for (int i = 0; i < rounds; i++) {
printf("执行任务 %d\n", cur->id);
cur = cur->next;
}
*tail = cur; // 更新尾,下次从尾部下一个开始(其实等价)
}
int main(void) {
TaskNode *tail = NULL;
tail = add_task(tail, 1);
tail = add_task(tail, 2);
tail = add_task(tail, 3);
// 循环执行 10 次
round_robin(&tail, 10);
return 0;
}
虽然这个小例子没有实际功能,但它展示了循环链表在轮询调度中的核心思想——你永远不需要判断 NULL,也永远能轮转下去。
八、双向链表 vs 单向链表:性能对比
| 操作 | 单向链表 | 双向链表 |
|---|---|---|
| 头插 | O(1) | O(1) |
| 尾插(无尾指针) | O(n) | O(n)(若维护尾指针可 O(1)) |
| 给定结点前插入 | O(n)(需找前驱) | O(1) |
| 删除给定结点 | O(n)(需找前驱) | O(1)(有 prev) |
| 正向遍历 | O(n) | O(n) |
| 反向遍历 | O(n²) 或额外空间 | O(n) |
| 内存开销 | 1 个指针/结点 | 2 个指针/结点 |
双向链表以额外的指针为代价,换来了更灵活的插入删除和双向遍历能力。当你需要频繁地在任意位置插入/删除、或者需要反向遍历时,选它准没错。
九、常见错误与陷阱
1. 插入时忘记更新 prev 指针
单向链表转双向,最易犯的错就是只更新了 next,忘记更新 prev,导致逆序遍历时出现断裂。
2. 删除尾部/头部时未更新边界
删除头结点要更新 head;如果还维护了尾指针,删除尾结点也要更新 tail。忘记更新会导致野指针。
3. 循环链表的死循环
遍历循环链表必须用 do-while 并判断是否回到了起始点,如果用 while (cur != NULL) 永远不会结束。
4. 对空循环链表解引用
空循环链表指针为 NULL,直接访问 tail->next 会崩溃。所有操作前先判空。
十、小结
今天我们在单向链表的基础上做了两件重要扩展:
- 双向链表:每个结点同时有
next和prev,插入删除更灵活,逆向遍历轻松。 - 循环链表:首尾相连,消除
NULL边界,适合轮转调度、缓冲区管理等场景。
这些链表变体是更高层数据结构的基础——比如队列、栈(用链表实现)、哈希表的冲突链、以及操作系统中的进程管理队列。
现在你已经有了三个有力的动态数据结构工具:数组(连续存储)、单向链表(单向前进)、双向/循环链表(灵活穿梭)。下一章,我们将用它们来实现两个经典的抽象数据结构——栈(Stack)和队列(Queue),分别用数组和链表两种方式构造,揭示接口与实现分离的设计之美。
课后小练习
- 实现一个完整的双向链表模块(
dlist.h/dlist.c),包含:dlist_create、dlist_insert_head、dlist_insert_tail、dlist_delete、dlist_find、dlist_free。 - 基于双向链表实现一个函数
void dlist_reverse(DNode **head),原地反转双向链表(交换每个结点的next和prev)。 - 判断一个单向链表是否为循环链表(有环),是的话返回环的入口结点;不是返回
NULL。提示:快慢指针找相遇点,再找入口。 - (小挑战)用循环双向链表实现约瑟夫环(Josephus)问题:n 个人围成一圈,从第 k 个人开始报数,数到 m 的人出列,求出列顺序。用
n=7, k=3, m=4测试。
我们下期见!
💡获取本系列示例代码请访问 GitCode 仓库。

1447

被折叠的 条评论
为什么被折叠?



