刷题刷到 hot 100 第二十五题,遇到“环形链表”这道题的时候,我停了一下。不是因为这道题本身有多难,代码量也很短,而是它正好卡在一个很微妙的位置:前面那些题基本都是“背模板”就能写的,到了环形链表这儿,突然要求你真正理解指针移动的过程,理解为什么快慢指针一定能相遇。很多人在这一步第一次体会到“算法题不是背出来的”这句话的含义。
这道题在 LeetCode 上是 141 题,hot 100 里的第二十五题,给一个链表的头节点 head,判断链表中是否有环。如果有环,返回 true,否则返回 false。题目有个进阶要求:你能不能只用 O(1) 的内存解决?如果没看过题解,第一次看到这个进阶要求,大概率会愣一下——常规思路是拿哈希表记走过的节点,但那是 O(n) 的内存,根本达不到要求。
这篇文章我不打算只贴一段能过审的代码,而是想把这道题背后的推导、边界条件、面试追问,以及我自己踩过的坑,一次性说清楚。无论你是刚开始刷链表题的初学者,还是准备面试想在白板上写出无懈可击解法的求职者,这篇都能给你点实在的东西。
1. 题目理解与核心思路拆解
1.1 这道题到底在考什么
先看题目本身。输入是一个链表的头节点 head,这个链表可能是普通的单链表,也可能在某个节点后面指回了之前的某个节点,形成一个环。你需要判断这个链表里有没有环。
很多人一看这题,第一反应是:这有什么难的?我遍历链表,每走一个节点就记下来,如果走到一个之前见过的节点,说明有环。如果走到 null,说明没环。这个思路完全正确,但它有一个致命的问题:你需要额外的空间来存“之前见过的节点”。链表有 n 个节点,你就要 O(n) 的空间。
题目里那个“能不能只用 O(1) 内存”的进阶要求,本质上就是在逼你换一种思路——能不能不用额外空间,仅用指针移动来判断?
这就是这道题真正的考点:不是你会不会遍历链表,而是你能不能想到用两个速度不同的指针,通过“追及问题”的方式来判断环的存在。它考察的是你对链表结构的抽象理解,以及对算法空间复杂度的敏感度。
1.2 两条路线:哈希表法和快慢指针法
先把两条路线的优缺点摆出来。
哈希表法是最直观的。遍历链表,把每个节点对象本身(不是节点的值)存入 HashSet,每到一个新节点,先检查这个节点是否已经在集合里。如果存在,说明回到了之前访问过的节点,也就是有环。如果遍历到 null,说明链表有尽头,没环。时间复杂度 O(n),空间复杂度 O(n)。
快慢指针法,也叫 Floyd 判圈算法,核心思路是:设置两个指针 slow 和 fast,都从 head 出发。slow 每次走一步,fast 每次走两步。如果链表中没有环,fast 会先到达末尾(null);如果链表中有环,slow 和 fast 最终一定会在环内相遇。
两条路线一对比,差距就出来了:
| 方法 | 时间复杂度 | 空间复杂度 | 实现难度 | 面试印象 |
|---|---|---|---|---|
| 哈希表法 | O(n) | O(n) | 简单 | 一般,容易被追问优化 |
| 快慢指针法 | O(n) | O(1) | 简单 | 加分,体现算法功底 |
我个人的建议是:哈希表法可以作为思考的起点,帮自己理清“有环意味着会回到旧节点”这个直觉。但最终给面试官写出来的,一定要是快慢指针法。因为这道题藏在题目描述里的“进阶”要求,就是面试官真正想考察的东西。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 快慢指针解法深入:为什么两步一定追得上一步
2.1 相遇的数学原理
快慢指针法最让人困惑的地方是:凭什么 slow 走一步、fast 走两步,有环就一定会相遇?会不会 fast 每次都恰好跳过 slow 所在的位置?
答案是:不会。只要步长差是 1,就一定会相遇。下面我把这个道理用最直白的方式讲清楚。
想象一下,你已经确认链表中有环,slow 和 fast 都进入了环内。假设某一时刻,slow 在环内的位置记为 A,fast 在环内的位置记为 B,fast 在 slow 前面,两者的距离差是 d(也就是 fast 需要再走 d 步才能追上 slow,注意 d 一定小于环的长度)。
之后每轮迭代,slow 走 1 步,fast 走 2 步,两者的距离差 d 就减少 1。因为每次迭代 fast 比 slow 多走 1 步,这个“多走的 1 步”就是用来缩短距离差的。所以经过 d 次迭代之后,距离差变成 0,也就是 fast 正好追上 slow,两者相遇。
这个过程和你绕操场跑步是一个道理。你跑得慢,另一个人跑得比你快,只要他一直跑下去,最终一定会套圈追上你。快指针每次比慢指针多跑一步,这个“一步”就是套圈的最小单位,永远不可能跳过。
这里有一个关键点:d 不一定能被 2 整除,但这不影响相遇。因为相遇是“距离差逐次减 1 变成 0”,而不是“fast 从后面跳过 slow”。fast 和 slow 在环里的相对运动是连续的离散化过程,相邻两次检查之间只差一步,不存在“跳过”的可能。这个认知是理解快慢指针的基石,也是面试时你可以主动讲解的亮点。
2.2 边界条件和循环终止的判定
代码写起来很简短,但边界条件恰恰是这道题最容易出错的地方。
先看 while 循环的终止条件。经典写法是:
java复制while (fast != null && fast.next != null) {
slow = slow.next;
fast = fast.next.next;
if (slow == fast) {
return true;
}
}
return false;
为什么是 fast != null && fast.next != null?因为 fast 每次走两步,如果 fast 本身是 null,马上结束循环;如果 fast.next 是 null,说明 fast 下一步会走出链表,链表到头了,肯定没环;如果 fast.next.next 需要访问,就必须保证 fast.next 不为 null,否则会出现空指针异常。
这里要特别注意判断顺序。fast != null && fast.next != null 这个顺序不能反过来写。如果你先判断 fast.next != null,但此时 fast 已经是 null,就会直接空指针。Java 的 && 是短路求值,前面的条件不满足就不会执行后面的判断,所以把 fast != null 放在前面是安全的。
什么时候进入不了循环?空链表(head == null)和只有一个节点(head.next == null)的情况。这两种情况下的链表都不可能形成环(因为环需要至少两个节点才能构成?实际上单节点指向自身也能成环,但这是“自环”的情况,此时这个节点的 next 指向自己,链表自身就形成了一个特例。题目中普通构造的测试用例极少出现这种自环,但你要知道这种极端情况存在——如果 head.next == head,那么 fast 下一步就是 head,slow 走一步到 head,两者会在第二轮相遇。但一旦出现 fast.next == null 不成立,即链表有尾节点,才能说没有环。对于单节点链表,如果 head.next == null,fast 直接满足退出条件,返回 false,这也正确覆盖了“无环单节点”的情况)。
循环结束后,如果一直没返回 true,说明 fast 已经走出了链表,也就是链表没有环,返回 false。这个逻辑非常干净。
3. 完整实现与细节打磨
3.1 三种语言的代码实现
先给一份 Java 版本,这是我刷题时最常用的写法。
java复制public class Solution {
public boolean hasCycle(ListNode head) {
if (head == null || head.next == null) {
return false;
}
ListNode slow = head;
ListNode fast = head.next;
// 这里也可以让 fast 直接从 head 开始,两种写法都行,后面会细说区别
while (slow != fast) {
if (fast == null || fast.next == null) {
return false;
}
slow = slow.next;
fast = fast.next.next;
}
return true;
}
}
再给一份 Python 版本,Python 写这类题很简洁,但同样要注意边界。
python复制class Solution:
def hasCycle(self, head: Optional[ListNode]) -> bool:
if not head or not head.next:
return False
slow, fast = head, head
while fast and fast.next:
slow = slow.next
fast = fast.next.next
if slow == fast:
return True
return False
C++ 版本和 Java 几乎一样:
cpp复制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;
}
};
上面这三种写法,核心都是快慢指针,只是循环的编排方式略有不同。第一种是“先判断边界再移动”,第二种是“先移动再判断能否继续移动”,两种各有各的清晰之处,看你习惯哪种风格。只要能保证空指针安全,逻辑正确,面试官都不会挑剔。
3.2 两种指针初始化的细微区别
注意上面 Java 版本里我写的是 ListNode fast = head.next,而 Python 和 C++ 版本里的 fast 是从 head 出发的。这两种初始化方式有什么区别?
如果 fast 也初始化为 head,意味着 fast 和 slow 一开始就是相等的。此时 while 循环不能写成 while (slow != fast),因为第一次进入就会直接返回 true,显然是错的。所以要么用“先移动再判断”的循环结构(while (fast != null && fast.next != null) { slow 走一步; fast 走两步; if (slow == fast) return true; }),要么把 fast 初始化为 head.next,让两个指针一开始就有差距,这样 while (slow != fast) 的写法才不会一开始就退出。
我个人的建议是:用 fast = head 配合“先移动再判断”的结构,因为这种写法和题目描述的“两个指针都从头出发”最一致,写伪代码时也最自然。从 head 出发,每次移动后再检查是否相遇,逻辑上更容易向面试官解释清楚。
3.3 复杂度分析与同类题对比
这道题的时间复杂度是 O(n),空间复杂度是 O(1)。
为什么是 O(n)?如果链表无环,fast 走两步,大约走 n/2 次就到头了,循环结束,所以是 O(n)。如果链表有环,设环的长度为 C,slow 进入环之后最多再走 C 步就会被 fast 追上,而进入环之前走的步数不超过链表长度 n,所以整体时间复杂度是 O(n)。
空间复杂度是 O(1),因为只用了两个指针变量,没有开辟任何跟链表规模相关的额外空间。这个复杂度正是题目“进阶”要求的答案。
拿它和哈希表法对比,哈希表法空间是 O(n),如果面试官给你一个超长链表(几百万节点),哈希表方案的内存开销就会非常可观。而快慢指针法无论链表多长,都只占常数额外空间。这是这道题选择快慢指针法作为最优解的核心理由。
4. 常见问题与排查技巧实录
4.1 为什么我的快慢指针会死循环
我见过不少朋友第一次写快慢指针,代码长这样:
java复制while (fast.next != null) {
slow = slow.next;
fast = fast.next.next;
if (slow == fast) return true;
}
return false;
如果链表没有环,这段代码可能在某一轮出现 fast.next 为 null,直接空指针。如果链表有环,fast 永远不会走到 null,循环可能正常退出返回 true。但在一个无环长链表上,当 fast 走到倒数第二个节点时,fast.next 不为 null,进入循环,fast.next.next 已经是 null,fast 被赋值为 null;下一轮 while 条件判断 fast.next,此时 fast 是 null,直接空指针崩了。
所以正确写法必须同时检查 fast != null 和 fast.next != null。这是最经典的坑,几乎每个刷这道题的人都踩过。记住一条铁律:凡是 fast 要走两步,就必须先确认 fast 和 fast.next 都不为 null。
4.2 fast 的步长能不能改成 3 或 4
理论上可以,但这里有个隐藏的数学问题。如果 fast 每次走 3 步,slow 走 1 步,两者的速度差是 2 步。每轮迭代,距离差 d 减少 2。问题来了:如果初始距离差 d 是奇数,减到 1 之后再减 2 就变成 -1,也就是说 fast 冲过头了,跑到了 slow 前面。这时候两者的关系不再是“fast 在 slow 前面 d 步”,而是“fast 超过了 slow 一步”。这种跨越式的追逐,需要额外的理论分析才能保证相遇,而且很容易让代码陷入死循环。
步长差是 1 的时候最安全:无论距离差 d 是多少,每轮减 1,最终一定平滑地减到 0,不会出现“跳过”的情况。所以面试中老老实实用 1 和 2 就好,不要为了炫技改步长。你要是主动跟面试官说你研究过步长差为 1 的数学保证,那加分;你要是写个步长差为 2 的版本然后解释不清,那是减分。
4.3 哈希表法的 set 里存的是节点,不是值
如果你用哈希表法写这道题,最常见的错误是往 HashSet 里存节点的值(比如 int val)而不是节点对象本身。
为什么这是个错误?因为链表节点的值可能重复,比如一个无环链表中存在两个值为 5 的节点。如果你把值存入 HashSet,遇到第二个值为 5 的节点时就会误判为“有环”。正确做法是存节点对象本身,即 set.add(head) 而不是 set.add(head.val)。因为对象引用是唯一的,即使两个节点的值相同,它们的引用地址也不同。
Java 里就是 HashSet<ListNode> set = new HashSet<>(),Python 里就是 set() 存 ListNode 对象。这一点看起来小,但面试手写代码时很容易顺手写成存值,写完检查时又不容易发现,因为如果测试用例恰好没有重复值,代码也能跑对。但一旦遇到重复值,就白白错一道题。
4.4 一个容易忽略的点:节点值相同不是环
顺着上面的话题多说一句,判断两个节点是否相同,用的是引用相等,也就是 slow == fast。不是判断 slow.val == fast.val。
有环的意思是“指针回到了之前经过的那个节点对象”。由于链表可以存在值相同的不同节点,slow.val == fast.val 完全可能是两个不同节点的值碰巧相同,不代表它们是一个节点。所以判等必须用引用比较。在 Java 中,== 比较两个对象时比较的是引用地址;在 Python 中,is 才是比较身份(引用),== 默认比较值。Python 写的时候就要注意用 if slow is fast,而不是 if slow == fast。这一点的优先级甚至比步长选择还高,因为它直接决定算法正确性。
5. 从这道题延伸出去:面试官会怎么追问
5.1 进阶:找到环的入口节点
环形链表这道题的后续题目是 LeetCode 142,环形链表 II,要求不仅判断有没有环,还要找到环的入口节点。一道题如果只是默写判环代码,显示不出能力;但如果你能接着回答“如何找入口”,就完全不一样了。
找入口的思路也很有意思。当 slow 和 fast 第一次相遇时,让 fast 重新指向 head,然后 fast 和 slow 每次都走一步,直到两者再次相遇,相遇点就是环的入口。
为什么?这个证明稍微复杂一点,但面试时能清晰讲出来非常加分。
假设链表头到环入口的距离是 a,环入口到第一次相遇点的距离是 b,第一次相遇点到环入口的距离是 c。环的长度就是 b + c。
slow 走的距离是 a + b。fast 走的距离是 a + b + k(b + c),其中 k 表示 fast 在相遇前已经在环里绕了 k 圈。因为 fast 走的距离是 slow 的两倍,所以有:
2(a + b) = a + b + k(b + c)
化简得到:
a + b = k(b + c)
即:
a = k(b + c) - b = (k - 1)(b + c) + c
这个式子说明,从链表头到环入口的距离 a,等于从相遇点继续走 c 步,再绕若干整圈的距离。所以让一个指针从 head 出发,另一个指针从相遇点出发,每次都走一步,它们一定会在环入口处相遇。这个结论是“快慢指针找入口”的数学基础。
面试时能把这个证明完整推导出来,面试官基本就能判断你对算法的理解深度了。我当时就在白板上画了链表结构图,一步步推演了一遍,面试官明显比只看代码的时候更满意。
5.2 进阶:计算环的长度
还有一个常见的追问:如果链表有环,怎么计算环的长度?
最简单的做法:先找到第一次相遇点,然后让一个指针停在原地,另一个指针每次走一步,再次回到这个点时,走过的步数就是环的长度。因为环是闭合的,你从环上某一点出发,绕一圈必然回到原点。
这个方法时间复杂度是 O(C),C 是环的长度,空间复杂度 O(1)。很干净。
也可以利用快慢指针的距离关系来算。第一次相遇后,再继续走,到第二次相遇,fast 比 slow 多走的距离恰好等于环的长度。但这个方法需要额外维护一个计数变量,而且逻辑相对没那么直观。我个人的建议是直接用“固定一个指针绕圈计数”的方法,简单直接,面试官也好理解。
5.3 这类题型的共性套路
环形链表其实属于“链表双指针”大类,和它同类型的题目还有“链表的中间节点”“链表的倒数第 k 个节点”等。它们的核心套路都是:利用指针速度差或位置差来达到某种目的。
中间节点是快指针走两步、慢指针走一步,快指针到结尾时慢指针刚好在中间。倒数第 k 个节点是快指针先走 k 步,然后快慢指针同步走,快指针到结尾时慢指针正好在倒数第 k 个位置。环形链表则是利用快指针“套圈”来判断环。把这些题放在一起刷,你会发现双指针的核心心智模型就三种:距离差、速度差、先后发。想明白这个,链表类题目基本就通关了。
6. 刷题与面试的私人经验
最后说点实在的。
环形链表这道题我前后刷过三遍。第一遍是刚入门的时候,照着题解抄了一遍,抄完觉得自己懂了,过两天再写还是卡在边界条件上。第二遍是准备面试前,重新从数学原理推导了一遍,才真正理解“为什么一定会相遇”——这个推导过程比代码本身值钱得多。第三遍是在模拟面试的时候,我需要在白板上一边写一边讲,那时我才发现,能把思路讲得比代码更清晰,才是真正的掌握。
所以我建议你刷这道题时,不要只追求提交通过,试着做三件事:把 while 循环的边界条件推导给别人听;把 fast 和 slow 的相遇过程用笔在纸上画一遍;把找环入口的数学证明自己独立推一遍。这三件事做完,你会发现这类题目以后再遇到,根本不需要背模板,直接就能写出来。
面试的时候如果遇到这道题,我会先在白板上画一个带头节点的链表,标注 head、中间节点、可能的环入口,然后问面试官:“题目里的 head 是第一个有效节点还是带头节点的虚拟头?”如果题目描述不明确,这种问题能展示你对边界情况的敏感。然后开始讲哈希表思路(作为朴素解),再对比快慢指针思路(作为进阶解),主动把空间复杂度的差异讲清楚。整个回答的节奏大概是:朴素解 -> 最优解 -> 边界条件 -> 延伸问题,这样一套组合拳打下来,面试官基本不会在这个题上继续刁难你。
以上是我在刷环形链表这道题时的全部思考。这道题虽然代码量短,但背后的数学原理、边界陷阱、面试延伸点都不少,值得你多花点时间吃透。如果刷题过程中遇到别的坑,欢迎来来回回讨论,我看到都会回复。
