☰
环形链表怎么解?快慢指针原理与LeetCode 141题深度拆解
2026/10/14 4:05:16 网站建设 项目流程

1. 题目到底在问什么:先别急着写代码

很多同学刷题有一个坏习惯,看到“环形链表”四个字,马上就去背快慢指针模板,背完就觉得自己会了,结果面试官换个问法立刻卡壳。所以今天这篇,我先把这道题真正想考的东西拆开讲清楚。

“每日算法题 14---14.环形链表”对应的是力扣(LeetCode)第141题Linked List Cycle,核心就一句话:给你一个链表的头节点head,判断链表中是否存在环。如果存在,返回true;不存在,返回false。

看起来很简单对吧?但这里有个隐藏的坑。什么叫“存在环”?不是链表里有循环引用就是环,而是指某个节点的next指针指回了链表之前的某个节点,导致你顺着next一直往下走,永远走不到null。面试官考察的不是你能不能背出快慢指针,而是你为什么要用快慢指针、凭什么它不会死循环、边界条件有哪些。换句话说,这题是考察链表遍历、指针操作和循环检测三者结合的基本功,也是后续很多链表题的基石。

适合什么样的人来学?第一类是刚开始刷链表题的初级选手,需要建立链表题的最小思维模型;第二类是准备面试但被链表绕晕的同学,需要把快慢指针的原理彻底弄明白;第三类是想拿这题引申出更多考法(比如找环入口、求环长度)的进阶选手。这篇博文会把这三类需求一次满足。

2. 两种解法,一次看透:从易到难的核心思路拆解

2.1 哈希表解法:最直观的“到访标记法”

很多同学第一次看到这题,最本能的思路是:我每走一个节点,就把它记在小本本上;如果走着走着发现下一个节点已经在小本本上了,说明之前来过,那就是有环;如果走到null,那就没环。这个思路完全正确,实现也不难:

public boolean hasCycle(ListNode head) { Set<ListNode> seen = new HashSet<>(); ListNode cur = head; while (cur != null) { if (seen.contains(cur)) { return true; } seen.add(cur); cur = cur.next; } return false; }

复杂度是 O(n) 的时间、O(n) 的空间。逻辑没错,但很多面试官会追问一句:“能不能不用额外空间?”因为哈希表存的是节点的引用地址,当链表非常长的时候,这个 Set 的内存开销是不可忽略的。更关键的是,哈希表法虽然“容易想到”,但没有体现出你对链表结构的理解——它更像一种通用的“去重”技巧,而不是专门为链表环检测设计的方法。

2.2 双指针为什么能用来判环:简单的“绕圈”问题

先抛一个问题:两个人绕操场跑步,一个人快、一个人慢,只要一直跑下去,快的那个最终会不会追上慢的?答案显然是会。因为操场是环形的,两个人沿着同一方向跑,速度快的那个人相对于速度慢的那个人,每秒都会“追近”一点距离,迟早会套圈撞上。

链表里的“环”本质上就是一条跑道。我们把链表想象成地图,慢指针一次走一步,快指针一次走两步。如果链表没有环,快指针会先一步遇到null,循环结束;如果链表有环,快指针会进入环里绕圈,慢指针随后也进入环里,这时候快指针就变成了那个“跑得快的人”,它必然会追上慢指针。

这里面有一个要命的细节:快指针和慢指针一定要在同一条环上相遇,而不是快指针自己绕了很多圈、慢指针还在环外面。好消息是,只要有环,慢指针迟早会进环,而且它进环的时候快指针一定已经在环里了,所以“追及”是必然发生的,只是时间问题。

