算法:寻找重复数

1,889 阅读3分钟

给定一个包含 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 = [1,1]
输出:1

示例 4:

输入:nums = [1,1,2]
输出:1

提示:

  • 1 <= n <= 10510^5
  • nums.length == n + 1
  • 1 <= nums[i] <= n
  • nums 中 只有一个整数 出现 两次或多次 ,其余整数均只出现 一次

要找出重复的整数,并且空间复杂度满足O(1), 首先想到的是暴力解法,直接两次遍历,找出第一个重复的数即为结果,时间复杂度O(N2N^2),空间复杂度O(1)。虽然,空间复杂度满足条件,但时间复杂度太高。

涉及到重复判断,可以考虑标记法,当标记出现次数大于1,说明为重复数。因为数值范围在[1, n]之间,可以在每个元素值对应的索引位置标记负号,表示已经访问过。例如数组[1, 3, 4, 2, 2]:

  • 1所在索引,nums[1]的值标记为-nums[1], 结果为-3;
  • 3所在索引,nums[3]的值标记为-nums[3], 结果为-2;
  • 4所在索引, nums[4]的值标记为-nums[4],结果为-2;
  • 2所在索引,nums[2]的值标记为-nums[2],结果为-3;
  • 2所在索引,因为nums[2]值为-3,表明已经访问过了,所以重复值为2; 找到结果后,再遍历一次数组,将所有数取绝对值,恢复原值。
/**
 * 标示法,因为数组范围在[1, n], 可以在每个元素值对应的所以位置添加符号标示第一次遇到。
 * 时间复杂度O(N),空间复杂度O(1)
 * @param {number[]} nums
 * @return {number}
 */
 var findDuplicate = function(nums) {
    if (!nums || nums.length <= 1) {
        throw new Error('nums长度必须大于1.')
    }
    const len = nums.len, maxVal = len - 1

    let result
    for (let i = 0; i < nums.length; i++) {
        // 在对应位置数字加上特别标示, 例如负号标示
        const val = Math.abs(nums[i])
        // nums的值在[1, len]之间
        if (nums[val] > 0) {
            nums[val] = -nums[val] 
        } else {
            // 之前已经遍历过,当前为第二次遇到,说明重复了
            result = val
        }
    }

    // 复原数组, 取绝对值
    for (let i = 0; i < nums.length; i++) {
        nums[i] = Math.abs(nums[i])
    }

    return result
};

实现的时间复杂度O(N),空间复杂度O(1),但由于修改了原数组,所以还是不满足题目要求。

观察重复数出现的位置特点,假如当前遍历到第i个位置,cnt[i]为nums数组中小于等于i的个数,例如数组[3,1,3,4,2], cnt[1] = 0, cnt[2] = 2, cnt[3] = 4,cnt[4] = 5, cnt[5] = 5。通过观察发现,如果重复数为i, 诺cnt[i]>i,说明重复数在i左边;否则,说明重复数在右边。
未命名文件.jpg
根据以上分析,可考虑使用二分查找法,若cnt[target] > target,将当前位置target预设为重复值,继续遍历左边列表。

/**
 * 二分查找法
 * 用cnt[i]记录i位置,小于等于数值的个数, 
 * 例如数组[1,3,4,2,2], cnt[1] = 1, cnt[2] = 3, 
 * 假设重复数为target,那么[1, target - 1]中所有数组满足cnt[i] <= i;
 * 而[target, n]中所有数满足cnt[i] > i
 * 因此可以采用二分查找法,找出重复的数字
 * 间复杂度:O(nlogn),其中n为nums数组的长度。
 * @param {number[]} nums
 * @return {number}
 */
 var findDuplicate = function(nums) {
    if (!nums || nums.length <= 1) {
        throw new Error('nums长度必须大于1.')
    }
    let l = 1, r = nums.length - 1, cnt = 0, result = -1

    while (l <= r) {
       let mid = (r + l) >> 1
       cnt = 0
       // cnt记录nums中小于等于mid的个数
       for (i = 0; i < nums.length; i++) {
           cnt += (nums[i] <= mid)
       }

       // 如果cnt为小于等于mid的个数, 如cnt <= mid,说明重复数在[mid, r]之间
       // 如果cnt > mid,说明mid可能为重复数,但还需要判断是不是在[r, mid)中
       // 例如数组[1,3,4,2,2], 初始l = 0, r = 4, cnt = 0, result = -1
       // 第一次遍历: mid = 2, cnt = 3, cnt > mid, mid可能为重复值,但需要继续判断 result = 2, r = 1
       // 第二次遍历:mid = 1, cnt = 1, cnt <= mid, 说明可能在右半部分, l = 2
       // 遍历终止,result = 2为重复值
       if (cnt <= mid) {
           l = mid + 1
       } else { 
           result = mid
           r = mid - 1
       }
    }

    

    return result
};

