- 环形链表这道题,说实在的,是很多人在力扣上"刷了又刷"的老朋友。简单难度、链表入门、高频面试,标签看着人畜无害,但我和不少同行交流下来,发现真正把这道题吃透的候选人远比想象中少。很多人能背出快慢指针的解法,但被追问"为什么快指针走两步而不是走三步""为什么两个指针一定会相遇"的时候,就开始含糊其辞。这篇文章不打算只给一个AC代码,而是把这个题背后的数学原理、工程直觉、边界陷阱和常见扩展一次讲清楚。不管你是刚开始刷题的新手,还是准备面试想补短板的老兵,看完应该都会有一些收获。
1. 题目到底在问什么——别急着写代码,先读懂这道题
1.1 从题面到抽象模型
力扣141题的题面很短:给定一个链表的头节点 head,判断链表中是否有环。这里的"环"指的是链表中某个节点的 next 指针指向了它之前的某个节点,导致从头节点出发永远走不到头。也就是说,链表变成了一个"带圈的轨道",你在上面遍历,循环往复,永远结束不了。
这个问题的数学本质其实很有意思:链表本质上是一个有向图,每个节点只有一个出边(next 指针)。在单链表中,每个节点最多有一个后继,所以整个结构要么是一条从 head 出发到 null 的链,要么是一条链上接了一个圈,不可能出现更复杂的拓扑结构。换句话说,链表中是否存在环,等价于判断"从 head 出发的这条链路是否会在某个地方和自己相交"。
很多初学者拿到这个题第一反应是懵:链表又不是数组,我没办法用下标去访问,我怎么知道绕回来没绕回来?这个问题本身就在提醒你:链表题的核心操作对象是"指针"而不是"索引",一旦你适应了"指针视角",链表问题会变得顺畅很多。
1.2 适合谁刷、解决什么问题
这道题适合三类人:
第一类是刚入门数据结构、想熟悉链表遍历和指针操作的新手。它比反转链表稍难一档,但不涉及递归、动态规划等复杂技巧,是很好的过渡题。
第二类是准备面试、想巩固"双指针思想"的人。141虽然简单,但它是双指针技术中"快慢指针"思路的经典入口,理解透这一题,后面做142(环形链表II)、876(链表的中间结点)、287(寻找重复数)都会轻松不少。
第三类是平时写业务代码但想优化程序性能的人。实际工程中,检测"数据链路是否成环"是很常见的需求——比如内存分配器的循环块管理、日志链判断、状态机的循环依赖检测,都会用到类似的思路。
这道题的价值也不在于"难",而在于它帮你打通了一个关键认知:在只允许 O(1) 额外空间的情况下,怎么通过"速度差"来检测结构上的循环性。这个思想一旦建立,你会发现它在很多算法题和系统设计题里都能迁移。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 最容易想到的解法——哈希表标记法,以及它的两笔账
2.1 最直觉的思路:记下每个走过的节点
在没有限定空间复杂度的情况下,这道题的常规解几乎是一秒就能想到的:遍历链表,每到一个节点,就把它存到一个哈希集合(HashSet)里。如果发现当前节点已经存在于集合中,说明之前来过这个节点——那就是有环;如果遍历到了 null,则说明无环。
用 C++ 实现大概是这样的:
cpp复制/**
* Definition for singly-linked list.
* struct ListNode {
* int val;
* ListNode *next;
* ListNode(int x) : val(x), next(NULL) {}
* };
*/
class Solution {
public:
bool hasCycle(ListNode *head) {
unordered_set<ListNode*> visited;
ListNode* cur = head;
while (cur != nullptr) {
if (visited.count(cur)) {
return true; // 曾经来过,说明成环
}
visited.insert(cur);
cur = cur->next;
}
return false;
}
};
这个解法思路直接,正确性非常好证明:如果链表中有环,遍历必然无限进行,而且第一次访问某个节点时是"新面孔",第二次访问同一个节点时哈希集合会立刻报"重复",此时即可判定有环;如果没有环,遍历会在遇到 null 后自然终止。
用 Python 版本写也差不多:
python复制class Solution:
def hasCycle(self, head: ListNode) -> bool:
seen = set()
while head:
if head in seen:
return True
seen.add(head)
head = head.next
return False
注意在 Python 版本中,哈希集合存储的是 ListNode 对象本身,而不是节点的值。因为链表节点可能存重复值,比如两个节点的 val 都是 1,但它们是完全不同的节点,用值判断会出错。很多新手在这里犯迷糊——记住,判断"是否来过"要依据节点的身份(内存地址或对象引用),而不是节点的值。
2.2 哈希表方案的两笔账:时间和空间的代价
哈希表解法的时间复杂度是 O(n),空间复杂度也是 O(n),其中 n 是链表的节点数。它的缺点是显而易见的:你为了判断有没有环,额外开了一片和链表规模相当的存储空间。在力扣的题目约束下,n 可能是几万、几十万,甚至更大,哈希表扩容、哈希冲突带来的性能损耗会让解法变得"偏胖"。
这里我要多说一句工程上的账:在面试或者真实项目中,空间复杂度为 O(n) 不一定是坏事,很多场景下我们用空间换时间是完全正确的选择。但在算法题的语境里,O(1) 额外的空间往往是一个"加分门槛"。141这题其实构思得比较精巧——它刚好是那种"存在 O(1) 空间解法"的题,而且这个解法还特别优雅。如果面试官在你给出哈希表解法之后追问一句"能不能把空间复杂度降到 O(1)",你就该想到快慢指针了。
3. 快慢指针:Floyd判圈算法的完整拆解
3.1 算法步骤与直觉
Floyd判圈算法,也叫龟兔赛跑算法,思路用一句话就能概括:让一个慢指针 slow 每次走一步,一个快指针 fast 每次走两步,同时从 head 出发往前走。如果链表中有环,那么快指针最终必然会"追上"慢指针;如果没有环,快指针会先到达 null。
很多人第一次听到这个算法时,直觉上会有一种"好像对,但不确定"的感觉。我们可以分步骤拆一下:
- 初始化:slow = head,fast = head。
- while 循环中,fast != null 且 fast->next != null 时,slow 前进一步,fast 前进两步。
- 如果某一步 slow == fast,说明两个指针在环内相遇,判定有环。
- 如果循环因 fast 到达 null 或 fast->next 为 null 而结束,说明链表无环。
用 C++ 写核心实现:
cpp复制class Solution {
public:
bool hasCycle(ListNode *head) {
if (head == nullptr || head->next == nullptr) {
return false;
}
ListNode* slow = head;
ListNode* fast = head;
while (fast != nullptr && fast->next != nullptr) {
slow = slow->next;
fast = fast->next->next;
if (slow == fast) {
return true;
}
}
return false;
}
};
用 Go 写同样逻辑:
go复制func hasCycle(head *ListNode) bool {
slow, fast := head, head
for fast != nil && fast.Next != nil {
slow = slow.Next
fast = fast.Next.Next
if slow == fast {
return true
}
}
return false
}
循环条件里必须同时检查 fast != nil 和 fast->next != nil,因为 fast 一次跳两步,如果 fast 已经离尾部很近,访问 fast->next->next 可能触发空指针异常。这个细节在面试手写代码时特别容易翻车。
3.2 为什么两个指针一定会相遇
这是整个算法最核心的问题,也是面试官最爱追问的点。很多博客直接用"快指针每次追一步,所以迟早追上"带过,但这个解释其实是不完整的——快指针"追上"慢指针,并不是像直线跑道那样简单的追及问题,而是在一个有环的封闭跑道上进行的追及。
我们用一个递推的逻辑来想:
假设链表无环部分长度为 a(从 head 到环入口的节点数),环的长度为 L。当慢指针 slow 到达环入口时,它走了 a 步;此时快指针 fast 因为速度是慢的两倍,已经走了 2a 步,所以 fast 已经在环内前进了 a 步(如果 a >= L,则相当于前进了 a mod L 步,但这不影响结论)。
从这一时刻起,slow 在环入口处,fast 在环内某个位置。快指针相对于慢指针的速度差是 1 步/轮(fast 每轮走2步,slow 每轮走1步,差值就是1步)。在环这个"封闭跑道"上,相对速度为 1 的追及问题等价于:每过一轮,fast 与 slow 之间的距离就缩短 1 个节点。由于环是封闭的,fast 最终一定会与 slow 重合。最坏的情况下,需要走一整圈 L 步才能追上。
这里的关键在于"速度差为 1"。正因为快指针每轮只比慢指针多走 1 个节点,两个指针在环上的距离才会严格单调递减,不会出现"跳过"对方的情况。
3.3 为什么快指针走两步而不是三步、四步
这是个很好的追问。快指针走3步、4步不行吗?当然也不会立即出错,但会有两个问题。
第一个问题是逻辑证明变得不那么直接。如果 fast 每轮走 3 步、slow 走 1 步,相对速度差是 2。当两个指针都在环内时,它们之间的距离每轮缩短 2 个节点。如果距离刚好是奇数,那么"追上"的时候可能会发生跨越——fast 从 slow 身边越过而没有检测到相等。此时算法需要在相遇判定上做额外处理,单靠 slow == fast 判断就不够了。虽然工程上可以通过每轮多次比较来解决,但代码复杂度显著上升。
第二个问题是效率反而下降。fast 走 2 步是最小且足够快的整数速度差方案。走 3 步、4 步虽然在极少数链表布局下可能让相遇更快发生,但在多数情况下没有本质区别,还白白增加了实现的复杂度。
所以我教别人的时候常说一句话:快指针走两步,不是因为走三步不行,而是因为走两步是"证明最简洁、实现最简单、性能最稳妥"的黄金选择。算法题不是越花哨越好,而是在正确性和可维护性之间找到平衡点。
3.4 数学补充:相遇点与环入口的关系
既然聊到了 Floyd 算法,就顺便把142题的数学基础也点了。假设 slow 在进入环后与 fast 第一次相遇时,slow 已经走了 x 步(从 head 开始算)。那么 fast 走了 2x 步。因为 fast 和 slow 在环内相遇,fast 比 slow 多走的步数一定是环长 L 的整数倍,即:
2x - x = kL
得到 x = kL。也就是说,从 head 出发到第一次相遇点,slow 走过的总步数 x 是环长 L 的整数倍。
这个结论直接导出了142题的做法:相遇后,把 slow 重置回 head,fast 留在原地,然后两个指针都以步长1前进,它们最终会在环入口相遇。推导也不难:head 到环入口距离为 a,相遇点在环内离入口 b 个节点(当然如果走了一圈,实际写成 a + b = x)。因为 x = kL,所以从头开始走 a 步到达环入口;而 fast 从相遇点走 a 步后,位置为环内 (b + a) mod L,因为 a + b = kL,mod L 后正好是0,即环入口。
这个数学推导是204的进阶版,搞懂 141 的相遇原理之后,再做 142 就是顺水推舟的事。
3.5 时间复杂度分析
很多人在分析快慢指针的时间复杂度时不够严谨。直观上可能会以为最坏情况是快指针绕环很多圈,导致 O(n^2) 之类的复杂度,但实际上并不是。
我们分段分析:
- 第一阶段,slow 从 head 走到环入口,耗时 O(a)。
- 第二阶段,从 slow 入环到两个指针相遇,由于相对速度为1,slow 最多走不到 L 步,fast 最多走 2L 步,耗时 O(L)。
所以总时间复杂度是 O(a + L) = O(n),其中 n = a + L 就是链表总节点数。空间复杂度则是 O(1)。这个分析也解释了为什么快慢指针方案是这道题的最优解。
4. 边界条件与常见误区——那些让AC变成WA的细节
4.1 空链表和单节点的处理
力扣的输入可能是空链表,也就是 head 为 null;也可能是只有一个节点且该节点的 next 指向 null。这两种情况都属于"无环",应该返回 false。
如果用快慢指针方案,初始化时如果直接让 slow = head、fast = head,然后进入循环判断 fast != null && fast->next != null,空链表和单节点链表都会安全地跳过循环体,自然返回 false。所以我前面给出的实现里加了两个前置判断:
cpp复制if (head == nullptr || head->next == nullptr) return false;
这两句不算冗余,它让你的代码意图更明确,也避免了后续解引用空指针的风险。有人觉得不写前置判断也能过,确实,因为 while 条件已经挡住了,但前置判断是面试中更稳妥的写法——你省下的两行,可能在极端输入下变成一次 segfault。
4.2 自环节点:一个让人意外的陷阱
如果链表的某个节点恰好自己指向自己,即 node->next = node,这种"自环"算不算有环?当然算。它本质上是一个长度为1的环,快慢指针进入这个节点后,会永远停留在该节点,能检测出来。
但我见过不少同学在写哈希表解法时,以为 val 相同就是同一个节点,导致自环场景判断失误。举个例子:
code复制节点A: val = 1, next = A(自环)
如果你的哈希集合存的是 val 而不是节点对象,第一次访问 A 时存入 val=1,第二次访问 A 时发现集合里有 1,判定有环——这次碰巧对了。但换个场景:
code复制节点A: val = 1, next = B
节点B: val = 1, next = null
如果哈希集合存的是 val,走到 B 时会发现集合里已经有 1,误判为有环,而实际上链表是无环的。这就是我前面强调"存节点对象/引用,而不是存值"的原因。在自环或重复值混杂的场景,只有按节点身份标记才能保证正确。
4.3 不要试图标记节点来"切断"环
有些初学者想出一种"投机"做法:遍历时每经过一个节点,就把它的 next 指向自己(或指向某个哨兵节点),然后继续走,如果发现某个节点的 next 已经被标记过,就说明有环。这种做法的确可以检测环,但问题是你修改了原始链表结构,这在真实工程里往往是不可接受的。力扣题目虽然只是判断返回布尔值,但面试官肯定会追问"你改变了输入数据,万一调用方还需要这份链表怎么办"。
如果你真的想用"空间标记"思路,上面哈希表那种"只记录不修改"已经足够了。这里也体现了一个工程态度:优先考虑不改变输入数据结构的算法,除非题目明确允许破坏性操作。
4.4 循环条件的空指针风险
这个坑我在面试中几乎每次都能看到。代码写快了就会变成:
cpp复制while (fast->next != nullptr && fast != nullptr) {
// ...
}
或者更糟:
cpp复制while (fast->next != nullptr) {
fast = fast->next->next;
// ...
}
两种写法都有问题。正确的判断顺序应该是先判断 fast 本身不为空,再判断它的 next 不为空,并且要在循环体内部先移动 slow、再移动 fast,而不是反过来。另外,Java 和 Go 这类语言会直接抛空指针异常,C++ 则可能直接崩溃,调试起来很痛苦。
建议的习惯是:在每一轮循环开始前,把快指针可能访问到的两个节点(fast 和 fast->next)都做空判断,因为 fast 一次走两步,一步都不能省。
5. 从141到全家桶——一道题扩展出的一片题海
5.1 衍生题1:环形链表II——找到环的入口
这道题是142,它要求在判断是否有环的基础上,返回环的入口节点。解法路线是把 Floyd 算法分成两步:第一步用快慢指针找到相遇点,第二步重置 slow 到 head,然后两个指针同步走,相遇点就是环入口。原理在 3.4 节已经证明过,代码也非常简洁。
这道题的价值在于:它不只是"知道有环",而是"精确定位环在哪里",在很多真实场景中更有用。比如你在分析一个循环依赖时,光知道有循环没用,你得告诉老板循环具体从哪开始断。
5.2 衍生题2:求环的长度
这个更简单,找到相遇点后,让一个指针停在原地,另一个指针绕环走一圈,计数即可。因为这个环已经确定了,走一圈必然会回到起点。用到的思路是"判断相遇 + 走圈计数",几乎是 141 的直接延伸。
5.3 衍生题3:判断两个链表是否相交
经典题160。两个链表相交的判别方法中,有一个解法是:先把第一个链表的尾节点接到自己的头部,形成环;再判断第二个链表是否有环,如果相交则必然有环,且环入口就是相交点。这个解法巧妙地把"相交问题"转化为"环检测问题",和 141 共享相同的核心算法。
5.4 快慢指针在其他场景的迁移
不只是链表题,快慢指针在数组题里也有应用。比如力扣287题"寻找重复数",题目给一个包含 n+1 个整数的数组,数字范围是 1 到 n,要求找出唯一重复的那个数。这道题可以把数组的下标和值看作链表节点的 next 指针映射——数组下标 i 映射到 nums[i],因为存在重复数,所以这个"隐式链表"必然成环,用快慢指针就能找到环入口,也就是重复的数。这个解法把"数组"玩成了"链表",思路和 141 一脉相承。
5.5 实际工程中的环形检测
我帮人排查线上问题时,遇到过内存池管理、状态机跃迁、依赖任务调度里出现环的场景。比如 A 依赖 B,B 依赖 C,C 又依赖 A,任务调度器如果不检测这个环,整个任务队列会死循环。其实判断依赖图是否有环可以抽象成拓扑排序,但如果是单链的任务流转,快慢指针的思路稍加变体也能用。这个思想并不局限于算法题,它是"用速度差检测循环"的一种通用工程思维。
6. 写在最后——刷题之外的几点体会
这道题刷完,我建议大家别急着做下一道,花十分钟做三件事:第一,把哈希表解法和快慢指针解法各写一遍,比较两种写法的细节差异;第二,自己手动模拟一遍 5 个节点的环链表,跟踪 slow 和 fast 的每一步,感受它们是怎么遇上的;第三,把142题的代码独立写一遍,不看题解,尝试从 141 推导出来。
我在实际面试中见过不少候选人,141 顺手就过了,但一追问原理就开始露怯。真正能拉开差距的,是你能不能用一句清晰的话解释"为什么快慢指针一定能相遇"。把这个过程想透了,这道题的收益才真正落袋。
最后再分享一个刷题习惯:不要满足于"过了"。力扣的难度标签仅供参考,141 虽然是简单题,但它和142、287、160这些中难题之间只有一层窗户纸。捅破这层窗户纸,你获得的不是一个题的解法,而是一种"双指针看结构"的思考方式。这道题,值得花点时间认真对待。
