循环双链表的实现——2027考研408数据结构全攻略
适用范围:2027年全国硕士研究生招生考试·计算机学科专业基础(408)
考纲章节:第二章 线性表 → 2.3 线性表的链式表示 → 循环双链表
难度等级:⭐⭐⭐⭐(进阶重点,综合应用型考点多)
目录
一、知识点概念
1.1 双向链表定义
双向链表(Doubly Linked List) 是在单链表的基础上,每个结点增加一个指向前驱结点的指针域 prior,使得链表可以双向遍历。
每个结点包含三部分:
- prior:指向前驱结点的指针
- data:数据域
- next:指向后继结点的指针
←────── prior ──────→ ←────── prior ──────→
[prior │ data │ next] ←→ [prior │ data │ next]
←─────── next ───────→ ←─────── next ───────→
1.2 循环双链表定义
循环双链表(Circular Doubly Linked List) 是双向链表的循环版本,其特点是:
- 首尾相连:头结点的前驱指向尾结点,尾结点的后继指向头结点
- 无 NULL 指针:遍历时以头结点作为终止标志,形成完整的环
循环双链表结构:
prior prior
↑ ↑
│ ┌──────────────────┘
│ │
[头] ←── prior │ data │ next ──→ [a1] ←── prior │ data │ next ──→ ... ←── prior │ data │ next
│ │ ↑ │
│ └──────────────────┘ │
└───────────────────────────────────────────┘
next next
空表结构:
[头] ── prior ──→ [头]
↑ ↓
└────── next ─────┘
(头结点的前驱和后继都指向自己)
1.3 核心特征
- 双向遍历:既可以从头向尾遍历,也可以从尾向头遍历
- 首尾相连:尾结点的
next指向头结点,头结点的prior指向尾结点 - 无 NULL 指针:以头结点作为双向循环的标志
- 插入删除更便捷:已知某结点时,其前驱和后继都可直接访问,插入/删除只需修改四个指针
1.4 循环双链表 vs 普通双链表
| 对比维度 | 普通双链表 | 循环双链表 |
|---|---|---|
| 首尾连接 | 否,prior/ next 可能为 NULL | 是,形成环 |
头结点 prior | NULL | 指向尾结点 |
尾结点 next | NULL | 指向头结点 |
空表 prior | NULL | 指向自己 |
空表 next | NULL | 指向自己 |
| 遍历终止(从头到尾) | p->next != NULL | p->next != L |
| 遍历终止(从尾到头) | p->prior != NULL | p->prior != L |
二、循环双链表的结构定义
2.1 C语言结构体定义
#include <stdio.h>
#include <stdlib.h>
#include <stdbool.h>
typedef int ElemType;
/* 循环双链表结点定义 */
typedef struct DNode {
ElemType data; // 数据域
struct DNode *prior; // 前驱指针
struct DNode *next; // 后继指针
} DNode, *DLinkList;
⚠️ 注意:循环双链表的结点结构与普通双链表完全相同,区别在于初始状态和遍历终止条件。考试中默认使用带头结点的循环双链表。
2.2 空表的三种表示
// 循环双链表(带头结点)空表的三种等价判断
L->next == L; // ① 后继指向自己
L->prior == L; // ② 前驱指向自己
L->next == L && L->prior == L; // ③ 两者同时成立
2.3 初始化状态
// 循环双链表初始化后的状态
[L] ──prior──→ [L]
↑ ↓
└─────next─────┘
三、循环双链表的实现(基本操作)
3.1 初始化(带头结点)
/* 初始化循环双链表(带头结点) */
bool InitList(DLinkList *L) {
*L = (DLinkList)malloc(sizeof(DNode));
if (*L == NULL) return false;
(*L)->prior = *L; // 前驱指向自己
(*L)->next = *L; // 后继指向自己(空表特征)
return true;
}
/* 判空 */
bool ListEmpty(DLinkList L) {
return L->next == L; // 后继指向自己即为空表
}
3.2 按位查找
/* 按位查找:返回第i个结点的指针(1-based)
* 从头结点出发向后遍历
*/
DNode* GetElem(DLinkList L, int i) {
if (i < 1) return NULL;
DNode *p = L; // p从头结点开始
int j = 0;
while (p != NULL && j < i) { // 找到第i个结点
p = p->next;
j++;
}
// 循环双链表中,由于是环,最终p会回到L,不会为NULL
// 但j < i时可能p == L,此时i超过表长
return (j == i) ? p : NULL;
}
/* 按位查找(循环双链表优化版)
* 利用循环双链表特性:已知表长时可直接判断越界
*/
DNode* GetElem_Opt(DLinkList L, int i, int len) {
if (i < 1 || i > len) return NULL;
DNode *p = L->next; // p指向第一个数据结点
for (int j = 1; j < i; j++)
p = p->next;
return p;
}
3.3 按值查找
/* 按值查找:返回第一个值为e的结点的位序,不存在返回0 */
int LocateElem(DLinkList L, ElemType e) {
DNode *p = L->next;
int i = 1;
while (p != L) { // 未回到头结点则继续
if (p->data == e)
return i;
p = p->next;
i++;
}
return 0;
}
3.4 插入操作
循环双链表的插入操作是其最大优势之一——已知某结点时,前插和后插都只需 O(1) 时间,且不需要遍历找前驱。
3.4.1 在p结点之后插入s(后插)
/* 在p结点之后插入元素e
* 核心:只需修改4个指针
*/
bool InsertNextDNode(DNode *p, ElemType e) {
if (p == NULL) return false;
DNode *s = (DNode *)malloc(sizeof(DNode));
if (s == NULL) return false;
s->data = e;
s->next = p->next; // ① s的后继指向p的后继
s->prior = p; // ② s的前驱指向p
p->next->prior = s; // ③ p的后继的前驱指向s
p->next = s; // ④ p的后继指向s
return true;
}
后插图解:
插入前: p ──next──→ [a1] ←─prior── L
↑
│
插入e=99:
① s->next = p->next → s指向[a1]
② s->prior = p → s指向p
③ p->next->prior = s → [a1]指向s
④ p->next = s → p指向s
插入后: p ──next──→ [s:99] ──next──→ [a1] ←─prior── L
↑________________________________↑
prior
3.4.2 在p结点之前插入s(前插)
循环双链表的前插操作同样高效,无需像单链表那样"偷天换日"。
/* 在p结点之前插入元素e
* 直接利用prior指针找到前驱,然后插入
*/
bool InsertPriorDNode(DNode *p, ElemType e) {
if (p == NULL) return false;
DNode *s = (DNode *)malloc(sizeof(DNode));
if (s == NULL) return false;
s->data = e;
s->prior = p->prior; // ① s的前驱指向p的前驱
s->next = p; // ② s的后继指向p
p->prior->next = s; // ③ p的前驱的后继指向s
p->prior = s; // ④ p的前驱指向s
return true;
}
📌 考研重点:循环双链表的前插和后插都只需 O(1) 时间,这是区别于所有单链表变体的核心优势!
3.4.3 按位序插入
/* 在第i个位置插入元素e(1-based) */
bool ListInsert(DLinkList *L, int i, ElemType e) {
if (i < 1) return false;
DNode *p = GetElem(*L, i - 1); // 找到第i-1个结点
if (p == NULL) return false;
return InsertNextDNode(p, e);
}
/* 头插法(高效) */
bool ListInsert_Head(DLinkList *L, ElemType e) {
return InsertNextDNode(*L, e); // 在头结点之后插入,即头插
}
/* 尾插法(高效) */
bool ListInsert_Tail(DLinkList *L, ElemType e) {
return InsertPriorDNode(*L, e); // 在头结点之前插入,即尾插
}
3.5 删除操作
3.5.1 删除p的后继结点
/* 删除p的后继结点(p本身不删)
* 循环双链表中,p的后继一定存在(不为NULL)
*/
bool DeleteNextDNode(DNode *p, ElemType *e) {
if (p == NULL) return false;
if (p->next == p) return false; // 空表,无法删除(只有一个头结点)
DNode *q = p->next; // q指向待删除结点
*e = q->data;
p->next = q->next; // ① p的后继指向q的后继
q->next->prior = p; // ② q的后继的前驱指向p
free(q);
return true;
}
删除后继图解:
删除前: p ──next──→ [q] ──next──→ [r]
↑
删除q:
① p->next = q->next → p指向r
② r->prior = p → r指向p
删除后: p ──next──→ [r]
3.5.2 删除p结点本身
/* 删除指定结点p(假设p不是头结点) */
bool DeleteDNode(DNode *p, ElemType *e) {
if (p == NULL) return false;
*e = p->data;
p->prior->next = p->next; // ① p的前驱的后继指向p的后继
p->next->prior = p->prior; // ② p的后继的前驱指向p的前驱
free(p);
return true;
}
⚠️ 注意:循环双链表中删除任意已知结点(除头结点外)的时间复杂度都是 O(1)!
3.5.3 按位序删除
/* 删除第i个位置的元素,并用e返回其值 */
bool ListDelete(DLinkList *L, int i, ElemType *e) {
if (i < 1) return false;
DNode *p = GetElem(*L, i - 1); // 找到第i-1个结点
if (p == NULL || p->next == *L) return false; // p->next是头结点则越界
return DeleteNextDNode(p, e);
}
3.6 求表长
/* 求循环双链表的表长 */
int ListLength(DLinkList L) {
int len = 0;
DNode *p = L->next; // p指向第一个数据结点
while (p != L) { // 未回到头结点则计数
len++;
p = p->next;
}
return len;
}
3.7 整表创建
3.7.1 尾插法创建
/* 尾插法创建循环双链表 */
DLinkList List_TailInsert(DLinkList *L) {
InitList(L);
DNode *r = *L; // r指向头结点(也是尾结点)
ElemType x;
DNode *s;
printf("请输入元素(输入9999结束): ");
scanf("%d", &x);
while (x != 9999) {
s = (DNode *)malloc(sizeof(DNode));
s->data = x;
// 尾插法:在尾结点之前插入(头结点之前即表尾)
s->next = r;
s->prior = r->prior;
r->prior->next = s;
r->prior = s;
r = s; // r移动到新的尾结点
scanf("%d", &x);
}
return *L;
}
3.7.2 头插法创建
/* 头插法创建循环双链表 */
DLinkList List_HeadInsert(DLinkList *L) {
InitList(L);
ElemType x;
DNode *s;
printf("请输入元素(输入9999结束): ");
scanf("%d", &x);
while (x != 9999) {
s = (DNode *)malloc(sizeof(DNode));
s->data = x;
// 头插法:在头结点之后插入
s->next = (*L)->next;
s->prior = *L;
(*L)->next->prior = s;
(*L)->next = s;
scanf("%d", &x);
}
return *L;
}
3.8 完整示例代码
#include <stdio.h>
#include <stdlib.h>
#include <stdbool.h>
typedef int ElemType;
typedef struct DNode {
ElemType data;
struct DNode *prior;
struct DNode *next;
} DNode, *DLinkList;
/* 初始化 */
bool InitList(DLinkList *L) {
*L = (DLinkList)malloc(sizeof(DNode));
if (*L == NULL) return false;
(*L)->prior = *L;
(*L)->next = *L;
return true;
}
/* 判空 */
bool ListEmpty(DLinkList L) {
return L->next == L;
}
/* 按位查找 */
DNode* GetElem(DLinkList L, int i) {
if (i < 1) return NULL;
DNode *p = L;
int j = 0;
while (p != NULL && j < i) {
p = p->next;
j++;
}
return (j == i) ? p : NULL;
}
/* 按值查找 */
int LocateElem(DLinkList L, ElemType e) {
DNode *p = L->next;
int i = 1;
while (p != L) {
if (p->data == e) return i;
p = p->next;
i++;
}
return 0;
}
/* 后插 */
bool InsertNextDNode(DNode *p, ElemType e) {
if (p == NULL) return false;
DNode *s = (DNode *)malloc(sizeof(DNode));
if (s == NULL) return false;
s->data = e;
s->next = p->next;
s->prior = p;
p->next->prior = s;
p->next = s;
return true;
}
/* 前插 */
bool InsertPriorDNode(DNode *p, ElemType e) {
if (p == NULL) return false;
DNode *s = (DNode *)malloc(sizeof(DNode));
if (s == NULL) return false;
s->data = e;
s->prior = p->prior;
s->next = p;
p->prior->next = s;
p->prior = s;
return true;
}
/* 按位序插入 */
bool ListInsert(DLinkList *L, int i, ElemType e) {
if (i < 1) return false;
DNode *p = GetElem(*L, i - 1);
return p != NULL && InsertNextDNode(p, e);
}
/* 删除后继 */
bool DeleteNextDNode(DNode *p, ElemType *e) {
if (p == NULL || p->next == p) return false;
DNode *q = p->next;
*e = q->data;
p->next = q->next;
q->next->prior = p;
free(q);
return true;
}
/* 删除指定结点 */
bool DeleteDNode(DNode *p, ElemType *e) {
if (p == NULL) return false;
*e = p->data;
p->prior->next = p->next;
p->next->prior = p->prior;
free(p);
return true;
}
/* 按位序删除 */
bool ListDelete(DLinkList *L, int i, ElemType *e) {
if (i < 1) return false;
DNode *p = GetElem(*L, i - 1);
if (p == NULL || p->next == *L) return false;
return DeleteNextDNode(p, e);
}
/* 表长 */
int ListLength(DLinkList L) {
int len = 0;
DNode *p = L->next;
while (p != L) {
len++;
p = p->next;
}
return len;
}
/* 尾插法创建 */
DLinkList List_TailInsert(DLinkList *L) {
InitList(L);
DNode *r = *L;
ElemType x;
printf("输入元素(9999结束): ");
scanf("%d", &x);
while (x != 9999) {
DNode *s = (DNode *)malloc(sizeof(DNode));
s->data = x;
s->next = r;
s->prior = r->prior;
r->prior->next = s;
r->prior = s;
r = s;
scanf("%d", &x);
}
return *L;
}
/* 打印(正序) */
void PrintList(DLinkList L) {
printf("循环双链表(正序,长度=%d): ", ListLength(L));
DNode *p = L->next;
while (p != L) {
printf("%d ", p->data);
p = p->next;
}
printf("\n");
}
/* 打印(逆序) */
void PrintList_Reverse(DLinkList L) {
printf("循环双链表(逆序): ");
DNode *p = L->prior; // 从尾结点开始
while (p != L) {
printf("%d ", p->data);
p = p->prior;
}
printf("\n");
}
/* 主函数测试 */
int main() {
DLinkList L;
ElemType e;
// 尾插法创建: 10 20 30 40 50
List_TailInsert(&L);
PrintList(L); // 正序: 10 20 30 40 50
PrintList_Reverse(L); // 逆序: 50 40 30 20 10
// 在第3位插入25
ListInsert(&L, 3, 25);
PrintList(L); // 正序: 10 20 25 30 40 50
// 在尾插(直接在头结点之前插入)
InsertPriorDNode(L, 5);
PrintList(L); // 正序: 5 10 20 25 30 40 50
// 删除第2位
ListDelete(&L, 2, &e);
printf("删除的元素: %d\n", e); // 输出: 10
PrintList(L); // 正序: 5 20 25 30 40 50
// 查找
printf("第3个元素: %d\n", GetElem(L, 3)->data); // 输出: 25
printf("元素30的位序: %d\n", LocateElem(L, 30)); // 输出: 4
free(L);
return 0;
}
四、循环双链表的进阶操作
4.1 正序与逆序遍历
/* 正序遍历(从头到尾) */
void Traverse_Forward(DLinkList L) {
DNode *p = L->next;
printf("正序: ");
while (p != L) {
printf("%d ", p->data);
p = p->next;
}
printf("\n");
}
/* 逆序遍历(从尾到头)——循环双链表独有! */
void Traverse_Backward(DLinkList L) {
DNode *p = L->prior; // 从尾结点开始
printf("逆序: ");
while (p != L) {
printf("%d ", p->data);
p = p->prior;
}
printf("\n");
}
📌 考研重点:循环双链表是唯一能 O(1) 时间逆序遍历的链表结构!普通单链表无法逆序遍历,普通双链表逆序遍历时间复杂度是 O(n)(因为不知道尾结点在哪)。
4.2 合并两个循环双链表
/* 合并两个循环双链表La和Lb,结果放在La中 */
DLinkList MergeList(DLinkList *La, DLinkList *Lb) {
if (*La == NULL || *Lb == NULL) return NULL;
// 找到La的尾结点和首结点
DNode *La_tail = (*La)->prior; // La的尾结点
DNode *La_head = *La; // La的头结点
DNode *Lb_tail = (*Lb)->prior; // Lb的尾结点
DNode *Lb_head = (*Lb)->next; // Lb的首结点
// 断开两个环,重新连接
La_tail->next = Lb_head; // La尾连Lb首
Lb_head->prior = La_tail;
Lb_tail->next = La_head; // Lb尾连La头
La_head->prior = Lb_tail;
free(*Lb); // 释放Lb的头结点
return *La;
}
4.3 循环双链表的逆置
/* 逆置循环双链表
* 思路:依次将每个结点的前驱和后继交换
*/
void Reverse(DLinkList *L) {
if (*L == NULL || (*L)->next == *L) return; // 空表
DNode *p = *L;
DNode *q;
// 依次交换每个结点(包括头结点)的前驱和后继
do {
q = p->next; // 保存后继
// 交换prior和next
p->next = p->prior;
p->prior = q;
p = q; // 移动到下一个结点
} while (p != *L);
// 此时链表方向已反转
}
4.4 删除所有值为x的结点
/* 删除循环双链表中所有值为x的结点 */
void DeleteX(DLinkList *L, ElemType x) {
if (*L == NULL || (*L)->next == *L) return; // 空表
DNode *p = (*L)->next;
while (p != *L) {
if (p->data == x) {
DNode *q = p;
p->prior->next = p->next;
p->next->prior = p->prior;
p = p->next; // 移动到下一个结点
free(q);
} else {
p = p->next;
}
}
}
4.5 C++ STL list 实现(了解)
#include <iostream>
#include <list>
using namespace std;
int main() {
list<int> L; // C++ STL list是双向链表
// 插入
L.push_back(10); // 尾插
L.push_front(5); // 头插
L.insert(++L.begin(), 15); // 在第二个位置插入
// 删除
L.pop_back(); // 尾删
L.pop_front(); // 头删
L.remove(10); // 删除所有值为10的结点
// 遍历(支持双向迭代器)
for (auto it = L.begin(); it != L.end(); ++it)
cout << *it << " ";
cout << endl;
// 逆序遍历
for (auto it = L.rbegin(); it != L.rend(); ++it)
cout << *it << " ";
cout << endl;
return 0;
}
五、时间与空间复杂度分析
5.1 时间复杂度
| 操作 | 最好情况 | 最坏情况 | 平均情况 | 说明 |
|---|---|---|---|---|
| 按位查找 GetElem | O(1) | O(n) | O(n) | 需遍历 |
| 按值查找 LocateElem | O(1) | O(n) | O(n) | 目标在起始位置最好 |
| 后插(已知位置) | O(1) | O(1) | O(1) | 只需4个指针修改 |
| 前插(已知位置) | O(1) | O(1) | O(1) | 只需4个指针修改 |
| 删除后继(已知位置) | O(1) | O(1) | O(1) | 只需3个指针修改 |
| 删除指定结点 | O(1) | O(1) | O(1) | 只需3个指针修改 |
| 头插法建表 | O(1)/次 | O(1)/次 | O(1)/次 | 每次O(1),共n次 |
| 尾插法建表 | O(1)/次 | O(1)/次 | O(1)/次 | 每次O(1),共n次 |
| 正序遍历 | O(n) | O(n) | O(n) | 遍历所有结点 |
| 逆序遍历 | O(1) | O(n) | — | 从尾到头O(1),但找尾需O(n) |
5.2 空间复杂度
| 类型 | 空间复杂度 | 说明 |
|---|---|---|
| 循环双链表 | O(n) | n个结点需2n个指针 |
| 各操作辅助空间 | O(1) | 仅需几个临时指针 |
📌 考研重点:循环双链表是所有链表结构中插入删除效率最高的!所有已知结点的插入删除操作都是 O(1),且能双向遍历。
六、优缺点对比
6.1 循环双链表优点
- 双向遍历:既可以从头到尾,也可以从尾到头遍历
- 插入删除极致高效:已知结点时,前插/后插/删除都是 O(1)
- 无需找前驱:双向指针使得前驱操作直接可得
- 尾操作高效:尾插、尾删无需遍历,O(1) 时间
- 无 NULL 指针:内存利用率高,边界处理统一
6.2 循环双链表缺点
- 存储密度更低:每个结点需要两个指针域(prior + next),比单链表多一倍
- 实现复杂度高:指针操作多,4指针修改容易出错
- 容易死循环:遍历终止条件错误会导致死循环
- 空间开销大:指针域占用的内存比单链表多一倍
七、四种链表横向对比
7.1 完整对比表
| 对比维度 | 顺序表 | 普通单链表 | 循环单链表 | 循环双链表 |
|---|---|---|---|---|
| 物理存储 | 连续 | 离散 | 离散 | 离散 |
| 指针个数/结点 | 0 | 1 | 1 | 2 |
| 存储密度 | 最高 | 较高 | 较高 | 较低 |
| 按位查找 | O(1) | O(n) | O(n) | O(n) |
| 按值查找 | O(n) | O(n) | O(n) | O(n) |
| 前插(已知位置) | O(n) | O(n)① | O(n)② | O(1) |
| 后插(已知位置) | O(n) | O(1) | O(1) | O(1) |
| 删除后继(已知位置) | O(n) | O(1) | O(1) | O(1) |
| 删除自己(已知位置) | — | O(n)③ | O(n)③ | O(1) |
| 正向遍历 | O(n) | O(n) | O(n) | O(n) |
| 反向遍历 | O(n) | 不可 | 不可 | O(n) |
| 尾插法建表 | O(1) | O(n²) | O(n) | O(n) |
| 约瑟夫环 | 不适合 | 不适合 | 适合 | 最适合 |
| 实现难度 | 简单 | 较低 | 较高 | 最高 |
① 普通单链表前插需要先找到前驱:O(n)
② 循环单链表前插可以用"偷天换日":O(1)
③ 单链表删除自己需要先找到前驱:O(n)
7.2 选择建议
| 场景 | 推荐结构 |
|---|---|
| 频繁随机访问(查找) | 顺序表 |
| 频繁在任意位置插入删除 | 循环双链表 |
| 只需头插/头删 | 单链表 / 循环单链表 |
| 约瑟夫环等环形问题 | 循环单链表 / 循环双链表 |
| 需要双向遍历 | 循环双链表 |
| 追求实现简单 | 顺序表 / 普通单链表 |
| 追求存储密度 | 顺序表 / 普通单链表 |
八、应用场景
8.1 适合使用循环双链表的场景
- 需要双向遍历:如文本编辑器(光标移动)、浏览器前进后退
- 频繁在任意位置插入删除:如LRU缓存淘汰算法
- 约瑟夫环问题:最合适的结构
- 操作系统调度:进程的双向就绪/阻塞队列
- 文件系统:目录树的双向链接
- 音乐播放器:播放列表的上一首/下一首切换
8.2 实际应用举例
| 应用 | 使用原因 |
|---|---|
| 浏览器前进/后退 | 双向链表天然支持前进后退 |
| LRU缓存淘汰 | 双向链表O(1)移动到表尾(最新访问) |
| Linux内核链表 | 内核中的经典数据结构 |
| 文本编辑器撤销/重做 | 双向链表维护操作历史 |
| 约瑟夫环 | 循环双链表效率最高 |
| 音乐播放器播放列表 | 支持上一首/下一首快速切换 |
九、高频考点与易错点
9.1 高频考点
考点1:四种指针修改 ⭐⭐⭐⭐
循环双链表插入/删除时,指针修改的数量和顺序是高频考点:
// 在p之后插入s(后插):修改4个指针
s->next = p->next; // ① s的后继 = p的后继
s->prior = p; // ② s的前驱 = p
p->next->prior = s; // ③ p的后继的前驱 = s
p->next = s; // ④ p的后继 = s
// 在p之前插入s(前插):修改4个指针
s->prior = p->prior; // ① s的前驱 = p的前驱
s->next = p; // ② s的后继 = p
p->prior->next = s; // ③ p的前驱的后继 = s
p->prior = s; // ④ p的前驱 = s
// 删除p的后继q:修改3个指针
p->next = q->next; // ① p的后继 = q的后继
q->next->prior = p; // ② q的后继的前驱 = p
free(q); // ③ 释放q
// 删除p本身:修改2个指针
p->prior->next = p->next; // ① p的前驱的后继 = p的后继
p->next->prior = p->prior; // ② p的后继的前驱 = p的前驱
free(p);
考点2:空表判断 ⭐⭐⭐
// 循环双链表(带头结点)空表的三种等价判断
L->next == L; // ✅ 后继指向自己
L->prior == L; // ✅ 前驱指向自己
L->next == L && L->prior == L; // ✅ 两者同时成立
考点3:循环遍历终止条件 ⭐⭐⭐
| 遍历方向 | 循环双链表终止条件 |
|---|---|
| 从头到尾 | p != L |
| 从尾到头 | p != L |
| 单链表(对比) | p != NULL |
| 循环单链表(对比) | p != L |
考点4:插入删除 O(1) 特性 ⭐⭐⭐⭐
循环双链表是唯一一种所有基本操作(除遍历查找外)都是 O(1) 的链表结构:
- 已知某结点 → 前插:O(1)
- 已知某结点 → 后插:O(1)
- 已知某结点 → 删除该结点:O(1)
- 已知某结点 → 删除其后继:O(1)
考点5:后插 vs 前插的选择 ⭐⭐
// 后插:已知位置 p,在 p 之后插入
InsertNextDNode(p, e); // s->next = p->next; s->prior = p; ...
// 前插:已知位置 p,在 p 之前插入
InsertPriorDNode(p, e); // s->prior = p->prior; s->next = p; ...
// 考研注意:若要在表尾插入,只能用前插(尾结点无后继!)
// 因为 p->next == L(头结点),前插会插到尾结点之前
考点6:循环双链表 vs 循环单链表 ⭐⭐
| 对比维度 | 循环单链表 | 循环双链表 |
|---|---|---|
| 指针数/结点 | 1 | 2 |
| 删除尾结点 | O(n)(需遍历找前驱) | O(1)(直接通过 prior) |
| 反向遍历 | ❌ 不支持 | ✅ O(1) 找尾,O(n) 遍历 |
| 前插 | O(1)(偷天换日) | O(1)(直接用 prior) |
| 存储密度 | 较高 | 较低 |
| 实现难度 | 较高 | 最高 |
9.2 易错点汇总
易错点1:前插/后插时指针顺序写错 ⭐⭐⭐
// ❌ 错误:先修改 p->next,再访问 p->next->prior(此时 p->next 已改变!)
s->next = p->next; // ① p->next 已指向 s
p->next->prior = s; // ② ❌ p->next 已是 s,无法正确链接!
// ✅ 正确顺序:先保存后继,再依次链接
s->next = p->next; // ① s 的后继 = p 的后继
s->prior = p; // ② s 的前驱 = p
p->next->prior = s; // ③ p 的后继的前驱 = s(此时 p->next 还没变)
p->next = s; // ④ p 的后继 = s
易错点2:删除时忘记处理前驱链接 ⭐⭐⭐
// ❌ 错误:只修改 next,忘记修改 prior
p->next = q->next; // ① 改 next
free(q); // ② 但 q->next 的 prior 还是指向 q!
// 结果:q->next->prior 仍指向已释放的 q!
// ✅ 正确:两个指针都要修改
p->next = q->next; // ① p 的后继 = q 的后继
q->next->prior = p; // ② q 的后继的前驱 = p
free(q); // ③ 释放 q
易错点3:删除尾结点时未处理循环关系 ⭐⭐
// 循环双链表中删除尾结点(p 为尾结点)
p->prior->next = p->next; // p->prior 为头结点(L)
// p->next 为头结点(L)
// 所以头结点的 next 应指向 p->next(即 L),无需额外处理
// 只需:p->prior->next = p->next(这一步已将 L 的 next 指向 L)
// 再:p->next->prior = p->prior(头结点的 prior 指向 p 的前驱,即 L)
// 最后 free(p)
// ✅ 循环双链表删除任意已知结点(除头结点外)都是 O(1)
易错点4:混淆普通双链表和循环双链表的判空 ⭐⭐
// 普通双链表(带头结点)空表判断
L->next == NULL; // ✅ 头结点 next 为 NULL
L->prior == NULL; // ✅ 头结点 prior 为 NULL
// 循环双链表(带头结点)空表判断
L->next == L; // ✅ 头结点 next 指向自己
L->prior == L; // ✅ 头结点 prior 指向自己
// ❌ 常见错误:循环双链表判空写成 NULL
if (L->next == NULL) // ❌ 这是普通双链表的写法!
易错点5:头插/尾插记混 ⭐⭐
// 头插(头结点之后插入):在 L 之后插入
s->next = L->next; // ① s 的后继 = 原来的第一个结点
s->prior = L; // ② s 的前驱 = 头结点
L->next->prior = s; // ③ 原第一个结点的前驱 = s
L->next = s; // ④ 头结点的后继 = s
// 尾插(头结点之前插入,即表尾):在 L->prior 之后插入
// 等价于:在尾结点之后插入(尾插法)
// 即在 L->prior 之后插入,可直接用前插
InsertPriorDNode(L, e); // ✅ 在头结点之前插入,即尾插
// 等价于:
s->next = L; // s 的后继 = 头结点
s->prior = L->prior; // s 的前驱 = 原尾结点
L->prior->next = s; // 原尾结点的后继 = s
L->prior = s; // 头结点的前驱 = s
易错点6:逆置时忘记头结点 ⭐⭐
// ❌ 错误:逆置时漏掉头结点的指针交换
// 只交换数据结点,忘记头结点的 prior 也要更新
// ✅ 正确:交换所有结点(包括头结点)的前驱和后继
void Reverse_Correct(DLinkList *L) {
DNode *p = *L;
DNode *q;
do {
q = p->next;
p->next = p->prior;
p->prior = q;
p = q;
} while (p != *L);
// 头结点也被正确交换了!
}
9.3 大题常见考法
- 选择题:四种指针修改的数量和顺序
- 选择题:循环双链表 vs 其他链表的复杂度对比
- 选择题:空表判断、遍历终止条件
- 选择题:给定结点指针,判断前插/后插/删除的时间复杂度
- 算法设计:删除循环双链表中所有值为 x 的结点
- 算法设计:合并两个循环双链表
- 填空题:写出前插/后插/删除操作的指针修改步骤
十、真题演练
题目1(选择题)⭐⭐⭐
题目:在循环双链表中,在指针 p 所指向的结点之后插入指针 s 所指向的结点,需要修改的指针数量为( )。
A. 2 B. 3 C. 4 D. 5
答案:C
解析:后插需要修改 4 个指针:
s->next = p->nexts->prior = pp->next->prior = sp->next = s
题目2(选择题)⭐⭐⭐
题目:在循环双链表中,删除指针 p 所指向的结点(假设 p 不是头结点),需要修改的指针数量为( )。
A. 1 B. 2 C. 3 D. 4
答案:B
解析:删除指定结点只需要修改 2 个指针:
p->prior->next = p->nextp->next->prior = p->prior
题目3(选择题)⭐⭐
题目:以下叙述中,正确的是( )。
A. 循环双链表不支持随机访问
B. 循环双链表的按位查找时间复杂度为 O(1)
C. 循环双链表的删除操作时间复杂度一定为 O(1)
D. 循环双链表不需要判满
答案:A
解析:
- A ✅:链表不支持随机访问,只能顺序访问
- B ❌:按位查找需遍历,时间复杂度为 O(n)
- C ❌:删除操作本身 O(1),但找到待删结点需要 O(n)
- D ✅:链表无需判满,但这不是循环双链表独有的特性
题目4(算法设计)⭐⭐⭐
题目:编写算法,在循环双链表中删除所有值为 x 的结点,并分析时间复杂度。
/* 删除循环双链表中所有值为 x 的结点 */
void DeleteX(DLinkList *L, ElemType x) {
if (*L == NULL || (*L)->next == *L) return; // 空表
DNode *p = (*L)->next;
while (p != *L) { // 遍历到头结点为止
if (p->data == x) {
DNode *q = p;
p = p->next; // 先保存下一个结点
q->prior->next = q->next; // 前驱的后继 = q 的后继
q->next->prior = q->prior; // q 的后继的前驱 = q 的前驱
free(q); // 释放 q
// 注意:p 已在上面保存,不需要重新找
} else {
p = p->next;
}
}
}
复杂度分析:时间 O(n),空间 O(1)。
题目5(算法设计)⭐⭐⭐
题目:编写算法,合并两个循环双链表 La 和 Lb,结果放在 La 中,并分析时间复杂度。
/* 合并两个循环双链表 La 和 Lb,结果放在 La 中 */
DLinkList MergeList(DLinkList *La, DLinkList *Lb) {
if (*La == NULL) return *Lb;
if (*Lb == NULL) return *La;
// 保存首尾指针
DNode *La_head = *La;
DNode *La_tail = (*La)->prior; // La 尾结点
DNode *Lb_head = (*Lb)->next; // Lb 首结点
DNode *Lb_tail = (*Lb)->prior; // Lb 尾结点
// 断开两个环,重新连接
La_tail->next = Lb_head; // La 尾连 Lb 首
Lb_head->prior = La_tail;
Lb_tail->next = La_head; // Lb 尾连 La 头
La_head->prior = Lb_tail;
free(*Lb); // 释放 Lb 的头结点
return *La;
}
复杂度分析:时间 O(1),空间 O(1)。这是循环双链表合并的巨大优势——无需遍历找尾结点!
十一、总结速记
🔑 核心概念速记
【循环双链表的本质】
= 双向链表 + 首尾相连
= prior 和 next 都形成环
= 无 NULL 指针
【四种指针操作速记】
后插(p后插入s):4步 s→后继 s→p 后继→s p→s
前插(p前插入s):4步 s→前驱 s→p 前驱→s p→s
删除后继(p的后继q):3步 p→后继 后继→p free(q)
删除自己:2步 前驱→后继 后继→前驱
【空表判断(三选一)】
L->next == L ✅
L->prior == L ✅
L->next == L && L->prior == L ✅
🔑 核心优势速记
循环双链表是所有链表结构中综合性能最强的:
✅ 按位查找:O(n)
✅ 前插:O(1)(已知结点)
✅ 后插:O(1)(已知结点)
✅ 删除:O(1)(已知结点)
✅ 正向遍历:O(n)
✅ 反向遍历:O(n)(可从尾结点直接开始)
✅ 合并两表:O(1)(无需遍历找尾)
🔑 循环双链表 vs 循环单链表一句话
循环单链表:只能单向遍历,删除尾结点需 O(n) 找前驱,但存储密度高
循环双链表:双向遍历,删除任意结点 O(1),但存储密度低(多一倍指针)
🔑 考试答题模板
写循环双链表算法时,必须包含:
- ✅ 后插:先保存后继,再依次修改 4 个指针
- ✅ 前插:先保存前驱,再依次修改 4 个指针
- ✅ 删除:同时修改前驱和后继的链接,共 2 步
- ✅ 遍历:
p != L(不能用p != NULL) - ✅ 空表:
L->next == L(不能用== NULL)
🔑 知识体系框架
循环双链表(Circular Doubly Linked List)
│
├── 结构特征
│ ├── prior → 指向前驱结点
│ ├── next → 指向后继结点
│ ├── 头结点的 prior → 尾结点(形成逆向环)
│ └── 尾结点的 next → 头结点(形成正向环)
│
├── 关键判断
│ ├── 空表:L->next == L(prior 也可)
│ ├── 遍历(正向):p != L
│ ├── 遍历(逆向):p != L
│ └── 尾结点:p->next == L
│
├── 核心优势
│ ├── 已知结点前插/后插:O(1)
│ ├── 已知结点删除自己:O(1)
│ ├── 双向遍历
│ └── 合并两表:O(1)
│
├── 指针操作
│ ├── 后插:4 个指针
│ ├── 前插:4 个指针
│ ├── 删除后继:3 个指针
│ └── 删除自己:2 个指针
│
└── 应用
├── 约瑟夫环
├── LRU 缓存淘汰算法
├── 浏览器前进/后退
├── 音乐播放器上一首/下一首
└── 操作系统进程调度
📚 参考资料
- 王道考研《数据结构》第2章 线性表
- 严蔚敏《数据结构(C语言版)》第2章
- 2027年408考试大纲·数据结构部分
本文涵盖2027年408考研数据结构循环双链表全部考点,建议结合真题反复练习代码实现。
如有疑问,欢迎在评论区交流!

1389

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



