双向链表与循环链表
极难4双向链表和循环链表:让链表更灵活、更好玩
你有没有玩过“传话游戏”?大家排成一列,只能往后传话。如果传话只能朝一个方向,万一中间有个人想往前传,就得从头开始。在编程中,单向链表就是这样:每个节点只有指向下一个节点的指针,不能回头。但如果我们给每个节点再装一个指向前一个节点的指针,它就变成了 双向链表——可以前后自由移动。如果再让最后一个节点的指针指向第一个节点,就变成了 循环链表——好像大家手拉手围成一个圈,永远没有终点。
这两种“升级版”链表在实际编程中非常有用。比如操作系统轮流执行任务时,就用循环链表让每个程序轮流获得CPU时间;再比如实现“撤销/重做”功能时,双向链表可以帮你轻松前后翻页。下面就让我们从生活例子出发,彻底搞懂它们!
生活中的例子:排队和丢手绢
- 单向链表:像幼儿园小朋友站成一列,每个人都只拉着前面人的衣服,老师喊“向后转”,大家只能一个个转头,很麻烦。
- 双向链表:每个小朋友都同时拉着前面和后面小朋友的衣服。你现在可以向前走,也可以向后走。比如你想从队尾走到队头,只需要不断往前找“前面的人”就行。
- 循环链表:最后一个小朋友的手搭在第一个小朋友的肩膀上,大家围成一个圈。这样无论从谁开始,都能转圈走。这就像“丢手绢”游戏,大家围坐一圈,手绢沿着圈传递。
这些生活中的场景,就是链表结构的基本思想。
一、双向链表:前后都有路
1. 概念与图示
双向链表的每个节点有 两个指针:prev(指向上一个节点)和 next(指向下一个节点)。头节点的prev是空(NULL),尾节点的next是空。
NULL <-- prev | 10 | next --> <-- prev | 20 | next --> <-- prev | 30 | next --> NULL
head tail
你可以从head出发,一路用next向尾部走;也可以从尾部出发,一路用prev向头部走。
2. 为什么需要双向链表?
- 双向遍历:反向遍历不需要重新从头开始,直接找尾节点就行。
- 快速删除任意节点:在单向链表中要删除一个节点,必须先找到它的前驱;而双向链表中有
prev指针,可以直接拿到前驱,时间复杂度从O(n)降为O(1)(前提是已知节点地址)。 - 缺点:每个节点多占一个指针的内存(在32位系统多4字节,64位多8字节)。
3. 常见错误(新手必看!)
- 忘记更新前驱指针:插入或删除时,只改了
next,没改prev,导致链表断裂。 - 访问空指针的
prev或next:比如在循环里cur = cur->next后,没有判断cur是否为NULL,就访问cur->prev。 - 头节点删除后没更新
head:删除头节点时,需要把head指向下一个节点,否则链表就丢了。
4. 完整代码(C++ 与 Python,带中文注释)
下面代码实现了双向链表的头插、尾插、删除、正向和反向打印。每行变量定义都加了注释,方便理解。
#include <iostream>
using namespace std;
// 定义双向链表节点结构体
struct Node {
int data; // 数据域
Node* prev; // 指向前一个节点的指针
Node* next; // 指向后一个节点的指针
Node(int val) : data(val), prev(nullptr), next(nullptr) {} // 构造函数
};
// 在链表头部插入新节点
void insertFront(Node* &head, int val) {
Node* newNode = new Node(val); // 创建新节点
if (head != nullptr) { // 如果链表非空
head->prev = newNode; // 原头节点的prev指向新节点
newNode->next = head; // 新节点的next指向原头节点
}
head = newNode; // 更新头指针为新节点
}
// 在链表尾部插入新节点
void insertBack(Node* &head, int val) {
Node* newNode = new Node(val); // 创建新节点
if (head == nullptr) { // 如果链表为空
head = newNode; // 新节点即为头节点
return;
}
Node* cur = head; // 临时指针cur从head开始
while (cur->next != nullptr) // 找到最后一个节点(next为nullptr)
cur = cur->next;
cur->next = newNode; // 最后一个节点的next指向新节点
newNode->prev = cur; // 新节点的prev指向原来的最后一个节点
}
// 删除第一个值为val的节点
void deleteValue(Node* &head, int val) {
Node* cur = head; // 从head开始查找
while (cur != nullptr && cur->data != val)
cur = cur->next; // 找到目标节点或遍历结束
if (cur == nullptr) return; // 没找到,直接返回
// 处理前驱指针
if (cur->prev != nullptr) { // 如果目标不是头节点
cur->prev->next = cur->next; // 前驱的next跳过当前节点
} else {
head = cur->next; // 目标为头节点,更新head
}
// 处理后驱指针
if (cur->next != nullptr) { // 如果目标不是尾节点
cur->next->prev = cur->prev; // 后驱的prev跳过当前节点
}
delete cur; // 释放内存
}
// 正向打印:从头到尾
void printForward(Node* head) {
Node* cur = head;
while (cur != nullptr) {
cout << cur->data << " <-> ";
cur = cur->next;
}
cout << "NULL" << endl;
}
// 反向打印:从尾到头(先找到尾节点)
void printBackward(Node* head) {
if (head == nullptr) return; // 空链表直接返回
Node* cur = head;
while (cur->next != nullptr) // 走到尾节点
cur = cur->next;
while (cur != nullptr) { // 从尾向前遍历
cout << cur->data << " <-> ";
cur = cur->prev;
}
cout << "NULL" << endl;
}
int main() {
Node* head = nullptr; // 初始化头指针为空
insertBack(head, 10); // 尾部插入10
insertBack(head, 20); // 尾部插入20
insertBack(head, 30); // 尾部插入30
insertFront(head, 5); // 头部插入5
cout << "正向:"; printForward(head); // 输出:5 <-> 10 <-> 20 <-> 30 <-> NULL
cout << "反向:"; printBackward(head); // 输出:30 <-> 20 <-> 10 <-> 5 <-> NULL
deleteValue(head, 20); // 删除值为20的节点
cout << "删除20后正向:"; printForward(head); // 输出:5 <-> 10 <-> 30 <-> NULL
return 0;
}
# 定义双向链表节点类
class Node:
def __init__(self, data):
self.data = data # 数据
self.prev = None # 前驱指针
self.next = None # 后继指针
# 定义双向链表类
class DoublyLinkedList:
def __init__(self):
self.head = None # 头指针
# 头部插入
def insert_front(self, val):
new_node = Node(val) # 创建新节点
if self.head: # 如果链表非空
self.head.prev = new_node # 原头节点的prev指向新节点
new_node.next = self.head # 新节点next指向原头节点
self.head = new_node # 更新头指针
# 尾部插入
def insert_back(self, val):
new_node = Node(val)
if not self.head: # 空链表
self.head = new_node
return
cur = self.head # 从头开始找尾
while cur.next:
cur = cur.next
cur.next = new_node # 尾节点next指向新节点
new_node.prev = cur # 新节点prev指向原尾节点
# 删除第一个值为val的节点
def delete_value(self, val):
cur = self.head
while cur and cur.data != val: # 查找
cur = cur.next
if not cur: # 没找到
return
if cur.prev: # 如果不是头节点
cur.prev.next = cur.next
else:
self.head = cur.next # 如果是头节点,更新head
if cur.next: # 如果不是尾节点
cur.next.prev = cur.prev
# 正向打印
def print_forward(self):
cur = self.head
while cur:
print(cur.data, end=" <-> ")
cur = cur.next
print("None")
# 反向打印
def print_backward(self):
cur = self.head
if not cur:
print("None")
return
# 先找到尾节点
while cur.next:
cur = cur.next
while cur:
print(cur.data, end=" <-> ")
cur = cur.prev
print("None")
# 测试代码
dll = DoublyLinkedList()
dll.insert_back(10)
dll.insert_back(20)
dll.insert_back(30)
dll.insert_front(5)
dll.print_forward() # 输出:5 <-> 10 <-> 20 <-> 30 <-> None
dll.print_backward() # 输出:30 <-> 20 <-> 10 <-> 5 <-> None
dll.delete_value(20)
dll.print_forward() # 输出:5 <-> 10 <-> 30 <-> None
二、循环链表:首尾相连,无限循环
1. 概念与图示
循环链表可以是单向,也可以是双向。它的特点是:最后一个节点的next指针不再指向NULL,而是指向第一个节点(头节点)。如果是双向循环链表,头节点的prev还会指向尾节点。
单向循环链表(图示):
+-----------------------+
| v
head->[1]->[2]->[3]--+
^
+------- (next of 3 指向 head)
双向循环链表(图示):
+----------------------------------------+
| v
[prev | 1 | next] <-> [prev | 2 | next] <-> [prev | 3 | next]
^ |
+--------------------------------------------------------------+
头节点的prev指向尾节点,尾节点的next指向头节点,形成了一个闭合的环。
2. 为什么需要循环链表?
- 适合“循环往复”的场景:比如多任务操作系统的时间片轮转,每个进程轮流占用CPU,用完时间片就排到队尾,循环链表可以轻松实现。
- 约瑟夫问题:N个人围成一圈,从1开始报数,报到M的人出列,循环链表能自然地模拟这个“围圈”过程。
- 不用考虑空链表? 小心,循环链表的遍历条件不再是
cur != NULL,而是cur != head(或者用一个计数器),否则会无限循环。
3. 常见错误(新手必看!)
- 死循环:在遍历循环链表时,如果忘记判断是否回到起点,就会一直转下去。解决办法:先记录起始点,当再次碰到它时停止。
- 删除所有节点后忘记处理:如果循环链表只有一个节点,删除它时要把头指针置空,并特别注意不要让指针指向自身。
- 插入时破坏循环:在尾部插入新节点时,要同时将新节点的
next指向头节点,并将原尾节点的next指向新节点(单向循环);双向循环则更复杂,需要更新四个指针。
4. 完整代码:单向循环链表(C++)
下面实现一个简单的单向循环链表,包含插入、打印、删除(演示约瑟夫环的删除逻辑用)。注意变量定义的中文注释。
#include <iostream>
using namespace std;
struct Node {
int data; // 数据
Node* next; // 下一个节点指针
Node(int val) : data(val), next(nullptr) {}
};
// 在循环链表尾部插入(实际上是把新节点添加到头节点之前?这里约定head指向头节点,尾节点指向head)
void insertAtEnd(Node* &head, int val) {
Node* newNode = new Node(val);
if (head == nullptr) { // 空链表
head = newNode;
newNode->next = head; // 自己指向自己,形成单节点循环
return;
}
// 找到尾节点(尾节点的next是head)
Node* cur = head;
while (cur->next != head) {
cur = cur->next;
}
cur->next = newNode; // 原尾节点指向新节点
newNode->next = head; // 新节点指向头节点
}
// 打印循环链表(从头节点开始,打印一圈后停止)
void printCircular(Node* head) {
if (head == nullptr) {
cout << "空链表" << endl;
return;
}
Node* cur = head;
do {
cout << cur->data << " -> ";
cur = cur->next;
} while (cur != head); // 回到头节点则停止
cout << "(回到头)" << endl;
}
// 删除第一个值为val的节点(适用于循环链表)
void deleteValueCircular(Node* &head, int val) {
if (head == nullptr) return;
Node* cur = head;
Node* prev = nullptr; // 前驱指针
// 先从头节点开始找
while (true) {
if (cur->data == val) break;
prev = cur;
cur = cur->next;
if (cur == head) return; // 转了一圈没找到
}
// 找到待删除节点cur
if (cur == head) { // 删除头节点
// 先找到尾节点
Node* tail = head;
while (tail->next != head) {
tail = tail->next;
}
if (head->next == head) { // 只有一个节点
delete head;
head = nullptr;
return;
}
head = head->next; // 头指针后移
tail->next = head; // 尾节点指向新头
delete cur;
} else {
prev->next = cur->next; // 前驱跳过cur
delete cur;
}
}
int main() {
Node* head = nullptr; // 初始化头指针为空
insertAtEnd(head, 10);
insertAtEnd(head, 20);
insertAtEnd(head, 30);
insertAtEnd(head, 40);
cout << "循环链表:";
printCircular(head); // 输出 10 -> 20 -> 30 -> 40 -> (回到头)
deleteValueCircular(head, 20);
cout << "删除20后:";
printCircular(head); // 输出 10 -> 30 -> 40 -> (回到头)
return 0;
}
三、经典应用:约瑟夫环(Josephus Problem)
约瑟夫环的故事:古代有N个人围成一圈,从第1个人开始报数,数到M的人出局,下一个人重新从1开始,直到只剩最后一个人。这就是约瑟夫问题,用循环链表实现最自然。
核心思路(C++ 伪代码,但可运行):
- 构建一个单向循环链表,节点数N。
- 设置当前指针
cur指向第一个节点。 - 当链表节点数大于1时:
- 向前走M-1步(因为当前已经算报1)
- 删除当前节点(出局)
- 让
cur指向被删节点的下一个
- 最后剩下的节点就是答案。
下面是一个完整可运行的C++代码(基于上面的循环链表,但专门为约瑟夫环优化):
#include <iostream>
using namespace std;
struct Node {
int data; // 存放编号(1~N)
Node* next;
Node(int val) : data(val), next(nullptr) {}
};
// 构建N个人的循环链表(编号1~N),返回最后一个节点(方便后续操作)
Node* buildCircle(int N) {
Node* head = new Node(1); // 头节点编号1
head->next = head; // 先自循环
Node* prev = head; // 前驱指针
for (int i = 2; i <= N; i++) {
Node* newNode = new Node(i);
newNode->next = head; // 新节点指向头
prev->next = newNode; // 前驱指向新节点
prev = newNode; // 更新前驱
}
return prev; // 返回尾节点(其next指向头)
}
// 约瑟夫环:N个人,数到M出局,返回幸存者编号
int josephus(int N, int M) {
Node* prev = buildCircle(N); // prev指向尾节点
Node* cur = prev->next; // cur指向头节点(第一个人)
while (cur->next != cur) { // 只要不止一个节点
// 走M-1步(当前已经算第1步)
for (int i = 1; i < M; i++) {
prev = cur; // 前驱更新
cur = cur->next; // 当前指针前进
}
// 删除cur节点(出局)
prev->next = cur->next;
cout << "出局:" << cur->data << endl;
delete cur;
cur = prev->next; // 从出局的下一个人继续
}
int survivor = cur->data;
delete cur;
return survivor;
}
int main() {
int N = 7, M = 3;
cout << "约瑟夫环:N=" << N << ", M=" << M << endl;
int winner = josephus(N, M);
cout << "幸存者编号:" << winner << endl;
return 0;
}
运行结果示例(N=7, M=3):
出局:3
出局:6
出局:2
出局:7
出局:5
出局:1
幸存者编号:4
(提示:如果看不习惯这个构建方式,也可以先构建单向循环链表再操作。关键在于循环遍历和删除节点。)
对应 Python 实现(简洁版)
# 定义节点类
class Node:
def __init__(self, data):
self.data = data
self.next = None
def josephus(N, M):
# 构建循环链表,返回头节点
head = Node(1)
head.next = head
prev = head
for i in range(2, N+1):
new_node = Node(i)
new_node.next = head
prev.next = new_node
prev = new_node
# 现在prev是尾节点,cur从头开始
cur = head
while cur.next != cur: # 多于一个节点
for _ in range(M-1):
prev = cur
cur = cur.next
# 删除cur
prev.next = cur.next
print(f"出局:{cur.data}")
cur = cur.next
print(f"幸存者:{cur.data}")
return cur.data
josephus(7, 3)
四、双向链表 vs 循环链表 vs 数组:如何选择?
| 特性 | 双向链表 | 循环链表 | 数组 |
|---|---|---|---|
| 内存占用 | 每个节点多一个指针,较大 | 同单向链表,多一个指针(如果双向) | 连续内存,无指针开销 |
| 随机访问 | 需要遍历,O(n) | 需要遍历,O(n) | O(1) |
| 插入/删除 | 已知位置时O(1) | 同单向链表O(1) | 可能需要移动元素O(n) |
| 双向遍历 | 支持 | 支持(双向循环) | 支持(索引前后) |
| 适用场景 | 需要频繁双向移动、撤销/重做 | 循环任务、约瑟夫环 | 需要快速随机访问 |
生活类比:
- 双向链表 → 电影播放器的进度条,可以前进后退。
- 循环链表 → 食堂打饭的“轮转窗口”,每个人打完饭就轮到下一个,永远循环。
- 数组 → 班级座位表,老师可以喊“第3排第4个同学”直接找到。
五、常见错误总结(再强调一遍)
- 指针迷路:在双向链表中插入或删除时,忘记同时更新
prev和next,导致链表断了。 - 空指针访问:在遍历时,
cur = cur->next后没有检查是否为空,就直接读cur->data,程序会崩溃。 - 未更新头指针:删除头节点后,忘记
head = head->next;或者在头部插入后,忘记head = newNode。 - 循环链表的死循环:遍历条件写成了
while(cur != NULL),但循环链表永远不会有NULL,于是变成死循环。正确的写法是while(cur != head)或使用do...while。 - 内存泄漏:动态分配节点后,忘记
delete(C++)或没有释放。Python虽然有垃圾回收,但最好显式断开引用。
六、总结与进阶指引
- 双向链表:每个节点有前后指针,适合需要来回移动的场景,代价是内存多。
- 循环链表:首尾相连,适合循环遍历,实现约瑟夫环、时间片轮转等。
- 双向循环链表:同时拥有两者优点,但实现稍微复杂。
- 学习路线:先完全掌握单向链表,再学双向,最后学循环。理解指针/引用的操作是基础。
如果你已经掌握了这些,可以继续学习:
- 静态链表:用数组模拟链表,节省指针空间。
- 链队列和链栈:用链表实现队列和栈,利用链表灵活扩展。
- 跳表:一种可以快速查找的链表,类似多层索引。
- LRU缓存:利用双向链表+哈希表实现最近最少使用淘汰策略。
数据结构的奥妙在于:选对结构,代码变简单。希望这篇文章能帮你轻松驾驭双向和循环链表!
例题精讲
在双向链表中删除一个已知节点p(非头尾节点),需要修改几个指针指向?
在带头节点的循环链表中,头指针head指向头节点,则判断链表为空的表达式是 head->next == head。
以下代码实现双向链表在节点p之后插入新节点newNode,请填空:
newNode->prev = p;
newNode->next = ___;
if (p->next != NULL) {
p->next->prev = newNode;
}
p->next = newNode;关于循环链表,以下说法正确的是?
在双向循环链表中,节点p满足 p->next == p->prev 时,p一定是唯一的节点(仅有一个实际数据节点)。