链表环问题:快慢指针的经典应用

链表环问题:快慢指针的经典应用

链表环问题是算法面试的必考题,也是快慢指针技巧的经典应用。

一句话理解链表环

链表环就是链表中的一个节点指向了前面的某个节点,形成了循环

复制代码
正常链表: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;
}

总结

链表环问题的核心

  1. 快慢指针是解决环问题的标准方法
  2. 时间复杂度O(n)空间复杂度O(1)
  3. 数学推导确保能找到环入口
  4. 应用广泛:检测循环依赖、无限递归等

记住三步法

  1. 判断是否有环:快慢指针是否相遇
  2. 找环入口:慢指针回起点,同速前进
  3. 计算环长:固定一点,走一圈

快慢指针技巧不仅用于链表环,还用于:

  • 找链表中间节点
  • 判断回文链表
  • 找两个链表的交点
  • 检测快乐数循环

掌握链表环解法,你就掌握了快慢指针这一重要算法技巧!

相关推荐
Sylvia-girl2 小时前
删除有序数组中的重复项
数据结构·算法
Wave8452 小时前
数据结构—栈与队列
数据结构
垫脚摸太阳2 小时前
二分查找经典算法题--数的范围
数据结构·算法
噜啦噜啦嘞好2 小时前
算法篇:二分查找
数据结构·c++·算法·leetcode
季明洵2 小时前
回溯介绍及实战
java·数据结构·算法·leetcode·回溯
minji...2 小时前
Linux 进程间通信(一)进程间通信与匿名管道
linux·运维·服务器·数据结构·数据库·c++
会编程的土豆2 小时前
【数据结构与算法】LCS刷题
数据结构·算法·动态规划
Jasmine_llq2 小时前
《B4258 [GESP202503 一级] 四舍五入》
数据结构·算法·整数运算实现四舍五入整十数算法·批量输入遍历算法·逐行输出算法·整数算术运算组合算法·顺序输入处理算法
j_xxx404_3 小时前
力扣--分治(归并排序)算法题I:排序数组,交易逆序对的总数
数据结构·c++·算法·leetcode·排序算法