背景:
很早以前在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