还有一个很多同学担心的点:快指针每次走两步,会不会“跳过”慢指针,导致永远碰不上?这里要用到同余的思想。因为快指针相对于慢指针的速度差是“每走一步,快指针比慢指针多走一步”,相当于慢指针静止、快指针以每次一步的速度逼近它。既然是逐步逼近,那就一定会在某个节点上相遇,不会出现步子太大跨过去的情况。合理解释就是:速度差等于步长差,而不是某个指针的绝对速度。快指针走两步、慢指针走一步,二者的相对速度是“一步”,所以追击过程是连续的。用数学表达就是:假设环的长度为L,快指针追上慢指针需要的步数不会超过L,因为在一圈之内,相对位移一定能凑成L的倍数(更精确地,是追上时快指针比慢指针多走了整数圈)。

这个思路一旦想通,代码就很简单:

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; }

注意这里我让fast初始化为head.next,而不是head。为什么?如果我们让快慢指针都从head出发,那第一轮判断slow != fast一定为假,会让程序直接误判成“有环”。所以要么像上面这样错开一步,要么让它们都从head开始、但先移动再判断:

public boolean hasCycle(ListNode head) { ListNode slow = head, fast = head; while (fast != null && fast.next != null) { slow = slow.next; fast = fast.next.next; if (slow == fast) { return true; } } return false; }

两种写法都可以,但第一种写法的好处是:循环条件清晰,退出时就能直接判断slow == fast是否成立。第二种更符合大多数人的直觉,而且不用特判head.next == null,因为fast.next为空时循环会直接终止。我更推荐第二种,因为它的循环退出条件能自然处理head为空、链表只有一个节点的情况,不容易漏边界。

3. 进阶一:找到环的入口,才是这道题真正的灵魂

3.1 为什么 141 只是开胃菜,142 才是高频考点

本来 141 题已经能提交了,但面试官很少只考“有没有环”这么简单。他大概率会追加一个经典变种:如果链表中存在环,请返回环的入口节点,也就是力扣第142题Linked List Cycle II。

为什么这个变种重要?因为“是否成环”只是定性判断,而“环入口在哪”是定量分析。很多业务场景中,你要修复一个 bug,不仅要确认系统里存在死循环,还要精确找到从哪里开始循环的。链表里也是一样,只知道有环,却不知道环从哪个节点开始,你是无法修复链表结构的。

142 的解法也是快慢指针,但要额外加一个技巧:当快慢指针第一次相遇后,把其中一个指针重新放回head,保持另一个指针停在相遇点,然后两个指针都以每次一步的速度继续走,第二次相遇的位置就是环入口。

这个结论很多同学背得滚瓜烂熟,但问“为什么”就说不上来了。这怎么行?我们必须把数学推导讲清楚。

3.2 几步推导,说清楚第二次相遇为什么是入口

设定以下几个符号:

  • a:从链表头部到环入口的距离
  • b:从环入口到快慢指针第一次相遇点的距离
  • c:从相遇点继续走回环入口的距离
  • 环的长度L = b + c

当快慢指针第一次相遇时,慢指针走了a + b步,快指针走了a + b + k*L步,其中k表示快指针比慢指针多绕了k圈(k ≥ 1)。因为快指针速度是慢指针的两倍,所以有:

2 * (a + b) = a + b + k*L

化简得:

a + b = k*L

也就是:

a = k*L - b = (k-1)*L + (L - b) = (k-1)*L + c

这个式子翻译成人话就是:从链表头走到环入口的距离a,等于从相遇点继续走到环入口的距离c,再加上若干圈环长(k-1)*L。

所以我们让一个指针从head出发,另一个指针从相遇点出发,两个都每次走一步。当从头出发的指针走了a步到达环入口时,从相遇点出发的指针也走了a步,而a = (k-1)*L + c,相当于它从相遇点出发,走了c步回到入口,然后又绕了(k-1)圈。殊途同归,两个指针在环入口相遇。

注意:这里的k具体是多少其实不重要,因为无论k取几,“多走的圈数”只会让相遇点出发的指针在环里多绕圈,但最终停下的位置一定是环入口。这就是为什么我们不需要知道k的确切值,直接让两个指针相向而行就行。

