给定一个包含 n + 1 个整数的数组 nums ,其数字都在 1 到 n 之间(包括 1 和 n),可知至少存在一个重复的整数。
假设 nums 只有 一个重复的整数 ,找出 这个重复的数 。
你设计的解决方案必须不修改数组 nums 且只用常量级 O(1) 的额外空间。
示例 1:
输入: nums = [1,3,4,2,2]
输出: 2
示例 2:
输入:nums = [3,1,3,4,2]
输出:3
示例 3
输入:nums = [1,1]
输出:1
示例 4:
输入:nums = [1,1,2]
输出:1
提示:
- 1 <= n <=
- nums.length == n + 1
- 1 <= nums[i] <= n
- nums 中 只有一个整数 出现 两次或多次 ,其余整数均只出现 一次
要找出重复的整数,并且空间复杂度满足O(1), 首先想到的是暴力解法,直接两次遍历,找出第一个重复的数即为结果,时间复杂度O(),空间复杂度O(1)。虽然,空间复杂度满足条件,但时间复杂度太高。
涉及到重复判断,可以考虑标记法,当标记出现次数大于1,说明为重复数。因为数值范围在[1, n]之间,可以在每个元素值对应的索引位置标记负号,表示已经访问过。例如数组[1, 3, 4, 2, 2]:
- 1所在索引,nums[1]的值标记为-nums[1], 结果为-3;
- 3所在索引,nums[3]的值标记为-nums[3], 结果为-2;
- 4所在索引, nums[4]的值标记为-nums[4],结果为-2;
- 2所在索引,nums[2]的值标记为-nums[2],结果为-3;
- 2所在索引,因为nums[2]值为-3,表明已经访问过了,所以重复值为2; 找到结果后,再遍历一次数组,将所有数取绝对值,恢复原值。
/**
* 标示法,因为数组范围在[1, n], 可以在每个元素值对应的所以位置添加符号标示第一次遇到。
* 时间复杂度O(N),空间复杂度O(1)
* @param {number[]} nums
* @return {number}
*/
var findDuplicate = function(nums) {
if (!nums || nums.length <= 1) {
throw new Error('nums长度必须大于1.')
}
const len = nums.len, maxVal = len - 1
let result
for (let i = 0; i < nums.length; i++) {
// 在对应位置数字加上特别标示, 例如负号标示
const val = Math.abs(nums[i])
// nums的值在[1, len]之间
if (nums[val] > 0) {
nums[val] = -nums[val]
} else {
// 之前已经遍历过,当前为第二次遇到,说明重复了
result = val
}
}
// 复原数组, 取绝对值
for (let i = 0; i < nums.length; i++) {
nums[i] = Math.abs(nums[i])
}
return result
};
实现的时间复杂度O(N),空间复杂度O(1),但由于修改了原数组,所以还是不满足题目要求。
观察重复数出现的位置特点,假如当前遍历到第i个位置,cnt[i]为nums数组中小于等于i的个数,例如数组[3,1,3,4,2], cnt[1] = 0, cnt[2] = 2, cnt[3] = 4,cnt[4] = 5, cnt[5] = 5。通过观察发现,如果重复数为i, 诺cnt[i]>i,说明重复数在i左边;否则,说明重复数在右边。
根据以上分析,可考虑使用二分查找法,若cnt[target] > target,将当前位置target预设为重复值,继续遍历左边列表。
/**
* 二分查找法
* 用cnt[i]记录i位置,小于等于数值的个数,
* 例如数组[1,3,4,2,2], cnt[1] = 1, cnt[2] = 3,
* 假设重复数为target,那么[1, target - 1]中所有数组满足cnt[i] <= i;
* 而[target, n]中所有数满足cnt[i] > i
* 因此可以采用二分查找法,找出重复的数字
* 间复杂度:O(nlogn),其中n为nums数组的长度。
* @param {number[]} nums
* @return {number}
*/
var findDuplicate = function(nums) {
if (!nums || nums.length <= 1) {
throw new Error('nums长度必须大于1.')
}
let l = 1, r = nums.length - 1, cnt = 0, result = -1
while (l <= r) {
let mid = (r + l) >> 1
cnt = 0
// cnt记录nums中小于等于mid的个数
for (i = 0; i < nums.length; i++) {
cnt += (nums[i] <= mid)
}
// 如果cnt为小于等于mid的个数, 如cnt <= mid,说明重复数在[mid, r]之间
// 如果cnt > mid,说明mid可能为重复数,但还需要判断是不是在[r, mid)中
// 例如数组[1,3,4,2,2], 初始l = 0, r = 4, cnt = 0, result = -1
// 第一次遍历: mid = 2, cnt = 3, cnt > mid, mid可能为重复值,但需要继续判断 result = 2, r = 1
// 第二次遍历:mid = 1, cnt = 1, cnt <= mid, 说明可能在右半部分, l = 2
// 遍历终止,result = 2为重复值
if (cnt <= mid) {
l = mid + 1
} else {
result = mid
r = mid - 1
}
}
return result
};
二分查找法的时间复杂度为O(NlogN),空间复杂度为O(1)。
回顾第一种方法,我们用负号来标记数组元素,修改了元素值,有没有其他标记法并且不修改元素值?题目中明确只有一个重复数,例如nums数组[1,3,4,2,2],和数组[1, 2, 3, 4]的区别在于多了一个重复数2,那么我们可以考虑用二进制统计法。
假如数组元素最高二进制位为bitMax,上述数组的bitMax为2,从0到bitMax遍历,设xnums中元素在i位二进制为1的统计和,y为[1, n - 1]数组各元素在第i位而金子为1的统计和。
我们知道本例的重复数为2,那么2所在的二进制位,x和y的大小关系是x > y, 所有我们只要找出所有x > y的二进制位,其表示的十进制数即为重复数。
/**
* 二进制法
* @param {number[]} nums
* @return {number}
*/
var findDuplicate = function(nums) {
if (!nums || nums.length <= 1) {
throw new Error('nums长度必须大于1.')
}
let bitMax = 31, n = nums.length, resultBit = 0
// 求长度n对应的最高二进制位, 因为必定有重复,所以数值范围为[1, n - 1]
while(!((n - 1) >> bitMax)) {
bitMax--
}
for (let i = 0; i <= bitMax; i++) {
let x = 0, y =0
for (let j = 0; j < n; j++) {
if (nums[j] & 1 << i) {
x += 1
}
// 这里j表示[1, n - 1],所以要排除0
if (j > 0 && (j & (1 << i))) {
y += 1
}
}
if (x > y) {
// 按位取或, 如101与011按位取或结果为111
resultBit |= 1 << i
}
}
return resultBit
};
二进制法时间复杂度为O(NlogN), 空间复杂度为O(1)。
以上实现方式都是围绕数组类型思考,寻找重复数类似于链表中的环路判断,对于重复的数值,必定存在环路,只要找到环路的入口,即找到了重复数。
本题可以采用链表中的快慢指针法(龟兔赛跑,又名Flyod判圈算法),先介绍下原理:
L为环走一圈的步数,L = b + c, 现在slow, fast从起点(最坐标位置)出发,slow每一次走一步,fast每一次走两步。如果在p点相遇时,则fast已经走了2(a + b)步;假设相遇时,fast已经延环饶了k圈,则有: 2(a + b) = a + b + kL,转换下公式可得a:
a = (k - 1)L + (L - b) = (k - l)L + c
通过上面的等式可得出,如果slow从起始位置出发,fast从p位置出发,当slow走了a步之后必定与fast在环入口处相遇。
由以上的分析,我们可以先求出p点,再根据p点位置推算出环入口位置,即重复数位置。
/**
* 「Floyd 判圈算法」(又称龟兔赛跑算法)
* @param {number[]} nums
* @return {number}
*/
var findDuplicate = function(nums) {
if (!nums || nums.length <= 1) {
throw new Error('nums长度必须大于1.')
}
let slow = 0, fast = 0
// 找出slow、fast相遇时p点的位置
do {
slow = nums[slow]
fast = nums[nums[fast]]
} while(slow != fast)
slow = 0
// 求出入口位置,也即重复数
while (slow != fast) {
slow = nums[slow]
fast = nums[fast]
}
return nums[fast]
};
该算法的时间复杂度为O(N),优于前几种方法,空间复杂度同样为O(1)。
算法系列目录地址: 算法系列
如果大家有其他问题可直接留言,一起探讨!最近我会持续更新Vue源码介绍、前端算法系列,感兴趣的可以持续关注。