环形链表这道题,在LeetCode Hot 100里排在第二十五位。很多人第一次看到它,觉得不就是判断链表有没有环嘛,快慢指针背下来就完了。但实际上,这道题在面试里的地位远不止“背模板”这么简单:它考察的是你能否把一个抽象的运动模型讲清楚,能否从“会不会写”进阶到“为什么对”。我见过不少候选人能默写出代码,但被问到“为什么快指针每次走两步而不是走三步”“两个指针相遇时slow到底走了多少步”“怎么证明一定相遇”就卡壳。这篇文章我把这道题从头拆到尾,包括两个问法、三个解法、相遇原理、入口推导、代码实现,以及它背后的Floyd判圈思想能迁移到哪些题上,希望能帮你把这道题吃透。
1. 两个版本、三个解法:先把环形链表考什么摸清楚
1.1 判断环和找入口是两种难度
环形链表在LeetCode上有两个常见版本。第一个版本是第141题,只问链表是否存在环,返回布尔值;第二个版本是第142题,不仅要判断有环,还要返回环的入口节点。Hot 100里收录的是141题,但当你在面试中遇到这道题时,面试官大概率会接着追问142题,尤其在你说“我会用快慢指针”之后。
这两个版本的区别绝不只是“多返回一个节点”。141题可以用“改值法”“哈希表法”“快慢指针法”三种思路解,效果都还行;但142题对哈希表法的容忍度就低了,面试官想听的是你能否用O(1)空间完成入口定位。也就是说,141题是“验证你写过链表题”,142题是“验证你理解链表题的底层逻辑”。所以下面我会把两个版本一起讲,代码上也只是几行之差,但思考深度差了一个量级。
1.2 三种解法的暴力程度排序
先说结论:这道题的解法谱系非常清晰,从“修改链表本身”到“空间换时间”再到“常数空间”,正好是算法优化的三个典型路径。
第一种解法是“改值法”。遍历链表,把访问过的节点值改成某个特殊值(比如极大极小值),如果后续遍历又遇到这个特殊值,说明有环。这个解法能过OJ,但面试基本不能提,因为破坏了输入数据。不过它提醒了我们一个重要的直觉:判断是否“再次访问同一个节点”,本质上是做“去过的地方”的标记。
第二种解法是“哈希表法”。用HashSet记录每个节点引用,遍历时先查集合里有没有当前节点,有则说明回到了环上的某个节点。这个解法逻辑直观,时间复杂度O(n),空间复杂度O(n)。理论上完全正确,也是很多人的第一反应。但在面试场景下,面试官会追问一句“能不能不用额外空间”,这就引出了第三种解法。
第三种解法就是快慢指针,也叫Floyd判圈算法。两个指针从head出发,慢指针每次走一步,快指针每次走两步,如果链表有环,两个指针一定会相遇;如果无环,快指针会先到达null。这个解法的时间复杂度O(n),空间复杂度O(1),是这道题的最优解,也是本文重点展开的内容。
1.3 这道题真正的考点
如果只看“会不会写快慢指针”,那这道题只是一个记忆题。但面试官真正想看的是三件事:第一,你能否从哈希表方案自然过渡到常数空间方案,体现优化意识;第二,你能否严谨解释“为什么快慢指针一定会相遇”,而不是只说“因为快指针会追上慢指针”;第三,你能否在141题基础上推出环入口位置,体现数学推导能力。这三件事,分别对应了算法面试的“优化能力”“原理理解”“深度拓展”三个层次。下面我从原理层开始拆。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 快慢指针为什么一定能抓住环:相对速度、步长选择与边界场景
2.1 相对速度才是理解核心
很多教程讲快慢指针,最喜欢说“快指针走得快,迟早追上慢指针”。这句话大方向没错,但不严谨。因为如果是直线链表,快指针直接走到头,根本不存在“追上”这回事。只有当链表里有环时,快指针进入环后会在环里绕圈,慢指针随后进入环,两个指针都困在环里,追及问题才成立。
更准确的理解方式是“相对速度”。想象你在环形操场上跑步,你跑得快,另一个人跑得慢,两人同向出发,只要一直跑下去,你快的人一定会从后面套圈追上慢的人。快慢指针在环里就是这么个场景:快指针相对慢指针的速度是“每单位时间接近一步”,不管环多大、起点位置多复杂,这个相对速度恒定为1,所以必然会相遇。
具体到代码里,慢指针每次走1步,快指针每次走2步,相对速度就是每步缩小1的距离差。假设入环前那段距离让慢指针进环时,快指针已经在环里多走了若干距离,但这个距离差是有限的,每迭代一次缩小1,最终一定缩小到0,也就是相遇。
2.2 为什么快指针每次走两步,而不是三步四步
这是一个高频追问。先说结论:步长差为1是保证“一定相遇”的充分条件,步长差为2(如走3步对1步)则可能出现跳过的情况,分析起来更复杂,还可能在某些条件下无法相遇。
我们严格推理一下:假设慢指针每次走1,快指针每次走k,那么快指针相对慢指针的速度是 (k-1)。只要 k-1 和环长 L 互质?不对,更准确的表述是:只要 k-1 是正整数,快指针相对慢指针会一步一步接近,但它每次“跨越”的间距是 (k-1),如果这个间距不是1,理论上有可能会跳过慢指针所在位置——虽然从连续运动的角度看“追上”是必然的,但在离散的“节点跳转”模型里,跳过是可能发生的。
举例来说,假设环长为2,只有两个节点A和B,慢指针在A,快指针在B,快指针每次走3步:它从B出发,走3步在环里还是回到B?我们来算一下,如果快指针每次走3、慢指针每次走1,相对速度是2,距离差是1(B到A的环向距离),每次减少2,第一轮就变成差值为1-2=-1,也就是反向差了1,再往后可能一直在“交错”而无法严格落到同一个节点上。虽然实际中步长选2或3在很多情况下都能相遇,但步长差为1是最容易证明、最稳定的选择。
所以标准答案就是:快指针走2步、慢指针走1步,保证相对速度为1,从数学上必然相遇,且不会出现离散跳过的情况。这个设计不是随手写的,而是经过严谨考虑的。如果你在面试中说出这层原因,面试官通常会比较认可。
2.3 极端场景验证:环很小、链表很长时发生什么
为了验证快慢指针的可靠性,我建议你在脑子里过几个极端场景。
场景一:链表很长,环很小。比如一万个节点的直线部分,环只有3个节点。这种情况快指针会先进入环,在环里绕了很多圈,慢指针才进环。但只要慢指针进环,两个指针都在环里了,相对速度为1,很快就会相遇。慢指针可能只走了一两步就撞上快指针。这种情况完全没问题。
场景二:整个链表就是一个环,从head开始就进环。头节点到环入口距离a=0。快指针从head出发,每次走2步,慢指针每次走1步,它们会在环里转。假设环长L=1,也就是只有一个节点且自己指向自己。这种情况下慢指针走1步回到原节点,快指针走2步也回到原节点,第一次迭代就相遇了,也没问题。
场景三:链表无环,直线链表。快指针每次走2步,会先到达null,循环条件判断fast != null && fast.next != null即可退出,返回false。这个也没有问题。
所以快慢指针在“有环/无环”这个二元判断上是非常稳的,不需要额外处理特殊情况。上面这三个场景,建议你在面试前自己推演一遍,会更有底气。
3. 环入口在哪:相遇点背后的距离推导
3.1 先用直觉理解“重置指针”的操作
142题要求返回环的入口节点。如果你只背过快慢指针判断环,到这里就容易卡住。我记得自己第一次做142题时,也想了很久,后来发现关键在于一个非常巧妙的性质:当快慢指针第一次相遇时,让其中一个指针回到head,另一个指针留在相遇点,然后两个指针都改成每次走一步,它们再次相遇的位置,就是环的入口。
这个结论看起来很魔幻,但数学上非常干净。我先把变量定义清楚,然后一步步推。
3.2 设变量:a、b、c三段距离
把链表画成一条线段加一个环。设:
- a:从链表头节点head到环入口节点的距离,也就是入环前的直线段长度;
- b:从环入口节点出发,沿着链表方向走到快慢指针第一次相遇点的距离;
- c:从相遇点继续沿着链表方向走,回到环入口节点的距离。
那么环长 L = b + c。
当快慢指针第一次相遇时,慢指针走了 a + b 步(这里的“步”指移动的节点数)。这个结论有一个前提:慢指针入环后,最多走不到一圈就会被快指针追上。为什么?因为快指针在慢指针入环时已经在环内了,它相对慢指针的速度是1,距离差小于等于环长L,所以追上的时候,慢指针在环内走的距离一定不超过L,即b < L。这一点很重要,后面推导要用。
快指针走的距离则是慢指针的两倍,因为它速度是慢指针的两倍,运动时间相同,所以快指针走的路程 = 2(a + b)。
另一方面,快指针的路径可以描述为:先走a到达环入口,然后在环里绕了k圈(k >= 1,因为快指针一定比慢指针先入环),再走了b到达相遇点。所以快指针走的路程 = a + b + kL,其中L是环长。
于是得到等式:
2(a + b) = a + b + kL
化简:
a + b = kL
也就是说,a + b 等于环长的整数倍。
再变形:
a = kL - b = (k - 1)L + (L - b) = (k - 1)L + c
这里的 L - b 就是 c,因为 L = b + c。
3.3 这个等式是什么意思
我们得到的结论是:a = (k - 1)L + c。翻译成人话:从链表头走到环入口的距离a,等于从相遇点继续走c步(绕回入口)再走若干整圈的距离。也就是说,一个指针从head出发走a步,与另一个指针从相遇点出发,每次也走一步,走过 (k-1) 圈加 c 步后,两者正好在环入口相遇。
这就是“重置指针法”的数学依据:相遇后,让慢指针回到head,快指针留在相遇点,两者都改为每次走一步,那么它们再次相遇的位置必然是环入口。因为慢指针从头走a步到达入口,与此同时快指针从相遇点走了a步,也就是走了 c + (k-1)L 步,也恰好绕圈回到入口。
整个推导里最容易忽略的条件是“慢指针入环后第一圈内就被追上”,也就是 b < L。这个条件保证慢指针从head到相遇点的距离是 a + b,而不是 a + b + 若干圈。如果慢指针在环里绕了好几圈才被追上,那等式中慢指针路程会变成 a + b + mL,推导会复杂很多。但根据相对速度的分析,这个条件在步长1和2的设置下天然成立,所以不用担心。
3.4 选择题式总结:相遇点、入口、头节点的几何关系
很多人在面试时会被突然问到:“如果快指针走的路程是慢指针的两倍,除了能判断有环,你还能推出什么?”这个问题的答案就是上面的等式。你可以很清晰地回答:从相遇点出发,与从head出发、同速走,再次相遇的点就是入口。这个结论可以直接记,但建议理解推导过程。因为面试官如果追问“为什么”,你只有讲出a = (k-1)L + c这一步,才算真正过关。
4. 代码落地:判断环与定位入口的标准实现
4.1 判断环:短小精悍的141题解法
先给出判断环的Python实现:
python复制class ListNode:
def __init__(self, x):
self.val = x
self.next = None
def hasCycle(head: ListNode) -> bool:
slow = head
fast = head
while fast and fast.next:
slow = slow.next
fast = fast.next.next
if slow == fast:
return True
return False
这段代码有几个细节需要注意。
第一,初始时slow和fast都指向head,这是最常见的写法。进入循环后slow走1步,fast走2步,然后比较。如果在一次移动后两个指针指向同一个节点,说明存在环。
第二,循环条件fast and fast.next,这是为了处理无环的情况。因为fast每次走2步,如果链表长度为奇数,fast可能在某一步变成None;如果长度为偶数,fast.next可能变成None。所以要同时判断fast本身和fast.next都不为None。
第三,为什么环内一定会相遇而不是无限循环?因为根据第2节的相对速度分析,进入环后两个指针的距离差逐步缩小,最终必然变为0,循环内的if判断一定会触发return True。所以这段代码不会死循环,除非链表本身有问题。
4.2 找入口:142题的完整实现
在141基础上增加入口定位逻辑,Python版本是这样的:
python复制def detectCycle(head: ListNode) -> ListNode:
slow = head
fast = head
has_cycle = False
while fast and fast.next:
slow = slow.next
fast = fast.next.next
if slow == fast:
has_cycle = True
break
if not has_cycle:
return None
slow = head
while slow != fast:
slow = slow.next
fast = fast.next
return slow
这里的关键改动是:第一次相遇后,把slow重置为head,fast保持在相遇点不动,然后两个指针都以步长1前进。当它们再次相等时,该节点就是环的入口,直接返回slow(或fast,此时它们相等)。
再补一个Java版本,方便面试时用:
java复制public class Solution {
public ListNode detectCycle(ListNode head) {
ListNode slow = head;
ListNode fast = head;
boolean hasCycle = false;
while (fast != null && fast.next != null) {
slow = slow.next;
fast = fast.next.next;
if (slow == fast) {
hasCycle = true;
break;
}
}
if (!hasCycle) {
return null;
}
slow = head;
while (slow != fast) {
slow = slow.next;
fast = fast.next;
}
return slow;
}
}
4.3 边界条件的自我拷问
链表题最容易在边界上翻车,我整理了一份自查清单:
- 空链表:head为null,直接返回None或false。上面两段代码中,while fast and fast.next / while (fast != null && fast.next != null) 已经覆盖了这个情况。
- 只有一个节点且next为null:无环,返回false。
- 只有一个节点且next指向自己:有环,入口就是这个节点。快慢指针第一次循环就相遇,slow重置为head后,while循环条件不成立(因为slow == fast),直接返回head。
- 链表头节点就是环入口:a=0,即head已经在环内。第一次相遇后slow重置为head,此时slow已经等于fast(因为相遇点就在环内,不一定等于head)。等等,这里要小心:如果a=0,相遇点不一定等于head。我重新验证一下:head就是入口,slow第一次进环的位置是head,fast已经在环内,两者追及相遇点可能在环内任意位置。相遇后slow重置为head,此时fast在相遇点,二者不相等,继续走。根据推导,slow从head走a步到入口,a=0,即slow一开始就在入口,而fast从相遇点走a步也回到入口,所以第二次相遇时fast走到入口,slow已经在入口等着,返回入口。实际上代码逻辑是slow和fast同时移动,slow第一步走到head.next……需要仔细模拟,但结论是正确的:最终它们会在入口相遇。
- 链表中存在多个节点指向同一个节点(环):快慢指针判断的是“节点引用相等”,这个没问题,因为链表节点引用是唯一的。
4.4 复杂度与候选方案对比
| 解法 | 时间复杂度 | 空间复杂度 | 是否破坏输入 | 面试推荐度 |
|---|---|---|---|---|
| 修改节点值 | O(n) | O(1) | 是 | 不推荐 |
| 哈希表 | O(n) | O(n) | 否 | 可作为过渡方案提及 |
| 快慢指针 | O(n) | O(1) | 否 | 首推 |
哈希表方案在工程上非常实用,可读性也最好。如果你在面试中先讲哈希表,再主动提出“我能用O(1)空间优化”,这个路径比直接背出快慢指针更自然,也更显思考过程。
5. 从链表环到数组题:Floyd判圈思想怎么迁移
5.1 Floyd判圈检测的本质是“状态重复”
快慢指针解决环形链表的底层逻辑,是Floyd判圈算法(Floyd's Cycle Detection),也叫龟兔赛跑算法。它的本质是:在一个状态转移系统里,如果某个状态会重复出现,那么一定存在环。链表只是这个思想最简单的载体:每个节点通过next指针转移到下一个节点,如果有环,某些节点会被重复访问。
这个思想可以迁移到很多看似与链表无关的题目。最典型的是LeetCode 202题“快乐数”:给定一个整数,不断求各位数字的平方和,如果最终能变为1则返回true,如果陷入循环则返回false。这里的“状态”就是每次计算得到的数字。如果它陷入循环,说明状态转移有环。用哈希表当然能解,但进阶做法就是快慢指针:slow每次算一次平方和,fast每次算两次平方和,如果两者相等,说明进入循环。
LeetCode 287题“寻找重复数”也是同一思路的变体。给定一个包含n+1个整数的数组,数字范围在[1, n]之间,只有一个重复数字,要求不修改数组、只用O(1)空间找到它。这道题的经典解法是把数组下标和值当作链表的next指针关系——从index=0出发,nums[0]作为下一个index,这就构造出一个隐含的链表。由于存在重复数字,这个“链表”必然有环,环的入口就是重复数字。用142题一模一样的方法就能解。
这些题表面上是不同的数据结构,底层都是Floyd判圈。如果你能把环形链表吃透,这三道题等于白送。
5.2 快慢指针与“求环长度”的扩展
有些面试官会在环形链表上继续加问:如果链表有环,你能求出环的长度吗?
方法也简单:第一次相遇后,让一个指针停在相遇点,另一个指针每次走一步并计数,再次相遇时走过的步数就是环长。因为此时两个指针都在环内,从相遇到再次相遇,正好绕环一圈。
这个扩展问题在面试中出现频率不低,它考察的不是新知识,而是你是否真的理解相遇点的含义。如果你能脱口而出“从相遇点再走一圈回到相遇点,步数就是环长”,面试官基本就放心了。
5.3 迁移时需要注意的陷阱
迁移Floyd判圈思想到数组或数字题时,有一个陷阱:不是所有状态转移都保证“有环”。比如快乐数里,如果数字最终变为1,状态转移会停在1(1的平方和还是1),这是一个自环。严格来说这也是环,长度为1的环。所以判断条件是“slow == fast”而不是“fast == 1”。你在写代码时要注意这个语义差异。
另外,构造“数组下标到值的映射”时要确认映射关系是单值的:每个index转移到的下一个index必须是确定的。LeetCode 287满足这个条件,因为数组元素值在[1, n]范围内,可以作为合法的下标。如果元素值可能越界,这个映射就不成立。
6. 面试复盘:三道高频追问与一个常见认知误区
6.1 高频追问一:快指针能与慢指针错过吗
这个问题问的是“离散跳步是否会跳过相遇”。答案是不会,原因是快指针每次走2步,慢指针走1步,相对速度为1。在每一轮移动中,两个指针之间的距离严格减少1。如果距离为0则相遇,否则距离从d变成d-1,最终一定会变成0。不会出现“距离从2变成0再变成-1”这种跳过,因为d-1是一个连续递减过程。
但如果你把快指针改成每次走3步,慢指针走1步,相对速度为2,就有可能出现“距离2变0再变2”的情况,也就是两个指针不断交错但永远不相遇?严格地说,在连续运动模型里还是会相遇,但在节点离散模型里可能跳过了。所以标准实现选择2和1,最安全。
6.2 高频追问二:相遇时慢指针真的没走完一圈吗
很多人在推导142题时卡在这里。答案:慢指针入环后,第一次被快指针追上时,它在环内走的距离一定小于环长。原因还是相对速度:快指针在慢指针入环前已经在环内了,最极端的情况是快指针刚在慢指针前方一个节点处,追上它只需要让距离差缩小1,也就是一步;最坏的情况是快指针刚好在慢指针前方L-1步处,追上需要L-1步,小于L。所以慢指针在环内走的步数b一定小于L。
这个条件保证了慢指针的总路程可以写成a + b,而不是a + b + mL,这是入口推导成立的前提。
6.3 高频追问三:为什么第二次相遇时slow和fast的步长都要变成1
根据第3节的推导,a = (k - 1)L + c。从head出发走a步到入口;从相遇点出发走a步,等价于走c步到达入口,再绕(k-1)圈回到入口。因此两个指针每一步都走1时,能在入口精确相遇。如果步长不一样,这个等式就不成立了。
这里有一个常见的误区:有人以为第二次相遇时,slow从head出发也走了k圈。其实不是,slow从head出发只需要走a步,a通常远小于kL,是直线段,不涉及绕圈。只有fast才在环里绕圈。正因为slow走的路径是从head到入口的“直线”,而fast走的路径是“绕圈回到入口”,二者长度相等且相遇于入口,这个操作才成立。
6.4 一个容易犯的编码错误:先移动再比较
写快慢指针时,初学者容易在循环开头直接判断slow == fast,这会导致初始时两者都指向head,直接返回true。正确做法是先移动,再比较。判断有环的代码里,while循环内部是“slow走一步、fast走两步、然后比较”;找入口的代码里,第二次while循环是“slow走一步、fast走一步、然后比较”。初始二者重合是人为设定的起点,不是相遇结果,必须移动后再判断。
还有一个隐藏细节:判断环的代码,如果不小心写成while fast.next and fast.next.next,在链表只有一个节点且无环时会报空指针错误,因为fast.next是null,再取.next越界。所以标准写法是while fast and fast.next,用短路求值先保证fast不为null。
6.5 我实际面试中会怎么答这道题
最后说说我在模拟面试里对这道题的标准回答路径,供你参考。第一段:先确认题意——是判断有环还是返回入口,是否允许修改链表,如果没提空间限制,我会先说哈希表解法,再主动优化到O(1)空间。第二段:讲快慢指针的思路,用“环形跑道上的追及”类比,强调相对速度为1。第三段:推导为什么相遇,为什么慢指针在环里不超过一圈。第四段:如果面试官追问入口,从相遇点重置一个指针到head,然后同速走,再次相遇即入口,并给出a = (k-1)L + c的推导。第五段:在代码实现上,我会说判断环的循环条件是fast and fast.next,找入口的第二次循环是while slow != fast,然后展示代码。整个回答控制在五分钟内,重点在原理而不在背代码。
这道题我从初学到现在带过不少人刷,最大的感受是:环形链表的代码量极小,但它的信息密度在Hot 100里排得上号。你到底是“背下来了”还是“想明白了”,面试官一追问题就知道。把这一题的原理吃透,你收获的不只是答案,而是Floyd判圈这一类问题的通用解法。后面再做快乐数、寻找重复数,甚至某些链表相交问题的变体,你都会比别人多一层“看到环”的直觉。
