找出1到n中重复的数字

背景:

很早以前在CSDN中MoreWindows的白话算法中看到,以为那个就是最佳的方法,后来在LeetCode中做到,再后来在和同学讨论中又深化了一下。

一道很经典的题目,算法无止境。

题目:

在数组nums[n + 1]中,数字都是1到n范围内的,那么至少有一个重复数字,找到它。

分析:

这其实是一道比较简单的算法题,但是,如果是面试题,面试官除了用时间复杂度和空间复杂度来约束你之后,还可能要求不能移动或改动数组(前者不能swap或sort,后者不能更改值)。这才是这道题的难点。所以接下来,我就用时间复杂度、空间复杂度,是否移动,是否改动原数组四个维度来判断算法。

并且希望大家看到这里的时候停顿一下,思考过后再看,不要直接看答案,这样是浪费了一道好题目。

方法1:

哈希:另外开创一个数组hash[],大小为n + 1,初始值都为0。因为原数组范围都在1到n内,所以我们可以直接通过对相应的位置上的hash[nums[i]]++来标志。

通过判断当前的hash[nums[i]]是否为0就可以知道其是否是重复的数字。

时间复杂度O(n),空间复杂度O(n),不用移动原数组,不用改动原数组。

代码如下:

int findDuplicate1(vector<int> nums) {
	vector<int>hash(nums.size() + 1);
	for (auto i : nums){
		if (hash[i] == 0)
			hash[i] ++;
		else
			return i;
	}
}


方法2:

排序:排序也是比较容易想到的方法,其实排序可以针对范围不止在1到n的数字。(这在另一方面就说明了排序的算法拓展了题目中的条件,所以是没有充分利用题目的条件,当然也就很难成为最优)

时间复杂度O(nlogn),空间复杂度O(1),需要移动原数组,不用改动原数组。

代码如下:


int findDuplicate2(vector<int> nums) {
     sort(nums.begin(), nums.end());
     for (int i = 1; i < nums.size(); ++i){
      if (nums[i] == nums[i - 1])
       return nums[i];
     }
}


 

方法3:

类基数排序:如果当前的数字不等于当前的位置,就把当前数字换到其对应的位置上去,然后依次类推,直到找到重复的元素为止。

相应的方法可以看:http://blog.csdn.net/morewindows/article/details/8204460

我这里举个栗子:比如对于nums为数组 [2, 4, 8, 5, 7, 6, 1, 9, 3, 2] 的情况。

nums[0]的位置是2,把2和它本来的位置上的数字nums[1]调换得到:[4, 2, 8, 5, 7, 6, 1, 9, 3, 2]

nums[0]的位置是4,把4和它本来的位置上的数字nums[3]调换得到:[5, 2, 8, 4, 7, 6, 1, 9, 3, 2]

nums[0]的位置是5,把5和它本来的位置上的数字nums[4]调换得到:[7, 2, 8, 4, 5, 6, 1, 9, 3, 2]

nums[0]的位置是7,把7和它本来的位置上的数字nums[6]调换得到:[1, 2, 8, 4, 5, 6, 7, 9, 3, 2]

nums[0]的位置是1,刚好!那么就不用调换,前进。

nums[1]的位置是2,刚好!那么就不用调换,前进。

nums[2]的位置是8,把8和它本来的位置上的数字nums[7]调换得到:[1, 2, 9, 4, 5, 6, 7, 8, 3, 2]

nums[2]的位置是9,把9和它本来的位置上的数字nums[8]调换得到:[1, 2, 3, 4, 5, 6, 7, 8, 9, 2]

nums[2]的位置是3,刚好!那么就不用调换,前进。

nums[3]的位置是4,刚好!那么就不用调换,前进。

nums[4]的位置是5,刚好!那么就不用调换,前进。

nums[5]的位置是6,刚好!那么就不用调换,前进。

nums[6]的位置是7,刚好!那么就不用调换,前进。

nums[7]的位置是8,刚好!那么就不用调换,前进。

nums[8]的位置是9,刚好!那么就不用调换,前进。

nums[9]的位置是2,而2的位置nums[1]上已经是2了,所以就找到了重复元素2。

时间复杂度O(n),空间复杂度O(1),需要移动原数组,不用改动原数组。

代码如下:

int findDuplicate3(vector<int> nums) {
	int n = nums.size();
	for (int i = 0; i < n; ++i){
		while (i != nums[i] - 1){
			if (nums[i] == nums[nums[i] - 1])
				return nums[i];
			swap(nums[i], nums[nums[i] - 1]);
		}
	}
	return -1;
}


方法4:

标记法:我们可以看到,其实在nums[n + 1]中,因为数字都是在1到n之内,所以nums[i]就像一个个指针,指着另外的位置上的数字。

有一个巧妙的办法是,我们遍历一遍数字,把nums[i]指向的数(即nums[nums[i]])做一个+n的操作,那么如果遇到一个nums[nums[i]]的值已经大于n了,说明这个数已经被其他数字指到过了,也就是找到了重复值。在执行的过程中,我们还要先判断一下nums[i]是否大于n(因为可能先前被别人指过所以+n了),用一个值来保存其原来的值。

具体的思路参考:http://blog.csdn.net/morewindows/article/details/8212446

时间复杂度O(n),空间复杂度O(1),不用移动原数组,需要改动原数组。

代码如下:


