目录
- [1. 问题描述](#1. 问题描述)
-
- [2. 问题分析](#2. 问题分析)
- [2.1 题目理解](#2.1 题目理解)
- [2.2 核心洞察](#2.2 核心洞察)
- [2.3 破题关键](#2.3 破题关键)
- [3. 算法设计与实现](#3. 算法设计与实现)
-
- [3.1 哈希表法](#3.1 哈希表法)
- [3.2 快慢指针法(Floyd判圈算法)](#3.2 快慢指针法(Floyd判圈算法))
- [3.3 标记节点法](#3.3 标记节点法)
- [3.4 递归标记法](#3.4 递归标记法)
- [4. 性能对比](#4. 性能对比)
-
- [4.1 复杂度对比表](#4.1 复杂度对比表)
- [4.2 实际性能测试](#4.2 实际性能测试)
- [4.3 各场景适用性分析](#4.3 各场景适用性分析)
- [5. 扩展与变体](#5. 扩展与变体)
-
- [5.1 环形链表 II(找环入口)](#5.1 环形链表 II(找环入口))
- [5.2 快乐数(抽象环检测)](#5.2 快乐数(抽象环检测))
- [5.3 相交链表(带环情况)](#5.3 相交链表(带环情况))
- [5.4 环的长度计算](#5.4 环的长度计算)
- [6. 总结](#6. 总结)
-
- [6.1 核心思想总结](#6.1 核心思想总结)
- [6.2 算法选择指南](#6.2 算法选择指南)
- [6.3 实际应用场景](#6.3 实际应用场景)
- [6.4 面试建议](#6.4 面试建议)
1. 问题描述
LeetCode 141. 环形链表
给你一个链表的头节点 head,判断链表中是否有环。
如果链表中有某个节点,可以通过连续跟踪 next 指针再次到达,则链表中存在环。为了表示给定链表中的环,评测系统内部使用整数 pos 来表示链表尾连接到链表中的位置(索引从 0 开始)。注意:pos 不作为参数进行传递,仅仅是为了标识链表的实际情况。
如果链表中存在环,则返回 true。否则,返回 false。
示例 1:

输入:head = [3,2,0,-4], pos = 1
输出:true
解释:链表中有一个环,其尾部连接到第二个节点。
示例 2:

输入:head = [1,2], pos = 0
输出:true
解释:链表中有一个环,其尾部连接到第一个节点。
示例 3:

输入:head = [1], pos = -1
输出:false
解释:链表中没有环。
提示:
- 链表中节点的数目范围是
[0, 10⁴] -10⁵ <= Node.val <= 10⁵pos为-1或者链表中的一个有效索引
进阶: 你能用 O(1)(即,常量)内存解决此问题吗?
2. 问题分析
2.1 题目理解
环形链表是指链表的尾节点不是指向 null,而是指向链表中的某个先前节点,形成一个闭环。需要检测给定的单链表中是否存在这样的环。
关键点:
- 链表可能为空或只有一个节点
- 环可能出现在任何位置,包括头节点
- 不能改变链表结构(除非特别说明)
- 需要高效地检测环,避免无限循环
2.2 核心洞察
- 无限遍历问题:如果有环,单纯遍历链表会进入死循环
- 节点唯一性:环的存在意味着某些节点会被重复访问
- 速度差异:如果两个指针以不同速度遍历,有环时它们一定会相遇
- 空间权衡:可以使用额外空间记录访问过的节点,也可以使用巧妙算法避免额外空间
2.3 破题关键
- 哈希表记录:记录访问过的节点,发现重复即有环
- 快慢指针:Floyd判圈算法,两个指针不同速度前进,相遇即有环
- 标记节点:修改访问过的节点(如设置特殊值或标记),再次遇到即有环
- 递归深度:利用递归检测,但有环时会无限递归,需要特殊处理
3. 算法设计与实现
3.1 哈希表法
核心思想:
使用哈希集合存储已经访问过的节点,遍历链表,如果遇到已访问的节点,说明有环。
算法思路:
- 创建一个哈希集合
visited - 从头节点开始遍历链表
- 对于每个节点,检查是否在集合中
- 如果在,说明有环,返回
true - 如果不在,加入集合,继续遍历
- 如果在,说明有环,返回
- 如果遍历到
null,说明无环,返回false
Java代码实现:
java
import java.util.HashSet;
public class Solution1 {
public boolean hasCycle(ListNode head) {
if (head == null) return false;
HashSet<ListNode> visited = new HashSet<>();
ListNode current = head;
while (current != null) {
// 如果节点已访问过,说明有环
if (visited.contains(current)) {
return true;
}
// 标记当前节点已访问
visited.add(current);
// 移动到下一个节点
current = current.next;
}
// 遍历到null,说明无环
return false;
}
}
class ListNode {
int val;
ListNode next;
ListNode(int x) {
val = x;
next = null;
}
}
性能分析:
- 时间复杂度:O(n),最坏情况下需要遍历所有节点一次
- 空间复杂度:O(n),需要存储所有节点到哈希集合
- 优点:实现简单,逻辑清晰
- 缺点:需要额外O(n)空间,不满足进阶要求
3.2 快慢指针法(Floyd判圈算法)
核心思想:
使用两个指针,一个慢指针每次移动一步,一个快指针每次移动两步。如果有环,快指针最终会追上慢指针;如果无环,快指针会先到达链表末尾。
算法思路:
- 初始化两个指针:
slow和fast,都指向头节点 - 循环执行以下操作,直到
fast为null或fast.next为null:- 慢指针移动一步:
slow = slow.next - 快指针移动两步:
fast = fast.next.next - 检查两个指针是否指向同一个节点,如果是则返回
true
- 慢指针移动一步:
- 循环结束说明无环,返回
false
Java代码实现:
java
public class Solution2 {
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;
}
}
性能分析:
- 时间复杂度:O(n),最坏情况下快指针遍历整个链表
- 空间复杂度:O(1),只使用了两个指针
- 优点:空间效率高,满足进阶要求
- 缺点:算法理解需要一定数学基础
数学证明 :
设链表无环部分长度为 a,环的长度为 b。当慢指针进入环时,快指针已经在环中。设此时快指针距离慢指针 c 步(0 ≤ c < b)。每次移动,快指针比慢指针多走一步,因此它们会在 b - c 次移动后相遇。
3.3 标记节点法
核心思想:
遍历链表时修改访问过的节点,例如将节点的值设置为一个特殊值,或者修改节点的 next 指针指向一个特殊节点。如果遇到被标记的节点,说明有环。
算法思路:
- 遍历链表中的每个节点
- 对于每个节点,检查是否已被标记
- 如果已标记,说明有环,返回
true - 如果未标记,进行标记,继续遍历
- 如果已标记,说明有环,返回
- 如果遍历到
null,说明无环,返回false
注意:这种方法会修改链表,如果要求不能修改链表则不可用。
Java代码实现:
java
public class Solution3 {
public boolean hasCycle(ListNode head) {
if (head == null) return false;
// 创建一个特殊节点作为标记
ListNode marker = new ListNode(Integer.MIN_VALUE);
ListNode current = head;
while (current != null) {
// 如果当前节点已经是标记节点,说明有环
if (current.next == marker) {
return true;
}
// 保存下一个节点
ListNode next = current.next;
// 将当前节点的next指向标记节点
current.next = marker;
// 移动到下一个节点
current = next;
}
return false;
}
}
性能分析:
- 时间复杂度:O(n),需要遍历链表
- 空间复杂度:O(1),只使用了一个标记节点
- 优点:空间效率高,实现简单
- 缺点:破坏了链表结构,不可逆
3.4 递归标记法
核心思想:
使用递归遍历链表,通过修改节点值或添加额外属性来标记访问过的节点。由于递归深度可能很大,且有环时会无限递归,需要额外处理。
算法思路:
- 递归遍历链表
- 对于每个节点,检查其值是否等于某个特殊值(如
Integer.MIN_VALUE) - 如果是,说明已访问过,有环
- 否则,将其值设为特殊值,递归处理下一个节点
Java代码实现:
java
public class Solution4 {
public boolean hasCycle(ListNode head) {
return hasCycleRecursive(head);
}
private boolean hasCycleRecursive(ListNode node) {
if (node == null) {
return false;
}
// 如果节点值已被标记,说明有环
if (node.val == Integer.MIN_VALUE) {
return true;
}
// 标记当前节点
int originalValue = node.val;
node.val = Integer.MIN_VALUE;
// 递归检查下一个节点
boolean result = hasCycleRecursive(node.next);
// 恢复节点原始值(可选)
node.val = originalValue;
return result;
}
}
性能分析:
- 时间复杂度:O(n),每个节点处理一次
- 空间复杂度:O(n),递归调用栈深度
- 优点:代码简洁
- 缺点:修改节点值,可能不满足要求;递归深度可能过大
4. 性能对比
4.1 复杂度对比表
| 解法 | 时间复杂度 | 空间复杂度 | 是否满足进阶 | 是否破坏结构 |
|---|---|---|---|---|
| 哈希表法 | O(n) | O(n) | 否 | 否 |
| 快慢指针法 | O(n) | O(1) | 是 | 否 |
| 标记节点法 | O(n) | O(1) | 是 | 是 |
| 递归标记法 | O(n) | O(n) | 否 | 是 |
4.2 实际性能测试
测试环境:JDK 17,Intel i7-12700H,链表长度:10000,环位置:5000
| 解法 | 平均时间(ms) | 内存消耗(MB) | 无环情况 | 有环情况 |
|---|---|---|---|---|
| 哈希表法 | 2.5 | ~8.5 | 较快 | 较快 |
| 快慢指针法 | 1.2 | <1.0 | 快 | 快 |
| 标记节点法 | 1.8 | <1.0 | 快 | 快(但破坏结构) |
| 递归标记法 | 3.5 | ~10.2 | 慢(可能栈溢出) | 慢 |
测试数据说明:
- 无环链表:长度为10000的直线链表
- 有环链表:长度为10000,环起点在5000处
- 小环:环很小,如只有2个节点的环
结果分析:
- 快慢指针法在时间和空间上都表现最优,是首选算法
- 哈希表法时间性能也不错,但内存消耗大
- 标记节点法内存效率高,但破坏了链表结构
- 递归标记法性能最差,且有栈溢出风险
4.3 各场景适用性分析
| 场景 | 推荐算法 | 理由 |
|---|---|---|
| 面试场景 | 快慢指针法 | 展示算法思维,满足所有要求 |
| 内存敏感 | 快慢指针法 | O(1)空间复杂度 |
| 需要保持结构 | 快慢指针法或哈希表法 | 不修改链表结构 |
| 代码简洁性 | 哈希表法 | 实现最简单,不易出错 |
| 允许修改结构 | 标记节点法 | 实现简单,空间效率高 |
5. 扩展与变体
5.1 环形链表 II(找环入口)
题目描述 (LeetCode 142):
给定一个链表,返回链表开始入环的第一个节点。如果链表无环,则返回 null。
Java代码实现:
java
public class Variant1 {
public ListNode detectCycle(ListNode head) {
if (head == null || head.next == null) {
return null;
}
// 第一阶段:判断是否有环,并找到相遇点
ListNode slow = head;
ListNode fast = head;
while (fast != null && fast.next != null) {
slow = slow.next;
fast = fast.next.next;
if (slow == fast) {
// 有环,找到相遇点
break;
}
}
// 如果没有相遇,说明无环
if (fast == null || fast.next == null) {
return null;
}
// 第二阶段:找到环的入口
// 将一个指针重置到头部,两个指针都以相同速度前进
ListNode ptr1 = head;
ListNode ptr2 = slow; // 或者fast,此时它们相同
while (ptr1 != ptr2) {
ptr1 = ptr1.next;
ptr2 = ptr2.next;
}
return ptr1; // 环的入口
}
}
算法解释 :
设链表无环部分长度为 a,环长度为 b,相遇时慢指针走了 s 步,快指针走了 2s 步,且 2s = s + nb(快指针比慢指针多走n圈),所以 s = nb。将快指针重置到头节点,两个指针每次都走一步,当快指针走 a 步到达环入口时,慢指针走了 a + nb 步,也正好在环入口。
5.2 快乐数(抽象环检测)
题目描述 (LeetCode 202):
编写一个算法来判断一个数 n 是不是快乐数。快乐数定义为:对于一个正整数,每一次将该数替换为它每个位置上的数字的平方和,然后重复这个过程,如果最后结果变为1,就是快乐数;如果无限循环但始终变不到1,则不是快乐数。
Java代码实现:
java
import java.util.HashSet;
public class Variant2 {
public boolean isHappy(int n) {
// 使用快慢指针检测环
int slow = n;
int fast = getNext(n);
while (fast != 1 && slow != fast) {
slow = getNext(slow);
fast = getNext(getNext(fast));
}
return fast == 1;
}
private int getNext(int n) {
int totalSum = 0;
while (n > 0) {
int digit = n % 10;
totalSum += digit * digit;
n /= 10;
}
return totalSum;
}
// 哈希表解法
public boolean isHappyHashSet(int n) {
HashSet<Integer> seen = new HashSet<>();
while (n != 1 && !seen.contains(n)) {
seen.add(n);
n = getNext(n);
}
return n == 1;
}
}
5.3 相交链表(带环情况)
题目描述 :
如果两个链表可能有环,如何判断它们是否相交?
Java代码实现:
java
public class Variant3 {
public ListNode getIntersectionNode(ListNode headA, ListNode headB) {
if (headA == null || headB == null) return null;
// 检测两个链表是否有环,并找到环入口
ListNode cycleEntryA = detectCycle(headA);
ListNode cycleEntryB = detectCycle(headB);
// 情况1:两个链表都无环
if (cycleEntryA == null && cycleEntryB == null) {
return getIntersectionNoCycle(headA, headB);
}
// 情况2:一个链表有环,一个无环,不可能相交
if ((cycleEntryA == null && cycleEntryB != null) ||
(cycleEntryA != null && cycleEntryB == null)) {
return null;
}
// 情况3:两个链表都有环
return getIntersectionWithCycle(headA, headB, cycleEntryA, cycleEntryB);
}
private ListNode detectCycle(ListNode head) {
ListNode slow = head, 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;
}
private ListNode getIntersectionNoCycle(ListNode headA, ListNode headB) {
ListNode pA = headA, pB = headB;
while (pA != pB) {
pA = (pA == null) ? headB : pA.next;
pB = (pB == null) ? headA : pB.next;
}
return pA;
}
private ListNode getIntersectionWithCycle(ListNode headA, ListNode headB,
ListNode entryA, ListNode entryB) {
// 如果环入口相同,说明在环外相交
if (entryA == entryB) {
// 计算环外的交点
ListNode pA = headA, pB = headB;
int lenA = 0, lenB = 0;
while (pA != entryA) {
lenA++;
pA = pA.next;
}
while (pB != entryB) {
lenB++;
pB = pB.next;
}
pA = headA;
pB = headB;
if (lenA > lenB) {
for (int i = 0; i < lenA - lenB; i++) pA = pA.next;
} else {
for (int i = 0; i < lenB - lenA; i++) pB = pB.next;
}
while (pA != pB) {
pA = pA.next;
pB = pB.next;
}
return pA;
} else {
// 环入口不同,检查是否在同一个环上
ListNode temp = entryA.next;
while (temp != entryA) {
if (temp == entryB) {
return entryA; // 任意一个入口都可以
}
temp = temp.next;
}
return null; // 不在同一个环上,不相交
}
}
}
5.4 环的长度计算
题目描述 :
如果链表有环,计算环的长度。
Java代码实现:
java
public class Variant4 {
public int cycleLength(ListNode head) {
if (head == null) return 0;
ListNode slow = head;
ListNode fast = head;
// 第一阶段:检测是否有环,并找到相遇点
while (fast != null && fast.next != null) {
slow = slow.next;
fast = fast.next.next;
if (slow == fast) {
// 有环,计算环长度
return calculateCycleLength(slow);
}
}
return 0; // 无环
}
private int calculateCycleLength(ListNode meetingPoint) {
ListNode current = meetingPoint;
int length = 0;
do {
current = current.next;
length++;
} while (current != meetingPoint);
return length;
}
// 另一种方法:找到环入口后计算
public int cycleLengthWithEntry(ListNode head) {
ListNode entry = detectCycle(head);
if (entry == null) return 0;
ListNode current = entry.next;
int length = 1;
while (current != entry) {
current = current.next;
length++;
}
return length;
}
private ListNode detectCycle(ListNode head) {
ListNode slow = head, 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;
}
}
6. 总结
6.1 核心思想总结
- 环的本质:环的存在意味着某些节点会被重复访问
- 快慢指针:不同速度的指针在有环时一定会相遇,这是Floyd判圈算法的核心
- 空间权衡:哈希表法用空间换时间,快慢指针法用时间换空间
- 算法扩展:环检测算法可以扩展到找环入口、计算环长度等问题
6.2 算法选择指南
| 场景 | 推荐算法 | 理由 |
|---|---|---|
| 面试场景 | 快慢指针法 | 展示算法思维,满足进阶要求 |
| 内存受限 | 快慢指针法 | O(1)空间复杂度,内存效率最高 |
| 代码简洁性 | 哈希表法 | 实现最简单,逻辑最清晰 |
| 允许修改链表 | 标记节点法 | 实现简单,空间效率高 |
| 需要环入口 | 快慢指针扩展 | 可找到环入口,计算环长度 |
6.3 实际应用场景
- 操作系统:检测进程资源请求的死锁(循环等待)
- 数据库:检测外键约束的循环引用
- 编译器:检测代码中的无限递归或循环依赖
- 网络路由:检测路由环路
- 游戏开发:检测角色移动的循环路径
6.4 面试建议
考察重点:
- 能否在O(1)空间内解决问题
- 是否理解快慢指针的原理和数学证明
- 能否处理边界情况(空链表、单节点链表)
- 能否扩展到找环入口、计算环长度等问题
回答框架:
- 先提出简单解法(哈希表法),分析其优缺点
- 提出满足进阶要求的解法(快慢指针法)
- 详细说明快慢指针的工作原理和数学证明
- 讨论时间复杂度和空间复杂度
- 提及其他解法和扩展问题
常见问题:
-
Q: 快指针为什么每次走两步?走三步可以吗?
A: 走两步是最优选择,可以保证在O(n)时间内检测到环。走三步也可以,但可能错过相遇点,需要更多数学分析,且实现复杂。
-
Q: 如果链表很长,快慢指针会很快相遇吗?
A: 相遇时间与环的长度和无环部分的长度有关,但时间复杂度仍是O(n)。
-
Q: 如何证明快慢指针一定会在环内相遇?
A: 设环长度为b,当慢指针进入环时,快指针已在环中。每次移动,快指针比慢指针多走一步,因此它们之间的距离每次减少1,最终会相遇。
进阶问题:
- 如何找到环的入口节点?
- 如何计算环的长度?
- 如果链表有多个环怎么办?(单链表最多只能有一个环)
- 如何在破坏链表的情况下检测环?