题目介绍
力扣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)。