[二分查找 双指针 位运算] 287.寻找重复数(值域二分、位运算、快慢指针找环入口)
287.寻找重复数
题目链接:https://leetcode-cn.com/problems/find-the-duplicate-number/
分类:
- 二分查找(对数组元素的值域做二分寻找重复数值)
- 位运算(统计数组所有元素二进制下每一列1的个数a[i],[1~n]所有元素二进制下每一列1的个数b[i],a[i] > b[i]则重复数的第i位值为1)
- 快慢指针法(将数组转换成带环的链表、快慢指针寻找环入口 = 重复数)
题目分析
本题给定的nums数组内的元素的取值范围是固定的,大小=n+1的数组,元素值范围在[1,n]上,根据抽屉原理数组内至少存在一个重复值,假设只有一个这样的重复值,需要我们找出来。
同时题目给出了三个限制条件:
1、不能修改原数组;
2、只能使用O(1)的空间;
3、时间复杂度小于O(N^2)
这些限制条件实际上就表示下列“在值域有限的数组里寻找一个重复数”的方法被禁用了:
- 排序法:将数组排序,然后遍历寻找重复数。违反了条件1。
- 哈希表:统计每个数字的出现次数,当一个数字出现次数>1时即找到重复数。违反了条件2。
- 计数排序(原地哈希):元素值范围固定在[1,n],可以直接利用原数组统计每个数字的出现次数(下标=元素值,元素值=出现次数)。违反了条件1;
- 暴力解法:枚举所有元素,对于每个元素都遍历一次数组寻找是否有和它相同的数字。违反条件3。
根据题目提示,时间复杂度小于O(N^2),我们可以猜想是不是可以使用二分查找来寻找重复数,但这里的二分查找和一般二分查找有所不同:
一般的二分查找是索引二分,不断缩小查找的索引范围最终定位到目标元素,前提是待查找数组是有序的;
这里的二分查找是值域二分,不断缩小查找的值域范围最终定位到目标数值,前提是待查找数组的元素值范围是有限的,固定的,但不要求数组本身有序。值域二分的理论依据是抽屉原理。
- 思路1值域二分是比较通用的解法,而快慢指针(思路3)更有技巧性,但并不通用。
- 思路2是从位运算的角度解题,隐含的规律比较简单,但这个角度比较难想到。
思路1:值域二分(非常规二分,时间换空间,O(NlogN))
题目给定的数组nums内的元素值范围在[1,n]上,一趟二分流程为:
获取值域的中位点mid=(1+n)/2,然后遍历整个数组,统计数组中元素值 <= mid的个数cnt,然后判断cnt的大小:
- 如果cnt > mid,根据抽屉原理,元素值<=mid的个数>mid,说明重复数的数值在[left,mid]范围内(对值域做了二分);
- 如果cnt <= mid,说明重复数的数值在[mid+1,right]范围内。
重复上述步骤,直到left==right,说明找到这个重复数的数值。
例如:[1,3,4,2,2],值域范围[1,4]
left=1,right=4,mid=2,遍历数组,统计<=mid的元素个数cnt=3>mid,所以重复数的数值在[1,2]之间;
left=1,right=2,mid=1,遍历数组,统计<=mid的元素个数cnt=1==mid,所以重复数的数值在[2,2]之间。
因为left==right,所以退出二分查找过程,返回left.
实现代码:
class Solution {
public int findDuplicate(int[] nums) {
int len = nums.length;
int left = 1, right = len - 1;//nums元素值的值域上下界
while(left < right){
//获取值域的中位点
int mid = left + (right - left) / 2;
//遍历数组,元素值统计<=mid的个数
int cnt = 0;
for(int num : nums){
if(num <= mid) cnt++;
}
//如果cnt>mid,则重复数的数值在[left,mid]上
if(cnt > mid) right = mid;
//如果cnt<=mid,则重复数的数值在[mid+1,right]上
else left = mid + 1;
}
return left;
}
}
- 时间复杂度:对值域[1,n]不断二分,总共需要O(logN)趟二分,每一趟二分查找都需要遍历整个数组,需要O(N),所以整体时间复杂度为O(NlogN)
- 空间复杂度:O(1).
思路2:位运算(O(N))
参考题解:详细通俗的思路分析,多解法
int型共32位,我们统计数组nums所有元素的二进制形式下每一列出现1的个数a,再统计[1~n]的二进制形式下每一列出现1的个数b,如果数组中存在一个重复数字k,那么如果k的二进制形式中第i位的值为1,则都有a[i]>b[i],我们找出32位中a[i]>b[i]的位并置1,就能得到重复数字k。
例如:[1,3,4,2,2]
统计数组元素二进制形式下每一列1的个数:
1 0001
3 0011
4 0100
2 0010
2 0010
a 0132
再统计[1~n]二进制形式下每一列1的个数:
1 0001
2 0010
3 0011
4 0100
b 0122
可以发现:a[2]>b[2],所以将一个全0数的第2位置1就是重复数字0010=2.
这个结论没有严格的证明,但可以这样归纳:
- 如果重复数字k只重复一次,则数组的元素包含[1~n]上的每一个数值,在此基础上还包含一个k,在统计数组所有元素的二进制每一列出现1的个数a和[1~n]所有元素每一列出现1的个数b时,数组的统计结果就是在[1~n]的统计结果的基础上增加一个k,所以在k的二进制形式第i位的值为1时,a[i]很明显>b[i]。
- 如果重复数字k重复不止1次,也就是在k只重复一次的基础上,选择数组中几个元素转换成k,被转换的元素在k值为1的位上无非有两种转换情况:原来值为0,转换成了1;原来值为1,则保持不变,在k只重复一次的基础上就存在a[i]>b[i],两种转换情况下都不会使a统计的1数量减少,最差也是持平的情况,所以a[i]>b[i]始终成立。
实现遇到的问题:
1、如何判断一个int型数字的第i位是否等于1?
设置一个掩码mask,例如要查看第2位,就只保留该数字的第2位,其他位都置0:
mask=(1 << 2);//0100
和数字做与操作,相当于只保留这个数字的第2位,其他位全部置0,如果这个数字的第2位是0,则相与的结果=0,如果第2位是1,则相与结果>0.
所以,根据数字和mask的相与结果是否>0可以判断这一位是否等于1.
2、如何在一趟遍历里同时统计数组所有元素和[1~n]所有元素第i位1的个数?
for-j循环遍历nums数组,遍历的起点是0,终点是n,拿nums[j]&mask就能统计数组所有n+1个元素;
同样直接拿j & mask就能统计1~n所有元素,因为j=0时,j&mask必定==0,b的值不会改变,所以相当于只统计1~n。
实现代码:
class Solution {
public int findDuplicate(int[] nums) {
int res = 0;//存放重复数字
//遍历所有元素的32位
for(int i = 0; i < 32; i++){
int a = 0, b = 0;//记录数组和[1~n]所有元素在第i位上出现1的个数
int mask = (1 << i);//掩码
//一趟遍历统计数组所有元素和[1~n]所有元素在第i位值为1的个数a,b
for(int j = 0; j < nums.length; j++){
if((nums[j] & mask) > 0) a++;
//
if((j & mask) > 0) b++;
}
//如果a>b,则将res的第i位置1
if(a > b){
res |= mask;
}
}
return res;
}
}
思路3:快慢指针法(数组 → 链表,环入口 = 重复数,O(N))
将数组转换成链表,其中元素下标作为节点的val,元素值作为节点的next,例如:
可以发现,重复数字就是链表中环的入口节点,问题转化成在链表里寻找环的入口节点。
我们可以使用快慢指针来寻找环入口:
- 快指针每次移动两个节点,慢指针每次移动一个节点,不断循环遍历数组,直到两个指针相遇,记下相遇节点的位置。
- 再使用两个同步移动的指针,一个从链表头结点出发,一个从相遇节点出发,两个节点相遇的节点就是环的入口节点,该节点的val就是重复值 == 元素下标。
如何将数组转换成链表?
只是逻辑上的转换,设指针为p,从一个节点移动到下一个节点就是:p = nums[p],所以快慢指针就按下面的代码工作:
slow=nums[slow];//一次移动一个节点
fast=nums[nums[fast]];//一次移动两个节点
实现代码:
class Solution {
public int findDuplicate(int[] nums) {
int slow = 0, fast = 0;
//快慢指针寻找相遇点
while(true){
slow = nums[slow];
fast = nums[nums[fast]];
if(slow == fast) break;
}
//指针复用,一个从起点开始,一个从相遇点开始同步移动,寻找环入口
slow = 0;
while(slow != fast){
slow = nums[slow];
fast = nums[fast];
}
return slow;
}
}
- 时间复杂度:O(N)
- 空间复杂度:O(1)