1. 从单链表到循环链表:理解循环链表的核心动机
1.1 为什么“最后一个结点”值得被特殊对待
先说一个我当年初学数据结构时一直没想明白的问题:单链表已经很方便了,为什么还要搞一个循环链表出来?后来真正在项目中处理“轮询”“调度”“缓冲”这类场景时,才意识到这个看起来只是“把尾结点的next指回头结点”的小改动,本质上是改变了数据结构的思维方式。
单链表有一个隐含的边界:走到最后一个结点之后,没法直接回到起点。想从头再走,必须额外保存一个头结点指针,或者重新遍历一遍。这就好比你手里有一串钥匙,每把钥匙指向下一把钥匙,但是最后一串钥匙的尾部是个悬空的,你必须另外记住第一把钥匙放在哪里才能从头再来。而循环链表做的事情,就是把最后一串钥匙挂回第一串钥匙上,整个结构首尾相连,彻底消除了“尽头”这个概念。
这个差异带来的实际收益非常明显:它允许你在“任意一个结点”上都能完整地遍历整个链表。约瑟夫环问题、操作系统的进程轮转调度、音视频播放器的循环播放列表、网络数据包缓冲队列,这些场景的共同需求都是“走了一圈之后要重新开始”。从循环链表的视角看,没有所谓的开始和结束,只有当前所处的结点。
从数据结构的角度讲,循环链表并没有增加任何新的操作类型,插入、删除、查找这几个基本操作和单链表几乎完全一致。真正的差别在于边界条件和循环终止条件的处理,而这恰恰是初学者最容易翻车的地方。
1.2 循环链表到底解决哪几类问题
我在实际使用中总结下来,循环链表主要解决三类问题:第一类是“周期性遍历”问题,所有结点需要被轮询访问,且永远是“访问完最后一个之后继续访问第一个”,比如处理器的轮转调度算法(Round Robin)。第二类是“环形缓冲区”问题,数据在固定大小的空间里循环写入和读取,读指针追着写指针跑,写指针绕一圈回到起点再追着读指针跑,这类场景如果用数组实现也可以,但链式结构在“频繁添加和移除头尾元素”时能避免整体搬迁数据。第三类是“约瑟夫环”这种经典的数学建模问题,n个人围成一圈,从某个位置开始报数,报到m的人出列,然后继续从下一个人开始报数,直到最后一个人出列为止。
如果你要准备考研或面试,循环链表绝对是在“必背清单”里的内容。特别是热词里面反复出现的“考研数据结构”“数据结构王道”“数据结构408”,不管是统考还是自命题,链表的变体——循环链表、双向链表、双向循环链表——都是高频考点。考试通常不会让你默写全部代码,但会给你一个具体的操作场景,让你徒手写出“往循环链表的末尾插入一个结点”“删除指定值的结点并保持循环特性”这类核心代码。
从学习曲线来看,循环链表是单链表和双向链表之间的一个承上启下的节点,理解了它,后面学双向循环链表、甚至更复杂的图结构里的邻接表都会顺一些。所以这篇文章我打算用最贴近考场和面试的风格,把循环链表从原理到实现再到应用一次讲透,代码全部用C语言实现。C语言版本的链表逻辑最直白,指针操作一目了然,看懂之后换Java、Python的链表实现基本无压力。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 结构定义与核心操作:先建立正确的“循环直觉”
2.1 结构体定义和带头结点与不带头结点的选择
循环链表的结点定义和单链表一模一样,就一个数据域加一个指针域:
c复制typedef struct Node {
int data; // 数据域
struct Node *next; // 指针域
} Node, *LinkedList;
操作上,循环链表有两种组织方式:一种是不带头结点,让尾结点的next直接指向第一个实际结点;另一种是带头结点,尾结点的next指向一个“哨兵结点”,头结点的next才指向真正的第一个数据结点。两者各有适用场景。
我在考研教学和实际编码中更推荐带头结点的写法。原因有两个:
第一,带头结点后,头结点的位置永远是稳定的,不管链表是不是空表,都有统一的表示法。空链表就是头结点的next指向它自己,这是一个非常有用的“空表状态”,写判断逻辑时特别干净。不带头结点的话,空表就是NULL,操作时经常需要判断“链表是否为空”“插入位置是不是表头”这些边界条件,容易漏。
第二,带头结点可以统一“插入到第一个位置”和“删除第一个结点”的操作逻辑,不需要单独修改外部头指针。这是初学链表时最容易出bug的地方,带个哨兵结点就能彻底绕开。
带头结点的循环链表,空表状态长这样:
c复制Node head;
head.next = &head; // 头结点的next指向自己
这个写法刚看到时可能会有点迷惑,但它表达的含义非常优雅:循环链表的空表不是一个孤零零的NULL,而是一个“自己绕着自己转”的头结点。你可以想象成一个人原地转圈,虽然什么都没带,但转圈这个动作本身已经就绪。后面写遍历条件、判空条件时,你会发现这行代码让整个实现简洁很多。
2.2 初始化、判空、遍历:三个最基础的函数
初始化函数的作用就是创建一个头结点,让它自循环。这一步写完之后,后续所有操作都基于这个“已经循环起来”的结构展开:
c复制LinkedList initList() {
LinkedList head = (LinkedList)malloc(sizeof(Node));
if (head == NULL) {
exit(1);
}
head->next = head; // 关键:空表时头结点的next指向自己
return head;
}
判空操作就是检查头结点的next是否还是自己:
c复制int isEmpty(LinkedList head) {
return head->next == head;
}
遍历的关键在于循环终止条件。单链表的遍历条件是“p != NULL”,也就是走到空指针就停。循环链表没有空指针,所以终止条件变成了“p != head”——从头结点的next出发,依次访问每个结点,当指针重新回到头结点时,说明一整圈走完了。
c复制void traverseList(LinkedList head) {
Node *p = head->next;
while (p != head) {
printf("%d ", p->data);
p = p->next;
}
printf("\n");
}
这里有一个我见过无数初学者(包括当年的我自己)都会犯的错误:遍历时用 while (p != NULL),然后程序直接死循环。因为循环链表里面根本没有NULL,p永远在链上转圈,永远不会停。我在实验室里第一次跑这种代码的时候,控制台刷了一整屏的数据还在往下滚,当时以为电脑卡死了,其实是循环条件写错了。所以看到“循环链表死循环”的问题,第一反应就应该是检查遍历终止条件是不是还在用 p != NULL。
2.3 插入与删除:差别在于“记住前驱”和“边界处理”
插入和删除操作在思想上和单链表完全一致,核心都是“找到目标位置的前驱结点”,然后修改指针。但循环链表有一个额外的要求:修改完指针之后,链表的循环性质必须依然成立。
以尾插法为例,假设我们维护了一个指向尾结点的指针rear(这个优化在循环链表中特别实用),那么在末尾插入新结点的逻辑是:
c复制void insertAtTail(LinkedList head, Node *rear, int data) {
Node *newNode = (Node*)malloc(sizeof(Node));
newNode->data = data;
newNode->next = rear->next; // 新结点指向头结点(原尾结点的next)
rear->next = newNode; // 原尾结点指向新结点
rear = newNode; // 更新尾结点指针
}
这个操作之所以比单链表优雅,是因为新结点插入到尾部之后,它的next天然就指向头结点,循环关系自动成立,不需要额外处理“尾部是边界”这种特殊情况。单链表在新结点插入尾部后必须手动把新结点next置为NULL,循环链表完全不需要。
删除操作的逻辑也类似,找到被删结点的前驱prev,然后执行:
c复制prev->next = target->next;
如果删除的恰好是尾结点,需要额外更新rear指针;如果删除后链表变成了空表,要确保头结点的next重新指向自己。这些都是容易踩坑的边界条件,后面在常见问题章节我会专门展开。
3. 手写一个循环链表的完整实现:从创建到销毁
3.1 准备工作与创建链表
下面我直接从零开始,手写一个带头结点的循环链表。代码环境是普通的C语言编译器,全程只用到 stdio.h、stdlib.h 这两个标准头文件。每个函数我都会讲清楚“这一步在干什么”“为什么这么写”,而不是单纯贴一段代码让你自己看。
整个实现分四个步骤:创建、遍历、插入删除、销毁。为了演示,我会实现一个“根据数组元素批量创建链表”的函数,它可以灵活地生成任意长度的循环链表,便于测试。
c复制#include <stdio.h>
#include <stdlib.h>
typedef struct Node {
int data;
struct Node *next;
} Node, *LinkedList;
// 根据数组批量创建循环链表,返回头结点
LinkedList createList(int arr[], int n) {
LinkedList head = (LinkedList)malloc(sizeof(Node));
head->next = head; // 空表自循环
Node *tail = head; // 尾指针,初始指向头结点
for (int i = 0; i < n; i++) {
Node *newNode = (Node*)malloc(sizeof(Node));
newNode->data = arr[i];
newNode->next = tail->next; // 新结点指向头结点
tail->next = newNode; // 尾结点指向新结点
tail = newNode; // 更新尾指针
}
return head;
}
注意 tail 这个变量的使用。很多人的第一版代码喜欢每次插入都从头遍历一次找到尾部,这样每插入一个结点的复杂度是O(n),整体创建变成了O(n²)。用 tail 指针记录当前尾部,插入一次O(1),整个创建过程线性的O(n)。这是一个非常朴素的“用空间换时间”的思路,也是一个好的链表实现和土味实现的分水岭。
3.2 打印遍历与测环验证
创建完成之后,写一个遍历函数,再从数组创建链表验证。遍历代码刚才已经写过,这里直接用一个验证例子:
c复制int main() {
int arr[] = {3, 5, 7, 9};
LinkedList list = createList(arr, 4);
traverseList(list);
return 0;
}
输出结果应该是 3 5 7 9。我来解释一下这里“正确”的隐含意义:输出正常,说明从头结点出发,确实依次访问了4个数据结点,并且在第4个数据结点的next指向头结点时停下来了。如果遍历条件是 p != NULL,代码会输出 3 5 7 9 3 5 7 9 3 5 7 9 ... 直到控制台崩溃。所以这个测试同时也在验证循环关系是否正确建立。
有一个小技巧可以快速验证链表是否真的“循环”了:把遍历函数里 p = p->next 改成每输出一个元素就 p = p->next,然后在循环体内输出当前 p 的地址。如果地址在头结点和各个数据结点之间循环重复出现,说明链表的循环性是好的。
3.3 指定位置插入与值删除的实现
接下来是插入操作。我在实际考试和面试的真题里见得最多的,就是“在指定位置插入结点”和“按值删除结点”。指定位置插入的完整代码:
c复制// 在第pos个位置(从1开始计数)之前插入结点,pos=1表示插入到头结点之后
void insertAtPos(LinkedList head, int pos, int data) {
Node *prev = head;
int count = 0;
// 找到插入位置的前驱
while (prev->next != head && count < pos - 1) {
prev = prev->next;
count++;
}
Node *newNode = (Node*)malloc(sizeof(Node));
newNode->data = data;
newNode->next = prev->next;
prev->next = newNode;
}
这里的边界条件是“prev->next == head”的时候停止,也就是已经找到尾部。如果pos超过链表长度,会插在末尾后面,这跟单链表的行为略有差异,但保持了循环性。按值删除的实现:
c复制int deleteByValue(LinkedList head, int value) {
Node *prev = head;
Node *target = head->next;
// 遍历整圈,找到data等于value的结点
while (target != head) {
if (target->data == value) {
prev->next = target->next; // 跳过目标结点
free(target);
return 1; // 删除成功
}
prev = target;
target = target->next;
}
return 0; // 没有找到
}
这个删除操作中隐藏了一个非常重要的边界处理:如果待删除的结点是“尾结点”(也就是next指向头结点的那个),上面的 prev->next = target->next 会把尾结点的前驱直接指向头结点,循环结构依然成立。这就是循环链表最舒服的地方,不需要像单链表那样讨论“删除的是不是头结点”和“删除后要不要处理尾部悬空”,循环结构把尾部边界自动消化掉了。
3.4 清空与销毁:避免内存泄漏的注意事项
链表销毁是一个经常被忽略但面试官非常爱问的细节。它的核心难点在于:循环结构里每一个结点都是被“某个结点”指向的,直接用free释放当前结点没问题,但释放完之后还要能够继续访问下一个结点,所以需要先保存next指针再释放。
c复制void destroyList(LinkedList head) {
Node *p = head->next;
while (p != head) {
Node *temp = p->next;
free(p);
p = temp;
}
free(head);
}
我见过很多人在销毁链表时“挂着free写着写着就丢了下一个结点的指针”,原因是直接 free(p); p = p->next;,但free之后p可能已经失效,再去取p->next就是野指针操作。所以必须先用 temp 保存 p->next,再free(p)。这个习惯在C语言链表相关题目里属于“保命题”,养成习惯后不只是循环链表,任何链式结构的清理都能避免内存泄漏。
提示:如果你用的是C++,清空链表后建议把指针置空,避免出现“悬空指针”。Java和Python这类有GC的语言不需要手动free,但理解这个逻辑依然很重要,因为面试时考官常常会追问“底层发生了什么”。
4. 经典应用实战:约瑟夫问题与环形队列
4.1 约瑟夫问题:循环链表最经典的“点名淘汰”模拟
约瑟夫问题(Josephus Problem)是循环链表最具代表性的应用,没有之一。问题描述很简单:n个人围成一圈,编号从1到n,从第1个人开始报数,报到m的人出圈,然后从出圈者的下一位重新开始报数,如此反复,直到最后只剩一个人,求最后幸存者的编号。典型场景就是古代“数数淘汰”的典故,或者你参加集体活动时“报数出列”那种游戏。
用循环链表来解这个问题的思路很直接:把n个人做成n个结点的循环链表,用一个计数器从1开始数,数到m时删除当前结点,然后从下一个结点重新开始数。删除n-1次后,链表中剩下的唯一的结点就是幸存者。
下面我给出一个完整可运行的C语言实现:
c复制// n个人围成一圈,从1号开始报数,报到m的人出列
// 返回最后幸存者的编号
int josephus(int n, int m) {
LinkedList head = (LinkedList)malloc(sizeof(Node));
head->next = head;
Node *tail = head;
// 初始化:创建编号1~n的循环链表
for (int i = 1; i <= n; i++) {
Node *newNode = (Node*)malloc(sizeof(Node));
newNode->data = i;
newNode->next = tail->next;
tail->next = newNode;
tail = newNode;
}
// 当前从head->next开始数(即1号)
Node *prev = head;
Node *current = head->next;
int count = 0;
// 当链表里只剩下一个数据结点时停止
while (head->next->next != head) { // 头结点的next->next指向说明剩余2个以上结点
count++;
if (count == m) {
// 淘汰当前结点
prev->next = current->next;
free(current);
current = prev->next;
count = 0; // 从下一个人重新报数
} else {
prev = current;
current = current->next;
}
}
int result = head->next->data;
destroyList(head);
return result;
}
这个代码的终止条件值得单独分析一下。循环链表中剩一个数据结点的状态是:头结点的next指向这个唯一结点,这个唯一结点的next又指向头结点。所以判断剩余结点个数可以用 head->next->next != head 作为“多于一个”的条件。在删除过程中,head->next 指向当前圈中第一个存活结点,当它等于唯一幸存结点时,head->next->next 就会回到head,循环停止。这个判断写起来简单,但逻辑上等价于“链表剩余数据结点数为1”,理解后记忆非常牢固。
测试一下:n=5,m=3的时候,淘汰顺序是3号、1号、5号、2号,最后幸存4号。程序输出4,正确。n=7,m=3的时候,淘汰顺序是3、6、2、7、5、1,幸存4号。这些经典用例可以用来验证你的代码逻辑是否和手推一致。
4.2 约瑟夫变体与循环链表的优势细节
约瑟夫问题有一个经常考的变体:要求输出“出圈顺序”,而不只是最后幸存者。这个变体只需要把代码中“淘汰当前结点”部分的 free(current) 改成先打印 current->data 再删除即可。
还有更进一步的变体,比如“从第k个人开始报数(k≠1)”,解法也很简单,只要在进入循环之前先把 current 和 prev 移动k-1次,让指针定位到第k个人,后续逻辑完全不变。这说明只要理解了“当前指针指向谁,报数就从谁开始”,约瑟夫问题所有的变体都能一个框架解决。
我在实际讲这类题的时候听到最多的问题是:“这个题不是有数学公式O(n)吗?为什么还要用链表模拟?”从做题的角度讲,数学公式确实更快,约瑟夫问题确实有一个递推关系能直接算出幸存者编号。但面试官如果出了链表题,他考察的重点不是“你能不能直接数学推导”,而是“你能不能灵活运用循环链表”,特别是在内存操作和边界处理上的细节。所以模拟法虽然在时间复杂度上不如数学公式,但在验证你数据结构基本功这件事上,反而更能加分。两种方法我都会说,并且我在面试中会先讲数学公式,再讲链表模拟,让面试官看到你既懂理论也能欣赏实操。
4.3 环形缓冲区:工程上更频繁的应用场景
除了约瑟夫问题,循环链表在工程上更常见的应用是做环形缓冲区。举一个具体的例子:在一台单片机上,有一路传感器不断产生数据,另一路通信模块需要定期获取数据上传。如果传感器每次都等通信模块取完才写入,会互相等待,降低吞吐;如果不加缓冲,传感器一次采样还没被取走就被覆盖,数据就丢了。解决办法就是在中间放一个环形的数据缓冲结构。
数组实现环形缓冲区容易有一个“队列满”和“队列空”的区分问题,因为头尾指针相遇时既可能是空也可能是满。而循环链表实现天然区分这两种情况:队列空就是头结点的next指向自己(一个数据结点都没有),队列满就是这个缓冲区的容量上限到达后的主动判断。链表的好处是当缓冲区满了的时候,可以选择覆盖最旧的数据而不需要整体搬迁数组元素,这在数据量大时非常重要。
我举一个简化版的应用场景:做一个固定容量为k的环形队列,支持“生产者不断往尾部写入”、“消费者从头部取出数据”、“队列满时自动丢弃最旧数据”三个操作。用循环链表实现这个“丢弃最旧数据”的操作非常顺手——找到头结点的next(也就是最旧数据),把它删除,然后整体平移:头结点指向下一个。数组版本想要达到同样的效果,最差情况下要O(k)的数据搬移,链表版本只要O(1)改几个指针。
这个思路在很多实时系统、网络抓包工具、音频播放器的花样播放逻辑里都有体现。音频播放器里的“单曲循环”其实就是循环链表的一个非常直接的工程应用:所有歌曲排成循环链表,从头唱到尾,到尾之后自动回到第一首,这不就是循环链表最朴素的动机吗?
5. 双向循环链表、与单链表的对比及面试考研考点
5.1 循环链表升级:双向循环链表
学完循环链表之后,还有一个非常自然的进阶方向是双向循环链表——每个结点既有next又有prior,分别指向后继结点和前驱结点,头结点的prior指向尾结点,尾结点的next指向头结点。相当于“首尾相连的双向通道”。
双向循环链表把“找前驱”的操作从O(n)直接降到了O(1),在需要频繁访问前驱结点的场景下非常有用。典型应用是操作系统的LRU缓存淘汰算法。LRU,全称Least Recently Used,也就是“最近最少使用”淘汰策略,它需要维护一个按访问时间排序的链表,每次访问命中某个结点时,要把它移动到链表头部;缓存满了之后要删除链表尾部的结点。这两个操作一个需要“找到前驱”,一个需要“快速访问尾部”,双向链表加一个tail指针就完美应对。
我在面一些候选人时喜欢用LRU来考察,因为它能把单向的、双向的、循环的以及哈希表这些数据结构串在一起。如果你能把双向循环链表讲透,说明你对链表家族的理解已经超过平均水平了。
5.2 循环链表 vs 单链表 vs 双向链表:一张表看懂差异
选哪种链表,取决于你的场景需求。下面这张表是我根据自己的经验整理的,考研复习和面试前拎出来看一眼非常有帮助:
| 对比维度 | 单链表 | 循环链表 | 双向链表 |
|---|---|---|---|
| 最后一个结点的next | 指向NULL | 指向头结点 | 双向链表尾结点也可指向NULL |
| 从头遍历整个链表 | 只能从头开始,到尾结束 | 任意结点都能走到全链 | 从头到尾,或从尾到头 |
| 找前驱结点 | 必须重新遍历,O(n) | 必须重新遍历,O(n) | O(1),有prior指针 |
| 是否存在“尽头” | 有,遍历会停 | 没有,天然循环 | 取决于实现方式 |
| 常见应用 | 普通集合存储、栈、队列的链式实现 | 约瑟夫问题、轮转调度、环形缓冲 | LRU缓存、编辑器的撤销列表 |
| 实现复杂度 | 低 | 中,边界条件在“循环终止” | 较高,需维护两个指针域 |
从这张表可以看出,循环链表最核心的卖点就是“没有尽头”和“任意结点开始都能遍历全链”。这不是性能上的优势(遍历复杂度还是O(n)),而是结构上的灵活性。在实际面试中,我常建议考生先判断“要不要从任意位置开始遍历”“要不要循环访问”“要不要删除尾部之后从头再来”,这三个判断做完答案基本就出来了。
5.3 从《大话数据结构》到408真题:这个考点怎么考
热词里出现了“大话数据结构”“数据结构王道”“考研数据结构”“数据结构408”这些词,说明这个话题的主要读者里备考的人不少。从我了解到的考研命题规律来看,循环链表这个考点常见考法有四类。
第一类是概念判断,比如“带头结点的空循环链表中head->next的指向?”答案是head自身。第二类是代码填空题,给你一段循环链表的插入或删除代码,让你补上某个关键语句,常见答案就是 prev->next = target->next 或者 head->next = head。第三类是用算法题,像约瑟夫问题“用循环链表实现,写出主要结构体和核心算法”,这是很多学校自命题的大题风格,代码不用完整写,但是核心部分要能默写。第四类是综合题,比如把它和双端队列、栈结合起来考,甚至在图结构的邻接表里融入链表的循环特性。
《大话数据结构》这本书把循环链表放在“线性表”章节,讲解风格很通俗,适合初学;王道的辅导书则更偏应试,里面有很多和考研真题风格一致的训练题。我的建议是:先用《大话数据结构》建立直观理解,再用王道的题来检验掌握程度,最后用408真题查漏补缺。只做题不读书容易死记硬背,只读书不做题容易眼高手低,两者配合效率最高。
面试的话,我总结了三个最常出现的问题:
- 如何判断一个链表中是否存在环?这个问题的变体是“怎么证明一个链表是循环链表”,核心方法是快慢指针法,一个指针每次走一步,一个指针每次走两步,如果在某一个时刻它们相遇了,说明存在环。
- 如何在循环链表上实现一个约瑟夫淘汰过程?重点在于报数的循环和删除的指针操作。
- “循环链表和单链表相比有什么优势?”一个标准且能让面试官满意的回答是:能从任意一个结点开始完整遍历整条链表,可以很方便地实现数据的循环复用,以及在某些场景下能避免无效的判空操作。
6. 常见问题与排查技巧实录
6.1 死循环问题:最经典也最吓人的一个
循环链表最常见的问题就是“程序运行后一直不结束,控制台疯狂输出”。像前面提到的,九成以上是因为遍历的循环终止条件写成了 p != NULL。这个问题的排查思路非常简单:第一,看看代码里有没有 while (p != NULL) 这类语句,有就换成 while (p != head);第二,检查插入操作里有没有某个结点的next始终没有正确绑定,导致链表在某处断开的假象——如果是这种情形,程序可能会先到某个结点之后跳到野地址,行为不可预测,需要借助调试器逐步跟踪。
如果你用的是IDE的调试器,可以加一个日志输出,在遍历循环里打印当前结点的地址。正常应该看到地址连续往返于头结点和数据结点之间,如果某一次打印的地址突然变成了0x0或一个奇怪的数字,说明链表的某个指针没有正确初始化。
提示:在循环链表代码中,建议在初始化头结点后就用
head->next = head把循环闭合,任何后续操作都不要破坏这个闭合,这样能避免一大半的“遍历超时”问题。
6.2 “最后一个结点丢失”和“插入后循环断裂”
另一个高频问题是插入结点的操作写完之后,链表的某一个地方断开了,导致遍历只能走到一半。这类问题的典型场景是“尾插时没有更新新结点的next”。举个例子:
c复制tail->next = newNode; // 忘记 new->next = tail->next;
如果一开始就忘了设置 newNode->next = tail->next,那么新结点的next就是一个随机值(取决于malloc之后内存原有的内容),整个链表就断了。这跟单链表一个道理,但因为循环链表还要保证“绕回来”,断开的后果更隐蔽——程序可能运行几千次之后才在某个不可预知的位置崩溃,很难定位。
我的习惯是写任何插入函数,先写“新结点指向原位置的next”,再写“前驱结点指向新结点”,顺序固定,脑子里永远保持“先连新、再掐旧”的原则。这个顺序听上去很简单,但在写复杂插入逻辑时可以救命。
6.3 快速查漏:一段必背的循环链表检查流程
最后分享一套我自己的代码检查流程,每次写完循环链表相关代码,按这个顺序自查,基本能解决95%的问题:
- 初始化时,检查
head->next == head是否成立(空表自循环)。 - 插入后,检查新插入结点的next是否指向了“应该指的结点”,特别是尾部插入时要指向head。
- 删除后,检查被删结点的前一个结点的next是否成功跳过了目标结点。
- 遍历前,检查终止条件用的是不是
p != head,不是就改。 - 销毁时,检查是否先保存
next再free当前结点。 - 最后测试两个极限情况:空表执行打印和插入操作,单结点链表的删除操作。
这套流程大概用不了两分钟,但能救下很多调试时间。说实话,我当年学链表的时候最崩溃的就是调试这种“指针乱飞”的bug,后来养成这套习惯之后,几乎再没在链表代码上翻过车。
7. 双端队列、排序算法与数据结构体系的串联
7.1 循环链表在双端队列中的位置
热词里出现了“双端队列”(deque),这是一个很好的延伸方向。双端队列的底层实现方式有几种,而链式实现中,双向循环链表是最优雅的方案:四个关键操作——头部插入、尾部插入、头部删除、尾部删除——都是O(1),而且因为是循环的,判断“空队列”只需检查 head->next == head。
普通的循环链表如果只用单向next指针,做双端队列尾部删除会非常麻烦,因为要删除尾结点必须知道它的前驱,单向结构需要遍历才能找到,所以工程中实现deque一般用双向循环链表或者“数组+头尾双指针”的组合。这里我把它提出来,是希望大家学习数据结构时不要把“循环链表”当成一个孤立知识点,它和队列、栈、双端队列都有天然的关联。理解了循环链表的“首尾相连”特性,再去看deque的链式实现,思路会顺畅很多。
7.2 排序算法与链表的适配关系
热词里还有一个“数据结构排序算法”,这里顺便聊一下链表场景下的排序适配。数组的归并排序依赖下标的中分,非常适合连续存储;链表是离散存储,归并排序依然可以做,只是实现时要通过快慢指针找中间结点。插入排序在链表上实现反而比数组更顺利,因为链表插入不需要搬移元素,只修改指针。但快速排序在链表上实现要麻烦一些,因为它非常依赖“随机访问拿到基准元素并和左右两端比较”,链表没有这个能力,需要额外维护一堆指针。
这些排序算法对循环链表同样适配,但循环链表在排序场景下反而多了一个麻烦:你无法通过“p == NULL”来判断排序是否扫完了全部元素,必须以“回到头结点”作为结束标志。写插入排序和归并排序时,循环链表的边界条件会比单链表多一层判断,代码容易出错。所以我的建议是,除非题目明确要求“用循环链表实现排序算法”,否则排序的代码练习还是以数组为主。数组能考察的所有关键逻辑(比较、交换、划分)都已经覆盖了,循环链表排序只是为了加深“边界处理”的理解,别在备考阶段花太多时间。
7.3 从循环链表看整个数据结构知识体系
从一个更高的视角来看,循环链表是线性表中的一个变体,线性表是整个数据结构课程的地基。你会发现很多后续章节的概念都能和线性表联系起来:栈和队列本质上是受限的线性表,字符串可以看成字符数组,数组和广义表是线性表的扩展,图的邻接表、十字链表等存储结构也大量使用链表思想,甚至操作系统里的进程管理、文件系统的空闲块管理,底层都是链式结构。
写循环链表这个“基础中的基础”,不只是为了应付一道题,更多是在训练一种思维:数据结构是为了某个具体问题场景而生的,解决“周期循环”的问题,自然就会想到“尾部回指头部”的做法。你理解了这个“因为什么、所以这样设计”的逻辑链条,后面学任何新结构都会比死记硬背高效得多。这也是为什么哪怕我已经工作很多年,在带新人或者做方案评审时,依然愿意花时间把链表这些基础知识掰开揉碎地讲清楚。
8. 一些实操后的心得体会
写到这里,我想把这几年来反复写循环链表、教循环链表、以及陪别人排查循环链表问题的过程中沉淀下来的几个体会分享出来。
第一个体会是:循环链表的难点不在“代码多”,也不在“概念难”,而在于人脑习惯用“线性的起止思维”去理解一个“环形结构”。每次写循环条件都会习惯性地想“走到哪里算结束”,而它的答案是“回到起点才算结束”。这需要一段时间的刻意练习,建议初学者多做“纸笔推演”:画出链表结构,用箭头模拟指针变化,推演一遍插入、删除、遍历的完整过程。画图真的比看十遍代码都管用。
第二个体会是:链表调试时,printf就是最好的朋友。别急着上高端调试器,先在外层循环里打印“当前结点地址”和“下一个结点地址”,看到地址规律重复出现,说明循环结构正常;看到乱跳的地址,顺着那次跳变就能找到出问题的指针操作。这比从头到尾单步调试省时间多了。
第三个体会是:循环链表是一种“一旦想通就再也忘不掉”的结构。早年我遇到“约瑟夫环”题目时,第一个念头是数学公式,第二个念头是数组模拟淘汰(把被删者标记为0),第三才想到链表。现在我的习惯是先判断“这个操作是否频繁删除和插入”,如果是,直接链表;如果只读,才考虑数组或公式。这不仅是考试技巧,也是工程选择的经验:数据结构的选型,本质上就是看清楚操作模式然后选一个最适配的。
第四个体会,也是最后想强调的:不要只背代码,一定要亲手敲一遍。再简单的头结点自循环 head->next=head,也需要你自己敲一遍才能对这个写法有肌肉记忆。我在带人的时候经常说,一个数据结构学得扎实不扎实,不看能背出多少代码,就看你能不能在三分钟之内徒手写出一个带插入删除遍历销毁的完整链表。这个要求听起来不高,但真正做起来,能挡掉一半以上的人。
循环链表只是一个开始,上面还有双向链表、二叉树、图、哈希表等着你去蹚。每往前走一步,回头看看这个基础的、绕成一圈的链表,你会发现它一直在那里,像整个数据结构大厦的一块砖。把这块砖砌稳了,后面的路会越走越顺。
