1. 为什么"找环"这件小事,能让一堆人栽跟头
先聊点实际的。链表找环这个题,网上题解一堆,背下来容易,真正写对的人不多。我见过不少候选人,闭着眼睛能默写快慢指针模板,但一问"为什么相遇后要把快指针重置到头节点"就卡壳,或者直接来一句"就是这样写的,标准答案"。
这种回答,面试官一听就知道是背的。要是放到真实工程里,问题更大:你要检测的不只是"有没有环",很多时候还要找到环的入口、计算环的长度,甚至要判断两个链表是否相交。这几个问题的解法全都绕不开那个关键的"重置"动作,但重置的位置、重置谁、重置之后怎么走,不同场景完全不一样。搞混了,代码跑起来就是死循环或者空指针,调试到怀疑人生。
这篇文章我想用实际推演的方式,把这个"重置"逻辑掰开揉碎。不追求把五种解法一口气堆出来,而是聚焦一个点:快慢指针相遇之后,到底发生了什么?为什么下一步必须是"重置"?重置之后为什么能精确停在环入口?以及在什么场景下,你其实不需要重置,反而该用另一种方案。
2. 从"追及问题"说起:快慢指针的第一性原理
2.1 两倍速度差下的数学必然性
先回到最基础的问题。一个链表里如果有环,一个指针每次走一步,另一个每次走两步,同时从head出发,它们会不会必然相遇?答案是肯定的,但很多人没想过为什么。
设链表无环部分长度为a,环的长度为b。两个指针进入环以后,慢指针在环内的位置记为s,快指针记为f。每次移动,快指针相对慢指针靠近一步。由于环是封闭的,快指针一定会在某个有限步数内"追上"慢指针,这个追及问题的解存在且唯一。
更关键的一个细节:快指针追上慢指针的位置,不等于环的入口。假设两指针相遇时,慢指针走了a加上它在环内走过的距离s,总共是a+s步;快指针走了2(a+s)步。因为快指针领先一圈才能追上,所以2(a+s) = a + s + kb,其中k表示快指针多绕的圈数。化简得到a+s = kb。这个等式是整个Floyd找环算法的基石,它直接告诉我们:从head到环入口的距离a,等于从相遇点继续走到环入口的距离(对应一倍的k*b减去s,正好是环长减去慢指针已走部分)。这就是为什么重置之后能精准定位环入口。
很多人教材里只写"把快指针重置到head,两个指针各走一步,相遇就是环入口",从不解释这个等式。结果就是:读者记住了操作,没记住原理。
2.2 为什么必须是2倍速,3倍行不行
有一个自然的好奇心:快指针走3步、慢指针走1步行不行?可以,但没必要。用3倍速,两个指针仍然会相遇,但相遇的判断逻辑变复杂了——快指针可能从慢指针头顶"跳过去"而不落在同一节点上,你必须在每轮移动中反复检查多个位置是否重合,代码写起来丑不说,还容易漏判。
更重要的是,2倍速下追击问题的数学表达式最干净,能直接推出a = 环入口到相遇点的距离。换成3倍速,等式变成a+s = k*b + (f-s)之类的形式,推导繁琐,重置之后的路径也不直观。工程上没有特殊需求就别整花活,2倍速是经过大量实践验证的最优解。
3. 遇到环之后:重置指针这一下到底发生了什么
3.1 代码模板里的三步动作,每一步都踩在等式上
看标准的找环入口代码:
public ListNode detectCycle(ListNode head) { if (head == null || head.next == null) { return null; } ListNode slow = head; ListNode fast = head; // 第一步:找到相遇点 while (fast != null && fast.next != null) { slow = slow.next; fast = fast.next.next; if (slow == fast) { break; } } // 没有环 if (fast == null || fast.next == null) { return null; } // 第二步:重置快指针到头节点 fast = head; // 第三步:各走一步,相遇即为环入口 while (slow != fast) { slow = slow.next; fast = fast.next; } return slow; }这里面最有意思的是第二步。相遇之后,slow停在相遇点,fast本来也在相遇点,现在单独把fast重置回head。此时两个指针距离环入口的路程分别是:fast需要走a步,slow需要走b-s步。前面推导出的等式a = b-s正好告诉我们:这两段路程长度完全相等。
所以新的一轮追逐里,两个指针以相同速度前进,必然同时到达环入口。这就是"重置"的物理意义:把快指针从"领先一圈"的状态,人为改造成"从同一起跑线出发的追赶者"。
3.2 重置之后为什么两个指针都变成了"慢指针"
另一个容易忽略的点:重置之后的while循环里,fast再也不是每次跳两步了,它和slow一样都是走一步。这是有意为之。
如果重置后还是两步,那又回到了第一阶段的相对速度问题,两个指针会在环里反复交错,永远无法在环入口精确重合。必须让两者速度一致,它们的相遇位置才由"初始距离差"唯一决定——而这个距离差恰好等于a步。所以第二阶段本质上不是"找相遇点",而是"等距离行走的汇合点"。
4. 场景辨析:有些问题需要重置,有些问题最好别重置
4.1 求环长度时的另一种思路:何须重置快指针
确定链表有环之后,想知道环的长度,一个自然方案是:从相遇点出发,slow保持不动,fast每圈走两步,等fast再次回到fast原来的位置,统计步数。这个方案不需要重置任何一个指针,只需要在相遇后让一个指针绕环走一整圈。
更好的方案其实只用一个指针:既然已经站在环内,那就固定住当前位置,用一个计数器变量,每次让slow走一步,直到重回这个节点,计数值就是环长。这个做法简单直接,完全绕开了快慢指针,只需要一个临时变量,省事不容易出错。
所以你看,"重置"不是所有环问题的万能解。它只服务于"找环入口"这个具体目标。你要算环长,就压根不需要重置。明确场景,才能选对工具,这是我反复想强调的一件事。
4.2 判断两个链表是否相交:重置思路的变体应用
两个链表是否相交,经典解法是把两个链表的指针分别指向各自的头节点,同步向后移动,先计算两个链表的长度差,长的链表指针先走差值步数,然后两指针同步前进,第一个相等的节点就是交点。
这个写法的核心跟"重置"异曲同工:调整两个指针的初始位置,让它们从同一"剩余路程"起跑。但注意,它并没有用快慢速度差来追及,而是用长度差手动拉平距离。这个差别很重要——前者靠速度制造相遇,后者靠几何距离对齐。很多初学者把这两个场景混在一起,试图用快慢指针解相交问题,我会在下一节细说。
4.3 每轮重置的"暴力方案":有想法,但时间复杂度不划算
还有一种常见思路:从head开始,每遍历一个节点,就从这个节点往后用一个辅助指针绕圈走,看能否回到head。本质上就是枚举所有起点,对每个起点做一次环路探测。这种方案不需要第二阶段的重置逻辑,但内外两层循环,时间复杂度退化到O(n^2)。
LeetCode上很多讨论区把这个方案称为"每轮重置",因为它每一轮都把快指针重置到慢指针后一个位置,再重新追。动机很好理解:如果某个节点是环入口,那么从它出发绕一圈必然回到自身。但问题是,这个方案的慢指针每向前走一步,都要把快指针从头开始追一遍,现实中链表一长,性能完全没法看。
所以结论很直白:如果只判断有没有环,用标准的快慢指针;如果要找环入口,用Floyd的"相遇后重置";如果靠暴力重置就能解决,说明你的数据规模还小,等数据大了这方案一定会拖垮程序。
5. 重置指针的一个反直觉边缘案例:入口就是头节点
5.1 当a=0时,推导等式依然成立吗
现在来看一个容易让代码出错的极端场景:链表头节点本身就是环入口,也就是a=0,整个链表就是一个圆环。
按照之前的推导,a+b = kb,当a=0时,相遇点s满足s = kb,说明s也是0。换句话说,快慢指针从头节点出发,第一次相遇点就是头节点本身。
在这个场景下,跑标准代码会遇到一个微妙的问题:第一阶段的while循环会在入口处直接相遇并break,此时slow和fast都指向head。如果代码里紧接着执行fast = head,fast和slow依然相同,第二阶段循环条件while (slow != fast)一次都不会执行,直接返回head。逻辑上完全正确。
但如果你在第一阶段用的是do-while结构,或者先把fast移动了一步才检查相等,就可能在a=0时误判为无环。这是我实际调试代码时踩过的坑,特此提醒。
5.2 自我环与两个节点的环
再往外扩展一下,尾节点指向自己的单节点环:head既指向节点1,节点1又指向自己。快指针从head开始,第一轮移动:slow到节点1,fast到节点1,相遇。重置后fast=head,slow在节点1,各走一步,两者都到节点1,返回节点1。代码逻辑依然正确,完全没问题。
只有两个节点的环(1->2->1)就更常见了:slow到2,fast绕一圈回到1,不相遇;第二轮slow到1,fast到2,不相遇;第三轮才在1相遇。这个场景下,相遇点不是环入口2,而是入口前的1。重置之后fast从头节点出发,slow从1出发,同步走,fast走到2时,slow也走到2,刚好在入口汇合。等式再次验证。这些边界场景建议读者自己推一遍,比背代码有效得多。
6. 现场手撕代码时,多数人会犯的三个隐蔽错误
6.1 第一阶段跳出条件的三种写法,只有一种严谨
判断无环,很多人写的是while (slow != fast),但没写快指针判空,一旦链表无环,fast会一路走到底,拿到null后继续fast.next直接抛空指针。
标准写法是while (fast != null && fast.next != null),循环自然结束时要么fast为空,要么fast.next为空,两种情况都意味着无环。还有一个细节:快指针的判空条件建议在循环开头统一处理,不要只判fast,不判fast.next,否则在偶数长度无环链表的最后一轮会炸。
6.2 重置阶段误把slow也挪回去
有人把标准流程记成了"相遇后两个指针都重置到head,再一起走",这是完全错误的。如果两个指针同时从头节点出发,以相同速度走,它们只会一直同步,永远不会第二次相遇,程序死循环。
正确的记忆方法是:相遇后,只重置快指针(或者干脆不叫快指针了,就当成另一个慢指针从头出发),慢指针原地不动。两者速度一致,各自向前走,由于a=b-s这个距离相等的保证,必然同时抵达环入口。慢指针动都不要动,动了就全乱套。
6.3 环入口为null的误判:什么时候该返回null
还有一种情况:链表无环,但第一阶段中快指针恰好指向null,而slow还在链表中间。有些人看到快慢指针相等(都是null)就break,然后进入第二阶段,slow != fast判断永假,返回null,歪打正着。
但如果链表是奇数长度且无环,比如1->2->3->null,第一轮后slow在2,fast在3,不相遇;第二轮slow在3,fast.next是null,循环以fast.next==null退出,此时slow在3,fast在null。如果不检查fast的值,贸然进入第二阶段,slow会继续往后走直接null,然后fast = head,while(slow != fast)里slow.next同样空指针。所以第二阶段前必须明确判断:第一阶段退出时是否真的是因为相遇。严谨做法是设一个flag或在退出时检查fast是否为null,再进入重置逻辑。
7. 收尾前的实操建议:三个可以直接用的自查小技巧
聊到这儿,核心逻辑讲完了。按我自己的经验,最后有几个自查动作很实用。
第一,写完代码后,先用手里的测试用例跑一遍三种形态:无环链表、带环入口在中间的链表、整体成环的链表。其中整体成环的情况最容易被遗漏,很多题解不会专门提。
第二,可以在纸上推演一遍小例子,比如head到环入口距离为3、环长为4的链表,手算确认相遇点位置和重置后的行走步数。别怕麻烦,推一次胜过刷十遍。
第三,如果要检查自己是否真的理解,试着回答一句话:为什么第二阶段两个指针必须以相同速度走?答案不是"因为代码这么写的",而是"因为这样才能让两个指针的差距不产生变化,让相遇点完全由初始位置差决定"。能说出这句话,说明你是真懂了。
快慢指针的这个"重置"动作,本质上是在利用几何距离的对称性。理解了这一点,下次再碰到变体题,比如"找链表中点""找倒数第k个节点",你都能很快反应过来该用哪种指针策略,而不是碰运气硬套模板。