实战中很多人会在这一步犯错:他们把“从相遇点出发的指针”重新放回head,然后把“留在相遇点的指针”继续往前跑,结果第二次相遇判断条件写错,导致死循环或返回错误节点。正确做法是先写一个找到第一次相遇点的辅助函数,然后单独做第二次移动,分工明确不容易乱。

public ListNode detectCycle(ListNode head) { ListNode slow = head, 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; }

3.3 环的长度怎么算,顺便解决一个问题

掌握了环入口,求环的长度就是顺手的事。在找到环入口之后,让一个指针从入口出发,每次走一步,直到它重新回到入口,走的步数就是环长。更简单的办法是:在第一次相遇的时候,让其中一个指针停在原地,另一个指针继续每次走一步,绕一圈回到原地,走过的步数就是环长。

其实环长还有一个更巧妙的计算方式:第一次相遇时,慢指针走了a + b,快指针走了2*(a+b),差值a+b必定是环长的整数倍。但要注意,这里差值是k*L,并不一定等于L,更不一定能直接推出“慢指针走了正好一圈”。所以最稳妥的求法还是老老实实从入口或相遇点绕一圈实测步数。这道题不会单独考你“求环长”,但它是 142 的常见追问,务必掌握。

4. 常见问题与排查技巧实录

4.1 快慢指针不会在环外相遇吗

不会。我们仔细推理:只要链表有环,慢指针最终一定进入环内;而快指针一定比慢指针先进入环内(或者同时进入,取决于head是否就是入口)。一旦两个指针都在环内,快指针就相当于以相对速度“一步”追赶慢指针。无论初始距离是多少,只要环长有限,追赶必然在一圈内完成。换句话说,相遇点一定在环内,一定不会出现在环外。

4.2 快指针走三步、四步行不行

很多同学觉得“快指针走两步”是拍脑袋定的,那我走三步不是更快吗?结论是:走两步是通用且稳妥的,走三步在某些特定长度的环上也能相遇,但需要额外保证“不会跨过慢指针”。快指针每次走m步、慢指针每次走n步,只要m > n,相对速度是m - n,追击依然成立。但是当步长差大于 1 时,快指针可能“跳过”慢指针,导致二者在相邻节点上交错而过,并且之后每一圈都在同样的位置交错,永远追不上。比如快指针走三步、慢指针走一步,环长为奇数时,这个“跳过”问题就可能发生。所以面试时最好答快指针走两步,简单、严密、无特例。

4.3 循环条件的判空顺序,差点把我整崩溃

回忆一下第二种写法的循环条件:while (fast != null && fast.next != null)。这个条件必须写成“先判断fast再判断fast.next”,顺序不能换。如果先判断fast.next != null,而fast本身已经是null,就会抛空指针异常。我在模拟项目X的开发中,曾见过一位同学为调试这个空指针问题花了半小时,其实只是条件顺序写反了。

另外,链表为空或只有一个节点时,fast != null && fast.next != null会自然终止循环,直接返回false。这比第一种写法里单独处理head.next更优雅,也是我推荐第二种写法的原因。

4.4 修改链表结构的检测法,最好别用

还有一种“开挂式”解法:遍历链表时把每个已经走过的节点的next指针指向一个特殊哨兵节点(比如head本身),如果后续遍历时遇到了哨兵,就说明之前来过,存在环。这种方法确实能做到 O(1) 空间,但代价是破坏了链表结构。在真实业务里,链表往往承载着数据,你无端改人家next指向,可能导致其他逻辑崩溃。面试时你用这招,面试官多半会认为你“太野”,不给加分还可能扣分。所以老老实实学双指针,这两种办法不要碰。

4.5 实战问题速查表

现象可能原因解决方法
快指针判断fast.next.next时空指针未判断fast.next是否为空循环条件加上fast.next != null
快慢指针从head同时出发,循环第一次就退出初始状态slow == fast,直接返回true先移动再判断,或让快指针先走一步
142 题第二次相遇时死循环误把“找第一次相遇点”和“找入口”两步混为一个循环分开两个循环,第一个找相遇,第二个找入口
返回值一直是head可能是没判hasCycle就进入第二个 while,此时slow = head,而fast也可能停在head先确认确实有环,再进入第二次移动

