资讯动态

单链表与循环链表:从指针操作到边界处理,一篇文章讲透

发布时间:2026/10/22 16:00:19 来源:尧图企业网站定制
说到单链表和循环链表几乎所有教材都会给你画一张节点箭头图然后让你背插入删除的代码。我当年也是这么过来的背得滚瓜烂熟可真到动手写一个命令缓存队列时才发现原来自己根本不会用循环链表跑的第一次测试就直接死循环控制台刷屏刷到只能强制杀进程。那次以后我才慢慢想明白链表这种结构画在纸上两分钟就懂但真正会用需要把指针指向哪里、什么时候停、边界在哪这三件事彻底想清楚。这篇文章不打算重新抄一遍教材我想从实际使用和面试考察的角度把单链表和循环链表从头到尾捋一遍它们各自解决了什么问题、操作里最容易出错的边界在哪儿、真实项目里有哪些场景在用它、面试题里那些变体到底在考什么。不管你是刚开始学数据结构的学生还是工作中突然需要手写一个链表的工程师应该都能从里面找到点有用的东西。1. 从数组的困境出发链表到底解决了什么1.1 数组的固定容量与搬迁代价数组在内存里是一段连续空间读取元素可以直接按下标找到地址这是它最大的优点也是很多场景优先选数组的原因。但数组有两个绕不开的麻烦。第一是容量固定申请了10个元素的空间想存第11个就得重新找一块更大的内存把旧数据全部搬过去这个操作本身就有成本。第二是在中间插入或删除元素时后面的所有元素都要往左或往右移动数据量一旦上来代价肉眼可见。我举一个很生活化的例子用数组存一篇文章的每一行。当你在第一段后面新加一段那么从第二段开始的每一行都要整体后移行数越多这次插入就越慢。如果是写一个小的文本编辑器用户连续打字的时候这种搬迁操作会频繁触发整体的性能感受就会很糟糕。这些道理在教科书上都会讲但我还是想强调连续内存这个根因为链表的所有特性都从不连续这三个字里长出来。1.2 链表用分散的内存换来自在的修改链表和数组的核心差异在于链表的每个节点可以单独分配内存物理地址上可能东一个西一个但节点之间通过指针串起来逻辑顺序的维护完全不需要依赖物理上的相邻。你说它好吧它确实好——在链表中间插入一个节点只需要修改前后两个指针其他节点一概不用动删除同样如此。你说它不好吧它也确实不好——想访问第n个节点你必须从头节点开始一个next一个next地走过去没办法直接按下标跳转。这就是典型的空间换时间还是时间换空间的取舍。我给别人讲链表时常用一个比喻数组像电影院里的连排座位新来一个人要坐在中间后面所有人都得挪一挪链表像一群朋友各自站在广场的不同角落手拉手串成一条线想插队的人只需要让前一个人的手改牵新人新人再牵住后面的人整个队伍不必整体移动。这个类比每次讲都能让人很快抓住重点。1.3 单链表和循环链表是一个家族的两兄弟顺着上面的思路继续单链表每个节点只有一条next指针顺着指针一路走到底最后一个节点的next是NULL到此为止。循环链表则把最后一个节点的next从NULL改回第一个节点让整个链条变成一个环从任意一个节点出发都能绕一圈回到自己。与其说它们是两种发明思路不如说它们是同一种设计在不同需求下的两种收尾方式一个选择戛然而止一个选择周而复始。理解这一点后面讲的所有操作差异都会变得顺理成章。循环链表并不是什么高深莫测的新结构它只是把单链表那条断头路接成了一条环形路。2. 单链表的血肉节点定义与基础操作拆解2.1 节点结构数据域与指针域C语言里一个最基础的单链表节点长这样typedef struct Node { int data; struct Node *next; } Node;数据域data用来存储业务信息指针域next用来存下一个节点的地址。你可以把data换成更复杂的结构体哪怕里面塞几十个字段链表的基本形态都不会变真正决定结构的是那个next指针。用Python写也一样类里面的next就是引用class ListNode: def __init__(self, val0, nextNone): self.val val self.next next你只要理解了节点是一个对象它持有另一个对象的引用这一点用什么语言都不影响对链表本质的认识。2.2 头指针、头节点与空链表这三者必须一次理清我在带初学者的时候发现最容易让人迷惑的不是节点定义而是头指针和头节点的区别。头指针是一个指针变量它存的是链表第一个节点的地址它本身不是节点。而头节点是我们在链表正式开始前额外附加的一个节点它不一定存放有效数据主要作用是让链表在空和非空两种状态下的操作保持一致。为什么要在意这个区别我用一个例子说明。如果不带头节点空链表就是一个NULL指针此时你要插入第一个节点必须单独处理链表为空的情况但如果你带了头节点空链表的头节点还在头节点的next等于NULL插入删除的逻辑就不用额外分叉。这两种方案我都写过个人体会是写小工具、做算法练习时不带头节点往往更简洁少一个节点的空间开销。但在正式的模块设计里我基本都会带头节点因为它能省掉大量稍不注意就漏掉的判空分支。2.3 插入和删除先把指针关系理清楚再写代码单链表插入的核心操作是两步先把新节点的next指向当前位置的下一个节点再把当前位置的next指向新节点。这两步顺序绝对不能反如果先改了当前节点的next后面那条链就找不到了新节点还没接上旧链条已经断掉。举个例子在头部插入一个节点Node *insertAtHead(Node *head, int val) { Node *newNode (Node *)malloc(sizeof(Node)); newNode-data val; newNode-next head; head newNode; return head; }一个非常容易忽略的细节是函数内部修改head这个形参并不会影响外面的head变量所以这里必须把新的头指针返回或者把参数声明成二级指针。void insertAtHead(Node **head, int val) { Node *newNode (Node *)malloc(sizeof(Node)); newNode-data val; newNode-next *head; *head newNode; }我自己早年就吃过这个亏函数里明明把head更新了外面打印链表却发现还是旧的样子排查了好一阵子才发现是传参的问题。现在我的习惯是在纸上先画出插入前后的两条链确认所有指针都指向该指向的节点再落代码。这一招让我的出错率降了很多。3. 循环链表的变奏从戛然而止到周而复始3.1 构造一个循环链表只需要把尾巴指回头从代码角度看把单链表变成循环链表差的就是一行把尾节点的next从NULL改为指向头节点。难的不是这一步而是改完之后所有遍历和查找的判断条件都变了。单链表里判断是否遍历到结尾写的是while (p ! NULL)p到达NULL就结束。循环链表里没有NULL可以当终点你必须自己定义起点比如while (p ! head)从第一个节点开始走走回head才算一圈结束。这个区别听上去简单实践里最容易出死循环。很多初学者直接把单链表的遍历代码搬过来p一路走永远不会等于NULL程序就卡死了。我自己也这么翻车过一次后面会细讲。3.2 为什么循环链表常用尾指针而不是头指针这是循环链表设计里很值得品味的一个点。先说结论在需要频繁尾部追加的场景循环链表几乎都带着尾指针来使用。原因也不难理解。单链表想在尾部插入一个节点由于必须知道尾节点是哪个你得从头节点一路走到尾时间复杂度O(n)。但如果这是一个循环链表而你手里拿着尾指针尾节点的next就是头节点此时在尾部插入一个节点只需要三步让新节点的next指向头节点再让尾节点的next指向新节点最后更新尾指针。整个过程O(1)。反过来如果你只拿着头指针想到尾节点还是要绕一圈快不起来。所以用尾指针不是风格问题是效率问题。这也是面试里常被追问的设计题答案就藏在这段复杂度分析里。3.3 循环链表的删除边界情况要格外小心删除一个节点先要找到它的前驱节点这是单链表的经典命题循环链表里同样存在。更麻烦的是循环链表中每个节点的地位都一样删除任何一个节点都可能影响别人持有的指针。举个例子如果你用尾指针管理循环链表要删除的节点恰好就是尾指针指向的那个节点那删除之后尾指针必须重新指向新的尾部否则整个环的入口信息就丢了。如果你想通过遍历找到待删除节点的前驱那么当前驱和待删除节点是同一边上的极端情况时处理起来会非常微妙。我的做法是删除操作里我要么记录待删除节点的前一个节点要么直接返回新的尾节点。宁可多用一个变量也不要在环里回头找路。4. 纸上谈兵容易真刀真枪的三种实战场景4.1 约瑟夫环教材为什么拿它来配合循环链表约瑟夫问题是这样的n个人围成一圈从某个人开始报数报到m的人出列剩下的人继续从1报数直到只剩一个人求最后留下的人是哪一个。循环链表模拟这个问题非常自然因为围成一圈本身就是循环链表的结构。每个人是一个节点报数相当于沿next方向走报到m时删除节点再从下一个节点继续。链表是环形的出列之后不需要处理队伍中断的问题整个流程和真实场景一一对应。我写过一个简化的模拟Node *josephus(int n, int m) { Node *head buildCircle(n); Node *p head; while (p-next ! p) { for (int i 1; i m - 1; i) { p p-next; } Node *victim p-next; p-next victim-next; free(victim); p p-next; } return p; }很多人会忽略的是for循环的步数它对应的是m和报数起始点之间的关系画一次图验证就能确认。这类代码放到生产环境里未必常见但它对理解循环链表操作的价值非常高值得亲手写一遍。4.2 环形缓冲区生产者和消费者之间的环我第一次在真实项目里写循环链表是做一个指令缓存模块一个线程持续产生指令另一个线程不断取出执行。数组实现需要维护读写两个下标还要处理下标越界回绕的问题用循环链表实现队列天然就没有头尾概念每个节点存一条指令尾指针指向最近插入的位置另一个指针指向待取的位置。这里有一条非常重要的经验当读写指针相等时队列可能为空也可能为满必须通过额外的计数器或者预留空节点来区分。我在第一次实现时就因为没区分这两种情况一个满队列被当成空队列处理导致数据处理器一直空转。后来我干脆引入一个size字段每次插入删除都更新才彻底解决问题。4.3 操作系统里的时间片轮转循环思想的隐形形态操作系统课程里有一个经典的调度算法叫时间片轮转就绪队列里的进程按顺序获得CPU用完时间片就排回队尾下一个进程继续如此周而复始。虽然底层实现不一定真的拿链表节点表示进程但把这种调度逻辑抽象出来本质上就是一个循环队列。类似的思想在时钟管理、外部设备的事件缓冲里也很常见。把这些场景和链表结构对照着看你就会理解教材里反复强调的循环链表适合循环访问到底在说什么。它不是为了考试而存在的抽象题而是一种被真实世界反复使用的组织方式。5. 面试高频变体题判断环、找环入口与链表反转5.1 快慢指针判断链表有没有环为什么步长是两步这是链表环问题最经典的解法慢指针每次走一步快指针每次走两步。如果链表里有环快指针最终会追上慢指针两者相遇就说明有环。很多人会问为什么快指针走两步而不是三步四步原因是两步能保证在有环的情况下快指针相对慢指针的每一次移动都是前进一个节点两者不会因为步长差太大而跳过彼此。走三步四步虽然也有可能相遇但推导起来要处理更多边界不如两步干净。结合前面讲的循环链表你会体会到这里的环是一个抽象概念不一定是完整的循环链表。一个带环的链表可能前半段是正常链后半段才进入环形区。快慢指针的思想正是用来应对这种既不是NULL也没有一个已知头节点当终点的情况。5.2 找环入口一次数学推导胜过背十遍代码确认存在环之后如果还要找出环的入口节点有一个经典做法让相遇时的快指针回到头节点快慢指针都改为每次走一步再次相遇时所在的节点就是入口。这个方法不需要死记硬背推导核心就一句话从头节点到环入口的距离等于相遇点到环入口的距离加上若干圈环长。第一次相遇之后让一个指针从头节点出发一个从相遇点出发两者步长一致必然在入口处重新相遇。Node *detectCycle(Node *head) { Node *slow head, *fast head; while (fast fast-next) { slow slow-next; fast fast-next-next; if (slow fast) break; } if (!fast || !fast-next) return NULL; fast head; while (slow ! fast) { slow slow-next; fast fast-next; } return slow; }这类题目考的东西其实并不深它考的是你能否在抽象的环结构里用两个指针做推理而不是靠猜。5.3 反转链表为什么总被问它考的是指针基本功反转链表是另一个高频题考察点非常纯粹在改变指针方向时保证链条不折断。我用迭代法时的思路是维护三个指针prev、cur、next。每一步先保存cur的下一个节点再让cur指向前面的节点然后三指针整体后移。边界条件是链表为空或只有一个节点时直接返回原链表。面试里就算代码写对了面试官往往还会追问头节点变了函数返回什么本质上还是在考头指针更新的意识也就是我在前面提到的C语言传参问题。链表相关的题翻来覆去就是在考这三件事指针的接与断、遍历的起点与终点、边界的处理。6. 我调试链表踩过的坑以及治本的办法6.1 第一次翻车插入节点之后半个链表凭空消失有一回我在循环链表尾部插入节点脑子里只有把新节点接上去直接写了new-next head; tail new;却忘了让原来的尾节点next指向新节点。结果是从链表中间劈成两半后半截完全丢掉了。排查的时候我把所有节点地址逐一打印出来才发现有两个节点没有进环。我后来给自己立了一条规矩凡是涉及插入删除先在纸上画指针关系图再对着图写代码。这个习惯看起来笨实际上非常有效尤其适合链表这种看不见内存关系的结构。6.2 第二次翻车遍历条件写成p ! NULL死循环刷屏这次是我写循环链表的遍历时习惯性用了单链表的判断条件while (p ! NULL)结果p永远不会是NULL程序一直在环里打转输出刷到终端直接卡死。事后我总结了一个特别简单的判断标准如果链表里根本不存在NULL终点你就要明确告诉程序转到哪里为止。含义是循环链表必须以某个固定节点作为比较基准要么用头指针要么用一个哨兵字段。没有这个基准就相当于走路没有终点只能走到哪儿算哪儿。6.3 第三次翻车删除头节点后头指针还指向被释放的空间删除头节点时我直接释放了旧头节点却忘了把头指针更新为新节点。之后再去访问链表读取的就是已经被释放的内存行为不可预期。带哨兵节点的设计能避免这个问题因为头节点通常不参与删除。这也是为什么很多底层库的链表实现里会把链表节点嵌入到业务结构体中用它做骨架来统一管理。等你亲手踩过这些坑再看那些源码会发现它们处处都在防自己出错。6.4 兜底手段画图、哨兵与打印调试一个都不能少调试链表最有效的不是各种花哨的工具而是这三件事画图、加哨兵、打印节点。画图解决的是思路问题插入和删除前先画几条箭头指针关系一目了然。加哨兵解决的是边界问题统一空链表和非空链表的操作逻辑。打印解决的是验证问题每一轮关键操作后输出当前节点的data和地址能让你快速定位链条在哪一步断了。最后再说一个小技巧如果你不想使用头节点也可以用一个指向头节点的指针变量配合统一操作但一定要确保它在链表改变时同步更新。我个人这些年用得最顺手的还是带头节点方案只要链表模块允许我额外分配这么一个小节点我就一定会加因为省下来的调试时间远比多出来的一个节点空间更值钱。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