【面试题03】数组中重复的数字
难度: 简单
要求: 找出数组中重复的数字
限制: 2 <= n <= 100000
在一个长度为 n 的数组 nums 里的所有数字都在 0~n-1 的范围内。数组中某些数字是重复的,但不知道有几个数字重复了,也不知道每个数字重复了几次。请找出数组中任意一个重复的数字。例如,如果输入长度为 7 的数组 {2, 3, 1, 0, 2, 5, 3},那么对应的输出是重复的数字 2 或者 3。
Leetcode题目对应位置: 面试题03:数组中重复的数字
思路一:排序
先对数组 nums 进行排序,然后从前向后遍历数组,看相邻元素中是否有相同的,相同即为我们要找的重复目标,直接 return。排序的目的就是将所有相同的数字挨到一起,这样就只需要和自己的下一位比较就能获取到重复值。
时间复杂度:O(nlogn),排序一个长度为 n 的数组需要 O(nlogn) 的时间。
空间复杂度:O(1)
特点:
- 1)此方法改变了原列表,要注意题设条件。
- 2)时间复杂度较高,不太适合题设关注时间复杂度的情况
# python
class Solution:
def findRepeatNumber(self, nums) -> int:
nums.sort()
n = len(nums)
for i in range(n):
if nums[i + 1] == nums[i]:
return nums[i]
return -1
细节问题:
1)不要将 len(nums)
写在 for 循环里,避免每次循环都要计算数组长度
2)关于 python 自带的 sort 函数的实现机制、采用了什么排序算法参考以下文章:
python sort函数内部实现原理
思路二:哈希表
创建一个哈希表,以列表元素作为键值。若当前值不在哈希表内,则将哈希表对应位置置1,否则就是找到了重复值,直接return。
时间复杂度:O(n),最坏情况下需要遍历整个数组。
空间复杂度:O(n),需要用辅助空间创建哈希表。
第一种: 使用一个 List 做哈希表,因为题目说了元素范围不会超过 0~n-1,所以初始化一个长度为 n 的列表是完全能包含所有元素在内的。然后使用元素本身作为键值(key),让 key 对应的 value 具有辨识度即可。意思就是不论是将 value 作为一个状态置 1,还是作为一个计数器 +1 都可以。
比较推荐作为一个状态位,若 key 对应的 value 为 0 说明还没有碰到过这个数,将 value 置 1,否则说明已经重复了,直接 return。
class Solution:
def findRepeatNumber(self, nums) -> int:
n = len(nums)
table = [0] * n
for i in range(n):
if table[nums[i]] == 0:
table[nums[i]] = 1 # 作为状态位置1
else:
return nums[i]
return -1
class Solution:
def findRepeatNumber(self, nums) -> int:
c = [0] * len(nums)
for i in range(len(nums)):
c[nums[i]] += 1 # 作为计数器
if c[nums[i]] > 1:
return nums[i]
return -1
第二种: 直接用 python 的字典做哈希表,因为字典的 key 本身就会被散列到一个地址上,所以也能实现 O(1) 的查找速度。
# python
class Solution:
def findRepeatNumber(self, nums: List[int]) -> int:
repeatDict = {}
for num in nums:
if num not in repeatDict:
repeatDict[num] = 1
else:
return num
特点:
- 1)哈希表方法需要额外的辅助空间,是一种以空间换时间的思路
- 2)不需要修改原数组
思路三:原地哈希 / 鸽巢原理 ⭐
由于题目中给了条件说列表元素值不会超过 列表长度 - 1
,所以可以用列表的位置
i
i
i 来存放元素
i
i
i。假设这个数组中没有重复的数字,那么数组排序之后数字
i
i
i 就会出现在下标为
i
i
i 的位置。由于实际上有重复数字,那么数组排序之后有些位置可能没有数字,而有些位置可能存在多个数字。那么只要进行原地排序,一旦发现某个位置
i
i
i 有第二个数值要存进来,就说明该数字是重复的。具体实现如下:
1)如果位置
i
i
i 的元素值
m
m
m 刚好等于
i
i
i,则不做处理继续扫描下一个元素;
2)若位置
i
i
i 的元素值
m
m
m 不等于
i
i
i,则将位置
i
i
i 上的元素与位置
m
m
m 上的元素做交换,使得位置
m
m
m 的索引和元素都等于
m
m
m。
3)当要将位置
i
i
i 上的元素
m
m
m 放到正确位置
m
m
m 上时,若发现位置
m
m
m 上的元素就是
m
m
m,则说明已经找到了重复的元素,直接 return。
时间复杂度:O(n)
空间复杂度:O(1),不需要额外的辅助空间。
class Solution:
def findRepeatNumber(self, nums: List[int]) -> int:
n = len(nums)
for i in range(n):
while nums[i] != i: # 这里必须是while
if nums[nums[i]] == nums[i]:
return nums[i]
nums[nums[i]], nums[i] = nums[i], nums[nums[i]]
细节问题:
1)注意上面 for 循环内部第一层必须是 while,不能写成 if,否则会漏判,比如测试用力 [1, 2, 4, 3, 2] 用 if 会漏掉 2。这是因为在顺序遍历数组 nums 时,每遇到一个数组元素 nums[i] 是将该元素值正确归位到数组的 num[i] 位置,即将 nums[i] 归位,而不是将数值 i 归位到位置 i,所以用 if 代替 while 就会出现漏判。
2)语句 nums[nums[i]], nums[i] = nums[i], nums[nums[i]]
实际上就是做两个数的互换。在 python 中 a, b = b, a
就相当于 t1 = a, t2 = b, a = t2, b = t1
,这是因为 python 和 C 不同,在C语言中,系统是为每个变量分配内存空间。而在python中,是为每个值分配内存空间。具体参考这篇文章:Python心法:a,b=b,a原理
3)语句 nums[nums[i]], nums[i] = nums[i], nums[nums[i]]
不可以写反成 nums[i], nums[nums[i]] = nums[nums[i]], nums[i]
,因为 nums[i] 如果先做了改变,到了 nums[nums[i]] 这个索引已经不对了,指向了其他位置,会形成死循环。
特点:
- 1)将数组本身作为哈希表,利用了题设中数组元素不会超过 0~n-1 这个条件
- 2)不需要额外的存储空间,但会修改原数组
借用剑指 offer 原书上给的例子帮助理解:
以数组 {2, 3, 1, 0, 2, 5, 3} 为例。数组的第 0 个数字是 2,与其下标(0)不等,故将它与下标为 2 的数字(1)进行交换,得到交换后的数组 {1, 3, 2, 0, 2, 5, 3}。此时数组第 0 个数字是 1,与其下标(0)仍不等,故再次将它与下标为 1 的数字(3)进行交换,得到交换后的数组 {3, 1, 2, 0, 2, 5, 3}。仍然不等,再次交换第 0 个数字和第 3 个数字,得到 {0, 1, 2, 3, 2, 5, 3}。此时第 0 个数字是 0,已经正确归位,扫描下一个数字。在接下来的几个数字中,第 1、2、3 个数字的下标和数值都相等,所以不做处理。继续扫描到第 4 个数字是 2,由于其下标和数值不等,所以准备交换第 4 个数字和第 2 个数字,此时发现第 2 个数字已经是 2,也就是数字 2 在下标为 2 和下标为 4 的两个位置都出现了,因此找到了重复的数字 2。
思路四:二分查找 ⭐
假如题目再添加额外要求: 1)不使用额外的存储空间,2)不允许修改原数组,那么前三种方法都无法满足要求,此时考虑一种以时间换空间的方法,即二分查找。
我发现剑指 offer 给出的例子无法找到类似 [0, 1, 2, 0, 4, 5, 6, 7, 8, 9]
这样用例的正确答案,主要原因是第一次二分后,0~4
这个范围在测试数组中有 5 个数,此时算法不能确定这 5 个数是无重复的情况(0, 1, 2, 3, 4
)还是有重复的情况(0, 1, 2, 0, 4
),最终找到的答案是不对的。
官方代码: 03_02_DuplicationInArrayNoEdit/FindDuplicationNoEdit.cpp
我在官方代码中添加了一个测试用例 test11,发现无法通过测试:
void test11()
{
int numbers[] = { 0, 1, 2, 0, 4, 5, 6, 7, 8, 9 };
int duplications[] = { 0 };
test("test11", numbers, sizeof(numbers) / sizeof(int), duplications, sizeof(duplications) / sizeof(int));
}
所以我重写了一个代码逻辑:
由于第一次二分可能无法确定重复的数字出现再哪一段里,所以可以考虑逐渐缩小段的长度,同时增加段的个数来寻找。在确定了重复数字所在段的情况下,再利用二分法在该段寻找,就不会掉进上面用例的坑里。需要注意的是,这里不是对数组 nums 分段,而是对数据范围 0 ~ n-1 分段。
1)第一次将 0 ~ n-1 分为 2 段 [low_1, high_1]
和 [low_2, high_2]
。依次对这两段数据统计在数组 nums 中,该数据范围中的数字出现的次数,寻找是否存在 数字个数 > high - low + 1
的情况,若存在说明重复数字就在这段里,直接 return low 和 high,后面的段不需要再统计。
2)若两段都统计过后,没有找到数字个数大于 high - low + 1 的情况,则细化段的长度。此时将 0 ~ n-1 分为 3 段。依次对这三段数据做与第一步相同的操作。
3)最坏情况下,需要将 0 ~ n-1 分为 n 段,即每个数字自成一段,此时实际上就是对每个数字单独统计在数组 nums 中出现的次数。
时间复杂度:O(n^3)
空间复杂度:O(1)
class Solution:
def findRepeatNumber(self, nums) -> int:
sec_num = len(nums)
# 确定重复数字所在段[low, high]
for i in range(2, sec_num + 1):
low, high = self.findSec(nums, i)
if low != -1: # 找到了重复数字所在段
break
# 二分法查找重复数字
while low < high:
mid = int(high - low - 1 + low)
c = self.countRange(nums, low, mid)
if c > (mid - low + 1):
high = mid
else:
low = mid + 1
return low
def findSec(self, nums, sec):
length = int(len(nums) / sec)
for i in range(sec):
low = i * length
high = i * length + length - 1
if i == sec - 1:
high = len(nums) - 1
c = self.countRange(nums, low, high)
if c > (high - low + 1):
return low, high
return -1, -1
def countRange(self, nums, low, high):
count = 0
for i in nums:
if i >= low and i <= high:
count += 1
return count
注意:由于这个思路是在加了约束条件的情况下写出来的,所以放在leetcode下运行会报超出时间限制,我是在 pycharm 里进行测试的。
给出几个测试用例:
nums = [0, 1, 2, 0, 4, 5, 6, 7, 8, 9]
nums = [1, 1, 1]
nums = [0, 1, 2, 4, 4]
二分法这个解决方案是一种时间换空间的方法,写出来的时间复杂度也是非常高了… 目前我也没有想到更好的解决方案,如果有大佬想到了更好的思路,希望多多指教呀~ 🙆♀️
参考资料:
[1] LeetCode 面试题03:数组中重复的数字
[2] python sort函数内部实现原理
[3] Python心法:a,b=b,a原理
[4] 剑指 offer 第二版