龟兔赛跑:快慢指针法详解(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. 判断链表是否有环
场景描述: 我们在链表中可能会遇到环形结构的问题,例如,链表中的某个节点指向之前的某个节点,形成一个环。这种情况会导致传统的遍历方法进入无限循环,而快慢指针能够有效地检测环的存在。
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+1
到i+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;
}
分析:
- ❌ 不属于典型快慢指针:没有速度差,没有相遇判定;
- ✅ 更接近滑动窗口 / 固定范围扫描;
- ✅ 可归类为"窗口内双指针比较法"或"局部趋势检测"。
这类算法是快慢指针的 并行指针结构的一种衍生对比形式,常用于时间序列分析、金融涨跌监测、局部波动检测等。
📚 拓展阅读
- Floyd 判圈算法 - Wikipedia
- LeetCode 141, 142, 287, 876 等经典题目
快慢指针法是一种空间效率极高的"动态双指针法",也是刷题、面试、系统开发中极具实战价值的算法技巧。熟练掌握它,将帮助你轻松解决一类复杂的问题!