一、问题理解
问题描述
给你一个链表的头节点 head,判断链表中是否有环。
如果链表中有某个节点,可以通过连续跟踪 next 指针再次到达,则链表中存在环。为了表示给定链表中的环,评测系统内部使用整数 pos 来表示链表尾连接到链表中的位置(索引从 0 开始)。注意:pos 不作为参数进行传递,仅仅是为了标识链表的实际情况。
如果链表中存在环,则返回 true;否则,返回 false。
示例
示例 1:
text
输入:head = [3,2,0,-4], pos = 1
输出:true
解释:链表中有一个环,其尾部连接到第二个节点。
(图:节点 -4 的 next 指向节点 2)
示例 2:
text
输入:head = [1,2], pos = 0
输出:true
解释:链表中有一个环,其尾部连接到第一个节点。
示例 3:
text
输入:head = [1], pos = -1
输出:false
解释:链表中没有环。
要求
-
时间复杂度: O(n)
-
空间复杂度: 哈希表解法 O(n),快慢指针解法 O(1)
-
链表不可修改
二、核心思路:如何检测环?
基本思想
检测链表是否有环的核心问题是:在遍历过程中,如何判断一个节点是否已经被访问过?
有两种主流思路:
-
哈希表法(额外空间):记录每个访问过的节点,如果再次遇到,说明有环。
-
快慢指针法(Floyd 判圈算法):使用两个速度不同的指针遍历链表,如果存在环,快指针终将追上慢指针。
两种方法对比
| 方法 | 空间复杂度 | 优点 | 缺点 |
|---|---|---|---|
| 哈希表 | O(n) | 直观,容易理解 | 需要额外空间 |
| 快慢指针 | O(1) | 空间最优,符合进阶要求 | 需要理解"相遇"原理 |
三、代码逐行解析
方法一:哈希表法(使用额外空间)
Python 解法
python
# Definition for singly-linked list.
class ListNode:
def __init__(self, x):
self.val = x
self.next = None
class Solution:
def hasCycle(self, head: ListNode) -> bool:
# 使用集合存储已经访问过的节点
seen = set()
curr = head
while curr:
# 如果当前节点已经在集合中,说明有环
if curr in seen:
return True
seen.add(curr)
curr = curr.next
# 遍历结束未重复,说明无环
return False
Java 解法
java
/**
* Definition for singly-linked list.
* class ListNode {
* int val;
* ListNode next;
* ListNode(int x) {
* val = x;
* next = null;
* }
* }
*/
public class Solution {
public boolean hasCycle(ListNode head) {
// 使用哈希表存储访问过的节点
Set<ListNode> seen = new HashSet<>();
ListNode curr = head;
while (curr != null) {
// 如果当前节点已在集合中,返回true
if (seen.contains(curr)) {
return true;
}
seen.add(curr);
curr = curr.next;
}
return false;
}
}
方法二:快慢指针法(最优解,O(1) 空间)
核心原理
-
定义两个指针:
slow每次走一步,fast每次走两步。 -
如果链表无环,
fast会先到达末尾(null)。 -
如果链表有环,
fast最终会在环中追上slow(相遇)。
Python 解法
python
class Solution:
def hasCycle(self, head: ListNode) -> bool:
# 初始化快慢指针
slow = head
fast = head
# 快指针每次走两步,所以需要保证 fast 和 fast.next 不为空
while fast and fast.next:
slow = slow.next # 慢指针走一步
fast = fast.next.next # 快指针走两步
# 如果相遇,说明有环
if slow == fast:
return True
# 循环结束说明 fast 到达末尾,无环
return False
Java 解法
java
public class Solution {
public boolean hasCycle(ListNode head) {
if (head == null || head.next == null) {
return false;
}
ListNode slow = head;
ListNode fast = head;
while (fast != null && fast.next != null) {
slow = slow.next;
fast = fast.next.next;
if (slow == fast) {
return true;
}
}
return false;
}
}
四、Java 与 Python 语法对比
| 操作 | Java | Python |
|---|---|---|
| 链表节点定义 | class ListNode { int val; ListNode next; ListNode(int x) { val = x; } } |
class ListNode: def __init__(self, x): self.val = x; self.next = None |
| 空值判断 | node == null |
node is None |
| 集合/哈希表 | Set<ListNode> set = new HashSet<>(); set.add(node); set.contains(node); |
set_ = set() set_.add(node) node in set_ |
| 循环条件 | while (fast != null && fast.next != null) |
while fast and fast.next: |
五、实例演示
示例:head = [3,2,0,-4],环的入口为节点 2(索引 1)
链表结构:3 → 2 → 0 → -4 → 2(回到节点 2)
哈希表法过程
-
遍历节点 3,加入集合
-
遍历节点 2,加入集合
-
遍历节点 0,加入集合
-
遍历节点 -4,加入集合
-
再次访问节点 2,发现已在集合中 → 返回
true
快慢指针法过程
| 步骤 | slow 位置 | fast 位置 | 说明 |
|---|---|---|---|
| 初始 | 3 | 3 | 同时出发 |
| 1 | 2 | 0 | slow 走一步到 2,fast 走两步到 0 |
| 2 | 0 | 2 | slow 到 0,fast 到 2 |
| 3 | -4 | -4 | slow 到 -4,fast 到 -4,相遇!→ 有环 |
六、关键细节解析
1. 为什么快慢指针一定会相遇?
假设链表有环,当 slow 进入环时,fast 已经在环内某处。此时相当于 fast 在追赶 slow,由于速度差为 1(每步相对靠近 1),fast 必然在有限步数内追上 slow。
2. 快慢指针的初始化为什么可以相同?
初始化都指向 head,然后立即开始移动。即使链表有环,第一次移动后它们就会分开。也可以初始化 slow = head, fast = head.next,但需要额外处理边界,不如统一初始化简单。
3. 循环条件为什么是 while fast and fast.next?
-
fast检查:防止链表为空或fast走到末尾(无环情况)。 -
fast.next检查:因为fast一次走两步,必须确保下一步存在,否则会引发空指针异常。
4. 如何处理空链表或单节点链表?
-
空链表:
head == null,直接返回false。 -
单节点且无环:
head.next == null,快慢指针循环条件不满足,返回false。 -
单节点自环:此时
head.next = head,快慢指针会相遇吗?初始slow = head, fast = head,循环中slow = head, fast = head.next = head,slow == fast为true,返回true,正确。
5. 哈希表法中为什么用集合而不是列表?
集合的查找时间复杂度为 O(1),而列表的 in 操作是 O(n),会使得整体复杂度变为 O(n²),不符合要求。
七、复杂度分析
方法一:哈希表法
-
时间复杂度: O(n),每个节点最多访问一次,集合查找 O(1)。
-
空间复杂度: O(n),最坏情况下存储所有节点。
方法二:快慢指针法
-
时间复杂度: O(n)
-
无环:
fast遍历完链表,约 n/2 步 → O(n) -
有环:相遇时慢指针走的步数不超过环长度 + 非环部分长度,总体 O(n)
-
-
空间复杂度: O(1),只使用了两个指针。
八、其他解法
解法三:标记节点法(修改链表,不推荐)
遍历链表,将每个节点的 next 指向一个特殊标记(如自身),如果遇到标记过的节点,说明有环。但这种方法会破坏原链表结构,不符合题目"链表不可修改"的隐含要求。
python
def hasCycle(self, head):
dummy = ListNode(0) # 标记节点
curr = head
while curr:
if curr.next == dummy: # 遇到过标记
return True
next_node = curr.next
curr.next = dummy # 标记当前节点
curr = next_node
return False
解法四:反转指针法(不推荐)
反转链表的过程中,如果遇到已经反转过的节点(即回到了之前的节点),说明有环。但反转会破坏链表,同样不可取。
九、常见问题与解答
Q1: 为什么快慢指针的步长必须一个是1,一个是2?可以是其他步长吗?
A1: 可以,但必须保证速度差为1(相对速度),才能确保在有限步内相遇。例如 slow 走1,fast 走3,相对速度为2,理论上也能相遇,但需要更精确的数学条件,且可能错过某些环(如环长度为奇数时可能永远追不上)。最常见的设定是1和2,简单且保证相遇。
Q2: 如何找到环的入口?
A2: 这是该问题的进阶(LeetCode 142)。当快慢指针相遇后,将其中一个指针移回 head,然后两者以相同速度(每次一步)前进,再次相遇的点即为环入口。
Q3: 如果链表很长,快慢指针会有溢出风险吗?
A3: 不会,指针只存储地址,不涉及长度计算。
Q4: 哈希表法中,节点对象作为键是否可行?
A4: 可行。在 Python 中,对象是哈希的(只要类定义了 __hash__ 和 __eq__),默认使用 id 作为哈希值,可以唯一标识节点。Java 中 HashSet 存储对象引用,也是基于内存地址,因此能正确判断是否重复访问。
十、相关题目
1. LeetCode 142. 环形链表 II
题目: 找到环的入口节点,并证明快慢指针相遇后,一个指针从头开始,另一个从相遇点开始,再次相遇即为入口。
python
def detectCycle(self, head):
slow = fast = head
while fast and fast.next:
slow = slow.next
fast = fast.next.next
if slow == fast:
# 相遇,找入口
slow = head
while slow != fast:
slow = slow.next
fast = fast.next
return slow
return None
2. LeetCode 160. 相交链表
题目: 找到两个无环链表的相交节点。思路是双指针分别遍历两个链表,走到末尾后切换到另一个链表头,相遇点即为相交点。这类似于快慢指针的变种。
3. LeetCode 287. 寻找重复数
题目: 给定一个包含 n+1 个整数的数组,数字范围 1~n,至少有一个重复数。要求不修改数组且 O(1) 空间。该题可以转化为环形链表 II 的问题:将数组索引视为节点,值视为 next 指针。
4. LeetCode 202. 快乐数
题目: 判断一个数是否为快乐数(最终能否变为 1)。也可以使用快慢指针检测循环。
十一、总结
核心要点
-
检测环的本质: 判断是否再次访问到已遍历过的节点。
-
最优解法: 快慢指针(Floyd 判圈算法),时间复杂度 O(n),空间复杂度 O(1)。
-
关键操作: 使用两个速度不同的指针,利用"相遇"条件判断环的存在。
算法步骤(快慢指针)
-
初始化
slow = fast = head。 -
循环条件:
fast != null且fast.next != null。 -
移动:
slow = slow.next,fast = fast.next.next。 -
如果
slow == fast,返回true(有环)。 -
循环结束,返回
false(无环)。
复杂度对比
| 解法 | 时间复杂度 | 空间复杂度 | 是否修改链表 |
|---|---|---|---|
| 哈希表 | O(n) | O(n) | 否 |
| 快慢指针 | O(n) | O(1) | 否 |
| 标记节点 | O(n) | O(1) | 是(破坏链表) |
扩展思考
快慢指针是链表问题中非常强大的工具,不仅能检测环,还能用于:
-
找链表中点(LeetCode 876)
-
找环入口(LeetCode 142)
-
判断回文链表(配合反转)
掌握快慢指针,相当于掌握了链表中"追及问题"的核心。环的检测是其中最简单也是最重要的应用,务必深刻理解其原理和代码实现。