int findDuplicate4(vector<int> nums) {
	int n = nums.size();
	for (int i = 0; i < n; ++i){
		int next = nums[i] - 1;
		if (nums[i] > n)
			next -= n;
		if (nums[next] > n)
			return next + 1;
		else
			nums[next] += n;
	}
	return -1;


方法5:(举一反三,适合教程)

二分搜索法:二分搜索其实是个很神奇的东西,用的好可以举一反三用在各种地方,其实,关键就是你把left和right当做是哪里的值,一般人都只是把这两个当做是数组中的min和max(在本题1和n),或者是数组中的头尾(本题中的nums[0]和nums[n])。所以,适用不了。

其实,换个思路,我们只要把left和right当做是ans的下上界限就能别有洞天。接着开展二分搜索中的搜索过程:mid取中值,那么nums中的数就被分成了[left - mid - right]两端了。

然后我们遍历一遍nums,统计所有<=mid的值count,如果left + count > mid + 1,说明[ left - mid ]段的数字中存在重复(ans为其区间的值),所以令right = mid。

反之就是[ mid - right ]的数字,所以令left = mid + 1;

知道其结束条件即可。

时间复杂度O(nlogn),空间复杂度O(1),不用移动原数组,不用改动原数组。

代码如下:

int findDuplicate5(vector<int> nums) {
	int n = nums.size();
	int left = 1, right = n;
	int mid, count;
	while (left < right)
	{
		count = 0;
		mid = (left + right) / 2;
		for (int i = 0; i < n; ++i)
		{
			if (nums[i] >= left && nums[i] <= mid)
				++count;
		}
		if (left + count > mid + 1)
			right = mid;
		else
			left = mid + 1;
	}
	return left;
}


 

方法6:(最佳)

链表找环法:首先,我们来复习一下链表找环:参考:http://blog.csdn.net/xudacheng06/article/details/7706245

有一个链表,要确定其中是否有环,以及环的入口:

寻找环的入口点: 

设置两个步长的指针:fast和slow,初始值都设置为头指针。其中fast每次2步,slow每次一步,发现fast和slow重合,确定了单向链表有环路。接下来,让fast回到链表的头部,重新走,每次步长1,那么当fast和slow再次相遇的时候,就是环路的入口了。

证明:在fast和slow第一次相遇的时候,假定slow走了n步,环路的入口是在p步,那么
           slow走的路径: p+c = n; c为fast和slow相交点 距离环路入口的距离
           fast走的路径: p+c+k*L = 2*n; L为环路的周长,k是整数
          显然,如果从p+c点开始,slow再走n步的话,还可以回到p+c这个点。
          同时,fast从头开始走,步长为1,经过n步,也会达到p+c这点。
          显然,在这个过程中fast和slow只有前p步骤走的路径不同。所以当p1和p2再次重合的时候,必然是在链表的环路入口点上。

请仔细理解上面这一段话,必要时自己画一画。

那么重点是:为什么本题能抽象为一个链表找环的问题?一定会有环的存在吗?一定会有环外的p的部分吗?

我们先来看[2, 4, 8, 5, 7, 6, 1, 9, 3, 2] 的栗子,

其中,如果按照2指向的是nums[1]即为4这样的思路:它们形成的图如下:

其中,因为每个数字都会指向其他数字,或指向自己,所以可能会有多个环或一个环,并且因为有重复数字,所以它所占的位置会被别人多次指到,相当于多了圆环中的两个节点的部分,所以它会是有一个把手突出,并且因为有0这个位置 ,但是没有0这个元素,所以一定会有从0开始的环外p部分。

以上几点说明,在本题是一定会形成带把手的环的形状,并且环的起点就是重复的元素。

时间复杂度O(n),空间复杂度O(1),不用移动原数组,不用改动原数组。

代码如下:

int findDuplicate6(vector<int> nums) {
	int slow = 0;
	int fast = 0;
	do{
		slow = nums[slow];
		fast = nums[nums[fast]];
	} while (slow != fast);
	fast = 0;
	while (fast != slow){
		fast = nums[fast];
		slow = nums[slow];
	}
	return fast;
}

方法7:(适用于只有一个重复元素且只重复一次的情况)

数学平方和法:如果题目中重复元素只重复一次的话,或者改一下题目,对于nums[n],有一个数字没有出现,还有一个数字重复。我们还可以用数学的方法来解决。

不妨设消失的数字为a,重复的数字为b。我们的思路是找到两个二元方程,就可以解出a和b。

如果按照原本的数字排列,有其总和为1+2+...+n = n*(1+n)/2。

有其平方和为1^2+2^2+...+n^2 = n*(n+1)*(2*n+1)/6。

现在的总和为1+2+...+n-a+b,

现在的平方和为1^2+2^2+...+n^2-a^2+b^2。

所以可以计算出a和b。

时间复杂度O(n),空间复杂度O(1),不用移动原数组,不用改动原数组。

代码如下:

int findDuplicate7(vector<int> nums) {//适用于只有一个重复元素,且该元素只重复一次
	int n = nums.size();
	int sum = 0;//sum of ni now
	int sum2 = 0;//sum of ni^2 now
	int origSum = (1 + n) * n / 2;//sum of (1+2+...+n)
	int origSum2 = n * (n + 1) * (2 * n + 1) / 6;//sum of (1^2+2^2+...+n^2)
	for (auto i : nums){
		sum += i;
		sum2 += i * i;
	}
	//a is the missing number, b is the duplicate number
	int a2minusb2 = origSum2 - sum2;
	int aminusb = origSum - sum;
	int aplusb = a2minusb2 / aminusb;
	int b = (aplusb - aminusb) / 2;
	return b;
}



 

结果截图:

参考资料:

其中方法3,4参考MoreWindows:

http://blog.csdn.net/morewindows/article/details/8204460

和http://blog.csdn.net/morewindows/article/details/8212446

方法5,6参考LeetCode的Discuss:

https://leetcode.com/discuss/questions/oj/find-the-duplicate-number

链表找环的参考:http://blog.csdn.net/xudacheng06/article/details/7706245

猜你喜欢

转载自blog.csdn.net/coolwriter/article/details/81362533
今日推荐