LeetCode 142 这道“环形链表 II”,我打赌你迟早会碰上。上一题“环形链表 I”只要判断链表里有没有环,很多人大手一挥就用快慢指针搞定了;但这题要求在“有没有环”的基础上,再把环的入口节点找出来。别小看这一步,多少人卡在“明明能判断有环,却不知道入口在哪儿”这个坎上。这道题在面试里出镜率非常高,一个大厂面试官如果问你链表题,拿出 142 的概率真不低。它考察的不是你背没背过答案,而是把链表遍历、双指针、数学推导三件事缝合在一起,能不能在十几分钟里讲清楚、写干净。这篇文章我打算直接从问题拆解、算法原理、公式推导、代码实现,到本地验证和面试翻车点,完整过一遍。适合刚刷链表专题的初学者,也适合准备给别人讲题、想把细节吃透的人。
1. 先搞清楚题目到底在问什么
1.1 这题和环形链表Ⅰ的差别在哪
环形链表 I 给你一个链表的头节点 head,问你链表里有没有环,返回 true 或者 false。环形链表 II 多了一个要求:如果有环,返回这个环的入口节点;如果没有环,返回 null。
所谓“环的入口”,可以理解成链表里第一个进入环的节点。举个例子,一个链表从 head 出发,走到某个节点之后不再有末尾,而是在环里无限绕圈,那个“开始绕圈”的节点就是入口。要注意的一点是,题目里的 pos 只是一个“构建测试用例用的内部索引”,用来告诉判题系统这个链表应该在哪个位置成环,它永远不会作为函数参数传给你。很多新手拿到题就在纠结“pos 参数我怎么拿”,其实完全不用管,你的函数只接收 head。
再补充一个限制:不允许修改原始链表。这个限制排除了“把访问过的节点标记成特殊值”这种思路,也提醒你有些做法虽然能解,但不符合题目要求。实际上标准解法也不会去改链表,所以这个限制更多是告诉你不要走歪门邪道。
1.2 为什么它是面试官眼里的“一题三考”
这道题被归为“中等难度”,但它的含金量比一批所谓“困难”题还高,因为一道题同时考了三层能力。
第一层是基本功:你能不能熟练地用双指针遍历链表,正确处理空指针和边界条件。第二层是逻辑思维:你不仅要知道快慢指针能判环,还要能推导出入口位置和相遇点之间的关系。第三层是代码落地:你在白板上写的代码能不能一次通过,几个循环的退出条件有没有写对。这三层任何一个掉链子,面试官都能一眼看出来。
当年我刷到这题的时候,第一次用哈希表做出来了,还觉得自己挺厉害。后来面试官问了一句“能不能用 O(1) 空间做”,我当场愣住,这才老老实实回去把 Floyd 判圈算法和公式推导啃干净。现在回头想,那道题才算真正“刷透了”。
1.3 一个必须澄清的误解:pos 到底是不是参数
网上经常有人在评论区问:“题目里给了 pos,为什么我在函数签名里看不到?”这个问题值得单独讲一下。
LeetCode 的链表题里,输入描述常常会给一个 pos,但它只是帮你理解“这个链表是怎么构成环”的,并不是你写函数时要接收的参数。你写的 detectCycle(ListNode *head) 只需要从头节点开始遍历,一切信息都藏在链表结构本身里。换句话说,你有 head 指针,通过不断 next 往前走,必然能经过所有节点;如果遇到某个节点回头指向了之前经过的节点,那就说明有环。根本不需要 pos 告诉你入口在哪,入口是需要你“找出来”的,而不是“被告知”的。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 解法一:哈希表,最容易想到但不完美
2.1 思路与代码
哈希表的思路非常直白:用一个集合记录已经访问过的节点,遍历链表,每走到一个节点就检查它是否在集合里。如果出现过,说明这个节点就是环的入口;如果走到链表末尾都没遇到重复,就说明没有环。
原理很好理解:一个节点只有两种情况,第一次出现或者之前出现过。环的第一个被重复访问的节点,必然是整个环的起点。这个思路几乎不可能出错,代码写起来也很短。
cpp复制ListNode *detectCycle(ListNode *head) {
unordered_set<ListNode*> seen;
ListNode *cur = head;
while (cur) {
if (seen.count(cur)) {
return cur; // 第一个重复访问的节点就是入口
}
seen.insert(cur);
cur = cur->next;
}
return nullptr; // 走到末尾都没重复,说明无环
}
这里用 unordered_set 而不是 set,是因为哈希表的查找平均时间复杂度是 O(1),整体遍历一次就是 O(n)。如果你用 set(通常是红黑树实现),查找会变成 O(log n),虽然代码差别不大,但性能上差了一截。
2.2 为什么面试时别急着用这版
哈希表解法的时间复杂度是 O(n),空间复杂度也是 O(n),因为你需要额外维护一个集合。判题系统对这种空间开销往往不会太苛刻,但面试官一定会追一句:“能不能把空间复杂度降到 O(1)?”
为什么题目这么执着于空间复杂度?因为链表这类题,很多时候考的就是“不用额外空间完成操作”的能力。哈希表相当于拿空间换时间,思路正确但不是最优解。如果你真在面试里写了哈希表,可以主动指出“这是最容易理解的版本,但面试要求 O(1) 空间的话,我会用双指针”,这样反而能展现出你思路的完备性。
我把两种方案的差距整理成一张表,方便你对照:
| 方案 | 时间复杂度 | 空间复杂度 | 能否找到入口 | 面试评价 |
|---|---|---|---|---|
| 哈希表 | O(n) | O(n) | 能 | 正确但不是最优 |
| 快慢双指针 | O(n) | O(1) | 能 | 标准答案 |
3. 解法二:Floyd 快慢双指针,空间 O(1) 的答案
3.1 龟兔赛跑的基本流程
Floyd 判圈算法,也叫龟兔赛跑算法,是这道题的标准解法。它的流程分两个阶段。
第一阶段:判断有没有环。慢指针 slow 每次走一步,快指针 fast 每次走两步,同时从 head 出发。如果链表无环,fast 会先碰到空指针,直接结束,返回 null。如果有环,fast 进入环之后会在环里不断绕圈,最终在某个位置追上 slow,也就是两个指针相遇。
第二阶段:找入口。当 slow 和 fast 第一次相遇时,把 fast 重新指向 head(或者保留一个指针在相遇点),然后让两个指针都每次走一步,继续走。它们再次相遇的位置,就是环的入口。
代码骨架长这样:
cpp复制ListNode *slow = head;
ListNode *fast = head;
// 第一阶段:找相遇点
while (fast && fast->next) {
slow = slow->next;
fast = fast->next->next;
if (slow == fast) {
// 第二阶段:从头和相遇点同步走
ListNode *ptr = head;
while (ptr != slow) {
ptr = ptr->next;
slow = slow->next;
}
return ptr;
}
}
return nullptr;
很多人学到这,能记住代码,但说不清第二阶段为什么成立。如果面试官多问一句“为什么这样走就一定能到入口”,答不上来就露馅了。所以我把公式推导完整写出来。
3.2 快指针为什么一次走两步,而不是三步或一步
先回答一个很自然的疑问:为什么快指针每次走两步,慢指针每次走一步?能不能走三步?
关键在于“追上”的过程必须保证不跳过。快指针每次比慢指针多走一步,相对速度是 1,这意味着在环里,快指针与慢指针之间的距离每一轮只会缩短 1,它不可能从慢指针头顶“飞过去”。如果快指针每次走三步,慢指针走一步,相对速度是 2,就可能出现一种情况:这一轮开始时快指针在慢指针后面 1 个位置,快指针走三步,慢指针走一步,两者交换了位置却没有指向同一个节点,也就是“擦肩而过”。
当然,“擦肩而过”之后继续跑,理论上后面还可能再次相遇,但那样算法的行为就变得不可控,公式推导也会复杂化。标准 Floyd 算法采用“一步 vs 两步”,是为了让相对速度为 1,保证在环内无论距离多远,最终都必然稳稳地停在同一个节点上。这个细节看似微不足道,却是整个算法成立的前提。
3.3 快指针一定能在有限步内追上慢指针吗
这个问题也经常有人问:万一快指针一直在绕圈,追不上呢?
不会。从相对运动的角度看,只要两个指针都在环内,慢指针是静止参照物,快指针就是每轮向慢指针靠近 1 步的移动物。环的长度是有限的,所以它最多绕一圈就能追上。而且慢指针进入环的瞬间,快指针已经在环内某个位置,最坏的情况是快指针刚好在慢指针前面一个节点,那么快指针只需要再走“环长减一”步就能追上慢指针。这个上界是确定的,不存在“永远追不上”的情况。
4. 公式推导:相遇后为什么从头同步走就能到入口
4.1 把路程关系写成方程
这里开始是整道题最精彩的部分。我先把变量定义清楚。
设链表头节点 head 到环入口的距离为 a。慢指针进入环之后,又走了 b 步,与快指针第一次相遇。设环的周长为 L,相遇点继续往前走,绕回入口的距离是 c,那么有:
text复制L = b + c
接下来看路程。慢指针从 head 出发到相遇点,总共走了:
text复制s_slow = a + b
快指针速度是慢指针的两倍,所以它走的路程是:
text复制s_fast = 2 * (a + b)
同时,快指针在相遇时,除了走完和慢指针一样的 a + b 之外,还在环里多绕了 k 圈(k 是正整数),所以也可以写成:
text复制s_fast = a + b + k * L
两个式子相等,于是得到:
text复制2 * (a + b) = a + b + k * L
化简:
text复制a + b = k * L
也就是说,慢指针从 head 到相遇点走过的总路程,恰好是环长的整数倍。
继续推导入口位置。因为 L = b + c,所以:
text复制a = k * L - b
= (k - 1) * L + (L - b)
= (k - 1) * L + c
这个式子意味着什么?如果有一个指针从 head 出发,走到入口需要 a 步;同时另一个指针从相遇点出发,先走 c 步就能回到入口,再走 (k-1) 个整圈,也是停在入口。两个指针速度相同,从各自起点同步出发,最终必然在入口处相遇。
这就是第二阶段“把 fast 指向 head,然后 slow 和 fast 同步一次走一步”的数学依据。整个过程绕了很多圈,但落点必然一致,而且第一次相遇就发生在入口,因为两个指针到达入口的步数相同,之前不可能再提前碰面。
4.2 用具体例子验证一下推导
光看公式容易晕,我带你看一个具体例子。
假设链表长 5 个节点,入口是第 2 个节点(从 head 算起,head 是第 0 个节点的话,入口就是下标 1),环的长度是 4。也就是 a = 1,L = 4。
模拟一下快慢指针的运行。头节点出发,慢指针走 1 步到入口,再走几步进环;快指针已经在环里绕。第一次相遇时,假设慢指针走了 a + b 步。根据公式,a + b 必须是 L 的整数倍,也就是 1 + b 必须是 4 的整数倍,b = 3。所以慢指针从入口进环后走 3 步,一共走了 4 步;快指针走了 8 步,其中前 1 步到入口,然后绕了 7 步。两者在入口往前数 3 个节点的位置相遇。
相遇点离入口的距离是 c = L - b = 4 - 3 = 1。也就是说,从相遇点再走 1 步就能回到入口。此时让一个指针从 head 出发,走 a = 1 步也到入口。两个指针同步走,走第 1 步之后同时到达入口,完美相符。
再举一个极端例子:整个链表就是一个环,head 本身就是入口。此时 a = 0。慢指针和快指针从 head 出发,快指针比慢指针快,它们在环内某个位置相遇。第二阶段,一个指针从 head 出发走 0 步,另一个指针从相遇点绕圈回到 head,也是 0 步,所以直接返回 head。代码里的循环不会执行,return 的就是 head,符合预期。
4.3 关于“慢指针走不满一圈”这个容易混淆的说法
网上很多题解喜欢说“慢指针进入环后,在一圈之内一定会被快指针追上”。这句话本身没有错,但很多人因此误会成“所有相遇都发生在第一圈”,这就错了。
准确的理解是:慢指针从入口进入环内开始,到它与快指针相遇,慢指针走的距离 b 一定小于环长 L。为什么?因为快指针早就进入环了,它此刻在环内某个位置。最坏情况是快指针正好在慢指针前面一步,那么快指针只需要再走 L-1 步就能追上慢指针,而这段时间里慢指针也走 L-1 步,仍然小于 L。所以“慢指针在第一圈内就被追上”是对的,但不代表快指针只在环里跑了一圈,它可能已经跑了好几圈了。
这个细节常常让推导过程变得混乱。我建议你只记住最核心的结论:慢指针从 head 到相遇点的总路程 a + b 等于环长的整数倍,至于 k 具体是几,根本不需要算,因为第二阶段推导自动消掉了。
5. 核心代码实现与细节打磨
5.1 C++ 标准写法
C++ 是最常见的刷题语言,我直接给一版可以一次提交通过的完整实现。
cpp复制class Solution {
public:
ListNode *detectCycle(ListNode *head) {
ListNode *slow = head;
ListNode *fast = head;
while (fast && fast->next) {
slow = slow->next;
fast = fast->next->next;
if (slow == fast) {
ListNode *ptr = head;
while (ptr != slow) {
ptr = ptr->next;
slow = slow->next;
}
return ptr;
}
}
return nullptr;
}
};
几个关键点说一下。
第一个,while (fast && fast->next) 这个条件同时判了 fast 不为空、fast->next 不为空。因为 fast 一次走两步,如果它指向的是最后一个节点,说明链表无环,此时 fast->next 就是空指针,不能再取 fast->next->next,否则会触发空指针访问。有些人在这一步写成 while (fast->next),当链表只有 0 个或 1 个节点时就会出错。
第二个,相遇之后的第二阶段,我习惯复用 slow 指针而不新开变量。有人喜欢把 fast 重新指向 head,然后让 fast 和 slow 同步走;也有人像我这样用一个新指针 ptr 从 head 出发,slow 留在相遇点。两种写法等价,但面试时最好只写一种思路,别在代码里混两种指针来源,容易把自己绕晕。
第三个,注意返回值。无环时返回 nullptr;有环时返回 ptr。有些编译器环境下 NULL 和 nullptr 都能通过,但 C++ 里推荐用 nullptr,类型安全更好。
5.2 Python 和 Java 版本
如果你主力语言是 Python,写起来会更简洁:
python复制class Solution:
def detectCycle(self, head: Optional[ListNode]) -> Optional[ListNode]:
slow = head
fast = head
while fast and fast.next:
slow = slow.next
fast = fast.next.next
if slow == fast:
ptr = head
while ptr != slow:
ptr = ptr.next
slow = slow.next
return ptr
return None
Python 里判断空指针用 None,其他逻辑和 C++ 完全一致。需要注意的是 Optional[ListNode] 类型标注和 Optional 的导入,LeetCode 环境默认已经帮你处理了,但本地跑要注意 from typing import Optional。
Java 版本也很接近:
java复制public class Solution {
public ListNode detectCycle(ListNode head) {
ListNode slow = head;
ListNode fast = head;
while (fast != null && fast.next != null) {
slow = slow.next;
fast = fast.next.next;
if (slow == fast) {
ListNode ptr = head;
while (ptr != slow) {
ptr = ptr.next;
slow = slow.next;
}
return ptr;
}
}
return null;
}
}
三种语言核心逻辑一模一样,区别只在语法细节。你熟悉哪门就用哪门,面试时更重要的是把逻辑讲清楚,语言反而是次要的。
5.3 三个最容易出 bug 的细节
第一个细节是快指针的初始化。有些教程会让 fast 初始化为 head->next,slow 初始化为 head,这样两个指针起点不同。这种写法在“只判断有无环”的情况下没问题,但在“找入口”的推导里会改变方程,很容易算错。我建议直接让 slow 和 fast 都指向 head,然后先移动再比较,这样推导和代码一一对应。
第二个细节是“相遇之后从 head 出发”的指针到底应该是哪个。如果这段代码写错了,可能陷入死循环或者返回一个错误节点。关键是在第一阶段结束时,slow 和 fast 都在相遇点;第二阶段从 head 出发的指针必须是一个新变量,而不是 fast 本身。如果你直接把 fast 改成指向 head,但循环里又使用 fast 和 slow 做比较,逻辑上没问题,但可读性差。我推荐的做法是新建一个 ptr,语义最清晰。
第三个细节是链表只有 0 个节点或 1 个节点的情况。head 为空时,while (fast && fast->next) 直接不进入,返回 nullptr;只有一个节点且没有环时,同样返回 nullptr。这是题目要求的正确行为。如果你把 while 条件写成 while (fast != nullptr) 然后内部再访问 fast->next->next,只有一个节点时必然越界。
6. 本地如何自己构造带环链表验证
6.1 构造带环链表的辅助函数
在 LeetCode 上提交很简单,但如果你想在本地调试或者自己跑例子,就需要自己构造带环的链表。这个功能很多新手不会写,我直接给一个 C++ 辅助函数。
cpp复制ListNode* buildCycleList(vector<int> vals, int pos) {
ListNode *dummy = new ListNode(0);
ListNode *cur = dummy;
ListNode *entry = nullptr;
for (int i = 0; i < vals.size(); i++) {
cur->next = new ListNode(vals[i]);
cur = cur->next;
if (i == pos) {
entry = cur;
}
}
if (entry) {
cur->next = entry;
}
return dummy->next;
}
这个函数接收一个整数数组和 pos 索引。先依次创建每个节点,同时记录 pos 指向的那个节点作为环入口;全部创建完之后,让尾节点的 next 指向入口,就构成了一个环。如果 pos 传 -1,入口记录为空,就构造一个无环链表。
注意构造带环链表后,内存释放是个麻烦事,因为环会导致 shared_ptr 无法自动释放,裸指针更是要手动解除环之后才能 delete。本地调试无所谓,但如果你用 ASAN 跑内存检测,需要考虑手动把环断开再释放。这一点不用过度纠结,理解即可。
6.2 测试思路与运行示例
有了构造函数,你可以很方便地验证算法正确性。下面是一段简单的测试代码。
cpp复制int main() {
vector<int> vals = {3, 2, 0, -4};
ListNode *head = buildCycleList(vals, 1);
Solution sol;
ListNode *entry = sol.detectCycle(head);
if (entry) {
cout << "环入口节点值: " << entry->val << endl;
} else {
cout << "无环" << endl;
}
return 0;
}
这个例子对应题目里经典的 [3, 2, 0, -4], pos = 1,入口是值为 2 的节点。你本地运行应该输出 “环入口节点值: 2”。
我还建议你测试几个边界场景:空链表 buildCycleList({}, -1),单节点无环 buildCycleList({1}, -1),单节点自环 buildCycleList({1}, 0),整个链表成环 buildCycleList({1, 2, 3}, 0)。这几个用例覆盖了绝大多数陷阱。
7. 高频变体与排查实录
7.1 变体:如何求环的长度
找入口会了之后,面试官很可能追问一个变体:怎么求环的长度?其实特别简单。
在第一次相遇之后,让 slow 留在相遇点,fast(或者另一个指针)继续每次走一步,绕环一圈回到相遇点时,走过的步数就是环的长度。
cpp复制ListNode *slow = head;
ListNode *fast = head;
while (fast && fast->next) {
slow = slow->next;
fast = fast->next->next;
if (slow == fast) {
int length = 1;
fast = fast->next;
while (fast != slow) {
fast = fast->next;
length++;
}
return length;
}
}
return 0;
注意这里 fast 在相遇点先走一步,然后才进入统计循环,所以 length 初始化为 1,避免漏数一圈。你也可以让 fast 留在原地,slow 走一圈,写法类似。
7.2 变体:如何证明链表无环
这个变体看似简单,但考察代码细节。你只需要用快慢指针从头走到尾,完整走完一遍没有相遇,就说明没有环。判断结束的条件就是 fast 和 fast->next 至少有一个为空。代码里最容易犯的错是只判断 fast != nullptr,然后访问 fast->next->next 时崩溃;或者少判断 fast->next != nullptr,导致快指针走到倒数第二个节点后报错。
无环时,快指针要么停在 null(链表长度为偶数),要么停在最后一个节点(链表长度为奇数),无论哪种情况,循环条件都会正确退出。
7.3 面试现场最常见的翻车点
翻了这么多题解和面试经验,我总结出几个高频翻车点,你现在看到就是赚到。
第一个是公式背不熟。很多人能写出代码,但被问“为什么第二步能从 head 同步走”就懵了。我的建议是把推导过程写成几句话:“慢指针到相遇点走的路程是 a+b,快指针是两倍,所以 a+b 是环长的整数倍,因此从 head 走 a 步和从相遇点走 c 步会在入口碰面。”能流畅说出这段话,面试官一般就不会再深挖。
第二个是把“相遇点”和“入口”搞混。有些同学代码第一段找到了相遇点,直接 return slow,这是错的。相遇点通常不是入口,只是环内的某个位置。你必须跑第二段循环才能找到真正的入口。
第三个是空指针细节。Python 因为运行时检查,往往能避免崩溃但可能返回错误结果;C++ 和 Java 里空指针访问直接崩。建议每次写链表遍历题前,先确认循环条件能否应对节点数为 0、1、2 的边界情况。
第四个是类比混淆。有人会把 Floyd 判圈算法和“哈希表找重复”混在一起,讨论时东拉西扯。这两种思路本质不同,哈希表是空间换时间,双指针是数学推导。答面试题时先说清楚自己用哪种思路,别含糊。
说句实在话,这道题我刷了很多遍,每次给别人讲的时候还是会重新推导一遍公式。它不像有些题背个模板就完事,而是真正需要理解底层逻辑才能写对的题。把这道题吃透之后,你会发现链表类的相关题目,比如求环长度、找链表中点、判断相交链表,思路都变得顺了很多。希望这篇文章能帮你跨过刷题路上这道坎,下次在面试里遇到,能底气十足地说一句:“这题我会,而且我知道每一步为什么这样走。”
