如果要把刷题路上的“分水岭”挑出来,环形链表绝对算一个。这是LeetCode第141题,也是“每日算法题”系列做到第14期时我最想认真拆的一道题。原因很简单:题面只有“判断链表中是否存在环”这一句话,但背后牵扯出来的东西很多——链表遍历的边界、空间复杂度的权衡、快慢指针的数学直觉、以及面试时怎么把思路讲得让人点头。很多新手卡在这里,不是不会写代码,而是没想明白“为什么快指针一定能追上慢指针”。这道题值得反复做,每做一遍都能看出不同层次的理解。
先说明一下适合谁看:正在刷题准备面试的开发者、刚学完链表想巩固数据结构基础的同学、以及想系统整理双指针技巧的人,都可以从这篇里拿走东西。我会先把三种解题路径完整过一遍,再深入快慢指针的原理,然后给Python、Java、C++三套可以直接跑的代码,最后把本地构造测试用例和常见报错一并说清楚。
1. 题目定位与核心价值拆解
1.1 这道题在算法体系里的“坐标”
链表类题目在算法面试中的出现频率非常高,而环形链表就是这套题型里的地基。它不像反转链表那样直接考察指针操作,也不像合并两个有序链表那样考验递归思维,它真正考的是“你怎么判断一个过程会不会无限走下去”。这个能力往小了说,是处理链表特殊结构;往大了说,是理解状态机、循环依赖和分布式系统里死循环检测的底层逻辑。
从刷题路线来看,环形链表题目分两档:判断是否有环(第141题)和找到环的入口(第142题)。141题是全系列的起点,适合初学;142题则要结合数学推导,是很多人在面试中展示思考深度的经典题。我认识的不少开发者,包括我自己,第一次手写快慢指针都是从141题开始的。这道题的代码量很少,十几行就能解决,但真正写对的人并不占多数。
还有一点容易被忽略:环形链表是“链表中点”“回文链表”等双指针进阶题的前置技能。可以说,把这道题彻底吃透,后面做链表中点、删除倒数第N个节点、约瑟夫环等题目都会顺利很多。刷题不是比数量,而是看你能把一个知识点串起多少题目。
1.2 单看题目描述,里面有几个隐藏信息
“判断链表是否存在环”这句话看起来直白,细细拆出来你会看到几个前提。第一,这是单链表;第二,不允许修改链表结构——很多没注意审题的人第一反应是“那我遍历一遍,把走过的节点标记一下”,这个思路本身没问题,但如果把节点值改成标记,那就破坏了原链表,显然不是出题人想要的。第三,返回值是布尔值,不是节点位置,所以在没有环的时候要能正常结束。
最关键的隐藏信息是:这里的“环”指的是链表中某个节点的next指针指回了之前某个节点,导致从head出发遍历时会无限绕圈。理解这一点才能明白为什么暴力解法很难写对——如果你不知道链表的长度,你根本不知道什么时候该停下来。
我见过不少人在面试时先说“那我最多走1000步,如果走到了就说明没环,否则就有环”——这显然是没理解问题的本质。链表长度是未知的,可能只有3个节点,也可能有10万个节点,任何固定步数都可能出错。所以这道题真正的考点,是在不借助额外标记、不改链表结构的前提下,设计一个必然终止且能正确判断的算法。
2. 三种解题路径逐一拆解
2.1 暴力解法为什么“看着简单,写完就想删”
先排除一个很容易想到但错误的方案:从头遍历,每走一步就把当前节点跟前面所有节点比较一次。这种双重遍历确实能判断环,但代价是O(n的平方)的时间复杂度,而且实现起来非常别扭——你需要额外维护一个“前面所有节点”的列表,每次还要遍历它。
即便你用计数器,比如设置最多走100万步,那也只是碰运气。链表没有定理保证长度不超过某个固定值,面试官只要追问一句“你这个100万怎么来的”你就答不上来了。
真正的问题在于:无环链表遍历到末尾时next为null,循环自然结束;有环链表则永远不会遇到null。暴力方案没办法在“不知道链表长度”的情况下区分这两种情况。所以这个方案的意义不在于用它,而在于帮你想清楚题目到底卡在哪里。我在给新人讲题时经常强调:先承认暴力解法的局限性,再去看哈希和双指针,你会自然理解每种优化到底解决了什么问题。
2.2 哈希表方案:最容易写出、也最容易被追问空间复杂度
哈希表思路是这样的:遍历链表时,把每个节点对象(注意是对象本身,不是节点的val)存进一个Set,每次移动前先检查当前节点是否已经在Set里。如果在,说明绕回来了,存在环;如果走到null,说明链表正常结束,没有环。这个方案在逻辑上无懈可击,代码也很好写。
为什么一定要存“节点对象”而不是“节点的值”?因为链表里完全可能有两个不同节点带着相同的值,比如1->2->2->3,第二个2和第三个2值相同但不是同一个节点。如果把值存进Set,遇到重复值就会误判成环。正确做法是存引用,在Python里就是ListNode对象本身,在Java里是对象引用,在C++里是指针。
这个方案面试时是很好的铺垫,因为它直观、正确、容易实现,但你马上会被追问:“空间复杂度是多少?能不能优化到O(1)?”这就是快慢指针出场的时机。哈希表方案本身是完整的解法,只是空间上不占优势。实际工程里如果内存不敏感,用哈希表判环完全可行,但面试官更希望看到你能想到更优解。
2.3 快慢指针的直觉推导:一个操场跑圈的类比
快慢指针的直觉其实特别生活化:想象你和朋友在一个圆形操场上跑步,你速度慢,他速度快。如果他足够快,迟早会从背后追上来和你并排——这在环形跑道上一定会发生,因为大家都在绕圈。
对应到链表上,“操场”就是环,“跑道距离”就是环的长度。慢指针每次走1步,快指针每次走2步,两个人从head一起出发。如果链表中存在环,那么当慢指针进入环之后,快指针已经在环里绕了。此时快指针相对于慢指针,每轮移动1个节点(2-1=1),所以两者的距离在逐步缩短,最终肯定能相遇。
如果链表没有环,那快指针会率先走到null,循环正常结束。整个过程不需要知道链表长度,不需要额外存储,也不需要修改链表,完美契合题目的所有限制。我第一次理解这个类比的时候,整个人是“哦——”的状态,比直接看代码清晰十倍。这也是为什么我一直觉得,算法题不能上来就背代码,先建立直觉模型,代码是顺理成章的事。
3. 快慢指针为什么是“黄金组合”:数学证据与步长讨论
3.1 环内相遇的必然性,这里有一个最简洁的证明
很多人都知道快慢指针解法的结论,但面试时如果只背出“快指针两步,慢指针一步,相遇就是有环”,会显得理解很浅。你要能解释:为什么快指针一定能追上慢指针,不会无限追下去?
设慢指针速度为1,快指针速度为2。两指针同时从head出发。如果链表不存在环,那很简单:快指针先遇到null,循环结束。麻烦的是存在环的情况。此时我们关心的是两者都在环内绕圈时的相对运动。
关键在于相对速度:快指针比慢指针每轮多走1格。无论它们在环内的初始距离是多少,这个距离每轮会减少1格。当距离减到0时,快指针追上慢指针,两者相遇。因为环的长度是有限的,初始距离也是有限的,所以经过有限轮次后距离必然归零。这就是相遇必然性的证明。
这里还有一个更反直觉但也很妙的说法:如果一直追不上,说明慢指针可以无限走远,但链表节点总数是有限的,所以矛盾。两句话都能证明,但第一句更直观,适合面试时说。
还有个细节值得注意:我们常说“快指针追上慢指针”,但在这个模型里,快指针是每轮走2格,慢指针走1格,慢指针不会停在原地等待,所以“追上”发生在两者处于同一节点的那一瞬间,而不是快指针跨过慢指针。步长差为1时不会出现“跨过去”的情况,这是选择2和1的重要原因之一。
3.2 如果把快指针改成3步,会发生什么
很多人会问:快指针每次走3步甚至4步不是更快吗?表面上看确实“跑得更快”,但数学上不能保证相遇。
原因很简单:快指针和慢指针的速度差变成了2(3-1=2)。此时快指针相对于慢指针,每轮移动2格。如果它们在环内相距1格,快指针这轮会“跨过”慢指针到达另一侧,两者没有在同一节点相遇。下一轮两者的距离又变成其他值,可能一直这样错开,形成无法交汇的局面。
我可以举一个具体的反例方便你理解。假设环的长度是3,快指针和慢指针在某一时刻处于相邻位置(距离为1)。慢指针走1格到某个节点,快指针走3格后从慢指针头顶“飞过”,没有踩到同一个节点。之后它们的相对位置关系会周期性变化,始终无法凑成“同一节点”。这样的环确实存在,所以步长差大于1的方案不是必然能判断出环的。
从工程角度看,快指针每次走3步还带来了额外的空指针风险:循环条件需要写成fast && fast.next && fast.next.next,稍微一疏忽就会访问到null。步长2配合步长1,速度差恰好为1,既保证相遇必然性,又让循环条件优雅安全,这就是它被称为“黄金组合”的原因。我在实际写代码时从来只用2和1,不是不知道其他组合,而是知道2和1是数学上最稳的。
3.3 复杂度分析也不能含糊
快慢指针的时间复杂度为什么是O(n)而不是O(无穷)?很多人担心,如果链表有环,是不是会绕很多圈导致时间不可控。实际上,慢指针从head出发,到它进入环之前最多走n步;进入环后,快指针在环内最多多绕一圈就会追上慢指针。总体来看,慢指针走的总步数不超过n,快指针走的总步数不超过2n,所以时间复杂度是O(n)。
空间复杂度是O(1),只用了两个指针变量,没有额外存储。这也是这道题被称为“最优解必考”的原因。面试时把这个复杂度讲清楚,已经能超过一半的候选人。
如果快指针设置的步长差为1,最坏情况是慢指针刚到环的入口时,快指针刚好从后面追上来,这时候两者也是“相遇”的;最慢的情况,快指针多绕了快一圈才追上。无论哪种,圈数都有限,不会出现死循环。
4. 代码落地:三大主流语言完整写法
4.1 Python实现:注意判空顺序和引用比较
Python的写法最接近伪代码,我先给出一个可以直接运行的版本:
class ListNode: def __init__(self, val=0, next=None): self.val = val self.next =next def has_cycle(head): if not head or not head.next: return False slow = head fast = head while fast and fast.next: slow = slow.next fast = fast.next.next if slow is fast: return True return False有两个地方要重点解释。第一,为什么一上来要先判断head.next?因为如果链表只有一个节点,它不可能成环(单节点的next指向null),但如果不提前判断,代码进入循环后第一次执行fast.next.next就会报错。提前判空可以少写一层分支,也让语义更清晰。
第二,slow is fast用的不是==,这是Python里的“引用比较”,判断两个变量是否指向同一个对象。如果写成==,Python会去比较ListNode对象的__eq__方法(默认是比较内存地址,所以自定义类下==也可能等于is),但为保险起见,明确用is表达“同一个节点”的意图最稳妥。
循环条件while fast and fast.next的意思是:只有当前节点和下一节点都不为空,快指针才能走出两步。如果快指针到了链表末尾,说明没有环,循环退出,函数返回False。这个条件顺序不能写反,一旦写成while fast.next and fast,当fast为null时取fast.next直接抛异常。
4.2 Java和C++实现:细节略有不同,但核心一致
Java版本几乎可以直接照搬Python的逻辑:
public class Solution { public boolean hasCycle(ListNode head) { if (head == null || head.next == null) { return false; } ListNode slow = head; ListNode fast = head; while (fast != null && fast.next != null) { slow = slow.next; fast = fast.next.next; if (slow == fast) { return true; } } return false; } }Java中==作用于对象引用时,判断的就是“是否同一个对象”,所以这里用==是正确的。注意不能写成slow.equals(fast),除非你对ListNode重写了equals,否则默认也是比较引用,但直接==更符合惯例。
C++版本在指针使用上更直白一些:
class Solution { public: bool hasCycle(ListNode* head) { if (!head || !head->next) return false; ListNode* slow = head; ListNode* fast = head; while (fast && fast->next) { slow = slow->next; fast = fast->next->next; if (slow == fast) return true; } return false; } };C++里比较两个指针是否相等,就是比较地址。这里slow == fast与你平时比较两个int变量不一样,它直接比较的是指针指向的地址,语义非常明确。写C++版本时唯一要注意的是判断空指针的写法,三段写法里,!head和head == nullptr等价,但while (fast && fast->next)这种紧凑写法在面试中很常见,别人读起来也顺畅。
三份代码的核心算法完全一样,区别只在语言本身的指针/引用理念。你要是能当场写出任意两种语言版本,面试官对你好感度会明显上升,因为这说明你不只是在背解法,而是真的理解逻辑。
4.3 一个容易忽视的初始化问题:快慢指针为什么要指向head
这里有一个很多新手会踩的坑:快慢指针到底是从head出发,还是fast从head.next出发?两种写法都可能对,但判断逻辑不同。
如果两个指针都从head出发,进入循环后必须先移动再判断,否则第一步直接比较就会发现slow == fast,还没判断任何东西就返回True了。如果fast从head.next出发,那么循环开头可以直接判断slow == fast,但要注意连续跳两步时可能一次性越过边界。我的建议是统一使用“同时从head出发,先移动再比较”,这样最不容易出错。
代码写完后,建议你用三个测试用例验证:空链表、单节点无环链表、尾节点指向中间节点的带环链表。很多人的代码能通过LeetCode,但自己写测试时就露馅,因为不了解怎么构造带环链表。这个问题我放到第5节的调试技巧里详细讲。
5. 常见错误与排查技巧实录
5.1 这套代码最常见的三类运行错误
我把实际调试中碰到的错误整理了一下,用一个表格列出来,方便你对照排查。
| 症状 | 原因 | 解决方案 |
|---|---|---|
空指针异常,报错在fast.next | 循环条件里先写fast.next再判fast != null | 条件顺序改为while (fast != null && fast.next != null),先判自身再判下一节点 |
| 程序死循环,永不返回 | 只让slow移动,fast没移动,或fast步长和slow相同 | 检查fast是否每次走两步,确认两指针速度差为1 |
| 单节点链表返回False但无环判断错误 | 没有处理head.next == null的边界 | 函数开头先判if (head == null || head.next == null) return false |
| 有环但返回False | 误用值比较而不是引用比较 | 确保比较的是节点对象/指针,不是node.val |
| 快指针从head.next出发但循环条件写错 | 初始位置和循环条件不匹配 | 统一两个指针都从head出发,先移动后比较 |
这里最隐蔽的错误是值比较。链表中完全可能存在值相同但节点不同的情况,如果代码里写slow.val == fast.val,有环但相遇位置恰好值不同就会漏判;即便值碰巧相同,也不能证明是同一个节点。所以无论什么语言,这题一定比较引用或地址。
5.2 本地调试的实用技巧:如何快速构造一个带环链表
LeetCode上你只管提交函数,但本地调试时你得自己构造链表。构造带环链表的核心操作是:先按正常方式建链,然后把最后一个节点的next指向链表中的某个节点。
def build_linked_list(values, pos): """ values: 节点值列表 pos: 从0开始的索引;-1表示无环 返回链表的头节点 """ if not values: return None head = ListNode(values[0]) nodes = [head] cur = head for val in values[1:]: cur.next = ListNode(val) cur = cur.next nodes.append(cur) if pos >= 0: cur.next = nodes[pos] # 尾节点指向前面某个节点,形成环 return head # 测试用例 list1 = build_linked_list([3, 2, 0, -4], 1) print(has_cycle(list1)) # 期望 True list2 = build_linked_list([1, 2], -1) print(has_cycle(list2)) # 期望 False list3 = build_linked_list([1], -1) print(has_cycle(list3)) # 期望 False这段代码在调试时非常好用,因为你可以自由控制环的位置。如果你想观察快慢指针各自的轨迹,还可以在循环内部加一行print(id(slow), id(fast)),打印两个节点在内存中的地址。观察地址变化能帮助你从直观上理解“追上”到底发生在什么时候。
我还有一个习惯:先写一个会死循环的错误版本,故意不移动fast,然后跑测试用例,看程序卡住。这种“反向验证”比只看正确代码更能帮你记住为什么必须移动两个指针。很多问题,自己亲手弄挂一次,记一辈子。
5.3 面试时怎么把思路讲得让人点头
代码写得对只是及格,面试里的表达往往决定你能拿几分。我给一个亲测有效的表达顺序:
先亮出结论:“这道题可以用快慢指针判断,时间复杂度O(n),空间复杂度O(1)。”然后解释直觉:慢指针走1步,快指针走2步,如果有环,两个人最终会相遇;如果没有环,快指针会先到达链表尾部。接着补一句关键证明:进入环以后,快指针相对慢指针每轮逼近1个节点,所以有限轮内必然追上。最后提一下边界:空链表和单节点链表一定无环,先做特判。
如果面试官问你能不能优化,你已经给出了最优解。如果接着问“那怎么找环的入口”,这就是第6节要讲的内容。主动提起这个话题会给你加分,因为它说明你不只背了一题,而是有后续延伸的思考。
还有一个小技巧:画图。在纸上画一个带环的链表,用两个不同颜色的笔标记slow和fast的移动轨迹,面试官马上就能看懂。算法面试里,能画图讲清思路的候选人,通常比只赶着写代码的候选人留下更好的印象。
6. 进阶:从“判断有环”到“找到入口”的一整套双指针应用
6.1 环的入口怎么找:一个需要推导两分钟的数学小题
判断有环之后,很多面试题会追加一问:找到环的入口节点。这时快慢指针还在,但逻辑要加一段。
先做数学推导。假设从链表头到环入口的距离是a,慢指针进入环后又走了b步与快指针相遇。环的长度为L。慢指针总共走了a+b步,快指针走了2(a+b)步。因为两者最终相遇,快指针比慢指针多走了n圈,也就是2(a+b) - (a+b) = nL,所以得到关键等式:a+b = nL。
这个等式说明:从相遇点继续往前走a步,正好回到环的入口。验证一下:相遇点相对入口的位置是b(慢指针在环里走了b步),从相遇点再走a步,相对入口的位置变成b + a = nL,正好是入口所在位置。同时,一个从头节点出发的指针走a步也会到达入口。所以解法就是:第一次相遇后,把一个指针放回头部,另一个留在相遇点,两个指针都每次走1步,第二次相遇的位置就是环的入口。
代码实现:
def detect_cycle(head): if not head or not head.next: return None slow = head fast = head while fast and fast.next: slow = slow.next fast = fast.next.next if slow is fast: break if not fast or not fast.next: return None slow = head while slow is not fast: slow = slow.next fast = fast.next return slow这段代码前半部分和判环一模一样,后半部分多了一个“重新出发”的过程。理解这个推导的人,写代码时自然顺手;不理解的人只能死记硬背。我很建议你花两分钟在纸上把a、b、L的关系画一遍,效果比看这篇文章十遍都好。
6.2 环长度、链表中点:双指针思想其实是一整个家族
快慢指针不是只能解决这一道题。学会这种“速度不同但路径相同”的思维后,你还能顺手拿下很多题目。
比如找链表中点:快指针一次走2步,慢指针一次走1步,当快指针到末尾时,慢指针正好在中间。这个技巧在回文链表判断里经常用到。再比如找环的长度:第一次相遇后让一个指针保持不动,另一个指针每次走1步,再次相遇时走过的步数就是环的长度。这些都是同一个思想体系的产物。
这也是为什么我总强调“刷题要刷系列”。环形链表这道题做到位,你把双指针的几个基础变体都掌握了,后面做链表中点、反转链表II、重排链表、回文链表,几乎都是条件反射级别的解法。初学者往往追求题目数量,但资深工程师更看重把一类题揉碎吃透。
6.3 工业场景里,环形结构真的存在吗
有人会觉得链表成环只是面试题,工程里哪会有这种事。实际上,环状结构在真实系统里很常见,而且检测手段就是快慢指针思想的变体。
举几个例子。在内存分配场景中,某些对象图可能存在循环引用,垃圾回收器需要判断对象图是否无环,这个场景与链表环检测高度相似。在分布式任务调度中,任务A依赖任务B、任务B又依赖任务A,就形成了依赖环,轻量级的检测可以用类似双指针的思路,复杂性高的场景则用更高级的拓扑排序算法。再有就是一些配置系统,指针引用配错后可能形成无限循环解析,排查起来往往需要人工画引用关系图。
我记得某一次处理线上问题,一个配置对象在解析时反复横跳,日志刷了几个G。后来用了一个非常朴素的“走一步留一个记号”的办法定位到循环引用,本质上就是哈希法判环。面试题里的思想,工程上完全用得上,关键在于你能否识别出问题的抽象本质。
7. 最后分享一点我做题方法上的体会
刷了这么多年算法题,我最大的感受是:环形链表这道题,几乎参与了我所有的双指针讨论。它简单到可以作为热身题,又深刻到值得反复推导。每次面试官让我讲快慢指针,我都会从这道题顺下去,因为它包含了一个好算法该有的所有要素:直觉类比、数学证明、边界条件、复杂度分析、以及一个自然的进阶追问。
如果你今天只记住一件事,我希望是这句话:快慢指针之所以成立,根本原因是两者速度差为1,在环内距离每轮缩短1格,因此必定相遇;其他的任何写法都是从这句话推出来的。
建议你做完141题,紧跟着做142题和链表中点那道题,形成一个半个月的小专题。刷题不是比谁刷得多,而是比谁能把一个点讲到别人听得懂、自己也真正消化。这一篇写到这里,剩下的就靠你把手放到键盘上去实践了。