寻找重复数

题目介绍

力扣287题:https://leetcode-cn.com/problems/find-the-duplicate-number/
给定一个包含 n + 1 个整数的数组 nums ,其数字都在 1 到 n 之间(包括 1 和 n),可知至少存在一个重复的整数。

假设 nums 只有 一个重复的整数 ,找出 这个重复的数 。
示例 1:

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

示例 2:

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

示例 3:

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

进阶:

  • 如何证明 nums 中至少存在一个重复的数字?
  • 你可以在不修改数组 nums 的情况下解决这个问题吗?
  • 你可以只用常量级 O(1) 的额外空间解决这个问题吗?
  • 你可以设计一个时间复杂度小于 O(n2) 的解决方案吗?

分析

  • 怎样证明 nums 中存在至少一个重复值?其实很简单,这是“抽屉原理”(或者叫“鸽子洞原理”)的简单应用。
  • 这里,nums 中的每个数字(n+1个)都是一个物品,nums 中可以出现的每个不同的数字(n个)都是一个 “抽屉”。把n+1
    个物品放入n个抽屉中,必然至少会有一个抽屉放了2个或者2个以上的物品。所以这意味着nums中至少有一个数是重复的。

方法一:保存元素法(存入HashMap)

首先我们想到,最简单的办法就是,遍历整个数组,挨个统计每个数字出现的次数。用一个HashMap保存每个数字对应的count数量,就可以直观地判断出是否重复了。
代码实现如下:

// 方法一:使用HashMap保存每个数出现的次数
public int findDuplicate1(int[] nums){
    HashMap<Integer, Integer> countMap = new HashMap<>();
    // 遍历所有元素,统计count值
    for (Integer num: nums){
        // 判断当前num是否在map中出现过
        if (countMap.containsKey(num))
            return num;    // 如果出现过,num就是重复数
        else
            countMap.put(num, 1);
    }
    return -1;
}

方法二:保存元素法改进(存入Set)

当然我们应该还能想到,其实没必要用HashMap,直接保存到一个Set里,就知道这个元素到底有没有了。
代码实现如下:

// 方法二:使用HashSet保存数据,判断是否出现过
public int findDuplicate2(int[] nums){
     HashSet<Integer> hashSet = new HashSet<>();
     // 遍历所有元素,添加到set中
     for (Integer num: nums){
         // 判断当前num是否在map中出现过
         if (hashSet.contains(num))
             return num;    // 如果出现过,num就是重复数
         else
             hashSet.add(num);
     }
     return -1;
 }

复杂度分析

  • 时间复杂度:O(n),我们只对数组做了一次遍历,在HashMap和HashSet中查找的复杂度是O(1)。
  • 空间复杂度:O(n),我们需要一个HashMap或者HashSet来做额外存储,最坏情况下,这需要线性的存储空间。
  • 尽管时间复杂度较小,但以上两种保存元素的方法,都用到了额外的存储空间,这个空间复杂度不能让我们满意

方法三:二分查找

这道题目中数组其实是很特殊的,我们可以从原始的 [1, N] 的自然数序列开始想。现在增加到了N+1个数,根据抽屉原理,肯定会有重复数。对于增加重复数的方式,整体应该有两种可能:

  • 如果重复数(比如叫做target)只出现两次,那么其实就是1~N所有数都出现了一次,然后再加一个target;
  • 如果重复数target出现多次,那在情况1的基础上,它每多出现一次,就会导致1~N中的其它数少一个。

例如:1~9之间的10个数的数组,重复数是6:

1,2,5,6,6,6,6,6,7,9

本来最简单(重复数出现两次,其它1~9的数都出现一次)的是

1,2,3,4,5,6,6,7,8,9

现在没有3、4和8,所以6会多出现3次。
我们可以发现一个规律:

  • 以target为界,对于比target小的数i,数组中所有小于等于它的数,最多出现一次(有可能被多出现的target占用了),所以总个数不会超过i。
  • 对于比target大的数j,如果每个元素都只出现一次,那么所有小于等于它的元素是j个;而现在target会重复出现,所以总数一定会大于j。

用数学化的语言描述就是:

我们把对于1~N内的某个数i,在数组中小于等于它的所有元素的个数,记为count[i]。则:当i属于[1, target-1]范围内,count[i] <= i;当i属于[target, N]范围内,count[i] > i。

所以要找target,其实就是要找1N中这个分界的数。所以我们可以对1N的N个自然数进行二分查找,它们可以看作一个排好序的数组,但不占用额外的空间。
代码实现如下:

// 方法四:二分查找,查找1~N的自然数序列,寻找target
public int findDuplicate4(int[] nums){
    // 定义左右指针
    int left = 1;
    int right = nums.length - 1;

    while (left <= right){
        // 计算中间值
        int mid = (left + right) / 2;

        // 对当前的mid计算count值
        int count = 0;
        for (int j = 0; j < nums.length; j++){
            if (nums[j] <= mid) count ++;
        }

        // 判断count和mid本身的大小关系
        if (count <= mid)
            left = mid + 1;    // count小于等于mid自身,说明mid比target小,左指针右移
        else
            right = mid;

        // 左右指针重合时,找到target
        if (left == right)
            return left;
    }
    return -1;
}

复杂度分析

  • 时间复杂度:O(nlog n),其中 n 为nums[] 数组的长度。二分查找最多需要O(logn)次,而每次判断count的时候需要O(n) 遍历 nums[] 数组求解小于等于 i 的数的个数,因此总时间复杂度为O(nlogn)。
  • 空间复杂度:O(1)。我们只需要常数空间存放若干变量。

方法四:快慢指针法(循环检测)

这是一种比较特殊的思路。把nums看成是顺序存储的链表,nums中每个元素的值是下一个链表节点的地址。那么如果nums有重复值,说明链表存在环,本问题就转化为了找链表中环的入口节点,因此可以用快慢指针解决。
比如数组:

[3,6,1,4,6,6,2]

保存为:
在这里插入图片描述
整体思路如下:
第一阶段,寻找环中的节点

  • a)初始时,都指向链表第一个节点nums[0];
  • b)慢指针每次走一步,快指针走两步;
  • c)如果有环,那么快指针一定会再次追上慢指针;相遇时,相遇节点必在环中

第二阶段,寻找环的入口节点(重复的地址值)

  • d)重新定义两个指针,让before,after分别指向链表开始节点,相遇节点
  • e)before与after相遇时,相遇点就是环的入口节点

第二次相遇时,应该有:

慢指针总路程 = 环外0到入口 + 环内入口到相遇点 (可能还有 + 环内m圈)
快指针总路程 = 环外0到入口 + 环内入口到相遇点 + 环内n圈

并且,快指针总路程是慢指针的2倍。所以:

环内n-m圈 = 环外0到入口 + 环内入口到相遇点。

把环内项移到同一边,就有:

环内相遇点到入口 + 环内n-m-1圈 = 环外0到入口

这就很清楚了:从环外0开始,和从相遇点开始,走同样多的步数之后,一定可以在入口处相遇。所以第二阶段的相遇点,就是环的入口,也就是重复的元素。
代码实现如下:

// 方法五:快慢指针
public int findDuplicate(int[] nums){
    // 定义快慢指针
    int fast = 0, slow = 0;
    // 1. 寻找环内的相遇点
    do {
        // 快指针一次走两步,慢指针一次走一步
        slow = nums[slow];
        fast = nums[nums[fast]];
    }while (fast != slow);

    // 循环结束,slow和fast相等,都是相遇点
    // 2. 寻找环的入口点
    // 另外定义两个指针,固定间距
    int before = 0, after = slow;
    while (before != after){
        before = nums[before];
        after = nums[after];
    }

    // 循环结束,相遇点就是环的入口点,也就是重复元素
    return before;
}

复杂度分析

  • 时间复杂度:O(n),不管是寻找环上的相遇点,还是环的入口,访问次数都不会超过数组长度。
  • 空间复杂度:O(1),我们只需要定义几个指针就可以了。

通过快慢指针循环检测这样的巧妙方法,实现了在不额外使用内存空间的前提下,满足线性时间复杂度O(n)。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值