快慢指针的艺术:从链表环探测到数组去重

本文由AI撰写生成

在算法世界中,快慢指针(Two Pointers)是一种优雅而强大的技巧。它通过两个指针以不同速度或策略遍历数据结构,巧妙地解决了许多看似复杂的问题。本文将带你深入探索快慢指针的精髓,涵盖其主要应用场景和实现细节。

什么是快慢指针?

快慢指针的核心思想很简单:使用两个指针以不同的速度遍历数据结构

  • 慢指针(slow):通常每次移动一步
  • 快指针(fast):通常每次移动两步(也可根据问题调整)

这种速度差产生的相对运动,成为解决各类问题的关键。

核心应用场景

1. 链表环检测

这是快慢指针最经典的应用。

问题:判断链表中是否存在环。

算法思想:如果链表中存在环,快指针最终会追上慢指针,就像在环形跑道上跑步一样。

public boolean hasCycle(ListNode head) {
    if (head == 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; // 快指针遇到null说明无环
}

2. 寻找环的入口节点

检测到环后,我们还可以找到环的起始位置。

算法步骤

  1. 先用快慢指针找到相遇点
  2. 将慢指针重新指向头节点
  3. 两个指针都以每次一步的速度前进
  4. 再次相遇的点就是环的入口
public 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) break;
    }
  
    if (fast == null || fast.next == null) return null; // 无环
  
    // 第二阶段:寻找入口
    slow = head;
    while (slow != fast) {
        slow = slow.next;
        fast = fast.next;
    }
    return slow; // 环的入口
}

数学原理:设从头节点到环入口距离为a,环入口到相遇点距离为b,相遇点到环入口距离为c,则有 a = c + (n-1)(b+c),这解释了为什么上述算法有效。

3. 寻找链表的中间节点

算法思想:快指针速度是慢指针的两倍,当快指针到达末尾时,慢指针正好在中间。

public ListNode findMiddle(ListNode head) {
    ListNode slow = head, fast = head;
  
    while (fast != null && fast.next != null) {
        slow = slow.next;
        fast = fast.next.next;
    }
    return slow;
}

4. 数组去重(保留最多k次重复)

这是快慢指针在数组上的精彩应用,体现了"历史窗口"的精妙思想。

保留唯一元素(LeetCode 26)

public int removeDuplicates(int[] nums) {
    if (nums.length == 0) return 0;
  
    int slow = 0;
    for (int fast = 1; fast < nums.length; fast++) {
        if (nums[fast] != nums[slow]) {
            slow++;
            nums[slow] = nums[fast];
        }
    }
    return slow + 1;
}

保留最多两次重复(LeetCode 80)

public int removeDuplicates(int[] nums) {
    if (nums.length <= 2) return nums.length;
  
    int slow = 2; // 前2个元素天然合法
    for (int fast = 2; fast < nums.length; fast++) {
        // 核心:比较当前元素与历史窗口起点元素
        if (nums[fast] != nums[slow - 2]) {
            nums[slow] = nums[fast];
            slow++;
        }
    }
    return slow;
}

理解"历史窗口":快慢指针的精髓

"历史窗口"概念是理解高级快慢指针应用的关键。让我们深入分析:

心智模型

  1. 可信历史:慢指针 slow维护着一个已处理的合法子数组(nums[0]nums[slow-1]

  2. 历史窗口:为了判断新元素是否合法,我们需要回顾最近的相关历史,即从 slow-k开始的部分

  3. 决策机制:比较 nums[fast]nums[slow-k]

    • 如果相等:说明新元素加入后将超过k次重复,拒绝
    • 如果不相等:说明新元素安全,可以加入可信历史

通用模板

对于"保留最多k次重复"的问题,通用解决方案为:

public int removeDuplicates(int[] nums, int k) {
    if (nums.length <= k) return nums.length;
  
    int slow = k; // 前k个元素天然合法
    for (int fast = k; fast < nums.length; fast++) {
        if (nums[fast] != nums[slow - k]) {
            nums[slow] = nums[fast];
            slow++;
        }
    }
    return slow;
}

这种方法的巧妙之处在于,用一个单点比较替代了复杂的频率统计,将时间复杂度从O(n²)降到O(n)。

使用注意事项

  1. 空指针检查:在移动快指针前务必检查 fastfast.next不为null
  2. 初始位置:根据问题需求正确初始化指针位置
  3. 循环条件:仔细设计循环终止条件
  4. 边界情况:考虑空链表、单节点等特殊情况

总结

快慢指针技巧展现了算法设计的优雅:

  • 职责分离:快指针探索,慢指针建设
  • 相对运动:速度差产生解决问题的契机
  • 历史窗口:通过有限的历史信息做出全局决策
  • 原地操作:O(1)空间复杂度的高效解决方案

掌握快慢指针不仅意味着学会几种算法,更是培养了一种重要的算法思维模式。无论是链表操作还是数组处理,这种"一快一慢、一探一建"的思想都能为我们提供清晰的解决路径。

下次面对类似问题时,不妨思考:能否用快慢指针的不同速度差来揭示数据结构的某种规律?这种思维训练,正是算法学习的真正价值所在。