快慢指针
题目链接:
这两道题是链表专题中最经典的"快慢指针"应用,也是面试中出镜率极高的题目。141 只要求判断是否有环 ,142 进一步要求找到环的入口点。两题一脉相承,本文合在一起讲解。
第一部分:LeetCode 141. 环形链表 I
1. 题目描述
给你一个链表的头节点 head,判断链表中是否有环。如果链表中存在某个节点,可以通过连续跟踪 next 指针再次到达,则链表中存在环。返回 true 或 false。
示例:head = [3,2,0,-4], pos = 1(尾节点连向下标为1的节点)
输出:true
2. 思路一:哈希表(先说,作为对比)
最直观的想法:遍历链表,把每个节点的地址 存入哈希表(unordered_set),如果遍历中遇到了已经存在哈希表中的地址,说明有环;如果走到了 nullptr,说明无环。
cpp
class Solution {
public:
bool hasCycle(ListNode *head) {
unordered_set<ListNode*> seen;
while (head) {
if (seen.count(head)) return true;
seen.insert(head);
head = head->next;
}
return false;
}
};
- 时间复杂度:O(N)
- 空间复杂度:O(N) ------ 这是它的硬伤。面试时这个解法是"保底答案",说完之后面试官一定会追问:"能不能用 O(1) 空间?"------答案就是下面的快慢指针。
3. 思路二:快慢指针(Floyd 判环算法)
3.1 算法描述
快慢指针 :慢指针
slow一次走一步,快指针fast一次走两步,两个指针从链表起始位置同时开始运行。
- 如果链表带环 ,两个指针最终一定会在环中相遇;
- 如果链表不带环 ,快指针会率先走到链表的末尾(
fast或fast->next为nullptr)。
形象理解:这就像在环形操场上跑步,一个快一个慢------只要跑道是环形的,快的人一定会从后面追上(套圈)慢的人;如果跑道是直线,快的人跑完就结束了,两人永远不会"相遇"。
3.2 代码实现
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) {
ListNode* slow = head;
ListNode* fast = head;
while (fast && fast->next) // fast 能走两步才继续
{
slow = slow->next; // 慢指针走一步
fast = fast->next->next; // 快指针走两步
if (slow == fast) // 相遇,说明有环
return true;
}
return false; // fast 走到末尾,无环
}
};
3.3 复杂度分析
- 时间复杂度:O(N)。无环时 fast 走到末尾,约 N/2 次迭代;有环时,slow 入环后一圈之内必被追上,总迭代次数不超过 O(N)。
- 空间复杂度 :O(1)。只用了两个指针。
3.4 代码细节说明
- 循环条件是
fast && fast->next,不是slow。因为快指针走得快、先"踩空"。只要fast和fast->next都不为空,fast->next->next就是安全的。slow 永远不会先越界,无需检查。 - 判断相等在移动之后。初始时 slow == fast == head,如果循环开始就判断会误判。把判断放在移动之后(或采用 do-while 结构)即可避开。
- 比较的是指针地址,不是节点值 。环的定义是"通过 next 能再次到达同一个节点",判断的是地址相等
slow == fast,不是slow->val == fast->val。
4. 面试必考追问:为什么一定能相遇?(数学证明)
这是面试官最爱挖的点。下面给出完整推理。
4.1 思考一:快指针走 2 步、慢指针走 1 步,有没有可能永远遇不上?
结论:不可能,一定相遇。
推理过程:
fast 比 slow 快,所以 fast 先进环 。当 slow 走完入环前的距离、即将进环的那一刻,fast 已经在环中某个位置了。设此时两者之间的距离为 N(沿前进方向,slow 落后 fast N 步,N 不超过环的周长)。
接下来进入"追逐阶段":
每移动一次:
slow 前进 1 步
fast 前进 2 步
两者距离缩小 1 步 (fast 每轮比 slow 多走 1 步)
距离变化:N → N-1 → N-2 → ... → 2 → 1 → 0(相遇)
距离每轮恰好缩小 1 ,必然在某一轮精确地缩小到 0,不存在"跨过"的可能。所以走 2 步 vs 走 1 步必定相遇。✅
4.2 思考二:快指针一次走 3 步、4 步......n 步,还行吗?
先说结论:理论上仍然可以相遇,但证明更复杂,且代码实现更繁琐,所以工程中统一用"1 步 vs 2 步"。
下面按 PDF 的思路分两步证明"走 3 步"的情形。
Step 1:距离变化分析
slow 每次走 1 步,fast 每次走 3 步,每移动一次,距离缩小 2 步。设 slow 刚入环时两者距离为 N,环的周长为 C:
距离变化:N → N-2 → N-4 → ...
情况 1:N 是偶数 → 距离依次减 2,第一轮就精确到 0,追上了。✅
情况 2:N 是奇数 → 距离减到 1 后,下一轮变成 -1,
即 fast 越过了 slow 一个身位,相当于新距离为 C-1,进入新一轮追逐:
a. C-1 是偶数 → 下一轮追上了 ✅
b. C-1 是奇数 → 每一轮都错过,永远追不上 ❌
表面上,追不上的条件是:N 是奇数 且 C 是偶数。但 Step 2 会证明这个前提根本不可能出现。
Step 2:证明"N 为奇数、C 为偶数"的情况不存在
设:环的周长为 C,头节点到环入口的距离为 L,slow 走 1 步、fast 走 3 步。
注意一个关键事实:N 与 C 的奇偶关系不是从总路程方程推出来的 (总路程中 fast 已走的 N 与后来要补的 C−N 会相互抵消,式子里根本不出现 N),而是从 fast 此刻的位置推出来的:
slow 走到入口时共走了 L 步,同一时间段内 fast 走了 3L 步,fast 恰好领先 slow 2L 步。这 2L 步的领先折算到环上,就是 fast 在环中的位置 N:
2L = q·C + N (领先 2L 步 = 绕 q 整圈 + 环上偏移 N,q ≥ 0)
现在分析奇偶性:等式左边 2L 一定是偶数 ,所以右边 q·C + N 也必须是偶数。可能的组合只有:
- 偶数 = 偶数 + 偶数
- 偶数 = 奇数 + 奇数
若 C 是偶数,则 q·C 是偶数,N 必然也是偶数------"C 偶、N 奇"的组合在结构上根本不可能出现。对照 Step 1 的结论:
- C 偶 ⇒ N 偶 → 对应 Step 1 的情况 1,第一轮就追上;
- N 奇 ⇒ C 奇 → 对应 Step 1 的 2-a,下一轮追上。
也就是说,Step 1 中唯一"永远追不上"的前提(N 奇、C 偶)根本不存在。所以快指针走 3 步最终也一定相遇。✅
💡 补充:相遇时的总路程方程为
2(L + m) = x·C(m 为 slow 入环后走的距离,x 为 fast 多绕的整圈数)------其中 fast 追逐前已走的 N 与追逐中要补的 C−N 相互抵消,因此式中不出现 N。该方程与位置关系2L = qC + N相减,即得讲义中常见的2L = (x−q)·C − N形式,二者是等价的。
快指针走 4、5......n 步的证明方式同上(相对速度 k−1 与周长的奇偶/整除分析),结论都是必定相遇。
💡 提示 :虽然已经证明快指针走几步都能相遇,但走 3 步以上在写代码时要额外处理"每步移动前检查空指针"的问题(如 PDF 示例代码中用一个内层循环逐位移动 fast,每次检查
fast->next),代码更繁琐、更易出错。因此涉及快慢指针的算法题,一律习惯使用"慢指针 1 步、快指针 2 步"的写法------相对速度为 1,逐格逼近,逻辑最干净。
第二部分:LeetCode 142. 环形链表 II
1. 题目描述
给定一个链表的头节点 head,如果链表中有环,返回开始入环的第一个节点 ;如果无环,返回 null。
示例:head = [3,2,0,-4], pos = 1
输出:返回索引为 1 的节点(值为 2 的那个节点)
2. 思路一:哈希表
和 141 题一样,遍历链表,第一个重复出现的节点地址就是环的入口。
cpp
class Solution {
public:
ListNode *detectCycle(ListNode *head) {
unordered_set<ListNode*> seen;
while (head) {
if (seen.count(head)) return head; // 第一个重复的节点即入口
seen.insert(head);
head = head->next;
}
return nullptr;
}
};
- 时间 O(N),空间 O(N)。同样是保底答案,面试官会追问 O(1) 空间解法。
3. 思路二:快慢指针 + 数学结论
3.1 算法分两步
第一步 :先用 141 题的快慢指针判断是否有环,并记录相遇点 (无环则直接返回 null)。
第二步:利用下面的核心结论找入口。
💡 结论 :让一个指针从链表起始位置 开始遍历,同时让另一个指针从判环时的相遇点 开始绕环运行,两个指针每次都只走一步 ,最终它们一定会在环的入口点相遇。
3.2 结论的证明(重点,面试必问)
符号设定(建议自己在纸上画一个带环链表对照):
H ------------ L ------------ E ------------ X ------------ M
起点 入口点 相遇点
←--------- R-X ------------
(M 沿环回到 E 的距离)
H:链表起始点
E:环的入口点
M:快慢指针的相遇点
L:H 到 E 的距离
X:E 到 M 的距离(沿环前进方向)
R:环的周长
则 M 到 E 的距离 = R - X
列方程:判环时,相遇点处两指针走过的路程:
slow 的路程: L + X
fast 的路程: L + X + n·R
这里有两个要点需要说明:
- fast 可能已经在环里绕了 n 圈(n ≥ 1)。因为 fast 先进环,它可能已经在环中转了好几圈之后,才在 M 点与 slow 相遇。
- slow 进环之后,fast 一定在 slow 走完一圈之内追上它 。因为 slow 进环瞬间,两者的距离最多就是一个环长 R,而每移动一次距离缩小 1 步,所以 slow 最多走 R 步(一圈)就会被追上。因此 slow 的路程就是
L + X,不会多绕圈。
fast 的速度是 slow 的 2 倍,路程也是 2 倍关系:
2(L + X) = L + X + nR
化简:
L + X = nR
L = nR - X
L = (n - 1)·R + (R - X) (n = 1, 2, 3...,n 的大小取决于环的大小,环越小 n 越大)
解读最后一个式子:
L------ 一个指针从起点 H 走到入口 E 的距离;(n-1)·R + (R-X)------ 一个指针从相遇点 M 出发,先走R-X步正好到达入口 E,再多绕n-1整圈,还是在入口 E。
极端情况最好理解:假设 n = 1,则 L = R - X,即"从 H 到 E 的距离"恰好等于"从 M 到 E 的距离"。
所以:一个指针从链表头出发,另一个指针从相遇点出发,都每次走一步,必然在入口点相遇(从相遇点出发的指针可能多绕了几圈,但相遇位置一定是入口)。✅
3.3 代码实现
cpp
/**
* Definition for singly-linked list.
* struct ListNode {
* int val;
* ListNode *next;
* ListNode(int x) : val(x), next(NULL) {}
* };
*/
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* p1 = head;
ListNode* p2 = slow; // 相遇点
while (p1 != p2)
{
p1 = p1->next;
p2 = p2->next;
}
return p1; // 相遇处即环的入口
}
}
return nullptr; // 无环
}
};
3.4 复杂度分析
- 时间复杂度:O(N)。第一阶段判环 O(N),第二阶段两指针各走不超过 L + R 步,整体仍是 O(N)。
- 空间复杂度 :O(1)。只用了常数个指针。
第三部分:面试官可能追问的问题汇总
这部分把两题相关的常见面试追问整理成问答,建议每条都能不看书复述。
Q1:为什么用快慢指针?哈希表不行吗?
哈希表可以,但空间复杂度是 O(N)。快慢指针把空间压到 O(1),时间不变。这是本系列题的核心考点------空间优化。
Q2:为什么 slow 进环后,fast 一定能在一圈之内追上它?
slow 进环瞬间,fast 与 slow 的距离最多为环长 R。此后每轮距离恰好缩小 1(相对速度为 1),所以最多 R 轮内距离归零。slow 走 R 步刚好一圈,故一圈之内必被追上。
Q3:快指针走 2 步,有没有可能"跳过"慢指针而永远不相遇?
不会。相对速度为 1,距离按 N, N−1, N−2, ..., 1, 0 逐格缩小,不存在跨越。这正是选"1 步 vs 2 步"而不是其他步数组合的根本原因------相对速度 1 保证逐格逼近。(走 3 步虽然理论上也能相遇,但证明复杂且代码要逐位检查空指针,工程上不用。)
Q4:142 题中,为什么从 head 和相遇点同步出发会在入口相遇?
复述推导:相遇时 2(L+X) = L+X+nR,化简得 L = (n−1)R + (R−X)。从头走 L 步到入口,从相遇点走 R−X 步加 n−1 圈也正好到入口,两者步数相同、同步出发,故在入口相遇。这个公式建议能默写推导。
Q5:142 题中第二阶段,从相遇点出发的指针为什么要"绕 n−1 圈"?
因为 fast 在 slow 入环前可能已经在环中绕了 n 圈。环越小、入环前路径 L 越长,n 就越大。结论式中的 (n−1)R 正是补偿这部分------无论 n 是多少,最终相遇位置都是入口。
Q6:边界情况怎么处理?
- 空链表 / 单节点无环 :
while (fast && fast->next)第一次就不成立,直接返回 false / nullptr,天然处理。 - 单节点自环 (
head->next == head):slow 走一步还在 head,fast 走两步也回到 head,相遇,正确判环。 - 两个节点的环:同上,一两轮内即相遇。
- 环入口就是头节点(L = 0):第二阶段 p1 从 head 出发不动也能在 head 处相遇......实际上 p1、p2 会各自走 R 步后在 head 相遇,结果正确。
Q7:判断环时比较 slow == fast 还是比较 val?
比较指针地址。环是结构上的概念(next 指针指回了前面的节点),与节点存的值无关。链表中完全可能有重复的 val。
Q8:第二阶段找入口时,会不会出现 p2 走得太快错过入口?
不会。第二阶段两个指针都走一步,速度相同,是相对静止的追逐------距离固定为 L 与 R−X 的差值对应的环上偏移,由公式保证恰好在入口点汇合。
Q9:这两个算法叫什么名字?有出处吗?
即 Floyd 判圈算法(Floyd's Cycle-Finding Algorithm),也叫"龟兔赛跑算法"(Tortoise and Hare)。面试中说出这个名字会加分。
Q10:快慢指针还能解决哪些链表题?(举一反三)
同一个"快慢指针"思想还能解决:
| 题目 | 快慢指针的用法 |
|---|---|
| 链表的中间节点(LeetCode 876) | fast 走 2 步、slow 走 1 步,fast 到尾时 slow 在中点 |
| 删除链表的倒数第 N 个结点(LeetCode 19) | fast 先走 N 步,再与 slow 同步走,fast 到尾时 slow 在倒数第 N+1 个 |
| 回文链表(LeetCode 234) | 先用快慢指针找中点,再反转后半段比较 |
| 相交链表(LeetCode 160) | 可借用"是否成环/双指针同步"思想 |
| 环形链表 I / II(本文) | 判环 + 找入口 |
总结
| LeetCode 141 | LeetCode 142 | |
|---|---|---|
| 问题 | 是否有环 | 环的入口在哪 |
| 核心方法 | 快慢指针(Floyd 判圈) | 先判环,再双指针同步找入口 |
| 关键结论 | 相对速度为 1,环中必相遇 | L = (n−1)R + (R−X),头指针与相遇点指针在入口相遇 |
| 时间复杂度 | O(N) | O(N) |
| 空间复杂度 | O(1) | O(1) |
| 面试加分项 | 证明必相遇;讨论走 3 步的情形 | 默写公式推导;说明 n 的含义 |
记忆口诀:"一快一慢必相遇,一头一遇同步走,相逢之处即入口。"