算法背景
给定一个包含 n+1 个整数的数组,其中每个整数都在 1 到 n 之间(包含1和n),假设只有一个重复的整数,找出这个重复的数。这道题是LeetCode 287题,表面上是简单的查找问题,但优秀的解法需要将其转化为链表判环问题,运用Floyd判圈算法(龟兔赛跑算法)在O(n)时间、O(1)空间内解决。
这道题的精妙之处在于:将数组下标和值看作指针关系,从而构建出一个必然存在环的隐式链表。这种将数组问题转化为图论问题的思想,在算法设计中非常有价值。
核心思路
关键转化:将数组 nums 看作一个函数图(functional graph),其中 i -> nums[i] 表示一条有向边。
由于数组有n+1个元素,值域是[1,n],从任意位置出发沿着 nums[i] 走,最终必然会进入环。因为有n+1个节点但只有n个不同的值,根据鸽巢原理,至少有一个值被访问两次,形成环。
Floyd判圈算法:
1. 找相遇点:慢指针每次走1步,快指针每次走2步,两者必然在环内某点相遇。
2. 找环入口:将一个指针重置到起点,两指针都以速度1前进,再次相遇点即为环入口(也就是重复数字)。
数学证明:设环前长度为a,环长为c,相遇时慢指针走了a+b步,快指针走了a+b+kc步。由于快指针速度是2倍,有2(a+b) = a+b+kc,即a+b = kc。将慢指针重置到起点走a步到达入口,相遇点再走c-b步也到达入口,而a = kc-b,所以两者同时到达入口。
完整Java实现
/**
* 寻找重复数 - Floyd判圈算法(龟兔赛跑)
* 时间复杂度: O(n) 空间复杂度: O(1)
*/
public class FindDuplicateNumber {
/**
* 核心算法:Floyd判圈
* 将数组视为隐式链表,利用快慢指针找环入口
*/
public int findDuplicate(int[] nums) {
// 阶段1: 快慢指针找相遇点
int slow = nums[0];
int fast = nums[nums[0]];
while (slow != fast) {
slow = nums[slow]; // 慢指针走一步
fast = nums[nums[fast]]; // 快指针走两步
}
// 阶段2: 找环入口(重复数字)
int ptr1 = 0; // 从起点出发
int ptr2 = slow; // 从相遇点出发
while (ptr1 != ptr2) {
ptr1 = nums[ptr1];
ptr2 = nums[ptr2];
}
return ptr1;
}
/**
* 二分搜索解法 - 不修改数组的替代方案
* 时间复杂度: O(n log n) 空间复杂度: O(1)
*/
public int findDuplicateBinarySearch(int[] nums) {
int left = 1, right = nums.length - 1;
while (left < right) {
int mid = left + (right - left) / 2;
// 统计 <= mid 的数字个数
int count = 0;
for (int num : nums) {
if (num <= mid) count++;
}
// 如果count > mid,说明重复数字在[left, mid]中
if (count > mid) {
right = mid;
} else {
left = mid + 1;
}
}
return left;
}
/**
* 位运算解法 - 利用二进制统计
* 时间复杂度: O(n log n) 空间复杂度: O(1)
*/
public int findDuplicateBit(int[] nums) {
int n = nums.length - 1;
int result = 0;
// 对每一位分别统计
for (int bit = 0; bit < 32; bit++) {
int mask = 1 << bit;
int countNums = 0, countRange = 0;
for (int num : nums) {
if ((num & mask) != 0) countNums++;
}
for (int i = 1; i <= n; i++) {
if ((i & mask) != 0) countRange++;
}
// 如果该位在数组中出现次数多于正常范围,重复数该位为1
if (countNums > countRange) {
result |= mask;
}
}
return result;
}
/**
* 通用版本:寻找链表中环的入口节点
* 经典Floyd判圈在链表中的应用
*/
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; // 无环
// 找入口
ListNode ptr = head;
while (ptr != slow) {
ptr = ptr.next;
slow = slow.next;
}
return ptr;
}
static class ListNode {
int val;
ListNode next;
ListNode(int x) { val = x; }
}
public static void main(String[] args) {
FindDuplicateNumber solution = new FindDuplicateNumber();
// 测试用例1: 基本场景
int[] nums1 = {1, 3, 4, 2, 2};
System.out.println("数组: [1,3,4,2,2], 重复数: " + solution.findDuplicate(nums1));
// 测试用例2: 重复数为1
int[] nums2 = {1, 1, 2};
System.out.println("数组: [1,1,2], 重复数: " + solution.findDuplicate(nums2));
// 测试用例3: 较大数组
int[] nums3 = {3, 1, 3, 4, 2};
System.out.println("数组: [3,1,3,4,2], 重复数: " + solution.findDuplicate(nums3));
// 对比三种解法
System.out.println("\n--- 不同解法对比 ---");
int[] test = {1, 4, 4, 2, 4};
System.out.println("Floyd判圈: " + solution.findDuplicate(test.clone()));
System.out.println("二分搜索: " + solution.findDuplicateBinarySearch(test.clone()));
System.out.println("位运算: " + solution.findDuplicateBit(test.clone()));
}
}
复杂度分析
| 解法 | 时间复杂度 | 空间复杂度 | 是否修改数组 | 核心思想 |
|---|---|---|---|---|
| Floyd判圈 | O(n) | O(1) | 否 | 隐式链表+快慢指针 |
| 二分搜索 | O(n log n) | O(1) | 否 | 鸽巢原理+范围收缩 |
| 位运算 | O(n log n) | O(1) | 否 | 按位统计差异 |
| 排序 | O(n log n) | O(1)或O(n) | 是 | 相邻比较 |
| 哈希表 | O(n) | O(n) | 否 | 空间换时间 |
扩展思考
- 寻找缺失数字:如果数组有n个元素,值域[0,n],找出缺失的那个数。可以用数学求和法:期望和 – 实际和。
- 寻找两个重复数字:如果数组有n+2个元素,有两个重复数字,能否用Floyd判圈?可以,但需要更复杂的处理,通常使用异或或分桶法。
- 循环检测的通用性:Floyd判圈不仅适用于链表和数组,任何可以定义”下一步”函数的结构都可以应用。