
1. 这道面试必考题到底在考什么先别急着看解法。环形链表这道题之所以能成为各大厂算法面试里的常客不只是因为它考一个会不会判断环更深层的考察点有三个链表的基本操作熟不熟练、有没有复杂度的意识、面对追及问题能不能联想到数学建模。我第一次刷到这道题的时候还是一个对链表一知半解的初学者。当时我脑子里第一个想法特别朴素遍历链表把每个节点存下来如果走到一个之前见过的节点那就是有环。这个思路没有错但它暴露了一个新手常见的短板——只想到了能不能做没去想做得好不好。链表长什么样这里简单回顾一下。最基本的结构是单向链表每个节点包含一个值和指向下一个节点的指针。常规链表走到最后会指向 null所以遍历是有终点的。而一旦出现一个环比如最后一个节点又指回了中间的某个节点整个遍历就成了死循环永远走不到头。这种结构不算罕见常见的业务场景像内存池的循环复用、约瑟夫环问题、音视频播放列表循环背后都可能有环形链表的身影。每次排查线上问题遇到服务卡死但CPU不高的情况我都会下意识想想是不是哪个数据结构的遍历路径上出现了环。这道题本身的描述非常短给你一个链表的头节点 head判断链表中是否有环。如果链表中有某个节点可以通过连续跟踪 next 指针再次到达则链表中存在环。返回 true 表示有环false 表示无环。如果你去搜索引擎或者刷题平台看这道题能得到一堆解法。但直接抄答案是效率最低的学习方式。这篇文章想跟你聊的不是把题解背下来而是把这道题背后的原理、推导、代码细节和面试延伸串起来让你真正吃透它。2. 从O(n)空间到O(1)空间哈希集合方案的思路与局限2.1 哈希集合方案的具体写法先来说最直观的方案。我们遍历链表每访问一个节点就把它的引用地址放进一个哈希集合里。如果遍历过程中发现当前节点已经在集合中说明又绕回来了链表有环。def hasCycle(head): seen set() cur head while cur: if cur in seen: return True seen.add(cur) cur cur.next return False这个代码风格非常朴素任何一个只要会遍历链表的人都能写出来。时间复杂度 O(n)空间复杂度也是 O(n)因为最坏情况下要把整条链表的节点引用都存进集合。2.2 为什么面试官听完这个方案后总会追问一句空间复杂度 O(n) 不算差但面试官听到这个答案后大概率会追问能不能把空间复杂度降到 O(1)这句话才是这道题真正的分水岭。为什么在意空间复杂度因为链表像一条河流如果河很长你一个节点一个节点地存内存开销就上去了。在实际生产环境里一条链表可能被多个并行运行的线程共享每个线程都要做环检测时空间放大效应就不可忽视。而且缓存友好的算法设计里局部变量越少越好。哈希集合方案的另一个隐患是节点引用作为哈希 key在不同语言里行为不一样。Python 里对象默认按 id 哈希只要对象活着不会出错但如果你不小心重写了eq或hash行为可能诡异。Java 里如果没有正确重写 hashCode直接用默认对象哈希倒没问题但一旦引入复杂对象结构还是容易踩坑。所以这个方案作为第一反应没问题但作为最终方案是不够的。2.3 暴力解法和额外标记法思路天真但能帮你理解题目边界除了哈希集合还有两种思路也值得提一下。一种是暴力法——限制遍历次数比如最多走 N 步N 为节点数如果走完还没遇到 null判定有环。这在不知道链表长度的情况下是拍脑袋的做法而且有严格的参数要求不推荐。另一种是修改节点结构给每个节点加一个 visited 标记。这相当于把哈希集合藏进了节点里空间复杂度依然不是 O(1)而且很多场景下不允许修改原始链表结构。比如链表的数据是从数据库读出来的用于别的业务逻辑你改了标记就得负责恢复一恢复又涉及额外的遍历。这些方案的价值在于它们在帮你理解一个核心概念判断有无环本质是判断遍历路径是否会重复经过某个节点。后面的所有优化手段都是围绕如何更聪明地完成这一步。3. 快慢指针Floyd判圈算法的原理与为什么它能成立3.1 从操场跑步说起追及问题的核心直觉假设你和朋友在一个环形跑道上跑步。你跑得慢他跑得快。只要你们一直在跑不管起点在哪里快的人迟早会从后面追上慢的人。但如果跑道是直的快的人一骑绝尘你们再也不会相遇。这就是快慢指针的核心直觉。设置两个指针慢指针每次走一步快指针每次走两步同时从 head 出发。如果链表有环快指针进入环之后会一直在环里绕圈慢指针进入环之后也在环里绕圈因为快指针相对慢指针每一步都在接近相对速度为一步所以它们终将相遇。如果链表无环快指针会率先走到链表末尾指向 null循环自然终止。3.2 数学证明两个关键的为什么这个直觉大多数人能理解但有两个问题需要严谨回答。问题一为什么快指针一定要每次走两步不能走三步或者四步从正确性角度讲只要快指针比慢指针快它们最终都会在环里相遇。但最终多久和步长有关。假如环的长度是 L快指针每次走 k 步慢指针每次走 1 步两人的相对速度是每次迭代接近 k-1 步。如果环长 L 和步长差 k-1 存在公因子相遇可能需要很多圈但依然会相遇。真正的问题出在编码实现的复杂度上——步长越大快指针越容易跳过链表的末尾判断边界条件更难处理而且快指针一次走两步时不会跳过任何节点检查起来简单。所以经典的 Floyd 算法默认就是快二慢一这不是唯一的方案而是工程上最稳妥的方案。问题二慢指针进环后快指针一定能在有限的圈数内追上它吗假设环的长度是 L慢指针刚进入环的瞬间记下此时快指针在环中的位置。因为快指针已经绕着环走了若干圈了所以两者的距离差 D 一定满足 0 D L。快指针每次迭代追上一步那么追上最多需要 D 次迭代而 D L。也就是说慢指针进环后在一圈以内必然被追上。不会出现那种追了一百年还差一点的极端情况。这一点数学上的保证让 Floyd 算法不仅能判断有无环还能用来找环的入口、算环的长度。3.3 为什么相遇一定有环的反向思考有环会相遇这没问题。但反过来如果两个指针相遇了是不是一定说明有环是的。因为如果链表没有环快指针会在某个时刻到达 null循环退出两个指针不可能相遇。这里有个反直觉的点快指针可能会越过慢指针吗比如快指针一步跨过慢指针的位置直接跳到下下个节点。如果步长是 2 且相对距离为 1 的时候快指针下一步不正好踩在慢指针的位置上吗所以奇数环长时快指针会先跑到慢指针前面一格下一步又正好相遇。对于环长 L 为奇数的情况它们会从不同的相对位置反复逼近总会在某一次迭代时重合。严谨的证明是每走一步两者的距离减少 1由于距离的奇偶性不影响差值归零所以必然会在某一步变成完全相同的位置。这个细节很多人没想清楚但面试时被追问时能解释清楚是明显的加分项。4. 代码实现与边界条件从能跑通到AC的细节打磨4.1 Python 版本最直观的写法直接用快慢指针写出来核心代码其实不超过十行。def hasCycle(head): slow fast head while fast and fast.next: slow slow.next fast fast.next.next if slow is fast: return True return False循环条件必须同时判断 fast 和 fast.next 不为空。为什么因为快指针一次走两步如果链表只有一个节点fast.next 为 null第二步就不知道往哪走了。如果 fast 本身是 null说明已经走到链表尽头更不可能存在环。4.2 Java 版本与空指针恐惧症习惯用 Java 刷题的朋友写法几乎一样但要格外小心 NPE空指针异常。public boolean hasCycle(ListNode head) { if (head null || head.next null) { return false; } ListNode slow head; ListNode fast head.next; while (slow ! fast) { if (fast null || fast.next null) { return false; } slow slow.next; fast fast.next.next; } return true; }这个版本把 slow 初始化为 headfast 初始化为 head.next循环体内先判断是否走到终点再移动指针。这种写法可以少写一个while fast and fast.next的复合条件逻辑上更清晰。两种初始化方式实际效果完全等价关键在于每次移动后都要立刻判断是否相遇不要等到下一次迭代开头才判断否则可能漏掉相遇点。4.3 边界条件测试用例面试前一定要自己跑一遍我见过不少同学代码写得看起来很顺但一跑用例就挂。环形链表这题最容易翻车的边界条件就这几个空链表head 为 null直接返回 false单节点链表且无环一个节点指向 nullfast 一上来就失利返回 false单节点链表且自环head 指向自己快慢指针第一次移动后就相遇返回 true长链表的末尾接环环的入口在链表末尾附近两个元素形成环head 指向 node2node2 指回 head建议你自己建一个辅助函数把数组转成带环的链表然后跑一遍所有用例。下面是一个简单的构造思路def build_list_with_cycle(values, pos): if not values: return None head ListNode(values[0]) cur head nodes [head] for v in values[1:]: cur.next ListNode(v) cur cur.next nodes.append(cur) if pos ! -1: cur.next nodes[pos] return head这样一来pos 表示环的入口下标-1 表示无环。我刷题时把这段代码存成模板每次做链表类题目都能复用省了很多时间。4.4 一个常被忽略的细节为什么不写slow fast而要写slow is fastPython 里 比较的是值is 比较的是内存地址。链表节点的值可能重复比如多个节点的 val 都是 1但节点的引用身份才是唯一的。判断两个指针是否指向同一个节点只能比较身份也就是 is。类似的Java 里要比较引用相等而不是 equals。这个细节如果没注意遇到值全部相同的链表节点时会发生误判。新手第一次写这道题十个里有三四个会在这里踩坑。5. 面试延伸环入口定位和环长度的推导与实现5.1 从判断到定位为什么这道题常常连着问两个变体面试官问你有没有环只是一个开胃菜。真正的正餐通常是如果有环请找到环的入口节点。这就是力扣 142 题。为什么爱这么问因为找到环的入口需要用到 Floyd 算法的完整推导比单纯判断有无环更能看出你对算法本质的理解。公司希望招进来的人能处理数据异常循环引用的排查而不是只会背模板。5.2 经典数学推导快慢指针相遇点到环入口的距离关系设链表头到环入口的距离为 a 段环入口到快慢指针相遇点的距离为 b 段相遇点继续走到环入口的距离为 c 段。环长 L b c。慢指针走过的总距离a b。 快指针走过的总距离a b nL其中 n 是快指针在环里绕的圈数n 1。由于快指针速度是慢指针的两倍所以 2(a b) a b nL 得到 a b nL也就是说a nL - b (n - 1)L c。这个式子的意思是从链表头走到环入口的距离 a等于从相遇点继续绕到环入口的距离 c再加上 n-1 圈。所以当快慢指针相遇后如果让一个指针从 head 重新出发另一个指针从相遇点出发每次都走一步那么它们必然在环入口处相遇。这就是找环入口的核心思路。5.3 找环入口的完整代码def detectCycle(head): slow fast head has_cycle False while fast and fast.next: slow slow.next fast fast.next.next if slow is fast: has_cycle True break if not has_cycle: return None slow head while slow is not fast: slow slow.next fast fast.next return slow这段代码简洁但注意一个细节找入口时fast 指针从相遇点出发每次也只走一步不再两步两步地跳。这一步改变了很多第一次接触这个算法的人为什么相遇之后反而要减速因为我们已经把数学关系推导出来了——两个速度相同的指针从两处出发将在入口相会。慢指针和快指针同时到达入口距离恰好在入口处拉平。5.4 延伸一步如何统计环的长度环的长度计算其实简单到让你意外。找到相遇点之后把 slow 或 fast 停住另一个指针每次走一步走回原位置所经过的步数就是环的长度。因为此时我们在环的确定位置上一整个圆周需要走 L 步。def cycle_length(head): meet detectCycle(head) if not meet: return 0 cur meet.next length 1 while cur is not meet: cur cur.next length 1 return length5.5 如果面试官让口述推导过程怎么讲最清晰不要一上来就搬公式建议按这个顺序讲先说明两个指针相遇时有环追及问题定义几个符号a、b、c、L列出快慢指针路程的倍数关系2(ab) abnL化简得到 a (n-1)L c说明从头走到入口等于从相遇点绕到入口得出结论同步移动双指针即可找到入口其间可以画一张简图辅助纸笔在手边说边画效果远好于干讲公式。6. 进阶视角哈希方案、快慢指针之外还有哪些思路6.1 算法工具并不能覆盖所有语言场景快慢指针 O(1) 空间已经足够优秀但它要求你能直接修改或访问节点的 next 指针。如果你面对的链表是只读接口或者节点结构在某框架里无法直接访问 next那 Floyd 算法就不适用了。这时候哈希集合几乎是唯一思路。我实际遇到过一种情况某个数据流的节点是远端网关返回的 JSON 对象每次访问 next 都意味着一次网络请求。这种场景下去遍历整条链表是灾难正确的做法是限制访问次数比如只查前 N 个节点或者依赖业务规则判定异常循环。所以算法的选择从来不是孤立的要结合数据访问成本去考虑。6.2 布伦特算法是怎么回事除了 Floyd判环还有一个较少被提及的布伦特算法Brents algorithm。它的思路是用一个传送带指针来限制步长。传送带每走 2 的幂次步才移动一次快指针在传送带限制范围内向前探索一旦追到慢指针就说明有环。相比 Floyd布伦特算法在部分场景下能减少比较次数常数因子更小。但它有一个弱点——理解起来不如 Floyd 直观面试口述的难度更大。我不会推荐大家在面试中主动使用布伦特算法因为面试官大概率没听过还得花时间解释。但在工程上处理超大规模环检测时它确实有存在价值有兴趣的读者可以去查一下作为知识拓展。6.3 为什么修改节点值作为标记法永远不是好解法有的同学会想那我遍历的时候把每个节点值改成 None 或者特殊值遇到特殊值不就说明有环了吗这个想法能过样例但有两个致命伤改变了链表的原始数据后续业务逻辑可能依赖这些值如果节点的值类型有限制比如 boolean特殊值根本塞不进去如果链表本来就包含和特殊值相同的合法值会误判所以标记法只适合在极少数允许修改结构的自定义场景里用刷题和面试里都别选它。7. 刷题与面试实战经验围绕环形链表踩过的坑和总结7.1 我实际踩过的三个坑第一个坑是初始化指针时把 slow 和 fast 都设为 head然后上来就判断 slow 是否等于 fast——这样第一次循环必然为 true直接误判无环链表有环。正确做法是让指针先动起来再判断相等。很多新手写的代码就在这一行翻车。第二个坑是忘记处理 head 为空的情况。虽然 Python 的 while fast and fast.next 能天然避开空链表但 Java 的同学如果不预先判断while 里调用 head.next 就会直接空指针异常。建议一上来就写好防御式判断。第三个坑是混淆了值相等和引用相等。我在 4.4 里已经提到过这是 Python 特有的但 Java 的 equals 方法也有类似陷阱。如果链表节点的值类型重写了 equals而你又用 equals 判断节点身份一样会出错。7.2 怎样自己在本地高效构造测试用例我在本地刷这类题目时会先准备一个通用的链表测试工具类包含数组转链表、链表转数组、构造带环链表、打印链表防死循环四个基础方法。每次都从工具类里调方法而不是现敲。class ListNode: def __init__(self, val0, nextNone): self.val val self.next next def arr_to_list(arr): dummy ListNode() cur dummy for x in arr: cur.next ListNode(x) cur cur.next return dummy.next def list_to_arr(head, limit100): res [] cur head for _ in range(limit): if not cur: break res.append(cur.val) cur cur.next return reslist_to_arr 里的 limit 很重要避免误操作导致无限循环。这个工具方法我在很多链表题上都用过省了不少时间。7.3 从面试官视角看这道题最加分的几个回答习惯面试不是算法竞赛更看重思维过程。想在这道题上拿高分这几个习惯很加分先主动说出我能想到的最简单方案哈希集合再自己分析它的缺点主动提出可不可以把空间复杂度降到 O(1)解释快慢指针时用追及问题的类比让面试官觉得你有建模能力代码写完后先用嘴跑一个简单用例再交出去遇到面试官追问两个指针会不会永远不相遇能流利地把 3.3 里那个距离差每次减一必然归零的论证讲出来我面试过一些候选人大家都能写对代码但很少有人能像我前面那样把数学推导讲清楚。这道题能不能出彩往往就在这一层差别上。7.4 快慢指针思路还能用在哪些题上环形链表学好后你顺手可以拿下好几道题查找链表的中间节点、判断回文链表、寻找重复数力扣 287。这些题目要么用快慢指针找中点要么用快慢指针在数组中查找循环。原理相通迁移起来非常快。我个人的学习经验是把一道经典题研究透比泛泛刷十道题更有价值。具体说回文链表那题的做法是先用快慢指针找到中点再反转后半段链表进行比较。判断重复数那题则把数组下标当作链表节点数值作为 next 的索引本质上就是在一个隐式链表上找环。你一旦形成快慢指针判环找中点的条件反射这些题就是送分题。写在最后的个人体会环形链表是我刷题生涯里自己钻研得特别透的一道题因为它同时汇聚了朴素思路、空间优化、数学推导和应用迁移四种层次的思考方式。每次重读这道题我都能发现一点新东西——最近一次我甚至在思考如果链表的 next 指针是异步获取的比如分布式系统中快慢指针还能不能跑得通这其实是另一个层面的工程问题了。如果你正在准备面试我建议你按这个顺序刷先看哈希集合方案再琢磨快慢指针为什么成立然后手推一遍入口定位公式最后把环长度、回文链表、找重复数这几个延伸题都做一遍。把这套流程走完你收获的就不只是一道题的答案而是一整套链表类问题的思维框架。