5. 这类题的延伸版图:一环扣一环,全是高频

5.1 链表中点问题

快慢指针还有一个经典用法:找链表中点。让快指针每次走两步、慢指针每次走一步,当快指针走到链表尾部时,慢指针正好停在中间。这个技巧在排序链表(148题)、重排链表(143题)里都会用到。

顺序很关键:链表长度为奇数时,慢指针正好指向中间节点;长度为偶数时,慢指针指向中间两个节点的前一个(或后一个,取决于你选择哪一侧作为中点)。很多题解代码直接复用这套逻辑,但不会告诉你这个细微区别,导致在偶数长度下“找后半段”时索引错位。应对办法是:划线模拟一遍长度为 6 和长度为 7 的链表各自走完的落点,你就不会搞混。

5.2 环形数组问题

与之思路相似但场景完全不同的题目是环形数组(457题Circular Array Loop),它要求判断一个数组里是否存在一个“环形路径”。这题虽然数据结构不同,但本质也是“用快慢指针检测循环”。区别在于,数组里的“环”需要满足方向一致、长度大于 1。如果你练熟了链表环检测,再去看 457 题,会有一种“换汤不换药”的亲切感。

5.3 重复数检测问题

力扣第287题Find the Duplicate Number也是快慢指针检测循环的经典应用,但它是在数组上模拟链表跑指针。数组下标看作节点下标,nums[i]看作next指针,因为存在重复数字,所以会出现两个下标指向同一个“节点”,这就构成了环。这题的技巧点在于:数组被视为一张静态链表,你要利用下标移动来复用 142 题“找环入口”的思路找重复值。很多人第一次看到会觉得特别绕,但一旦你把“数组模拟链表”这个思路想透,这题就是送分题。

5.4 两个链表相交问题

力扣第160题Intersection of Two Linked Lists也是链表题的高频题。它不用快慢指针,但用到了“消除路程差”的数学思路:两个指针分别从两个链表头出发,走完自己的链表后转到对方的链表头继续走,最终会在相交点相遇。这种解法背后其实是“把两个链表拼接起来,让走路程相等”的想法。我把这题放在延伸里,是想告诉你:链表题的高阶考法往往不是算法有多炫技,而是你懂不懂“两个指针在不同初始条件下一前一后地凑齐路程差”的数学建模。

6. 每日刷题到底怎么刷:一年积累下来的实操心得

6.1 一道题至少做三遍,而且每一遍的要求不同

第一遍:看题,不看题解,尝试独立写出暴力解和你能想到的最优解。写不出来也没关系,但你要能准确说出自己卡在哪一步,是思路缺失,还是边界处理失手。第二遍:看懂题解后,合上电脑,重新自己写一遍完整代码。第三遍:过一周以后再做一遍,重点考察自己是否还记得数学推导,而不是只背代码。

我认识一位A同学,刷题一年,每天三道题,从不复盘,结果面试时连 141 这种题都只能默写模板,被追问“为什么快慢指针一定能相遇”就当场愣住。后来他改成每天一道题、三道变式、三种语言,效果立竿见影。所以说,刷题质量远比数量重要。

6.2 画图是最快的解题武器

链表题最怕的就是纯靠脑补。你在大脑里模拟指针跳来跳去,很容易把自己绕进去。我自己的习惯是,在草稿纸上简单画一条链、一个圈,再标上slow和fast的位置,一步步走两三轮,所有边界条件就清楚了。比如判断链表是否有环,画一条含 5 个节点、环在第三个节点的链表,手动推演一遍,比阅读十篇题解都管用。

6.3 不要忽略复杂度分析的书写

