1. 场景引入:一个让无数人卡住的链表题
写链表题的时候,最怕遇到什么?不是指针交换绕晕,不是递归爆栈,而是——代码看起来没问题,运行却死循环,或者链表莫名其妙成环了。日常业务里链表被写成环的情况其实挺常见的,比如某次内存缓存操作失误,或者并发环境下对链表的错误修改,排查的时候异常痛苦。LeetCode上专门有一道题就是干这个的,第141题“环形链表”,题目描述很简单:给定一个链表,判断链表中是否有环。
就是这么一道“easy”题,每次面试都有一大批人栽跟头。不是因为题目本身有多难,而是很多人只会背快慢指针的模板,却不理解它为什么一定能追上,更不知道环入口怎么求、环长度怎么算。等到面试官追问一句“如果快指针每次走三步呢”,就彻底卡壳了。
这篇文章就围绕“环形链表”这个核心考点展开,我从问题拆解、原理推导、代码实现到面试延伸,按我平时带人的思路一次讲透。不管你是刚刷题的萌新,还是准备跳槽想巩固基础的老手,这篇文章都能帮你在“链表环”这个知识点上直接拉满。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 问题拆解:环形链表到底在考什么
2.1 先搞清楚“环”的两种含义
刚开始刷题的人容易把“环形链表”理解成一种物理结构,觉得链表真是一个圆圈。实际上在算法题里,“判断链表有没有环”和“构建一个环状链表”是两回事。
- 有环链表:链表的某个节点的 next 指针指回了它之前的某个节点,导致从某个位置开始遍历永远走不完,像一条蛇咬住了自己的尾巴。
- 无环链表:正常尾部节点的 next 为 null,遍历会正常结束。
题目给的是一个普通链表的头节点 head,它本身可能没环。我们需要判断的是:沿着 next 指针走,会不会进入一个永远出不来的循环。
一个很容易踩坑的认知是:把“环形链表”等同于“单向循环链表”。单向循环链表是尾节点指向头节点,形成一个大圈;而 LeetCode 里的“有环链表”可以是任意位置成环,并不一定绕回头部,更常见的是中间某个节点接了回头路。理解这个区别,后面写判断逻辑的时候思路才不会歪。
2.2 暴力法为什么会爆
最直观的思路是什么?遍历每个节点,记录已经访问过的节点引用,如果某个节点被访问第二次,说明有环。这个思路完全正确,代码也好写,用哈希集合存一下就行了。
但问题在于空间复杂度是 O(n)。在面试场景里,如果第一反应只有这个方法,通常面试官会继续追问:能不能做到 O(1) 空间?因为链表题目最常考的就是“指针操作 + 空间优化”,你作为一个写过不少链表题的人,应该习惯性地往双指针方向想。
再说一个实际工程里的例子:生产环境的某个配置服务用链表缓存热数据,某天一个 bug 导致某条记录 next 指回了自己,整个遍历线程卡死。如果你知道怎么判断链表有环,排查这个问题就是几行代码的事。虽然业务里不会直接喊你“写个环形链表判断”,但这个能力是通用技能。
2.3 两种主流解法一览
针对“环形链表”这个题,常见的解法就两种:
| 解法 | 核心思路 | 时间复杂度 | 空间复杂度 |
|---|---|---|---|
| 哈希表法 | 遍历时把节点存进 Set,遇到重复即说明有环 | O(n) | O(n) |
| 快慢指针法(Floyd判圈算法) | 慢指针一次走一步,快指针一次走两步,若有环则二者必然相遇 | O(n) | O(1) |
哈希表法是“记录法”,思路直白但占空间;快慢指针法也叫“龟兔赛跑”,空间 O(1),是面试官更想听到的答案。接下来重点拆解快慢指针的原理,因为很多人只是背代码,根本不知道它为什么能判断有环。
3. 核心原理:为什么快慢指针一定能追上
3.1 从“龟兔赛跑”说起
快慢指针的思路很简单:两个指针同时从 head 出发,慢指针 slow 每次走一步,快指针 fast 每次走两步。如果链表没有环,fast 会先到达 null,遍历结束,说明无环;如果链表有环,两个指针都会进入环内,而且由于速度差,最终必然会相遇。
很多人不理解的核心问题是:为什么快指针每次走两步,慢指针每次走一步,就一定能相遇?如果快指针走三步、四步,还能相遇吗?
这里的关键在于“相对速度”。快指针比慢指针每次多走一步,相当于在环内,快指针以“每轮一步”的速度去追赶慢指针。假设环的长度是 L,两个指针在环内的初始间距是 d(这个 d 一定小于 L),那么每轮追近 1,最多 L-1 轮后必然追上,因为根本不存在“永远差一步”的情况——如果差一步,下一轮快指针多走一步就正好踩到同一位置。
用生活里的例子:环形跑道上,一个人跑步速度是另一个人的两倍。只要方向相同,速度快的那个人最终一定会从后面追上速度慢的那个人。如果跑道是直的,速度快的人只会先到终点消失,永远谈不上“追上”。
3.2 三种步长方案对比
面试时经常有变体问题:“快指针一次走三步行不行?”这需要具体分析。
先明确判断逻辑:如果有环,快指针进入环后,会在环内循环绕圈,慢指针也在环内移动,只是速度不同。能否相遇取决于两者位置关系是否在某轮对齐。
- fast = 2, slow = 1:相对速度为 1,任意初始间距都能在有限步内归零,必然相遇。
- fast = 3, slow = 1:相对速度为 2,如果环长 L 是偶数,且初始间距是奇数,那么每轮追 2,奇偶性永远无法对齐,可能永远错开。
- fast = 4, slow = 1:相对速度为 3,同理,当间距与 3 不整除时可能无法精确归零。
所以快指针每次走两步并不是唯一可行的方案,但它是“最稳妥、最容易证明”的。面试时如果说“fast 走三步也能相遇”,面试官大概率会让你举反例,所以只记住“两步一定会相遇”这个结论就够了。
3.3 环入口:快慢指针相遇后怎么做
第141题只要求“判断是否有环”,但面试常升级为第142题“环形链表 II”:不仅判断有环,还要返回环的入口节点。
这里要记住一个经典结论:快慢指针相遇后,把其中一个指针拉回 head,另一个留在相遇点,两个指针都改为每次走一步,再次相遇的位置就是环入口。
这个结论背后的数学推导是这样的:假设 head 到环入口的距离为 a,环入口到相遇点的距离为 b,相遇点继续走到环入口的距离为 c,那么环长 L = b + c。
慢指针走了 a + b 步,快指针走了 2(a + b) 步。快指针在环内可能绕了 n 圈,所以有:
2(a + b) = a + b + nL
化简得:
a + b = nL
也就是:
a = nL - b = (n-1)L + c
这说明:从 head 走到环入口的距离 a,等于从相遇点出发继续走 (n-1) 圈再加 c 的距离。因此,两个指针同时以步长 1 继续走,一个从 head 出发,一个从相遇点出发,一定会在环入口相遇。
这个推导我第一次看的时候也觉得绕,后来想通一个关键点就好了:快指针追上慢指针的超圈距离,正好被“head 到环入口的距离”补回来了。你不需要背结论,理解一次就好,后面做题就快了。
4. 代码实现与边界条件
4.1 双指针判断环:标准写法
以 JavaScript 为例,最常用的写法是这样的:
javascript复制function hasCycle(head) {
if (head === null || head.next === null) {
return false;
}
let slow = head;
let 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。这样做的原因是可以少做一次“先判断是否相同”的检查,也让代码逻辑更顺滑。但是要注意循环里的判空:fast 在移动前必须检查它本身和它 next 是否为 null,否则访问 fast.next.next 会直接抛异常。
另一种写法是 fast 和 slow 都从 head 开始,用 do-while 或者先让指针移动再比较:
javascript复制function hasCycle(head) {
let slow = head;
let fast = head;
while (fast !== null && fast.next !== null) {
slow = slow.next;
fast = fast.next.next;
if (slow === fast) {
return true;
}
}
return false;
}
这两种写法都行,第二种更不容易漏判空条件,我个人更推荐第二种,因为逻辑更直白:先检查 fast 能不能走两步,能走就移动并比较;不能走说明链表到底了,无环。
4.2 找环入口:升级版代码
判断环有了之后,找环入口的代码其实只多了一个阶段:
javascript复制function detectCycle(head) {
if (head === null || head.next === null) {
return null;
}
let slow = head;
let fast = head;
let hasCycleFlag = false;
while (fast !== null && fast.next !== null) {
slow = slow.next;
fast = fast.next.next;
if (slow === fast) {
hasCycleFlag = true;
break;
}
}
if (!hasCycleFlag) {
return null;
}
slow = head;
while (slow !== fast) {
slow = slow.next;
fast = fast.next;
}
return slow;
}
这段代码的逻辑分两段:第一段和上面判断环的逻辑一模一样,只是找到相遇点后 break 出来;第二段将 slow 重置到 head,然后两个指针都每次走一步,再次相遇的位置就是入口。
我一开始写这段代码时犯过一个错:第二段里忘记把 fast 移动改成一步,还继续用两步走的逻辑去跑,结果死循环。这里特别提醒一下,第二段和第一段的步长是完全不同的,别偷懒复制粘贴忘了改。
4.3 边界条件速查
链表题最烦的就是边界,环形链表这个题也不例外。我自己整理了一个速查表,写代码前先过一遍:
| 场景 | 判断逻辑 | 预期结果 |
|---|---|---|
| head 为 null | 直接判空 | 无环 |
| 只有一个节点 | head.next 为 null | 无环 |
| 两个节点成环 | tail.next = head | 有环,入口为 head |
| 节点中间成环 | 某个节点的 next 指回前面 | 有环,入口是前面那个节点 |
| 无环且很长 | 遍历到 null | 无环 |
上面任何场景漏掉判空都会出 bug,尤其 head 为 null 的时候,如果直接访问 head.next 就是空指针异常。真实面试里,你先写“if (head === null || head.next === null) return false/return null”基本上就能堵住大部分边界坑。
5. 时间复杂度和空间复杂度分析
5.1 为什么有环时也是 O(n)
有人可能会问:快慢指针在有环的情况下会不会一直在环里转,时间复杂度变成 O(n²) 或者无穷大?
不会。原因是慢指针在环内最多绕一圈就能被快指针追上。为什么?因为快指针相对慢指针的速度是 1 步/轮,所以追上所需的轮数不超过环长 L。而慢指针从 head 走到环入口需要 a 步,进入环后再走最多 L 步就会被追上,所以总步数是 a + L 量级的。
a 最多是链表的无环部分长度,L 是环长,两者之和不超过总节点数 n。因此时间复杂度是严格的 O(n)。
5.2 空间优化带来的收益
哈希表法需要存每个节点的引用,空间 O(n),在极端情况下——比如链表很长而且环在末尾——会额外占用大量内存。快慢指针法只需要两个指针变量,空间 O(1)。
在工程场景里这个差异很真实。我遇到过排查线上内存问题时,发现某个监控脚本为了判断一个超长链表是否成环,用 Set 存了几十万个节点引用,内存直接飙了好几百兆。换成快慢指针之后,内存占用忽略不计,排查时间也从“OOM 边缘”变成了稳定秒级返回。
所以在面试里,如果你提到“哈希表法虽然简单但空间 O(n),快慢指针能优化到 O(1)”,面试官对你的好感会明显上升,因为你展示了“会分析复杂度 + 有优化意识”这两项能力。
6. 常见问题和排查技巧实录
6.1 代码死循环怎么排查
刷题的时候,如果代码在本地跑起来不返回,多半是判断逻辑写错了,导致循环条件永远为真。最常见的错误是:
- 忘记移动某个指针,比如只写了 slow = slow.next,漏了 fast 的移动。
- fast 移动步长写错,比如 fast = fast.next.next.next,在三个节点的短链表上可能直接越界。
- 第二段找环入口时 fast 步长没改回 1。
排查方法:先在纸上画出链表,模拟 3-5 轮指针移动。如果纸面上的模拟都对,就打印每一步 slow 和 fast 的当前值,对比你脑子里的推演。一般 10 分钟内就能定位问题。
6.2 快慢指针一定要从 head 出发吗
不是。让 fast 从 head.next 出发,slow 从 head 出发,也可以判断有环。事实上上面第一种代码就是这么写的。
但注意一个细节:如果题目要求返回环入口,两种起点写法的后续逻辑会有差异。从 head 出发的版本在第二段把 slow 重置为 head 时,fast 保持在相遇点,逻辑更对称,不容易出错。所以遇到“找入口”的变体时,建议统一用“两个都在 head 出发”的版本,减少心智负担。
6.3 判断有环后怎么求环长度
有些面试官会顺便问:除了找入口,还能不能算环的长度?这个其实很简单。快慢指针相遇后,保持 slow 不动,让 fast 继续每次走一步(或者反过来),绕环一圈回到 slow 位置时走过的步数就是环长。
代码思路:
javascript复制// 此时 slow 和 fast 已在相遇点
fast = fast.next;
let length = 1;
while (fast !== slow) {
fast = fast.next;
length++;
}
这里 length 就是环的节点数。注意循环从 fast 先走一步开始,所以 length 初始化为 1。
6.4 哈希表法的代码参考
虽然快慢指针是主流解法,但哈希表法代码简单,有时候作为“第一反应”说出来也能加分:
javascript复制function hasCycle(head) {
const seen = new Set();
let cur = head;
while (cur !== null) {
if (seen.has(cur)) {
return true;
}
seen.add(cur);
cur = cur.next;
}
return false;
}
核心是利用 Set 存储节点引用,JavaScript 里 Set 对对象的比较是按引用而不是按值,所以即使两个对象长得一模一样,只要不是同一个引用,就不会被判重。这一点对其他语言也适用,只要用基于引用的集合类型就行。
7. 面试与工程双视角的实战建议
7.1 面试时的回答节奏
如果面试官出了这道题,我的建议是不要一上来就写代码,先用 30 秒说思路。比如这样回答:
“我可以先用哈希表记录访问过的节点,判断重复,但这样空间是 O(n)。如果想优化到 O(1) 空间,可以用快慢指针,慢指针每次走一步,快指针每次走两步,如果有环它们必然相遇。如果还要找环入口,就用经典的数学推导,相遇后一个指针回 head,另一个留在原地,同步走一步,再次相遇就是入口。”
这段话覆盖了解法、复杂度、扩展能力,面试官基本不需要再追问。比闷头写完代码再去解释要高效得多。
7.2 工程里什么场景真的会用到
很多人觉得链表题只是面试用,工程里根本碰不到。这个想法不太对。实际场景里,凡是“沿着 next 指针链式遍历”的数据结构都可能出现环,比如:
- 对象图里的循环引用(JSON 序列化器需要检测环来防止死循环)。
- 消息队列的消费者链,如果某个节点的 next 被错误设置为前面的节点,消费就会卡死。
- 内存缓存里的 LRU 链表,被并发 or 逻辑 bug 改坏后可能成环。
- 分布式一致性协议里的状态机转移表,某些错误配置可能形成循环跳转。
在这些场景里,“判断有没有环”往往是调试的第一步。你掌握了快慢指针,等于多了一把瑞士军刀,排查问题的时候不用重启服务打日志,直接写个小函数检测一下就知道了。
7.3 我的几个实操体会
这道题我前后教过不少人,有几点体会想单独说说。
第一,别急着背代码。我见过太多人把快慢指针代码背得滚瓜烂熟,但一问“为什么 fast 每次走两步就一定会相遇”就卡住。理解相对速度之后,代码根本不用背,每次自己推一遍就写出来了。
第二,手动画图真的管用。链表题在所有数据结构题里最适合画图,因为它的结构直观。遇到不会的变体,画一个 5 个节点的链表,其中一个节点指向第 2 个节点,然后模拟两个指针的移动轨迹,很多疑问自然就解决了。
第三,代码里最危险的是“看起来像能跑但其实逻辑错误”的情况。比如判空判断放在不恰当的位置、循环跳出条件写错、第二段忘记重置指针等,都不是编译错误,而是运行时错误。建议写完代码后,用小例子手动跑一遍,再提交,能省不少调试时间。
第四,面试的时候如果时间允许,可以在写完代码后补充说明一下时间复杂度分析和边界条件处理。这不会显得啰嗦,反而会让面试官觉得你思路完整、考虑周到。
8. 变体与扩展:从一个题带出一串题
环形链表这道题最值钱的不是它本身,而是它可以衍生出的一大类题目。我整理了几个常见的变体,建议都动手写一遍:
| 变体题目 | 核心考点 | 解法思路 |
|---|---|---|
| LeetCode 141 环形链表 | 判断是否有环 | 快慢指针/哈希表 |
| LeetCode 142 环形链表 II | 找到环入口 | 快慢指针 + 数学推导 |
| 判断链表是否相交 | 双指针/哈希表 | 先求长度差再同步走 |
| 计算环的长度 | 追击问题 | 相遇后绕一圈计数 |
| 判断链表中点 | 快慢指针 | 快指针到末尾时慢指针在中点 |
看到没有?只要会了快慢指针,链表中点、环入口、环长度、链表相交这些题的核心思路都是相通的。基础扎实的同学刷题时会把有联系的题目放一起做,效率比孤立刷题高好几倍。
就我个人经验来说,学链表面试经常刷“环形链表”类题,工程里检查数据结构是否被破坏也常用到这个思路。它不只是一个面试考点,更是一种“用 O(1) 空间解决循环检测问题”的通用方法论。只要理解了相对速度这个核心,无论是快慢指针、找环入口还是后续各种变体,都只是同一个思路的不同应用。
