一、题目是啥?一句话说清
给定一个链表,如果链表有环,返回环的起始节点;如果无环,返回 null。不能修改链表。
二、解题核心
使用快慢指针法:先找到快慢指针的相遇点,然后让一个指针从头开始,另一个从相遇点开始,以相同速度移动,它们再次相遇的点就是环的入口。
这就像两个人在环形跑道上跑步:
- 快的人速度是慢的人的两倍,他们最终会相遇。
- 相遇后,让快的人回到起点,然后两人以相同速度跑步,他们再次相遇的地方就是跑道的入口。
三、关键在哪里?(3个核心点)
想理解并解决这道题,必须抓住以下三个关键点:
1. 快慢指针的相遇点
- 是什么:快指针每次走两步,慢指针每次走一步,如果存在环,它们一定会相遇。
- 为什么重要:相遇点是我们找到环入口的起点。
2. 数学关系推导
- 是什么:从头节点到环入口的距离 = 从相遇点到环入口的距离 + n圈环长。
- 为什么重要:这个数学关系保证了从头节点和相遇点同时出发的两个指针,会在环入口相遇。
3. 第二次移动的同步速度
- 是什么:找到相遇点后,两个指针都以每次一步的速度移动。
- 为什么重要:这样能确保它们正好在环入口相遇,而不是错过。
四、看图理解流程(通俗理解版本)
假设链表:1 → 2 → 3 → 4 → 5 → 3(形成环,5指向3)
-
第一階段:找到相遇点
- 慢指针每次走一步,快指针每次走两步。
- 慢指针路径:1 → 2 → 3 → 4 → 5 → 3 ...
- 快指针路径:1 → 3 → 5 → 4 → 3 → 5 ...
- 它们在节点4相遇(示例中可能相遇在其他点,但总会相遇)。
-
第二階段:找到环入口
- 将快指针重新指向头节点(节点1)。
- 快指针和慢指针都每次走一步:
- 快指针从1开始:1 → 2 → 3
- 慢指针从4开始:4 → 5 → 3
- 它们在节点3相遇,节点3就是环的入口。
五、C++ 代码实现(附详细注释)
#include <iostream>
using namespace std;
// 链表节点定义
struct ListNode {
int val;
ListNode *next;
ListNode(int x) : val(x), next(nullptr) {}
};
class Solution {
public:
ListNode *detectCycle(ListNode *head) {
// 处理空链表或单节点链表的情况
if (head == nullptr || head->next == nullptr) {
return nullptr;
}
// 初始化快慢指针
ListNode *slow = head;
ListNode *fast = head;
// 第一階段:找到相遇点
while (fast != nullptr && fast->next != nullptr) {
slow = slow->next; // 慢指针移动一步
fast = fast->next->next; // 快指针移动两步
if (slow == fast) {
// 找到相遇点,开始第二階段
break;
}
}
// 如果快指针遇到null,说明无环
if (fast == nullptr || fast->next == nullptr) {
return nullptr;
}
// 第二階段:找到环入口
fast = head; // 将快指针重新指向头节点
while (fast != slow) {
fast = fast->next; // 两者都每次移动一步
slow = slow->next;
}
return fast; // 相遇点就是环入口
}
};
// 辅助函数:创建带环的链表用于测试
ListNode* createCycleList() {
ListNode* node1 = new ListNode(1);
ListNode* node2 = new ListNode(2);
ListNode* node3 = new ListNode(3);
ListNode* node4 = new ListNode(4);
ListNode* node5 = new ListNode(5);
node1->next = node2;
node2->next = node3;
node3->next = node4;
node4->next = node5;
node5->next = node3; // 形成环:5指向3
return node1;
}
// 测试代码
int main() {
Solution solution;
// 测试有环的情况
ListNode* cycleList = createCycleList();
ListNode* entry = solution.detectCycle(cycleList);
if (entry != nullptr) {
cout << "环的入口节点值: " << entry->val << endl;
} else {
cout << "无环" << endl;
}
// 注意:实际使用时需要释放内存,这里简化示例
return 0;
}
六、注意事项
- 空指针检查:在移动指针时,始终检查指针是否为null,避免空指针异常。
- 无环处理:如果快指针遇到null,说明无环,直接返回null。
- 数学理解:理解为什么第二次移动能找到环入口是关键,但不要求严格证明,掌握直觉即可。
- 内存管理:在实际应用中,如果需要创建测试链表,记得释放内存,但面试中通常更关注算法逻辑。
七、总结
理解此题的关键在于:
- 快慢指针相遇:首先确认链表有环,并找到相遇点。
- 重新出发:让一个指针从头开始,另一个从相遇点开始,以相同速度移动。
- 相遇即入口:它们再次相遇的点就是环的入口。
掌握这三点,你就能高效解决环形链表入口问题。这道题是快慢指针法的进阶应用,也是面试中常见的高频题目。多练习几次,理解其中的数学直觉,就能熟练运用。
数学原理(为什么第二次相遇就是入口?)
这是最关键的部分,我们来解释下。
我们定义几个点:
A: 链表起点。B: 环的入口(也是我们想找的点)。C: 快指针和慢指针第一次相遇的点。
再定义几个距离:
AB=a:从起点到环入口的距离。BC=b:从环入口到第一次相遇点的距离。环的周长=c。
第一次相遇时,它们跑了多少?
-
慢指针跑的路程:
S_slow = a + b- (从A到B是
a,再从B到C是b)
- (从A到B是
-
快指针跑的路程:
S_fast = a + b + n * c- 它已经跑完了
a+b,并且还在环里额外绕了n圈(n是大于等于1的整数)。
- 它已经跑完了
关键关系:因为快指针速度是慢指针的两倍,所以在相同时间内,快指针跑的路程是慢指针的两倍。
S_fast = 2 * S_slow
代入公式:
a + b + n * c = 2 * (a + b)
现在我们解这个方程:
a + b + n * c = 2a + 2b
n * c = a + b
a = n * c - b
这个 a = n * c - b 就是整个问题的灵魂!
它意味着什么?
n * c - b可以理解为:从相遇点C出发,走n*c - b步,会到达哪里?n*c是绕环n圈,等于没动,还是在C点。-b的意思是 从C点倒退回b步。- 从
C点退回b步,正好就回到了环的入口B! - (
BC = b,所以CB就是-b)
- 从
所以,a = n * c - b 意味着:从起点A到入口B的距离a,等于从相遇点C倒退b步的距离。
现在再看我们第二阶段的操作:
- 快指针被放回起点A,它离入口B的距离正好是
a。 - 慢指针留在相遇点C,它离入口B的距离是
c - b(因为从C点再走c-b步也能绕回B点,但注意我们的公式是a = n*c -b,对于慢指针来说,n=1时,它离入口的距离就是c - b)。
让它们现在都以每次一步的速度前进:
- 快指针从A点出发,走
a步后,恰好到达B点(环的入口)。 - 慢指针从C点出发,走多少步能到B点呢?它需要走
c - b步。- 但是看我们的公式
a = n*c - b。如果n=1,那么a = c - b。慢指针走a(即c-b)步正好也到B点。 - 如果
n>1,比如n=2,a = 2c - b。快指针需要走a(即2c-b) 步到B点。慢指针从C点走2c - b步,相当于在环里绕了2圈再倒退b步,最终停留的位置依然是B点!
- 但是看我们的公式
结论:无论n是多少,快指针和慢指针以相同速度同时出发,它们都将在走a步后,在环的入口点B相遇!