尧图网络科技YAOTU DIGITAL 获取报价
获取报价
首页 / 资讯中心 / 文章详情

双向循环链表上机题全解析:原理、C#实现与避坑指南

发布时间:2026/9/26 17:19:33

资讯中心
01
ARTICLE

双向循环链表上机题全解析:原理、C#实现与避坑指南

双向循环链表上机题全解析:原理、C#实现与避坑指南
上机题考双向循环链表听起来好像只是数据结构课程里的一道普通题目但真到了考场或者面试现场这道题能拦住不少人。链表本身不难难的是“双向”和“循环”两个词凑在一起之后操作逻辑一下子绕了起来指针指来指去边界条件又特别多稍不留神就写出死循环或者把链表拆成两截。我见过不少基础还算不错的开发者平时写业务代码很麻利一到手写双向循环链表就开始犯迷糊。这篇文章就是围绕这类上机题来写的。我会先拆穿出题人到底想考什么再把双向循环链表的核心逻辑用最直白的方式讲透然后给出一套可以直接照着写的完整实现最后把上机过程中最容易翻车的几个隐蔽细节逐个拿出来过一遍。无论你是正在准备考试的学生还是面试前临时抱佛脚的开发者或者只是想把链表这块地基重新补一补这篇内容应该都能帮上忙。1. 为什么上机题总爱拿双向循环链表开刀先别急着写代码搞清楚出题人的意图比什么都重要。上机题和平时写业务代码有一个本质区别考试环境里没有编译器帮你兜底没有调试器让你慢慢看变量很多时候甚至是白板或一个简单的文本编辑器。这种条件下题目考的不只是“你会不会写链表”更是“你脑子里的模型是不是足够清晰”。1.1 链表类题目在机试中的位置链表在数据结构里的地位很微妙。数组和哈希表在业务开发里用得最多但只要学过数据结构的都会知道链表才是真正考验指针操作功底的东西。而在所有链表变体里双向循环链表几乎是把所有难点集中到了一起每个节点有两个指针域前驱和后继都要维护表尾和表头相连判断终点的方式不再是“指针为空”而是“指针回到起点”插入和删除操作涉及多个指针的重新指向顺序一错数据就乱。出题人选它本质上是想借助一道题同时考察三件事你是否理解链表的内存模型你是否能正确处理边界条件以及你在压力下写代码是否够稳。上机题平时练习写十遍都不嫌多就是因为这种题目容错率很低。1.2 双向循环链表相比单向链表的加分点很多人会问单向链表也能实现同样的功能为什么非要用双向循环这里有一个很实际的考量——双向循环链表在某些操作上有着单向链表无法比拟的优势。最典型的例子是反向遍历。单向链表想从尾部走到头部要么重新遍历一遍要么额外维护一个栈而双向链表天生就有前驱指针直接往前跳就行。再比如删除某个指定节点的操作如果只给了一个节点的引用单向链表必须从头找到它的前驱才能完成删除时间复杂度是O(n)双向链表因为有前驱指针直接就能拿到前驱删除操作瞬间变成O(1)。还有一个容易被忽略的点循环这个特性让“从任意位置开始遍历整个链表”变得异常方便。你不需要记住头节点在哪从任何一个节点出发往前或者往后走一圈一定能回到自己身上。这种特性在某些场景比如报数出圈、轮询调度里非常自然。所以出题人安排这道题往往是既想考基础又想看你有没有能力理解“不同数据结构解决不同问题”的深层逻辑。1.3 我见过的几类常见出题套路虽说题目千变万化但万变不离其宗双向循环链表的上机题基本逃不出下面这几类套路类型典型题目实际考点基础构建创建双向循环链表并实现增删改查节点定义、指针维护、边界处理约瑟夫环围成一圈报数数到M的人出圈循环遍历、节点删除、递归或迭代思路双向操作从中间节点向前向后交替遍历前驱指针的使用、循环终止条件进阶合并两个循环链表合并成一个尾节点处理、拼接时的指针顺序其中“约瑟夫环”几乎是最经典的双向循环链表考题因为你需要真的在环里转圈、删人、继续转这个过程把“循环”和“删除”两个难点全带出来了。后面我会拿它做例子把完整实现过一遍。2. 动手前先把双向循环链表的内在逻辑想透很多人在上机题上翻车不是因为语法不熟而是脑子里的模型是模糊的。写代码之前我得先花几分钟在纸上把这个链表长什么样画出来想清楚每一步指针的变化然后才动手。这一步看似耽误时间实际上能帮你省下大量的调试时间。2.1 三个关键特性的理解双向、循环、头节点先把概念拆开。双向指的是每个节点同时持有前一个节点和后一个节点的引用循环指的是最后一个节点的后继不是null而是指回头节点头节点的前驱也不是null而是指向尾节点头节点则是整个链表的入口。把这三个特性叠加在一起就形成了这样一个结构任何一个节点都站在一个“环”上从任意位置出发沿着next的方向一直走一定能走回原点沿着prev的方向一直走同样也能走回原点。这就像操场跑道没有起点也没有终点你只是人为地画了一条起跑线方便绕圈计数。这里要特别提醒循环链表里永远不要拿“指针是否为空”来判断有没有走到头而应该拿“指针是否回到了头节点”来判断。这是初学者最容易犯的错误没有之一。2.2 C#引用类型和链表节点的对应关系很多从C/C转过来的开发者在写C#链表时会有一种别扭感C#里没有“指针”这个说法那链表的“指向”到底是怎么实现的C#里用引用类型来实现链表底层其实还是指针但语言层面帮我们隐藏了地址操作。定义一个Node类里面放两个Node类型的字段一个叫Prev一个叫Next这两个字段存的就是对其他节点对象的引用。当你写nodeA.Next nodeB的时候本质上就是在让nodeA的Next字段指向nodeB所代表的那块堆内存。一个很常见的坑是在C#里用struct去定义链表节点。struct是值类型赋值时会拷贝整个对象一旦你的节点是struct链表的引用关系就会变得极其混乱因为你根本不知道哪一份是“原来的”。所以记住一个铁律链表节点必须用class定义不要用struct。2.3 画图是解决链表问题的最有效手段链表问题如果只在脑子里转十个里有九个会转晕。我自己每次做链表相关题目哪怕是已经写过无数遍的基础操作也会先在纸上画三个框画头节点和当前要操作的节点用箭头标出Prev和Next原本指向的位置再画出修改之后箭头应该指向的位置。把旧的指向和新的指向画清楚之后再回头写代码指针赋值的顺序就水到渠成了。举一个最简单的例子在P节点后面插入新节点N。如果按“先连N再接原链表”的顺序来写你需要操作四条引用N.Next P.NextN.Prev PP.Next.Prev NP.Next N。这四条缺一不可而且顺序上要先保证N的引用都设置好再去动P和P.Next因为P.Next一旦改了后面再取原后继就找不到了。只有画过图之后才能自然而然地想明白这个顺序的由来。3. 从零写一个能用的双向循环链表以典型上机题为例这一节我会拿一道非常典型的上机题出来实际操作一遍实现一个双向循环链表支持头插、尾插、指定位置插入、按值删除和遍历输出并在此基础上解决一个约瑟夫环的问题。整个过程会一步步展开代码能直接抄但更希望你理解每一步为什么这么写。3.1 节点类与链表类的设计首先定义节点类。按照前面说的用class定义包含数据域和两个指针域public class Node { public int Data { get; set; } public Node Prev { get; set; } public Node Next { get; set; } public Node(int data) { Data data; Prev null; Next null; } }这里我用了int作为数据域的例子实际题目里可能是字符串、对象甚至更复杂的结构但节点本身的组织方式是完全一致的。接下来是链表类。我采用的是“带头节点”的实现方式即链表里有一个虚拟的头节点不存储有效数据只作为起点和环的锚点。带头节点的好处非常明显无论链表是否为空head始终存在很多边界判断可以统一处理。public class DoublyCircularLinkedList { private Node head; private int count; public int Count count; public DoublyCircularLinkedList() { head new Node(default); head.Prev head; head.Next head; count 0; } }构造方法里让head的Prev和Next都指向它自己这个操作构成了一个只包含头节点的“空环”。以后所有插入和删除操作都围绕这个环进行代码会非常统一。3.2 双向循环链表的初始化细节初始化这一步很关键因为空链表的状态决定了后续所有操作的写法。头节点的Prev和Next都指向自己意味着插入第一个有效节点时它的Prev应该指向headNext也应该指向head此时head.Prev和head.Next同时指向这个新节点形成一个由头节点和一个数据节点组成的“两节点环”。这个设计最妙的地方在于插入和删除操作可以不用特殊处理空链表的情况。因为空链表不是一个真的“空指针”而是一个只有一个节点的环所有插入操作都是在“某个节点的后面”或“某个节点的前面”进行头节点只是一个普通的环成员而已。对比一下不带头节点的实现每次插入第一个节点时都要单独判断head是否为null然后花额外代码去初始化环。带头节点之后这类分支就全部省略了代码整体会简洁不少。3.3 插入操作在指定位置插入新节点先实现最简单的尾插法也就是在head的前面插入新节点。尾插的逻辑是找到当前尾节点tail即head.Prev然后在tail和head之间插入新节点。public void AddLast(int data) { Node newNode new Node(data); Node tail head.Prev; newNode.Prev tail; newNode.Next head; tail.Next newNode; head.Prev newNode; count; }注意这里有个很多人都踩过的坑必须先设置newNode的Prev和Next然后再修改tail.Next和head.Prev。如果你先把tail.Next指向newNode紧接着想通过tail.Next拿到“原来的尾节点”去做后续操作拿回来的就是新节点自己后面的赋值就全乱了。按索引插入其实可以复用类似的逻辑先找到目标位置的前一个节点然后把新节点插到它后面。为了支持从任意位置插入我需要一个索引查找的方法public Node GetNodeAt(int index) { if (index 0 || index count) throw new IndexOutOfRangeException(索引越界); Node current head.Next; for (int i 0; i index; i) current current.Next; return current; }拿到目标节点node之后在它后面插入新节点的方法跟尾插几乎一样只是把tail换成了nodepublic void InsertAfter(Node node, int data) { Node newNode new Node(data); newNode.Prev node; newNode.Next node.Next; node.Next.Prev newNode; node.Next newNode; count; }这四行赋值的顺序同样遵循“先补全新节点的引用再动原链表”的原则。顺序一改链表会在瞬间断成两截且毫无提示。3.4 删除操作按值删除的有效实现删除节点的核心思路是让待删节点的前驱的Next指向待删节点的后继同时让待删节点的后继的Prev指向待删节点的前驱。这中间有一个隐藏的技巧如果待删节点正好是head也就是要删除头节点本身怎么办由于head是虚拟头节点不存有效数据正常业务中不需要删除head但为了安全可以在方法里加一个判断。public bool Remove(int data) { Node current head.Next; for (int i 0; i count; i) { if (current.Data.Equals(data)) { current.Prev.Next current.Next; current.Next.Prev current.Prev; count--; return true; } current current.Next; } return false; }这个for循环的次数是count保证从head.Next开始走一圈恰好能回到head所以不需要显式判断“current head”来跳出循环。每次循环结束时current current.Next走count步之后current恰好又回到head.Next循环结束。这种方式比while(true)加break要安全得多也更容易向面试官解释清楚。删除之后节点对象本身不需要做额外的清理C#的垃圾回收会处理不再被引用的对象。很多从C/C转过来的朋友会下意识想释放内存在C#里反而容易画蛇添足。3.5 遍历与查找循环终止条件的处理遍历双向循环链表最大的坑在于终止条件。很多人习惯性写成current ! null这在普通单向链表里没错但循环链表里根本不存在null指针这么写会变成无限循环。一种稳妥的写法是用for循环从head.Next开始走count步public Listint ToList() { Listint result new Listint(); Node current head.Next; for (int i 0; i count; i) { result.Add(current.Data); current current.Next; } return result; }另一种写法是用do-while循环先执行一次再判断是否回到头节点public void PrintAll() { if (count 0) return; Node current head.Next; do { Console.Write(current.Data ); current current.Next; } while (current ! head); Console.WriteLine(); }两种写法都能正确终止。区别在于for循环版本不需要在循环内部判断head边界逻辑更直白代码复用性也更好do-while版本更接近“转圈”这个语义但从head出发遍历时必须先判断空链表否则会有一个小坑如果链表为空current headdo-while至少执行一次就会访问到head的数据字段存的是default输出一个无意义的值。找到节点后如果需要往后走k步循环里对k取模即可处理k比链表长度大的情况current current.Next;这里不展开后面约瑟夫环会自然用到这个思路。4. 上机过程中最容易翻车的几个隐蔽细节前面代码写得再漂亮真到上机环境里还是有那么几个细节能把人绊倒。这些东西在本地编译器里可能根本不会出错但一旦放到考试环境或者面试官给定一个特制的输入问题就全暴露出来了。我按踩坑的频率排序一个一个说。4.1 死循环循环链表里最经典的鬼打墙双向循环链表里写死循环通常是两个原因造成的。第一个原因是遍历终止条件写错。比如拿current ! null当退出条件这在普通链表里很自然但在循环链表里永远成立程序就卡死在循环里。遇到这种情况先检查所有遍历代码的退出条件凡是用“是否为null”判断的全部改成“是否回到头节点”或者“走了有效节点数步”。我自己在考试时吃过一次亏当时就因为没有意识到这个问题代码运行到遍历阶段直接卡住浪费了整整十分钟才反应过来。第二个原因更隐蔽链表结构本身被破坏了某个节点的Next指向了自己导致真正成环的只是部分节点头节点永远无法从这条路径被走到。这种情况往往是插入或删除操作的指针顺序写反了把某个节点的Next误指到了自身。排查办法是加一个计数器在遍历时设置最大迭代次数为count加一个安全值一旦超过这个值直接抛出异常。用这个“安全气囊”快速定位问题比肉眼盯着代码看半天高效得多。4.2 删除尾节点时头尾指针没有同步更新这个坑在单向链表里也存在但双向循环链表里更容易犯因为很多人的注意力全放在两个方向的指针重新指向上忘了头节点本身的Prev指针需要一起更新。以我上面的Remove实现为例删除尾节点时current.Prev.Next current.Next这一句会把新的尾节点的Next指向head但还需要current.Next.Prev current.Prev这一句来把head的Prev指向新的尾节点。如果这行漏掉了尾节点虽然被移除了但head.Prev仍然指着那个已经被删除的旧节点。这个bug最坑的地方在于它不会立刻报错打印遍历时因为走的是Next方向看起来完全正常只有当你再执行一次尾插或者反向遍历时才会发现新插入的节点“丢”了链表里出现了诡异的数据错乱。调试这类问题建议把当前链表打印正序一遍、逆序一遍对照着看马上就能发现问题。4.3 插入位置合法性与越界判断按索引插入时很多人只考虑“索引在范围内”却忽略了两个特殊位置index等于count和index等于0的情况。在双向循环链表里index等于count意味着插入到链表末尾这其实是合法的但如果你是用GetNodeAt(index)来定位插入点这个调用本身就会因为index count而抛出越界异常。比如链表里有5个元素你想通过“在位置5插入”来实现尾插用InsertAfter(GetNodeAt(4), data)没问题但直接用InsertAfter(GetNodeAt(5), data)就会挂。我在实现时候的处理办法是InsertAfter不做索引合法性检查只负责插入。按索引插入的方法里单独判断边界public void InsertAt(int index, int data) { if (index 0 || index count) throw new IndexOutOfRangeException(索引越界); if (index count) { AddLast(data); } else { Node node GetNodeAt(index); InsertBefore(node, data); } }这里还额外用了一个InsertBefore的操作实现思路跟InsertAfter镜像对称先拿到node的前驱然后在“前驱的后面”插入新节点。这样写的好处是向index位置插入等效于在原来index位置的节点前面插入逻辑上比用GetNodeAt(index - 1)再InsertAfter要自然也不容易因为index为0而越界。4.4 判断链表为空的标准写法循环链表判断是否为空标准写法是检查count 0或者检查head.Next head。这两种写法本质上是等价的但考试时很多同学会写成head null这在带头节点的实现里是错的因为head从来都不为null。还有一个相关的小陷阱获取链表长度。有人在循环链表里遍历统计数量结果因为终止条件写错而陷入死循环。其实只要在插入和删除时维护好count长度获取就是O(1)的完全没必要去遍历。上机题的时间有限能省一步是一步。4.5 一个完整的调试案例删除节点之后链表“丢数据”下面还原一个真实的排查过程这个场景我最近还教一个学弟处理过非常典型。现象删除一个中间节点之后重新遍历链表发现从head出发走了一圈只输出了原来一半的元素且顺序是乱的。排查的第一步我先让他打印正序遍历结果和逆序遍历结果。结果正序输出了4个元素逆序也输出了4个元素但是两边的顺序对不上。这说明链表结构已经不是单环了而是出现了两个互相独立的环。第二步是检查删除操作的四行赋值。他写的代码是这样的current.Next.Prev current.Prev; current.Prev.Next current.Next;看起来顺序没什么问题但问题的关键在于current.Next.Prev current.Prev这一步执行时如果current.Prev恰好是head而current又是尾节点那head.Prev会被更新指向current.Next但其实current.Next此时指向的是头节点自己或另一个节点这取决于current的位置。我让他把删除前后所有相关节点的Prev和Next画出来对比结果发现他在删除尾节点时head.Prev没有指向新的尾节点而是仍然指向被删除的旧节点。原因是他写的是current.Next.Prev current.Prev这里的current.Next如果先被修改了再访问就会出错。实际上他真正漏掉的是当current为尾节点时current.Next是headhead.Prev确实被更新了但如果current.Next在赋值之前因为之前的语句已经被改掉了那后面再取current.Next取的已经不再是原来的后继了。绕来绕去有点复杂但核心教训就一条指针赋值过程中如果你需要读取一个节点“原来的Next”或“原来的Prev”一定要在该节点的引用被改写之前去读取。一旦你先改了current.Next再去读current.Next来赋值读到的就是新值而不是旧值。我们后来把四行赋值顺序改成了“先读后写”再跑问题立刻消失。5. 答好链表上机题的几个实战策略代码能跑通是一回事能不能拿高分是另一回事。上机题的评分很多时候不只是看结果对不对还看代码结构、边界处理、答题过程的规范性。这几个策略是我带人刷题和实际考试中总结出来的非常管用。5.1 先想清楚接口再动手写实现上机考试时时间紧迫很多人拿到题就开始埋头写代码这是大忌。我推荐的做法是先用两到三分钟把类的公开方法列出来确定好每个方法的签名然后再逐个实现。比如这道双向循环链表题我拿到手先写public class DoublyCircularLinkedList { public void AddLast(int data); public void AddFirst(int data); public void InsertAt(int index, int data); public bool Remove(int data); public bool Contains(int data); public Listint ToList(); }把接口定下来之后各个方法之间的依赖关系就清楚了InsertAt很可能内部使用AddLast和InsertBeforeRemove需要先找到节点再调整指针ToList用于遍历输出。这部分本身也在向考官展示你的设计能力比闷头写一堆没有结构的方法要加分。5.2 边界测试用例至少准备四个代码写完别急着交先在脑袋里过一遍测试用例。链表相关的题目至少要覆盖这四种情况往空链表里插入第一个节点删除唯一的节点在头部插入和在尾部插入删除头节点和删除尾节点。如果题目要求按索引操作还要加测index为0和index等于count这两种边界。上机环境里如果能实际跑测试就把这四个场景都跑一遍如果只能静态提交就在代码注释里说明你对边界情况的处理方式这也能让阅卷人看到你的严谨。5.3 时间复杂度和空间复杂度怎么答才稳上机题如果附带追问复杂度分析很多人会卡住。其实双向循环链表的复杂度模式很固定记住核心结论就行在已知节点前后插入或删除指定节点O(1)按值查找或按索引访问O(n)遍历输出全部节点O(n)空间复杂度O(n)。这里有个容易说错的点双向循环链表按索引访问和按值查找都是O(1)不没有这种好事。虽然它比单向链表多了前驱指针可以在某些场景下从尾部往回找但查找的整体复杂度仍然需要遍历。不过我补充一个优化思路如果面试官追问怎么让查找更快你可以说维护一个“当前位置指针”对于部分场景比如约瑟夫环中反复从当前位置往后走k步可以把单次寻址从O(n)摊还到接近O(1)。实际编码里把当前指针更新一下就行非常实用。5.4 扩展题如何判断双向循环链表是否成环面试官经常会顺手问一道经典扩展题如何检测链表是否成环。对于普通单向链表经典解法是快慢指针一个每次走两步一个每次走一步如果两者最终相遇就有环。但这里有个容易搞混的点双向循环链表的“循环”本身就是成环的根本不用检测。真正需要检测的是你的链表结构是否因为bug而出现了“意外小环”——比如某个节点的Next指向了它自己或者链表中出现了多个互不相连的环。这种扩展题其实考察的是你对循环链表内部结构是否有深入理解。一个可行的检测方法是从头节点出发进行两次遍历先沿Next方向走一遍同时沿Prev方向走一遍记录访问过的节点集合如果在走回head之前重复访问了某个节点说明结构被破坏了。不过说实话这种问题在实际上机题里很少单独出现更常见的还是在代码审查时发现结构错误。写完这道题之后的一点复盘心得双向循环链表这道题我前前后后写过很多遍也在不同场合帮人排查过类似的问题。它给我的感觉就像一个手艺活逻辑本身不复杂但每一步都得手稳尤其是插入和删除的那几行指针赋值写的时候必须心里有图。如果让我给还在准备上机题的朋友一句建议那就是不要背代码不要指望靠记忆把链表题硬写出来。你只需要把“节点”“前驱”“后继”“头节点”这几个概念彻底想清楚手边放一张纸边画边写这种题目就会从老虎变成纸老虎。我一直觉得链表题是最能反映一个人数据结构功底的形式因为它逼着你用最底层的方式组织数据而一旦把这种思维方式练出来了写任何高级数据结构都会顺手得多。遇到不会的题目不丢人遇到因为边界条件没想清楚而写错也不丢人。丢人的是拿到题目连从哪里入手分析都不知道就开始对着编辑器发呆。下次再遇到双向循环链表不妨先停下来问自己三个问题这个环从哪里开始走多少步回到起点操作过程中哪些引用会被改变。想明白了这三个问题代码自然就写得出来了。
02
RELATED NEWS

相关资讯

更多网站建设与数字化升级内容

03
WHY YAOTU

想打造同款高转化官网?

懂行业、懂生意,从建站到增长一站式陪跑

◈

场景化定制

不做模板站,围绕你的业务场景量身设计,小众不撞款。

◐

营销型架构

以转化目标组织内容与路径,让官网真正带来询盘。

▲

全周期服务

设计、开发、运营、运维一体,上线只是开始。

免费获取你的建站方案

留下需求,专属顾问 24 小时内为你输出方案建议。