一、填坑时刻:从"首尾相连"说起
上一篇博客的末尾,我留下了一个悬念:
"如果让最后一个节点的
next指回头节点,形成一个环,又会诞生什么样的数据结构呢?"
今天,这个坑该填上了。
这种"首尾相接"的链表,就是我们要认识的链表家族最后一位成员------循环链表(Circular Linked List)。
至此,链表家族的三大成员我们已经全部集齐:
| 类型 | 简称 | 核心特征 |
|---|---|---|
| 单链表 | SLL | 单向线性,尾节点指向 NULL |
| 双向链表 | DLL | 双向线性,头尾各指向 NULL |
| 循环链表 | CLL | 首尾相接,形成闭环 |
二、循环链表:没有终点的链表
普通链表(单链表、双向链表)都是"有头有尾"的------走到最后一个节点,再往前就是 NULL,意味着"此路不通"。
而循环链表 做了一个大胆的改动:让最后一个节点不再指向 NULL,而是绕回来指向链表中的某个节点(通常是头节点)。
于是,链表不再是"一条线",而变成了一个环。
单向循环链表
这是单链表的"循环版"。每个节点仍然只有一个 next 指针,但最后一个节点的 next 不再指向 NULL,而是指回头节点。
┌─────────────────────────┐
↓ │
┌───┬───┐ ┌───┬───┐ ┌───┬───┐
│ 10│next│───→│ 20│next│───→│ 30│next│──┘
└───┴───┘ └───┴───┘ └───┴───┘
↑
head
在这个环里,你可以从 head 出发,沿着 next 一直走,最终又会走回 head。链表 没有真正的"末尾"。
双向循环链表
这是双向链表的"循环版"。头节点的 prev 指向尾节点,尾节点的 next 指回头节点,形成一个可以双向无限循环的环。
┌─────────────────────────────────────┐
↓ │
┌───┬───┬───┐ ┌───┬───┬───┐ ┌───┬───┬───┐
┌──│prev│10 │next│←──│prev│20 │next│←──│prev│30 │next│──┐
│ └───┴───┴───┘ └───┴───┴───┘ └───┴───┴───┘ │
└─────────────────────────────────────────────────────┘
三、循环链表 vs 普通链表
| 特性 | 单/双向链表 | 循环链表 |
|---|---|---|
| 尾部指向 | NULL |
头节点(或前驱节点) |
| 遍历终止条件 | p == NULL |
p == head(绕了一圈回来) |
| 是否有明确的"起点/终点" | ✅ 有 | ❌ 没有,只有相对的头节点 |
| 从尾到头 | 需要重新从头遍历 | 直接沿着环走即可 |
| 内存占用 | 相同 | 相同(只是指针指向不同) |
关键区别 :普通链表靠 NULL 来判断"到头了",循环链表靠"是不是又回到了起点"来判断。
四、循环链表的核心操作
循环链表的操作和普通链表大同小异,但有几个细节需要特别注意。
1. 遍历:别陷入死循环
普通链表遍历的写法:
c
while (p != NULL) { ... } // 走到 NULL 就停
循环链表如果这么写,就 永远停不下来了 (因为根本没有 NULL)。正确的写法是:
c
if (head == NULL) return; // 空链表特判
Node* p = head;
do {
// 处理当前节点
printf("%d ", p->data);
p = p->next;
} while (p != head); // 走回起点就停
用 do-while 是为了确保至少执行一次(因为一开始 p == head)。当然,用 while 配合 if 判断也可以。
2. 尾部插入:要同时照顾"新尾巴"和"头"
在单向循环链表中,往尾部插入一个新节点,需要做两件事:
-
原尾节点的
next指向新节点 -
新节点的
next指回head插入前: ... → 30 → head(10)
插入后: ... → 30 → 40 → head(10)
3. 只有一个节点时的特殊性
当循环链表里只有一个节点时,它的 next 指向自己:
┌────────┐
↓ │
┌───┬───┐ │
│ 10│next│───┘
└───┴───┘
↑
head
这时候删除这个唯一节点,要记得把 head 设为 NULL,否则链表虽然空了,但 head 还悬着一个野指针。
五、C语言实现:单向循环链表
下面实现一个支持尾部插入 和遍历打印的单向循环链表:
c
#include <stdio.h>
#include <stdlib.h>
// 1. 节点定义(和单链表一模一样)
typedef struct Node {
int data;
struct Node* next;
} Node;
// 2. 创建新节点
Node* createNode(int data) {
Node* newNode = (Node*)malloc(sizeof(Node));
newNode->data = data;
newNode->next = NULL; // 先默认指向 NULL,后面再调整
return newNode;
}
// 3. 尾部插入
void append(Node** head, int data) {
Node* newNode = createNode(data);
// 情况1:链表为空
if (*head == NULL) {
*head = newNode;
newNode->next = *head; // 自己指向自己,形成自环
return;
}
// 情况2:链表非空,找到最后一个节点
Node* tail = *head;
while (tail->next != *head) { // 尾节点的 next 等于 head
tail = tail->next;
}
// 接入新节点
tail->next = newNode; // 原尾节点指向新节点
newNode->next = *head; // 新尾节点指回头节点
}
// 4. 遍历打印循环链表
void printList(Node* head) {
if (head == NULL) {
printf("链表为空\n");
return;
}
Node* p = head;
printf("循环链表: ");
do {
printf("%d ", p->data);
p = p->next;
} while (p != head); // 回到起点就结束
printf("(回到起点)\n");
}
// 5. 释放链表
void freeList(Node** head) {
if (*head == NULL) return;
Node* p = (*head)->next;
while (p != *head) {
Node* temp = p;
p = p->next;
free(temp);
}
free(*head); // 释放头节点
*head = NULL; // 别忘了把 head 置空
}
// 测试
int main() {
Node* head = NULL;
append(&head, 10);
append(&head, 20);
append(&head, 30);
printList(head); // 循环链表: 10 20 30 (回到起点)
freeList(&head);
return 0;
}
代码中的两个关键点
| 位置 | 代码 | 为什么 |
|---|---|---|
| 空链表插入 | newNode->next = *head; |
只有一个节点时,它自己指向自己 |
| 遍历 | do { ... } while (p != head); |
确保至少打印一次,且不会死循环 |
六、循环链表的经典应用
循环链表虽然看起来只是"把尾巴连回了头",但这个特性让它在特定场景中大放异彩:
1. 约瑟夫问题(Josephus Problem)
n 个人围成一圈,从第 k 个人开始报数,数到 m 的人出局,下一个人再从 1 开始报数......求最后剩下的人。
这个问题天然就是"围成一圈"的模型,用循环链表来模拟再合适不过------每个人是一个节点,出局就是删除节点,删完继续沿着 next 报数。
2. 操作系统的进程调度
操作系统中的进程调度算法(如时间片轮转调度)会把所有就绪进程排成一个循环队列。CPU 依次执行每个进程一个时间片,执行完就轮到下一个,周而复始。循环链表完美契合这种"轮流来、不停转"的逻辑。
3. 多人轮流游戏/轮播图
比如一款回合制游戏,玩家按顺序行动,最后一个玩家行动完后又回到第一个玩家。用循环链表存储玩家顺序,逻辑非常自然。
七、链表家族大总结
至此,我们已经走完了链表家族的完整版图:
链表(Linked List)
│
┌───────┴───────┐
│ │
线性链表 循环链表
│ │
┌──┴──┐ ┌──┴──┐
↓ ↓ ↓ ↓
单链表 双向链表 单向循环 双向循环
(SLL) (DLL) (SCLL) (DCLL)
| 数据结构 | 一句话记忆 |
|---|---|
| 单链表 | 一条路走到黑 |
| 双向链表 | 能进能退,双向通行 |
| 循环链表 | 没有终点,周而复始 |
链表的世界看似复杂,但万变不离其宗:节点 + 指针 = 链表。理解了这一点,无论是单链、双链还是循环链,都只是在这个基础上做的"加减法"。