目录

题目描述

287. 寻找重复数

image-20250420072915017

image-20250420072927527

题意分析

给定一个长度为 n + 1 的数组,每个元素的取值都落在 [1, n] 这个闭区间内,要求把那个重复出现的数字找出来并返回。

有三条约束几乎直接决定了做法,值得逐条拆开看。

第一条是「n + 1 个数、取值只有 n 种」。这是一个数量上的强信号:抽屉不够用,重复一定存在,所以不需要考虑「找不到答案」的分支。

第二条是「只有一个数字重复,但它可能重复不止一次」。也就是说答案唯一,但出现次数不定,可能是 [1, 3, 4, 2, 2] 这种出现两次,也可能是 [2, 2, 2, 2, 2] 这种占满整个数组。任何依赖「重复元素恰好出现两次」的技巧(比如异或消元、求和作差)在这里都会失效。

第三条是进阶要求:不能修改数组,且只能用常数级额外空间。这一条把最顺手的两类做法都挡在门外——排序会破坏原数组,哈希表或计数数组要吃掉 $O(n)$ 空间。

边界方面还要留意:n 最小可以是 1,此时数组为 [1, 1];数组元素取值不含 0,所以下标 0 这个位置永远不会被任何元素「指向」,这一点后面会变成解法的关键支点。

解法:快慢指针找环入口

核心思路

问题关键:数组不能修改,额外空间又要求 $O(1)$,排序、哈希表和原地标记都不合适。突破口是元素值都在 [1, n]:每个值都能作为下标,于是可把 i -> nums[i] 看成一条有向边。

从下标 0 开始反复执行 next = nums[next]。路径只有有限个位置可走,必然重复并进入环。设路径中第一个重复到达的节点为 d,它在路径上有两个不同前驱;而节点 v 的入度就是数值 v 在数组中的出现次数。题目只有一个重复数,所以 d 正是重复数,也是环入口。

为什么选 Floyd:问题已经转成“链表找环入口”,快慢指针能在线性时间、常数空间内完成,且不改数组。值域二分也满足空间要求,但每轮都要扫描数组,时间是 $O(n \log n)$,只适合作为备选。

不变量与正确性:第一阶段中,慢指针每轮走 1 步,快指针走 2 步;进入环后,快指针相对慢指针每轮前进 1 步,因此一定相遇。设起点到入口距离为 a,入口到相遇点距离为 b,环长为 c。相遇时两者路程差是整圈,得到 a + b ≡ 0 (mod c),即从相遇点到入口的距离与 a 同余。把一个指针放回 0,两者每次各走一步,下一次相遇点必是环入口,也就是重复数。

解题步骤

面试时可按下面 4 步口述:

  1. 把数组解释为函数图:下标 i 指向 nums[i]
  2. 0 出发,慢指针走一步、快指针走两步,直到在环内相遇。
  3. 将慢指针重置到 0,快指针留在相遇点;两者改为每次都走一步。
  4. 再次相遇的位置就是环入口,返回该下标。

[1,3,4,2,2] 为例,路径是 0 -> 1 -> 3 -> 2 -> 4 -> 2 ...。第一阶段相遇在 4;一个指针回到 0 后同步前进,最终在入口 2 相遇,因此重复数是 2

代码实现

class Solution {
    public int findDuplicate(int[] nums) {
        int slow = 0;
        int fast = 0;

        do {
            slow = nums[slow];
            fast = nums[nums[fast]];
        } while (slow != fast);

        slow = 0;
        while (slow != fast) {
            slow = nums[slow];
            fast = nums[fast];
        }
        return slow;
    }
}
func findDuplicate(nums []int) int {
    slow, fast := 0, 0
    for {
        slow = nums[slow]
        fast = nums[nums[fast]]
        if slow == fast {
            break
        }
    }

    slow = 0
    for slow != fast {
        slow = nums[slow]
        fast = nums[fast]
    }
    return slow
}

复杂度分析

  • 时间复杂度:$O(n)$。两个阶段的移动次数都不超过链长与环长的常数倍。
  • 空间复杂度:$O(1)$。只使用两个指针,且不修改输入数组。

关键点总结

  • “值域落在合法下标范围内”是把数组转换成函数图的信号。
  • 重复数对应入度大于 1 的节点;从 0 出发的路径第一次重复处就是环入口。
  • Floyd 第一阶段只保证在环内相遇,第二阶段才定位入口。
  • 若面试中暂时想不到 Floyd,可先给出值域二分的 $O(n \log n)$ 解法,再说明如何优化到线性时间。

易错点总结

  • 第一阶段的相遇点不一定是答案。[1,3,4,2,2] 中相遇点可为 4,入口才是 2
  • 第二阶段必须把一个指针重置到下标 0,并让两个指针都改为每次走一步。
  • 两个指针若都初始化为 0,不能先判断相等再移动;Java 用 do-while,Go 用先移动后判断的无限循环。
  • 异或或求和只适用于重复次数受限的模型,本题重复数可能出现多次。
  • 排序、哈希表、负号标记虽能找出答案,但分别违反“不修改数组”或“常数额外空间”的要求。

相似题目

题目 难度 考察点
141. 环形链表 简单 只判断环是否存在,不必求入口,是 Floyd 的第一阶段
142. 环形链表 II 中等 真链表上求环入口,本题第二阶段的原型
202. 快乐数 简单 环由数位平方和函数隐式生成,需判断环是否落在 1
457. 环形数组是否存在循环 中等 每个下标都要当起点试一遍,且环需方向一致、长度大于一
LCR 022. 环形链表 II 中等 142 的同题改编,可与哈希记录法对照复习
面试题 02.08. 环路检测 中等 同为求环入口,常被追问 $O(1)$ 空间与哈希解法的取舍
442. 数组中重复的数据 中等 允许修改数组,用正负号标记的原地哈希找出全部重复元素
645. 错误的集合 简单 同时求重复数与缺失数,考察值与下标的双向映射