Appearance
循环链表
相比普通链表的改进
循环链表将尾结点的指针指回头部,形成一个环。用尾指针就能 O(1) 同时访问表头和表尾,从任意结点出发都能遍历全部结点。典型应用场景包括:
- 操作系统中的轮转调度(Round-Robin)
- 经典的约瑟夫问题(Josephus Problem)
- 需要反复循环处理的数据缓冲区
核心思想
循环链表的核心特点:
- 循环单链表:尾结点的
next指针不为NULL,而是指向头结点(带头结点时)或第一个数据结点(不带头结点时) - 循环双链表:在循环单链表的基础上,头结点的
prior指向尾结点,尾结点的next指向头结点,形成双向闭合环 - 判空条件不同:普通链表判
p->next == NULL;循环链表判p->next == L(是否回到头结点)
循环单链表结构示意:
┌───┬───┐ ┌───┬───┐ ┌───┬───┐
│ L │ ──┼──→│ a₁│ ──┼──→│ a₂│ ──┼──┐
└───┴───┘ └───┴───┘ └───┴───┘ │
↑ │
└─────────────────────────────────┘循环双链表结构示意:
┌──────────────────────────────────┐
↓ │
┌───┬───┐ ┌───┬───┐ ┌───┬───┐ │
│ L │ │←──│ a₁│ │←──│ a₂│ │ │
│ │ ──┼──→│ │ ──┼──→│ │ ──┼─────┘
└───┴───┘ └───┴───┘ └───┴───┘循环链表 vs 普通链表:终止条件对比
| 对比项 | 普通链表 | 循环链表 |
|---|---|---|
| 尾结点指针 | next = NULL | next = L(指向头结点) |
| 判空条件 | L->next == NULL | L->next == L |
| 遍历终止 | p != NULL | p != L |
易错:循环链表的判空条件与普通链表不同:普通链表判
head->next == NULL,循环链表判head->next == head(带头结点时)。408 选择题常考这个区别。
交互可视化
通过下方的交互动画,你可以逐步观察循环链表的执行过程:
操作详解
循环单链表
结点定义
c
typedef struct LNode {
int data;
struct LNode *next;
} LNode, *LinkList;初始化(带头结点)
初始化时,头结点的 next 指向自身,形成空的循环链表。
c
bool InitList(LinkList *L) {
*L = (LNode *)malloc(sizeof(LNode));
if (*L == NULL) return false;
(*L)->next = *L; // 指向自身,而非 NULL
return true;
}判空
c
bool Empty(LinkList L) {
return L->next == L; // 注意:不是 L->next == NULL
}遍历
与普通单链表的关键区别在于终止条件:不再判 p != NULL,而是判 p != L(是否回到头结点)。
c
void Traverse(LinkList L) {
LNode *p = L->next; // 第一个数据结点
while (p != L) { // 回到头结点时停止
printf("%d ", p->data);
p = p->next;
}
}在第 i 个位置插入
关键步骤与普通单链表相同,找到第 i-1 个结点后执行插入。唯一区别是查找过程中终止条件从 p != NULL 变为 p != L。
c
bool ListInsert(LinkList L, int i, int e) {
if (i < 1) return false;
LNode *p = L;
int j = 0;
while (p->next != L && j < i - 1) { // 终止条件不同
p = p->next;
j++;
}
if (j < i - 1) return false;
LNode *s = (LNode *)malloc(sizeof(LNode));
if (s == NULL) return false;
s->data = e;
s->next = p->next;
p->next = s;
return true;
}删除第 i 个结点
c
bool ListDelete(LinkList L, int i, int *e) {
if (i < 1) return false;
LNode *p = L;
int j = 0;
while (p->next != L && j < i - 1) {
p = p->next;
j++;
}
if (p->next == L || j > i - 1) return false;
LNode *q = p->next;
*e = q->data;
p->next = q->next;
free(q);
return true;
}尾指针的妙用
如果设置一个尾指针 rear 指向尾结点(而非头指针),则:
- 访问尾结点:
rear,时间 O(1) - 访问头结点:
rear->next,时间 O(1) - 访问第一个数据结点:
rear->next->next,时间 O(1)
这在两个循环单链表的合并中特别有用,可以在 O(1) 时间内完成:
c
// 将 Lb 合并到 La 的尾部(均用尾指针表示,假设两表均非空)
LinkList Connect(LinkList La, LinkList Lb) {
LNode *headA = La->next; // 保存 La 的头结点
La->next = Lb->next->next; // La 尾接 Lb 第一个数据结点
free(Lb->next); // 释放 Lb 的头结点
Lb->next = headA; // Lb 尾接 La 头结点
return Lb; // 返回新的尾指针
}注意:以上是教材标准写法,隐含前提是两表均非空。若 Lb 为空表(
Lb->next == Lb),尾指针 Lb 就是它自己的头结点,free(Lb->next)会把 Lb 本身释放掉,后续访问就是错误的。考试默认两表非空即可;自己实现时应先特判空表。
循环双链表
结点定义
c
typedef struct DNode {
int data;
struct DNode *prior, *next;
} DNode, *DLinklist;初始化(带头结点)
头结点的 prior 和 next 都指向自身。
c
bool InitDLinkList(DLinklist *L) {
*L = (DNode *)malloc(sizeof(DNode));
if (*L == NULL) return false;
(*L)->prior = *L; // 前驱指向自身
(*L)->next = *L; // 后继指向自身
return true;
}判空
c
bool Empty(DLinklist L) {
return L->next == L;
}遍历
c
void Traverse(DLinklist L) {
DNode *p = L->next;
while (p != L) { // 回到头结点时停止
printf("%d ", p->data);
p = p->next;
}
}在结点 p 之后插入结点 s
与普通双链表的操作基本一致,但因为是循环结构,不需要特判 p 是否为尾结点——p->next 永远不会是 NULL。
c
bool InsertNextDNode(DNode *p, DNode *s) {
if (p == NULL || s == NULL) return false;
s->next = p->next;
p->next->prior = s; // 无需判空,循环链表中 p->next 必有效
s->prior = p;
p->next = s;
return true;
}删除结点 p 的后继结点
同样无需特判尾结点。但要注意不能删除头结点(即 p->next == L 时不可删除)。
c
bool DeleteNextDNode(DLinklist L, DNode *p) {
if (p == NULL || p->next == L) return false; // 后继是头结点则无法删除
DNode *q = p->next;
p->next = q->next;
q->next->prior = p;
free(q);
return true;
}循环双链表的优势
相比循环单链表,循环双链表可以在 O(1) 时间内找到任意结点的前驱,因此:
- 在某结点之前插入:O(1)
- 删除当前结点自身:O(1)(不需要找前驱)
这使得循环双链表成为实际工程中最常用的链表形式之一。
应用:约瑟夫问题
**约瑟夫问题(Josephus Problem)**是循环单链表的经典应用:n 个人围成一圈(编号 1~n),从第 1 个人开始报数,报到 m 的人出列,然后从下一个人重新报 1,如此循环直到所有人出列。求出列顺序。
这个场景里每个结点都是"人",没有逻辑上的表头,所以用不带头结点的循环单链表最自然——出列就是摘结点,圈空了算法结束。
c
// 约瑟夫问题:输出 n 个人按报数 m 的出列序列
void Josephus(int n, int m) {
// 1. 建表:不带头结点的循环单链表,结点依次存编号 1~n
LNode *head = (LNode *)malloc(sizeof(LNode));
head->data = 1;
LNode *tail = head;
for (int i = 2; i <= n; i++) {
LNode *s = (LNode *)malloc(sizeof(LNode));
s->data = i;
tail->next = s;
tail = s;
}
tail->next = head; // 尾指回首,成环
// 2. 报数出列:pre 始终是 p 的前驱,方便摘除 p
LNode *pre = tail, *p = head;
while (p->next != p) { // 圈内剩下超过 1 人
for (int k = 1; k < m; k++) { // p 从"报 1"走到"报 m"的人
pre = p;
p = p->next;
}
printf("%d ", p->data); // 报到 m,出列
pre->next = p->next; // 从圈中摘除 p
free(p);
p = pre->next; // 下一轮从出列者的下一人重新报 1
}
printf("%d\n", p->data); // 圈内最后一人
}以 n = 5、m = 3 验证:出列顺序为 3, 1, 5, 2, 4。时间复杂度 O(n·m)——每出列一人要走 m-1 步,共出列 n 人。
易错:①出列后下一轮从出列者的下一个人报 1,不是从原起点重新数;②循环条件是
p->next != p(圈内剩 1 人时它的 next 指向自己),别写成p != NULL——循环链表里没有 NULL。
复杂度分析
循环单链表
| 操作 | 时间复杂度 | 说明 |
|---|---|---|
| 头插 / 头删 | O(1) | 在头结点之后操作 |
| 尾插(头指针) | O(n) | 需遍历找到尾结点 |
| 尾插(尾指针) | O(1) | 尾指针直接定位 |
| 按位查找 | O(n) | 需从头遍历 |
| 按值查找 | O(n) | 最坏遍历整个表 |
| 两表合并(尾指针) | O(1) | 仅修改指针 |
循环双链表
| 操作 | 时间复杂度 | 说明 |
|---|---|---|
| 在已知结点后插入 | O(1) | 修改四个指针 |
| 在已知结点前插入 | O(1) | 利用 prior 直接定位 |
| 删除已知结点 | O(1) | 利用 prior 无需遍历 |
| 按位查找 | O(n) | 需从头遍历 |
| 按值查找 | O(n) | 最坏遍历整个表 |
空间复杂度:所有操作均为 O(1),只需常数级辅助空间。
考研高频考点
- 循环链表的判空条件:
L->next == L(对比普通链表的L->next == NULL) - 循环链表遍历终止条件:
p != L(对比普通链表的p != NULL) - 尾指针表示循环单链表的优势:O(1) 时间访问头尾结点、O(1) 时间合并两个循环链表
- 约瑟夫问题(Josephus Problem):n 个人围成一圈,从第 k 个人开始报数,报到第 m 个人出列,循环进行直到所有人出列。这是循环链表的经典应用,考研中常以算法设计题出现(完整实现见上文"应用:约瑟夫问题"一节)
- 循环双链表插入、删除操作的指针修改顺序(选择题/代码填空题高频)
- 循环链表 vs 普通链表的适用场景对比
- 循环双链表中无需特判尾结点的原因分析
相关知识
- 循环链表是单链表的变体,将尾结点的
next指回头结点形成环 - 循环队列的设计思想与循环链表类似——都是通过"首尾相连"解决空间浪费或遍历受限的问题
- 队列的链式存储也可以使用循环链表实现,尾指针表示时入队出队均为 O(1)
- 循环双链表结合了双链表和循环结构的优势,插入删除时无需特判边界