链表环问题:快慢指针的经典应用
链表环问题是算法面试的必考题,也是快慢指针技巧的经典应用。
一句话理解链表环
链表环就是链表中的一个节点指向了前面的某个节点,形成了循环。
正常链表:1 → 2 → 3 → 4 → 5 → null
有环链表:1 → 2 → 3 → 4 → 5
↑ ↓
←←←←←←←←←←
链表环的三种经典问题
| 问题 | 描述 | 解法 |
|---|---|---|
| 1. 判断是否有环 | 检测链表中是否存在环 | 快慢指针 |
| 2. 找到环入口 | 找到环开始的节点 | 快慢指针 + 数学推导 |
| 3. 计算环长度 | 计算环中节点的数量 | 快慢指针相遇后计数 |
核心算法:快慢指针(Floyd判环算法)
算法思想
// 两个指针,一快一慢
// 慢指针每次走1步,快指针每次走2步
// 如果有环,快指针一定会追上慢指针(相遇)
// 如果没环,快指针会先到达null
可视化理解
有环情况:
初始:快、慢指针都在起点
步骤1:慢走1步,快走2步
步骤2:慢走1步,快走2步
...
最终:快指针会从后面追上慢指针,两者相遇
无环情况:
快指针会先走到null
代码实现
1. 定义链表节点
class ListNode {
int val;
ListNode next;
ListNode(int val) {
this.val = val;
this.next = null;
}
ListNode(int val, ListNode next) {
this.val = val;
this.next = next;
}
}
2. 问题1:判断链表是否有环
public class LinkedListCycle {
/**
* 方法1:使用快慢指针判断是否有环
* 时间复杂度:O(n)
* 空间复杂度:O(1)
*/
public boolean hasCycle(ListNode head) {
// 边界条件:空链表或只有一个节点
if (head == null || head.next == null) {
return false;
}
ListNode slow = head; // 慢指针,每次走1步
ListNode fast = head; // 快指针,每次走2步
while (fast != null && fast.next != null) {
slow = slow.next; // 慢指针走1步
fast = fast.next.next; // 快指针走2步
if (slow == fast) { // 快慢指针相遇,说明有环
return true;
}
}
return false; // 快指针走到null,说明无环
}
/**
* 方法2:使用HashSet(空间复杂度O(n))
* 思路:遍历链表,将节点存入Set,如果遇到重复节点则有环
*/
public boolean hasCycleWithHashSet(ListNode head) {
Set<ListNode> visited = new HashSet<>();
ListNode current = head;
while (current != null) {
if (visited.contains(current)) {
return true; // 遇到重复节点,有环
}
visited.add(current);
current = current.next;
}
return false; // 遍历完没有重复,无环
}
// 测试用例
public static void main(String[] args) {
LinkedListCycle solution = new LinkedListCycle();
// 创建有环链表:1→2→3→4→5→3(形成环)
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
System.out.println("有环链表检测结果: " + solution.hasCycle(node1)); // true
// 创建无环链表:1→2→3→4→5→null
ListNode node6 = new ListNode(1);
ListNode node7 = new ListNode(2);
ListNode node8 = new ListNode(3);
ListNode node9 = new ListNode(4);
ListNode node10 = new ListNode(5);
node6.next = node7;
node7.next = node8;
node8.next = node9;
node9.next = node10;
node10.next = null;
System.out.println("无环链表检测结果: " + solution.hasCycle(node6)); // false
}
}
3. 问题2:找到环的入口节点
public class LinkedListCycleII {
/**
* 找到环的入口节点
* 步骤:
* 1. 用快慢指针判断是否有环,并找到相遇点
* 2. 将慢指针放回头节点,快指针留在相遇点
* 3. 两个指针每次都走1步,再次相遇的点就是环入口
*
* 数学原理:
* 设头节点到环入口距离为 a
* 环入口到相遇点距离为 b
* 相遇点回到环入口距离为 c
* 快指针走的路程是慢指针的2倍:2(a+b) = a + n(b+c) + b
* 推导出:a = c + (n-1)(b+c)
* 所以从头节点和相遇点同时出发,一定会在环入口相遇
*/
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;
}
// 第二步:找环入口
// 慢指针回到头节点,快指针留在相遇点
// 两个指针都每次走1步
slow = head;
while (slow != fast) {
slow = slow.next;
fast = fast.next;
}
return slow; // 相遇点就是环入口
}
/**
* 使用HashSet找到环入口
*/
public ListNode detectCycleWithHashSet(ListNode head) {
Set<ListNode> visited = new HashSet<>();
ListNode current = head;
while (current != null) {
if (visited.contains(current)) {
return current; // 第一个重复的节点就是环入口
}
visited.add(current);
current = current.next;
}
return null; // 无环
}
// 测试
public static void main(String[] args) {
LinkedListCycleII solution = new LinkedListCycleII();
// 创建有环链表:1→2→3→4→5→3(环入口是节点3)
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; // 形成环
ListNode entry = solution.detectCycle(node1);
if (entry != null) {
System.out.println("环入口节点值: " + entry.val); // 3
} else {
System.out.println("无环");
}
}
}
4. 问题3:计算环的长度
public class LinkedListCycleLength {
/**
* 计算环的长度
* 步骤:
* 1. 用快慢指针找到相遇点
* 2. 固定其中一个指针,另一个指针每次走1步
* 3. 当两个指针再次相遇时,移动的步数就是环长
*/
public int cycleLength(ListNode head) {
if (head == null || head.next == 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 countCycleLength(slow);
}
}
return 0; // 无环
}
/**
* 在相遇点开始计算环长度
*/
private int countCycleLength(ListNode meetingPoint) {
ListNode current = meetingPoint;
int length = 0;
do {
current = current.next;
length++;
} while (current != meetingPoint);
return length;
}
/**
* 另一种方法:找到环入口后,从入口开始走一圈
*/
public int cycleLengthByEntry(ListNode head) {
LinkedListCycleII cycleDetector = new LinkedListCycleII();
ListNode entry = cycleDetector.detectCycle(head);
if (entry == null) {
return 0; // 无环
}
// 从环入口开始,走一圈回到入口
ListNode current = entry;
int length = 0;
do {
current = current.next;
length++;
} while (current != entry);
return length;
}
// 测试
public static void main(String[] args) {
LinkedListCycleLength solution = new LinkedListCycleLength();
// 创建有环链表:1→2→3→4→5→6→3(环长=4:3→4→5→6→3)
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);
ListNode node6 = new ListNode(6);
node1.next = node2;
node2.next = node3;
node3.next = node4;
node4.next = node5;
node5.next = node6;
node6.next = node3; // 形成环
int length = solution.cycleLength(node1);
System.out.println("环的长度: " + length); // 4
}
}
数学推导:为什么能找到环入口?
Floyd算法的数学证明
假设:
- 头节点到环入口距离 = a
- 环入口到相遇点距离 = b
- 相遇点回到环入口距离 = c
- 环的总长度 = b + c
推导:
慢指针走的路程:a + b
快指针走的路程:a + n(b+c) + b (n是快指针在环中转的圈数)
因为快指针速度是慢指针的2倍:
2(a + b) = a + n(b + c) + b
简化:a + b = n(b + c)
推导:a = (n-1)(b+c) + c
结论:
a = c + (n-1) * 环长
所以从头节点走a步,从相遇点走c+(n-1)环长步,会在环入口相遇
实际应用场景
场景1:检测资源循环依赖
// 检测模块之间的循环依赖
public class ModuleDependencyChecker {
static class Module {
String name;
List<Module> dependencies = new ArrayList<>();
Module(String name) {
this.name = name;
}
void addDependency(Module module) {
dependencies.add(module);
}
}
/**
* 检测模块依赖图中是否有环
*/
public boolean hasCircularDependency(List<Module> modules) {
Map<Module, Integer> state = new HashMap<>(); // 0=未访问,1=访问中,2=已访问
for (Module module : modules) {
if (dfs(module, state)) {
return true; // 发现环
}
}
return false;
}
private boolean dfs(Module module, Map<Module, Integer> state) {
if (state.getOrDefault(module, 0) == 1) {
return true; // 发现环
}
if (state.getOrDefault(module, 0) == 2) {
return false; // 已访问过,无环
}
state.put(module, 1); // 标记为访问中
for (Module dep : module.dependencies) {
if (dfs(dep, state)) {
return true;
}
}
state.put(module, 2); // 标记为已访问
return false;
}
}
场景2:检测递归中的无限递归
// 在递归调用中检测是否进入无限递归
public class RecursionDepthChecker {
private Map<String, Integer> callStack = new HashMap<>();
private final int MAX_DEPTH = 1000;
public void recursiveMethod(String key) {
// 检测是否可能无限递归
int depth = callStack.getOrDefault(key, 0) + 1;
if (depth > MAX_DEPTH) {
throw new RuntimeException("可能进入无限递归: " + key);
}
callStack.put(key, depth);
try {
// 实际递归逻辑...
if (shouldContinue(key)) {
recursiveMethod(transformKey(key));
}
} finally {
callStack.remove(key);
}
}
private boolean shouldContinue(String key) {
// 判断是否继续递归
return key.length() > 1;
}
private String transformKey(String key) {
// 转换key
return key.substring(1);
}
}
场景3:检测链表中的重复节点
// 在链式结构中检测重复(如对象引用链)
public class ObjectReferenceChecker {
/**
* 检测对象引用链中是否有环
* 类似于深拷贝时检测循环引用
*/
public boolean hasCircularReference(Object root) {
Set<Object> visited = new IdentityHashSet<>(); // 用身份哈希,而不是equals
return hasCircularReference(root, visited);
}
private boolean hasCircularReference(Object obj, Set<Object> visited) {
if (obj == null) {
return false;
}
if (visited.contains(obj)) {
return true; // 发现环
}
visited.add(obj);
// 遍历对象的所有引用字段
for (Field field : obj.getClass().getDeclaredFields()) {
if (field.getType().isPrimitive()) {
continue; // 基本类型跳过
}
field.setAccessible(true);
try {
Object fieldValue = field.get(obj);
if (fieldValue != null && hasCircularReference(fieldValue, visited)) {
return true;
}
} catch (IllegalAccessException e) {
// 忽略
}
}
visited.remove(obj);
return false;
}
}
性能对比
| 方法 | 时间复杂度 | 空间复杂度 | 优点 | 缺点 |
|---|---|---|---|---|
| 快慢指针 | O(n) | O(1) | 空间最优 | 实现稍复杂 |
| HashSet | O(n) | O(n) | 实现简单 | 需要额外空间 |
| 标记法 | O(n) | O(1) | 空间最优 | 会修改原链表 |
变种问题
变种1:找到环的中间节点
public ListNode findMiddleNode(ListNode head) {
if (head == null || head.next == null) {
return head;
}
ListNode slow = head;
ListNode fast = head;
while (fast != null && fast.next != null) {
slow = slow.next; // 慢指针走1步
fast = fast.next.next; // 快指针走2步
}
return slow; // 当快指针到末尾时,慢指针在中间
}
变种2:判断两个链表是否相交
public ListNode getIntersectionNode(ListNode headA, ListNode headB) {
if (headA == null || headB == null) {
return null;
}
ListNode pA = headA;
ListNode pB = headB;
// 相当于创建了一个虚拟环
// 两个指针都走完A和B,最终会在交点相遇
while (pA != pB) {
pA = (pA == null) ? headB : pA.next;
pB = (pB == null) ? headA : pB.next;
}
return pA; // 交点或null
}
常见错误
错误1:空指针异常
// ❌ 错误:没有检查fast.next是否为null
while (fast != null) { // 可能fast.next为null
slow = slow.next;
fast = fast.next.next; // 可能NPE
}
// ✅ 正确
while (fast != null && fast.next != null) {
slow = slow.next;
fast = fast.next.next;
}
错误2:修改了原链表
// ❌ 错误:标记法会修改原链表
public boolean hasCycle(ListNode head) {
while (head != null) {
if (head.val == Integer.MIN_VALUE) { // 修改了节点值
return true;
}
head.val = Integer.MIN_VALUE; // ❌ 修改了原链表!
head = head.next;
}
return false;
}
错误3:无限循环
// ❌ 错误:没有正确处理无环情况
ListNode slow = head;
ListNode fast = head;
while (slow != fast) { // 初始slow==fast,可能不进入循环
slow = slow.next;
fast = fast.next.next;
}
// 如果有环,会无限循环
// 如果无环,fast会走到null,然后NPE
实战练习
题目:LeetCode 141 - 环形链表
// 实现 hasCycle 方法
public boolean hasCycle(ListNode head) {
// 你的实现
}
题目:LeetCode 142 - 环形链表 II
// 实现 detectCycle 方法
public ListNode detectCycle(ListNode head) {
// 你的实现
}
题目:LeetCode 202 - 快乐数
// 判断一个数是否是快乐数
// 快乐数定义:重复计算各位数的平方和,最终得到1
// 不是快乐数的数会进入循环
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 sum = 0;
while (n > 0) {
int digit = n % 10;
sum += digit * digit;
n /= 10;
}
return sum;
}
总结
链表环问题的核心:
- 快慢指针是解决环问题的标准方法
- 时间复杂度O(n) ,空间复杂度O(1)
- 数学推导确保能找到环入口
- 应用广泛:检测循环依赖、无限递归等
记住三步法:
- 判断是否有环:快慢指针是否相遇
- 找环入口:慢指针回起点,同速前进
- 计算环长:固定一点,走一圈
快慢指针技巧不仅用于链表环,还用于:
- 找链表中间节点
- 判断回文链表
- 找两个链表的交点
- 检测快乐数循环
掌握链表环解法,你就掌握了快慢指针这一重要算法技巧!