许多同学刷题时只关注“能不能通过”,忽略了在代码注释或笔记里写下“为什么空间复杂度是 O(1)”。实际上,这里的 O(1) 不是说你只用了一个变量,而是你用了有限个指针变量,它们不随链表长度增长而增长。把这点写清楚,对你后续复习大有帮助,面试时也能体现出你对复杂度的掌控力。

6.4 用“每日算法题”坚持打卡的具体操作

如果你也想开一个“每日算法题”系列,我给你的建议是:给每一题设定一个固定模板,包含题目链接、题目描述、思路手写稿、代码、复杂度分析、易错点六项。不要只贴代码就完事,因为代码是死的,你过两周就忘;思路推导过程反而能帮你唤醒记忆。我自己的文件命名是这样的:D014_环形链表_快慢指针,一目了然。

6.5 如果想深入掌握快慢指针,还可以自己画状态表

把一次“追及”的完整过程做成表格,每次记录slow指向的节点编号和fast指向的节点编号。比如一个 7 个节点的链表,环从第 3 个节点开始,环长 5(节点3到节点7成环)。你可以看到第 1 步slow=1、fast=2,第 2 步slow=2、fast=4,第 3 步slow=3、fast=6,第 4 步slow=4、fast=3(快指针已经进环绕了一圈),第 5 步slow=5、fast=5。这个模拟过程做一次,你对快慢指针的理解就会远超只会背模板的人。

7. 个人操作中的几个独门检查清单

下面这几点是我自己刷链表题时反复使用的自检清单,每次写完代码,对照一遍,基本能把低级错误全部扫掉。现在分享给你:

  1. head为空的处理,确认循环条件是否覆盖;
  2. head.next为空的处理,确认不会访问null.next;
  3. 快指针移动前,确认fast和fast.next都不为null;
  4. 想要判断是否有环,确认不存在“没环但死循环”的情况;
  5. 如果最终要返回一个节点,思考它是否有可能返回null,这个null是否有业务含义;
  6. 画一遍 3 个节点的链表(无环)和 4 个节点的链表(有环,环入口在第二个节点),对照代码走一遍;
  7. 如果能用两种语言各写一遍(比如 Java 和 Python),抓住解释器处理引用的差异,对语言理解也很有帮助。

把这份清单打印出来贴在显示器旁边,也许比收藏一堆题解更实用。我第一次做每日算法题系列时,就是靠这些清单把每一题吃透,而不是被题目淹没。

8. 纸上得来终觉浅:手动模拟一次完整的判环流程

很多读者可能有疑问:“我看懂了原理,但真到了面试白板写代码时,还是容易紧张出错。”这里我强烈建议你找一个单链表,例子里有 5 个节点,其中第 4 个节点的next指向第 2 个节点。让我们手动推演一下第二种写法的全过程,加深肌肉记忆。

初始状态:slow = 节点1,fast = 节点1。

第 1 轮:slow走到节点2,fast走到节点3,二者不相等;
第 2 轮:slow走到节点3,fast走到节点5,二者不相等;
第 3 轮:slow走到节点4,fast走到节点3(因为节点5的next是节点2,节点3?不对,让我们重新数一下:链表的next顺序为 1→2→3→4→2,也就是节点4指向节点2,形成环)。所以第 3 轮时fast从节点5走到节点2,慢指针从节点3走到节点4,不相等;
第 4 轮:slow走到节点2,fast从节点2走到节点4,不相等;
第 5 轮:slow走到节点3,fast从节点4走到节点2,不相等;
第 6 轮:slow走到节点4,fast从节点2走到节点4,此时相等,返回true。

这个模拟过程说明:快指针在环里绕了两圈之后,终于追上慢指针,而且追上的位置不一定在入口。你手动模拟一次,比看十遍代码都有用。

再强调一次:快慢指针相遇的位置只是“相遇点”,不是环入口。想找入口要用 3.2 节推导的方法。如果面试官问你“那相遇点离入口有多远?”,你还可以说出公式:c = 环长 - b,也就是从相遇点继续走一圈回到相遇点,中途会经过入口。