二分查找法的时间复杂度为O(NlogN),空间复杂度为O(1)。

回顾第一种方法,我们用负号来标记数组元素,修改了元素值,有没有其他标记法并且不修改元素值?题目中明确只有一个重复数,例如nums数组[1,3,4,2,2],和数组[1, 2, 3, 4]的区别在于多了一个重复数2,那么我们可以考虑用二进制统计法。

假如数组元素最高二进制位为bitMax,上述数组的bitMax为2,从0到bitMax遍历,设xnums中元素在i位二进制为1的统计和,y为[1, n - 1]数组各元素在第i位而金子为1的统计和。 二进制法.jpg 我们知道本例的重复数为2,那么2所在的二进制位,x和y的大小关系是x > y, 所有我们只要找出所有x > y的二进制位,其表示的十进制数即为重复数。

/**
 * 二进制法
 * @param {number[]} nums
 * @return {number}
 */
 var findDuplicate = function(nums) {
    if (!nums || nums.length <= 1) {
        throw new Error('nums长度必须大于1.')
    }
    let bitMax = 31, n = nums.length, resultBit = 0
    
    // 求长度n对应的最高二进制位, 因为必定有重复,所以数值范围为[1, n - 1]
    while(!((n - 1) >> bitMax)) {
        bitMax--
    }
    for (let i = 0; i <= bitMax; i++) {
        let x = 0, y =0
        for (let j = 0; j < n; j++) {
            if (nums[j] & 1 << i) {
                x += 1
            }
             // 这里j表示[1, n - 1],所以要排除0
            if (j > 0 && (j & (1 << i))) {
                y += 1
            }
        }
        if (x > y) {
            // 按位取或, 如101与011按位取或结果为111
            resultBit |= 1 << i
        }
    }

    return resultBit 
};

二进制法时间复杂度为O(NlogN), 空间复杂度为O(1)。

以上实现方式都是围绕数组类型思考,寻找重复数类似于链表中的环路判断,对于重复的数值,必定存在环路,只要找到环路的入口,即找到了重复数。

本题可以采用链表中的快慢指针法(龟兔赛跑,又名Flyod判圈算法),先介绍下原理:

龟兔赛跑法.jpg
L为环走一圈的步数,L = b + c, 现在slow, fast从起点(最坐标位置)出发,slow每一次走一步,fast每一次走两步。如果在p点相遇时,则fast已经走了2(a + b)步;假设相遇时,fast已经延环饶了k圈,则有: 2(a + b) = a + b + kL,转换下公式可得a:

a = (k - 1)L + (L - b) = (k - l)L + c

通过上面的等式可得出,如果slow从起始位置出发,fast从p位置出发,当slow走了a步之后必定与fast在环入口处相遇。

由以上的分析,我们可以先求出p点,再根据p点位置推算出环入口位置,即重复数位置。

/**
 *  「Floyd 判圈算法」(又称龟兔赛跑算法)
 * @param {number[]} nums
 * @return {number}
 */
 var findDuplicate = function(nums) {
    if (!nums || nums.length <= 1) {
        throw new Error('nums长度必须大于1.')
    }
    let slow = 0, fast = 0
    // 找出slow、fast相遇时p点的位置
    do {
        slow = nums[slow]
        fast = nums[nums[fast]]
    } while(slow != fast)
    
    slow = 0
    // 求出入口位置,也即重复数
    while (slow != fast) {
        slow = nums[slow]
        fast = nums[fast]
    }
    
    return nums[fast]
};

该算法的时间复杂度为O(N),优于前几种方法,空间复杂度同样为O(1)。

算法系列目录地址: 算法系列

如果大家有其他问题可直接留言,一起探讨!最近我会持续更新Vue源码介绍、前端算法系列,感兴趣的可以持续关注