龟兔赛跑:快慢指针法详解(Floyd's Tortoise and Hare Algorithm)

龟兔赛跑:快慢指针法详解(Floyd's Tortoise and Hare Algorithm)

📌 简介

快慢指针法(Floyd's Tortoise and Hare Algorithm)是一种优雅而高效的算法技巧,广泛应用于链表、数组等线性数据结构的问题求解。它以"快慢"两个指针的相对速度差为核心,通过巧妙的移动策略解决诸如 环形检测中点查找重复元素定位 等问题。该方法由 Robert W. Floyd 提出,因其形象地将快慢指针比喻为"乌龟与兔子"而得名。

常用于判断链表是否有环、找中点、定位环起点或数组中重复元素等问题。


🚀 基本原理

🧠 快慢指针法的核心在于利用两个指针以不同速度遍历数据结构:

  • 慢指针(slow pointer,乌龟) :每次移动 1 步;
  • 快指针(fast pointer,兔子) :每次移动 2 步。

🧠 根据问题的特性:

  • 如果数据结构中存在环,快慢指针最终会在环内相遇;
  • 如果不存在环,快指针会率先到达结构的末尾(例如链表的 null 或数组边界)。

🧠 Floyd 判圈算法(也称"乌龟与兔子算法")通过快慢指针解决此问题,分为两个阶段:

  • 检测环并找到相遇点:使用快慢指针,快指针每次走 2 步,慢指针每次走 1 步,若存在环,两者会在环内相遇。
  • 定位环入口:从链表头部和相遇点同时以慢速(每次 1 步)前进,两者相遇的节点即为环入口。

🧠 详细解释与数学证明

  • L :环外部分的长度(从链表头到环入口的步数)。
  • C :环的长度(环内节点总数)。
  • k :慢指针在环内走的步数(相遇时)。
  • 相遇点:快慢指针第一次相遇的位置。
  • 环入口:链表进入环的第一个节点。

❓ 为什么能相遇?(数学推导)

  • 初始时,快指针和慢指针都从链表头部开始。

  • 慢指针速度为 1,设其到达环入口时走了 L 步,此时位置为环入口。

  • 快指针速度为 2,此时快指针走了 2L步,可能已在环内绕了几圈。

  • 相遇时:

    • 慢指针总步数:L+k(环外 L 步 + 环内 k步)。
    • 快指针总步数:2(L+k) (速度是慢指针的两倍)。
  • 由于快指针在环内可能多绕了几圈,设其绕了 n 圈(n≥0),则: ​ 2(L+k)=L+k+nC 化简: ​ L+k=nC 这表明,快慢指针相遇时,慢指针在环内走了 k 步,快指针在环内走了 k+nC步,相遇点距离环入口 k步(环内位置为 k mod  C )。

❓ 为什么能在环入口相遇?

  • 相遇后,将一个指针(记为 ptr1)重置到链表头部,另一个指针(记为 ptr2)留在相遇点。

  • 两者以相同速度(每次 1 步)前进:

    • ptr1 从头走 L步到达环入口。

    • ptr2 从相遇点(距环入口 k 步)走:

      • 相遇点到环入口的距离为 C−k(环内逆向距离)。
      • 但从相遇点继续向前走 L 步:L=nC−k ptr2 从相遇点走 L 步,相当于在环内走 nC−k 步后回到环入口(因为 nC 为整圈)。
  • 因此,ptr1 和 ptr2 会在环入口相遇。

📝 图示说明(文字描述)

假设链表为:A -> B -> C -> D -> E -> C(环入口为 C)。

  • 环外:A -> B(L=2)。

  • 环内:C -> D -> E -> C(C=3)。

  • 快慢指针:

    • 慢指针:A -> B -> C -> D(走 3 步)。
    • 快指针:A -> C -> E -> D(走 6 步),在 D 相遇。
  • 相遇点 D 距环入口 C 为 1 步(k=1)。

  • L+k=2+1=3=1⋅C。

  • ptr1 从 A 走 2 步到 C,ptr2 从 D 走 2 步(D -> E -> C),在 C 相遇。


👣 快慢指针法的一般流程(简洁版)

  1. 初始化快慢指针(一般都指向起点);
  2. 快指针每次移动两步,慢指针每次移动一步;
  3. 依据场景判断终止条件(如是否相遇、是否到达终点等);
  4. 若需要定位特定位置(如环起点),可引入第三指针从头与慢指针同步推进。

🧪 常见应用及深入解析

1. 判断链表是否有环

场景描述: 我们在链表中可能会遇到环形结构的问题,例如,链表中的某个节点指向之前的某个节点,形成一个环。这种情况会导致传统的遍历方法进入无限循环,而快慢指针能够有效地检测环的存在。

ini 复制代码
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;
}

2. 找到环的起始节点

场景描述: 当链表中存在环时,除了判断环的存在,我们可能还需要定位环的起始节点。利用快慢指针相遇的特性,我们可以通过从头和相遇点同时开始遍历来找到环的起点。

ini 复制代码
public ListNode detectCycle(ListNode head) {
    if (head == null || head.next == null) return null;
    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;
}

3. 寻找链表中点

场景描述: 在链表中,我们常常需要找到链表的中间节点。通过使用快慢指针,快指针以两倍的速度前进,慢指针则每次走一步,当快指针到达末尾时,慢指针恰好位于链表的中点。

ini 复制代码
public ListNode findMiddle(ListNode head) {
    if (head == null) return null;
    ListNode slow = head;
    ListNode fast = head;
    while (fast != null && fast.next != null) {
        slow = slow.next;
        fast = fast.next.next;
    }
    return slow;
}

4. 数组中查找重复元素

场景描述: 在数组中,我们可能需要检测是否有重复的元素。通过将数组看作一个链表,我们可以利用快慢指针来有效地找到重复元素。

ini 复制代码
public int findDuplicate(int[] nums) {
    int slow = nums[0];
    int fast = nums[0];
    do {
        slow = nums[slow];
        fast = nums[nums[fast]];
    } while (slow != fast);
​
    int ptr1 = nums[0];
    int ptr2 = slow;
    while (ptr1 != ptr2) {
        ptr1 = nums[ptr1];
        ptr2 = nums[ptr2];
    }
    return ptr1;
}

说明:

  • 在使用这种算法时,数组的元素 必须 在有效的索引范围内,即 nums[i] 必须满足 0 <= nums[i] < 数组长度
  • 如果数组中的元素超出了有效索引范围,那么程序会抛出 ArrayIndexOutOfBoundsException 错误。 这个限制是快慢指针法在数组中应用时的一个重要前提,确保可以通过元素值来安全地访问数组中的其他元素。

5. 查找链表中倒数第 N 个节点(固定差快慢指针)

场景描述: 在链表中,我们可能需要找到倒数第 N 个节点。通过让快指针先走 N 步,再让慢指针和快指针同时前进,直到快指针到达链表末尾,慢指针即为倒数第 N 个节点。

ini 复制代码
public ListNode findNthFromEnd(ListNode head, int n) {
    ListNode fast = head;
    ListNode slow = head;
​
    // fast 先走 n 步
    for (int i = 0; i < n; i++) {
        if (fast == null) return null;
        fast = fast.next;
    }
​
    // fast 和 slow 同步前进
    while (fast != null) {
        fast = fast.next;
        slow = slow.next;
    }
​
    return slow;
}

说明: 虽然两个指针速度相同,但由于存在固定的"距离差",当快指针走到末尾时,慢指针恰好在目标节点。可以视为快慢指针的 滞后型变体

6. 判断链表是否为回文结构

场景描述: 判断一个链表是否为回文链表,意味着要检查链表的前半部分和后半部分是否一致。通过使用快慢指针找到链表中点,再反转后半部分并与前半部分进行比较,可以有效地解决这个问题。

ini 复制代码
public boolean isPalindrome(ListNode head) {
    if (head == null || head.next == null) return true;
​
    // 找中点
    ListNode slow = head, fast = head;
    while (fast != null && fast.next != null) {
        slow = slow.next;
        fast = fast.next.next;
    }
​
    // 反转后半部分
    ListNode prev = null;
    while (slow != null) {
        ListNode next = slow.next;
        slow.next = prev;
        prev = slow;
        slow = next;
    }
​
    // 比较两部分
    ListNode left = head, right = prev;
    while (right != null) {
        if (left.val != right.val) return false;
        left = left.next;
        right = right.next;
    }
    return true;
}

7. 找到两个链表的相交节点

场景描述: 在两个链表中,可能有一部分节点是共享的。使用快慢指针的技巧,我们可以找到两个链表相交的第一个节点。

css 复制代码
public ListNode getIntersectionNode(ListNode headA, ListNode headB) {
    ListNode a = headA, b = headB;
    while (a != b) {
        a = (a == null) ? headB : a.next;
        b = (b == null) ? headA : b.next;
    }
    return a;
}

📈 时间与空间复杂度分析

操作 时间复杂度 空间复杂度 备注
判断是否有环 O(n) O(1) 最多遍历所有节点
寻找中点 O(n) O(1) 快指针走完链表
找环起点 O(n) O(1) 相遇后最多再走 L 步
找重复数 O(n) O(1) 数组模拟链表,线性时间收敛
倒数第 N 个 O(n) O(1) 两次遍历压缩成一个固定差双指针
回文链表 O(n) O(1) 快慢指针+链表反转+比较
找相交节点 O(n) O(1) 指针同步抵消长度差

✅ 使用建议与注意事项

  • 避免空指针访问。
  • 明确快慢速度关系,步长不能随意变。
  • 数组转链表时注意索引越界问题。

🌍 实际应用场景

  • 检测链表/图结构是否存在环;
  • 操作系统中检测死锁;
  • 查找循环依赖;
  • 数组类问题的环判断(如找重复数)。

📎 快慢指针的非典型变种与对比算法

虽然"快慢指针"一词常用于速度不同的双指针同步推进,但并非所有使用双指针的算法都属于快慢指针法。 以下是一种常见的"窗口内趋势检测"方法,看起来像双指针,实则与快慢指针的核心思想不同:

案例:局部趋势检测

数据是按时间排序的,例如一年中每个月的数据,或一个月中每天的数据。 问题描述:

在任意一个点,判断其后 5 天以内是否有超过 ±30% 的涨幅或跌幅。

思路:

  • 外层指针遍历每个时间点 i
  • 内层在 i+1i+5 范围内查找;
  • 如果 (data[j] - data[i]) / data[i] 的绝对值大于 30%,记录该趋势。
ini 复制代码
public boolean hasStrongTrend(double[] data) {
    int n = data.length;
    for (int i = 0; i < n; i++) {
        for (int j = i + 1; j <= i + 5 && j < n; j++) {
            double ratio = (data[j] - data[i]) / data[i];
            if (Math.abs(ratio) >= 0.3) {
                return true;
            }
        }
    }
    return false;
}

分析:

  • ❌ 不属于典型快慢指针:没有速度差,没有相遇判定;
  • ✅ 更接近滑动窗口 / 固定范围扫描;
  • ✅ 可归类为"窗口内双指针比较法"或"局部趋势检测"。

这类算法是快慢指针的 并行指针结构的一种衍生对比形式,常用于时间序列分析、金融涨跌监测、局部波动检测等。


📚 拓展阅读


快慢指针法是一种空间效率极高的"动态双指针法",也是刷题、面试、系统开发中极具实战价值的算法技巧。熟练掌握它,将帮助你轻松解决一类复杂的问题!

相关推荐
老马啸西风1 分钟前
Neo4j GDS-09-neo4j GDS 库中路径搜索算法实现
网络·数据库·算法·云原生·中间件·neo4j·图数据库
xiongmaodaxia_z733 分钟前
python每日一练
开发语言·python·算法
zy_destiny1 小时前
【非机动车检测】用YOLOv8实现非机动车及驾驶人佩戴安全帽检测
人工智能·python·算法·yolo·机器学习·安全帽·非机动车
rigidwill6662 小时前
LeetCode hot 100—搜索二维矩阵
数据结构·c++·算法·leetcode·矩阵
短尾黑猫2 小时前
[LeetCode 1696] 跳跃游戏 6(Ⅵ)
算法·leetcode
矛取矛求2 小时前
栈与队列习题分享(精写)
c++·算法
袖清暮雨2 小时前
【专题】搜索题型(BFS+DFS)
算法·深度优先·宽度优先
LuckyLay2 小时前
LeetCode算法题(Go语言实现)_46
算法·leetcode·golang
alicema11112 小时前
Python-Django集成yolov识别模型摄像头人数监控网页前后端分离
开发语言·后端·python·算法·机器人·django