9. 刷题记录复盘:几个人最容易踩的坑,我全部替你试过

9.1 不画图直接写,边界条件全靠猜

踩过最多的坑是拿到题就开始写代码,写完跑测试用例发现head == null直接崩溃。链表题和数组题不一样,数组题你可以从下标 0 开始一路往下走,链表题每一步指针跳转前都要确认“这个指针现在还活着”。所以我现在的习惯是,先在注释里写出伪代码,分三步:判空、移动、判断。伪代码写清楚后,再翻译成 Java 或 Python。

9.2 混淆“找到相遇点”和“找到入口”

如果你已经把 141 写成模板,做 142 的时候很容易顺手把检测环的代码复制过来,然后天真地认为“相遇点等于入口”。只要画个反例,比如环很长、入口离链表头很近,你立刻就会发现相遇点根本不是入口。所以 142 的代码里,“第一次相遇”和“第二次相遇”一定要分开写两个循环,各自独立,注释也写清楚,避免概念混淆。

9.3 快慢指针步长调成 3 导致永不终止

在一些讨论区能看到“三步快指针”的做法,理论上部分链表也能工作,但遇到环长为 2 的链表,极大概率会出现无限循环。由于面试时间有限,你很难在面试现场临时推导步长与环长的互质关系。最稳妥的做法就是写死“慢走 1、快走 2”,这也是这道题标准答案里最不容易出错的组合。

9.4 没注意每个节点的对象引用是唯一的

哈希表解法里,我们将ListNode本身放入Set中,判断的是“节点的引用是否出现过”,而不是“节点的值是否相等”。这个区别至关重要。链表里可能存在两个值相同的节点,但它们的内存地址并不相同。如果误用equals或==去比较val,就会出现“值相同但节点不同”的误判。

10. 根据个人经验补充三个小技巧

10.1 把链表题做成“模板卡片”,随时翻阅

我刷题时会为每类链表题做一张“模板卡片”,正面写题目名称、思路分类(快慢指针、虚拟头节点、归并等),背面写代码模板和复杂度。比如环形链表这张卡片会写:快慢指针判环、找到相遇点后,再从 head 和相遇点同步走,找入口。这样复习时不用从头读一遍完整文章,扫一眼卡片就能快速回忆。

10.2 学完 141 和 142 后立刻做 287 题,你会感谢这个安排

我自己的经验是,同一天做“环形链表 + 重复数检测”,比隔一个月再做效果好得多。因为数组模拟链表的思路刚刚建立,趁热打铁能让你瞬间理解“下标即节点、元素值即 next”的抽象方式。那种“原来还能这么玩”的顿悟感,是两个知识点深度绑定的最佳时机。

10.3 需要调试快去“快照打印”

如果实在改不出 bug,别在脑内硬跑循环。快速在每次指针移动后打印当前slow.val和fast.val,把所有回合列出来,肉眼找规律。这招虽然原始,但对付链表题的指针绕圈问题特别有效。我在模拟项目Y里排查一个“链表中点错位”的问题时,就是靠快照打印两轮循环定位到原因:快指针提前一步走到了null,导致中点计算结果整体偏移了一位。

11. 写给还卡在这题的同学们:思路比代码值钱

每天一道算法题,核心不是为了攒题数,而是为了训练“把一个模糊的问题,转化成一个清晰模型”的能力。环形链表这题,最值钱的地方不在于快慢指针这个模板,而在于“为什么用快慢指针”“为什么一定相遇”“相遇点和入口的关系是什么”这一整套推理链条。

你要是真的想把这类题吃透,今天做完 141,明天做 142,后天做 287,大后天做 160,四天时间,链表双指针这个专题基本上就能横着走了。到了那时候你再回头看这篇博客,会觉得每个字都在意料之中,却又觉得如果当初有人早点把这些坑替你踩平,你会省下不少时间。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询