一、题目描述
给定一个包含 n + 1 个整数的数组 nums,其数字都在 [1, n] 范围内(包括 1 和 n),可知至少存在一个重复的整数。
假设 nums 只有一个重复的整数,返回这个重复的数。
要求:
- 不修改 数组
nums - 只用常量级
O(1)的额外空间
示例 1:
输入: nums = [1,3,4,2,2]
输出: 2
示例 2:
输入: nums = [3,1,3,4,2]
输出: 3
示例 3:
输入: nums = [3,3,3,3,3]
输出: 3
二、解题思路总览
| 方法 | 时间复杂度 | 空间复杂度 | 说明 |
|---|---|---|---|
| Floyd 判圈(快慢指针) | O(n) | O(1) | 转化为环形链表检测 |
| 二分查找 | O(n log n) | O(1) | 基于抽屉原理 |
| 位运算 | O(n) | O(1) | 统计每一位出现次数 |
| 哈希表 | O(n) | O(n) | 简单但不符合要求 |
Floyd 判圈是满足 O(1) 空间的最优解。
三、方法一:Floyd 判圈算法(推荐)
3.1 核心思想
将数组转化为环形链表:
- 数组值代表下一个节点的索引
- 因为有重复数字,所以存在环
- 找到环的入口点就是重复数字
为什么能转化成环形链表?
数组: [1, 3, 4, 2, 2]
索引: 0 -> 1 -> 3 -> 2 -> 4 -> 2 -> 3 -> 2 -> 4 -> ...
值: 1 3 4 2 2
从索引0出发:
0 -> nums[0] = 1
1 -> nums[1] = 3
3 -> nums[3] = 2
2 -> nums[2] = 4
4 -> nums[4] = 2 (重复!)
2 -> nums[2] = 4 (进入环)
...
链表结构:
0 -> 1 -> 3 -> 2 -> 4 ----+
↑ |
+----------------|
环的入口是 2,就是重复数
3.2 算法流程图
数组: [1, 3, 4, 2, 2],转换为链表:
0 -> 1 -> 3 -> 2 -> 4 -+
↑ | |
+------------------|
环的入口节点是 2(重复数)
+------------------------------------------------------------+
| Phase 1: 检测环(找相遇点) |
+------------------------------------------------------------+
初始化:
slow = 0, fast = 0
+------------------------------------------------------------+
| 第一次迭代: |
| slow = nums[slow] = nums[0] = 1 |
| fast = nums[nums[fast]] = nums[nums[0]] = nums[1] = 3 |
| slow=1, fast=3, 不相等,继续 |
+------------------------------------------------------------+
slow fast
0 -> 1 -> 3 -> 2 -> 4 -+
↑ |
+------------+
+------------------------------------------------------------+
| 第二次迭代: |
| slow = nums[slow] = nums[1] = 3 |
| fast = nums[nums[fast]] = nums[nums[3]] = nums[2] = 4 |
| slow=3, fast=4, 不相等,继续 |
+------------------------------------------------------------+
slow fast
0 -> 1 -> 3 -> 2 -> 4 -+
↑ |
+------------+
+------------------------------------------------------------+
| 第三次迭代: |
| slow = nums[slow] = nums[3] = 2 |
| fast = nums[nums[fast]] = nums[nums[4]] = nums[2] = 4 |
| slow=2, fast=4, 不相等,继续 |
+------------------------------------------------------------+
slow fast
0 -> 1 -> 3 -> 2 -> 4 -+
↑ |
+------------+
+------------------------------------------------------------+
| 第四次迭代: |
| slow = nums[slow] = nums[2] = 4 |
| fast = nums[nums[fast]] = nums[nums[4]] = nums[2] = 4 |
| slow=4, fast=4, 相等!找到相遇点 |
+------------------------------------------------------------+
slow/fast
0 -> 1 -> 3 -> 2 -> 4 -+
↑ |
+------------+
Phase 1 完成: slow 和 fast 在节点 4 相遇
+------------------------------------------------------------+
| Phase 2: 找环入口(入口就是重复数) |
+------------------------------------------------------------+
初始化:
slow = 0 (从起点开始)
fast = 4 (从相遇点开始)
+------------------------------------------------------------+
| 第一次迭代: |
| slow = nums[slow] = nums[0] = 1 |
| fast = nums[fast] = nums[4] = 2 |
| slow=1, fast=2, 不相等 |
+------------------------------------------------------------+
slow fast
0 -> 1 -> 3 -> 2 -> 4 -+
↑ |
+------------+
+------------------------------------------------------------+
| 第二次迭代: |
| slow = nums[slow] = nums[1] = 3 |
| fast = nums[fast] = nums[2] = 4 |
| slow=3, fast=4, 不相等 |
+------------------------------------------------------------+
slow fast
0 -> 1 -> 3 -> 2 -> 4 -+
↑ |
+------------+
+------------------------------------------------------------+
| 第三次迭代: |
| slow = nums[slow] = nums[3] = 2 |
| fast = nums[fast] = nums[4] = 2 |
| slow=2, fast=2, 相等!找到环入口 |
+------------------------------------------------------------+
slow/fast
0 -> 1 -> 3 -> 2 -> 4 -+
↑ |
+------------+
答案: slow = 2,即重复数
3.3 完整代码(slow版本)
cpp
class Solution {
public:
int findDuplicate(vector<int>& nums) {
int slow = 0, fast = 0;
// Phase 1: 找相遇点
while (1) {
slow = nums[slow];
fast = nums[nums[fast]];
if (slow == fast) break;
}
// Phase 2: 找环入口
int head = 0;
while (slow != head) {
slow = nums[slow];
head = nums[head];
}
return slow;
}
};
3.4 代码逐行解析
Phase 1: 检测环
cpp
int slow = 0, fast = 0;
while (1) {
slow = nums[slow]; // 慢指针走一步
fast = nums[nums[fast]]; // 快指针走两步
if (slow == fast) break; // 相遇说明有环
}
- 慢指针每次走一步
- 快指针每次走两步
- 如果有环,快慢指针一定会相遇
Phase 2: 找环入口
cpp
int head = 0;
while (slow != head) {
slow = nums[slow];
head = nums[head];
}
- 环入口的数学性质:起点到入口的距离 = 相遇点到入口的距离
- 让一个指针从起点开始,一个从相遇点开始,每次走一步
- 再次相遇时就是环入口
3.5 数学证明(理解要点)
设:
起点到环入口的距离 = F
环入口到相遇点的距离 = a
环周长 = C
Phase 1:
慢指针走的距离 = F + a
快指针走的距离 = F + a + nC (n >= 1)
快指针速度是慢指针的2倍:
2(F + a) = F + a + nC
F + a = nC
相遇时: n >= 1,所以 F + a >= C
Phase 2:
从起点到环入口距离 = F
从相遇点到环入口距离 = C - a
相遇点到环入口 = (nC - F) - a = (n-1)C + (C - a - F)
如果 n = 1: F + a = C, 所以 C - a = F
所以从相遇点和从起点出发的指针,会在环入口相遇
3.6 复杂度分析
| 复杂度 | 值 | 说明 |
|---|---|---|
| 时间 | O(n) | Phase 1 和 Phase 2 各 O(n) |
| 空间 | O(1) | 只用两个指针变量 |
四、方法二:二分查找
4.1 核心思想
基于抽屉原理(鸽巢原理):
- 数组值在
[1, n]范围 - 如果小于等于
mid的数字出现超过mid次,则重复数一定在[1, mid]区间
4.2 算法流程图
数组: [1, 3, 4, 2, 2], n = 5
初始: left = 1, right = 5
+------------------------------------------------------------+
| mid = 3, 统计 <= 3 的元素个数 |
| <= 3 的元素: 1, 3, 2, 2 |
| count = 4 |
| count(3) = 4 > mid(3), 超过,说明重复数 <= 3 |
| right = mid = 3 |
+------------------------------------------------------------+
+------------------------------------------------------------+
| mid = 2, 统计 <= 2 的元素个数 |
| <= 2 的元素: 1, 2, 2 |
| count = 3 |
| count(2) = 3 > mid(2), 超过,说明重复数 <= 2 |
| right = mid = 2 |
+------------------------------------------------------------+
+------------------------------------------------------------+
| mid = 1, 统计 <= 1 的元素个数 |
| <= 1 的元素: 1 |
| count = 1 |
| count(1) = 1 不大于 mid(1), 没超过,说明重复数 > 1 |
| left = mid + 1 = 2 |
+------------------------------------------------------------+
left=2, right=2 -> 答案 = 2
4.3 完整代码
cpp
class Solution {
public:
int findDuplicate(vector<int>& nums) {
int left = 1, right = nums.size() - 1;
while (left < right) {
int mid = left + (right - left) / 2;
int count = 0;
// 统计 <= mid 的元素个数
for (int num : nums) {
if (num <= mid) {
count++;
}
}
// 如果 count > mid,说明重复数在 [1, mid]
if (count > mid) {
right = mid;
} else {
// 否则重复数在 [mid+1, right]
left = mid + 1;
}
}
return left;
}
};
4.4 复杂度分析
| 复杂度 | 值 | 说明 |
|---|---|---|
| 时间 | O(n log n) | 每次统计 O(n),二分 log n 次 |
| 空间 | O(1) | 只用常数个变量 |
五、方法三:位运算
5.1 核心思想
统计每一位上 1 出现的次数:
- 如果某一位上 1 的出现次数超过 n/2,说明这一位对重复数有贡献
- 通过逐位确定,得到重复数
5.2 算法流程图
数组: [1, 3, 4, 2, 2], n = 5, 重复数 = 2 (二进制 010)
统计每一位:
+------------------------------------------------------------+
| 第0位: |
| 数组中第0位为1的数: 1(1), 3(1) |
| 1出现次数 = 2 |
| 正常应该 <= 5/2 = 2.5,所以2次是正常的 |
+------------------------------------------------------------+
+------------------------------------------------------------+
| 第1位: |
| 数组中第1位为1的数: 2(1), 3(1), 2(1) |
| 1出现次数 = 3 |
| 正常应该 <= 5/2 = 2.5,3 > 2.5,说明重复数的第1位是1 |
+------------------------------------------------------------+
+------------------------------------------------------------+
| 第2位: |
| 数组中第2位为1的数: 4(1) |
| 1出现次数 = 1 |
| 正常应该 <= 2.5,1 <= 2.5,说明重复数的第2位是0 |
+------------------------------------------------------------+
得到: 重复数 = 010 = 2
5.3 完整代码
cpp
class Solution {
public:
int findDuplicate(vector<int>& nums) {
int n = nums.size();
int ans = 0;
// 统计每一位
for (int bit = 0; bit < 31; bit++) {
int count = 0;
int mask = 1 << bit;
for (int num : nums) {
if (num & mask) {
count++;
}
}
// 如果 count > n/2,说明这一位是 1
if (count > n / 2) {
ans |= mask;
}
}
return ans;
}
};
5.4 复杂度分析
| 复杂度 | 值 | 说明 |
|---|---|---|
| 时间 | O(n * 31) ≈ O(n) | 统计 31 位 |
| 空间 | O(1) | 只用常数个变量 |
六、方法对比总结
| 方法 | 时间复杂度 | 空间复杂度 | 推荐指数 |
|---|---|---|---|
| Floyd 判圈 | O(n) | O(1) | ★★★★★ |
| 二分查找 | O(n log n) | O(1) | ★★★★☆ |
| 位运算 | O(n) | O(1) | ★★★☆☆ |
| 哈希表 | O(n) | O(n) | ★☆☆☆☆ |
Floyd 判圈的优势:
1. O(1) 空间,不修改数组
2. 时间复杂度 O(n)
3. 代码简洁
4. 体现数学思维(环形链表检测)
为什么快慢指针能检测环?
- 快指针走两步,慢指针走一步
- 如果有环,快慢指针一定相遇
- 数学保证
七、面试追问 FAQ
| 问题 | 回答 |
|---|---|
| Q: 为什么快慢指针一定会相遇? | 快慢指针速度差为1,在环中相当于相对速度为1,一定会追上 |
| Q: 为什么从相遇点和从起点出发的指针会在环入口相遇? | 数学性质:起点到入口的距离 = 相遇点到入口的距离(相位差) |
| Q: 为什么数组值可以作为下一个索引? | 因为数组值在 1,n 范围,且数组长度为 n+1,所以值总是一个有效索引 |
| Q: 如果有多个重复数怎么办? | Floyd 算法仍然能找到环入口,但可能不是题目要求的那个 |
| Q: 二分查找的抽屉原理怎么理解? | 如果每个数最多出现一次,则 <= mid 的数最多有 mid 个,超过就说明有重复 |
| Q: 位运算方法的时间复杂度? | O(32n) = O(n),常数因子大 |
八、相关题目
| 题目 | 难度 | 关键点 |
|---|---|---|
| 287. 寻找重复数 | 中等 | Floyd 判圈 / 二分 |
| 142. 环形链表 II | 中等 | Floyd 判圈求环入口 |
| 141. 环形链表 | 简单 | Floyd 判圈检测环 |
| 136. 只出现一次的数字 | 简单 | 异或运算 |
| 268. 丢失的数字 | 简单 | 异或 / 数学公式 |
九、总结
| 要点 | 说明 |
|---|---|
| 核心思想 | 将数组转化为环形链表,找环入口 |
| Phase 1 | 快慢指针检测环,找相遇点 |
| Phase 2 | 从起点和相遇点同时出发,在环入口相遇 |
| 二分法 | 基于抽屉原理,统计 <= mid 的元素个数 |
| 时间复杂度 | O(n) |
| 空间复杂度 | O(1) |
| 关键性质 | 数组值作为索引 -> 隐式链表结构 |