题目地址
给定一个包含 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
提示:
1 <= n <= 10^5
nums.length == n + 1
1 <= nums[i] <= n
nums 中 只有一个整数 出现 两次或多次 ,其余整数均只出现 一次
进阶:
如何证明 nums 中至少存在一个重复的数字?
你可以设计一个线性级时间复杂度 O(n) 的解决方案吗?
解法:二分查找
参考:指路
背景:
题目要求查找重复的整数,很容易想到使用「哈希表」,但是题目中要求:「你设计的解决方案必须不修改数组 nums 且只用常量级O(1)的额外空间」,因此使用「哈希表」不满足题目的要求;
但是题目中还说:「数字都在1到n之间(包括1和n)」,因此可以使用「二分查找」。
可以使用「二分查找」的原因:
因为题目要找的是一个 整数,并且这个整数有明确的范围,所以可以使用「二分查找」。
重点理解:这个问题使用「二分查找」是在数组 [1, 2,…, n] 中查找一个整数,而并非在输入数组中查找一个整数。
每一次猜一个数,然后 遍历整个输入数组,进而缩小搜索区间,最后确定重复的是哪个数。
理解题意:
n + 1 个整数,放在长度为 n 的数组里,根据「抽屉原理」,至少会有 1 个整数是重复的;
抽屉原理:把 10 个苹果放进 9 个抽屉,一定存在某个抽屉放至少 2 个苹果。
二分查找的思路是先猜一个数(有效范围 [left…right] 里位于中间的数 mid),然后统计原始数组中小于等于mid 的元素的个数 cnt:
- 如果 cnt 严格大于mid。根据抽屉原理,重复元素就在区间[left…mid]里;
- 否则,重复元素就在区间 [mid + 1…right] 里。
与绝大多数使用二分查找问题不同的是,这道题正着思考是容易的,即:思考哪边区间存在重复数是容易的,因为有抽屉原理做保证。
代码解释:
题目中说:长度为 n + 1 的数组,数值在 1 到 n 之间。因此长度为 len,数值在 1 到 len - 1 之间;
使用 while (left < right) 与 right = mid; 和 left = mid + 1; 配对的写法是为了保证退出循环以后 left 与 right 重合,left(或者right)就是我们要找的重复的整数;
在循环体内,先猜一个数 mid,然后遍历「输入数组」,统计小于等于 mid 的元素个数 cnt,如果 cnt > mid 说明重复元素一定出现在 [left…mid] 因此设置 right = mid;
如果觉得上面这句话比较绕的话,可以用一个具体的例子来理解:如果遍历一遍输入数组,统计小于等于4的元素的个数,如果小于等于4的元素的个数严格大于4,说明重复的元素一定出现在整数区间 [1…4],依然是利用了「抽屉原理」。
复杂度分析:
时间复杂度:O(NlogN),二分法的时间复杂度为O(logN),在二分法的内部,执行了一次 for 循环,时间复杂度为O(N),故时间复杂度为O(NlogN)。
空间复杂度:O(1),使用了一个 cnt 变量,因此空间复杂度为 O(1)。
补充:
但本题的场景和限制是极其特殊的,实际工作中和绝大多数算法问题都不会用「时间换空间」;
这题二分查找和快慢指针都不是常规思路,面试的时候最好提一下:因为有各种限制,才用二分这种耗时的做法,用快慢指针是因为做过类似的问题。
通俗易懂的解释:
写一个通俗易懂的解释给xdm看看: 首先数字是在1-n之间的乱序,给的数组长度是n+1,所以通过抽屉原理可知:必定有一个数字重复。 因为原数组是无序的,而给的数字是1-n的连续数字,那么原数组排完序后必定也是1~n的顺序。 所以在这里二分之前可以不用显示的调用api进行一次排序,而是当它就是一个排好序的数组, 直接取left = 1, right = len - 1,然后算mid,其结果相当于排次序再算mid。 (取left = 1, 是因为给的数字是从1开始的,这里我们要根据数字来找,而不是下标) 这样就将排好序的数组分成个数大致相同的两部分,然后遍历整个数组的元素,统计出小于等于(其实大于等于也可以,但是后面相应的cnt的判断条件也要变)mid的元素个数,因为是隐形排完序的数组,所以按照正常没有重复数字的情况来说,cnt == mid,比如1,2,3,4,5 小于等于2的数字个数肯定为2,如果cnt > mid, 比如1,2,2,3,4 ,那说明在[1,2,2]中一定有重复的数字(抽屉原理) 然后用二分在[1,mid]中继续找,反之亦然。
(在这里的代码,原数组的下标left即为我们要查找的整数。)
代码实现:
class Solution:
def findDuplicate(self, nums: List[int]) -> int:
left = 1
right = len(nums) - 1
while left < right:
mid = left + (right - left) // 2 # '//':整数除法,返回商的整数部分(向下取整);'/':浮点数除法,返回商(带有小数)
cnt = 0
for num in nums:
if num <= mid:
cnt += 1
# 根据抽屉原理,小于等于 4 的个数如果严格大于 4 个,此时重复元素一定出现在 [1..4] 区间里
if cnt > mid:
# 重复元素位于区间 [left..mid]
right = mid;
else:
# if 分析正确了以后,else 搜索的区间就是 if 的反面区间 [mid + 1..right]
left = mid + 1
return left
二分查找解法要注意的地方:
1.把定义区间成为左闭右闭区间,左右边界是无差别的,弄成左闭右开,反而增加了思考的复杂程度;
2.明确 int = left + ( right - left ) / 2 这里除以 2 是下取整;
3.明确 while(left <= right) 和 while(left < right) 这两种写法其实在思路上有本质差别, while(left <= right) 在循环体内部直接查找元素,而 while(left < right) 在循环体内部一直在排除元素,第 2 种思路在解决复杂问题的时候,可以使得问题变得简单;
4.始终在思考下一轮搜索区间是什么,把它作为注释写到代码里面,就能帮助我们搞清楚边界是不是能取到,等于、+1 、-1 之类的细节;
5.思考清楚每一行代码背后的语义是什么,保证语义上清晰,也是写对代码,减少 bug 的一个非常有效的策略。