LeetCode-03-二分查找
第三讲 二分查找
3.1 介绍
- 二分查找也叫折半查找,顾名思义,就是查找的时候只查找其中的一半,这样的话大大降低了查找的时间复杂度,为 O(log n) ,其中n为数组的长度
- 二分查找也可以看作双指针的一种特殊情况,但我们一般会将二者区分。
- 双指针类型的题,指针通常是一步一步移动的;
- 二分查找时,指针每次移动半个区间长度。
- 注意:
- 对于端点上的处理(如果对端点的处理不够细心,有可能会导致无法得出正确答案或者无法退出循环等问题)
- 端点的处理方式:左闭右开(符合大多数语言的习惯),左闭右闭(方便处理边界条件)
- 首先,养成固定使用一个方式处理的习惯
- 其次,思考当区间中有一个或两个或没有元素时,是否能正确执行并退出循环
- 二分查找中关于mid的选取
- mid良好的赋值方式应为
mid = low + ((high - low) / 2)
,而不是mid = (low + high) / 2
,这是因为当数组较大时,可能会出现溢出的情况 - 对mid向上还是向下取整要根据实际情况来判断
- mid良好的赋值方式应为
- while(left <= right) 和 while(left < right) 的区别
- while(left <= right) 表示在区间中还剩下一个元素的时候,我们还要在进行一次循环
- 通常和
right = mid -1
,left = mid + 1
配合使用
- 通常和
- while(left < right) 表示在区间中还剩下一个元素的时候,停止循环
- 通常和
right = mid
,left = mid + 1
或right = mid - 1
,left = mid
配合使用
- 通常和
- while(left <= right) 表示在区间中还剩下一个元素的时候,我们还要在进行一次循环
- 对于端点上的处理(如果对端点的处理不够细心,有可能会导致无法得出正确答案或者无法退出循环等问题)
- 来自 LeetCode @liweiwei1419 对于二分搜索编码的建议
- 循环终止条件写成:while (left < right) ,表示退出循环的时候只剩下一个元素;
- 在循环体内考虑如何缩减待搜索区间,也可以认为是在待搜索区间里排除一定不存在目标元素的区间;
- 根据中间数被分到左边和右边区间,来调整取中间数的行为;
- 如何缩小待搜索区间
- 从 nums[mid] 满足什么条件的时候一定不是目标元素去考虑,进而考虑 mid 的左边元素和右边元素哪一边可能存在目标元素。
- 一个结论是:当看到 left = mid 的时候,取中间数需要上取整,这一点是为了避免死循环;
- 退出循环的时候,根据题意看是否需要单独判断最后剩下的那个数是不是目标元素。
- 边界设置的两种写法:
right = mid
和left = mid + 1
和mid = left + (right - left) / 2
一定是配对出现的right = mid - 1
和left = mid
和mid = left + (right - left + 1) / 2
一定是配对出现的
- 使用二分查找的要点
- 找到某些规则或规律使得指针可以一次移动一半的区间
3.2 平方根
69. x 的平方根(简单)
-
思路
- 同2.6练习中的367
-
代码
public int mySqrt(int x) { if (x <= 1) { return x; } if (x < 4) { return 1; } int left = 2; int right = x / 2; int mid = 0; while (left <= right) { mid = left + (right - left) / 2; long temp = (long)mid * (long)mid; if (temp < x) { left = mid + 1; } else if (temp > x) { right = mid - 1; } else { return mid; } } return (long)mid * (long)mid > x ? mid - 1 : mid; }
-
注意:
int
除int
会导致直接向下取整,这里导致了精度上的损失。所以,最后要多判断一下mid - 1
的平方int
乘int
会先得出int
类型的结果,然后将这个结果转换成long
,这回导致溢出,所以在乘之前要先转成long
类型或者统一使用long
最后在返回的时候转成int
3.3 查找区间
34. 在排序数组中查找元素的第一个和最后一个位置(中等)
-
分析
- 使用二分查找找一个特定的数字并不难实现自然而然的,可以想到,我们先找到这个target,然后从target的位置开始试探,通过试探可以找出第一次出现的位置和最后一个出现的位置。
- 理想情况下,只需要几次试探即可找到,这样时间复杂度为 O(log n)
- 但是最坏的情况,当一个数组中全部都是target时,那么这样时间复杂度就会升级成为 O(n) ,不符合题目要求
- 那么能不能通过调整二分查找的一些策略,找到我们需要的位置呢?
- 以找第一次出现的位置举例
- 找到target之后,不着急停止循环,而是将right定位在这个位置。然后继续向前查找,直到找不到位置。那么最后一次right的位置就是它的起始位置了
- 使用二分查找找一个特定的数字并不难实现自然而然的,可以想到,我们先找到这个target,然后从target的位置开始试探,通过试探可以找出第一次出现的位置和最后一个出现的位置。
-
代码
public int[] searchRange(int[] nums, int target) { if (nums == null || nums.length == 0) { return new int[]{-1, -1}; } if (nums.length <= 1) { return nums[0] == target ? new int[]{0, 0} : new int[] {-1, -1}; } int[] res = new int[]{-1, -1}; int left = 0; int right = nums.length - 1; // 找到第一次出现的位置 while (left < right) { int mid = left + (right - left) / 2; if (nums[mid] >= target) { right = mid; } else { left = mid + 1; } } res[0] = nums[right] != target ? -1 : right; if (res[0] == -1) { return new int[] {-1, -1}; } // 找最后一次出现的位置 left = right; right = nums.length - 1; while (left < right) { int mid = left + (right - left) / 2; if (nums[mid] > target) { right = mid; } else { left = mid + 1; } } res[1] = nums[left] == target ? left : left - 1; return res; }
-
注意:
- 两次二分查找时对于左右边界上的处理,如果处理不当,会导致死循环的错误
3.4 旋转数组查找数字
81. 搜索旋转排序数组 II(中等)
-
分析
- 仔细观察旋转后的数组会发现,旋转过的数组分为两部分,这两部分都是有序的,既然是有序的,那么就可以使用二分查找进行搜索
- 记数组下标为
0,1,...,i,i+1,...,n-1
,其中nums[i+1] < nums[i]
- 那么我们只需要对
[0,i]
和[i+1, n-1]
这两块分别进行二分搜索即可,那么只需要找到i
即可- 要找到
i
必然要对数组进行遍历,并且因为数组中存在重复的元素,所以必须要遍历整个数组,才能确定出i
。因此,这一部分的时间复杂度为 O(n)
- 要找到
- 找到
i
时间复杂度为 O(n) ,二分查找时间复杂度为 O(log n),总的时间复杂度为 O(n)
-
代码
public boolean search(int[] nums, int target) { if (nums.length == 0) { return false; } if (nums.length == 1) { return nums[0] == target; } int flag = 0; for (int i = 0; i < nums.length - 1; i++) { if (nums[i] > nums[i + 1]) { flag = i; } } return binarySearch(0, flag, nums, target) || binarySearch(flag + 1, nums.length - 1, nums, target); } public boolean binarySearch(int left, int right, int[] nums,int target) { while (left <= right) { int mid = left + (right - left) / 2; if (nums[mid] > target) { right = mid - 1; } else if (nums[mid] < target) { left = mid + 1; } else { return true; } } return false; }
-
改进(来自LeetCode官方题解)
- 要使用二分搜索,那么就要求被搜索的区间是有序的,而由题目给出的数组是局部有序的,那么我们能不能只在有序的部分进行搜索呢,忽略无序部分?
- 这启示我们可以在常规二分搜索的时候查看当前 mid 为分割位置分割出来的两个部分
[left, mid]
和[mid + 1, right]
哪个部分是有序的,并根据有序的那个部分确定我们该如何改变二分搜索的上下界,因为我们能够根据有序的那部分判断出target
在不在这个部分:- 如果
[left, mid]
是有序数组,且target
的大小满足[nums[left],nums[mid])
,则我们应该将搜索范围缩小至[left, mid-1]
,否则在[mid + 1, right]
中寻找。 - 如果
[mid, right]
是有序数组,且 target 的大小满足(nums[mid+1],nums[right]]
,则我们应该将搜索范围缩小至[mid + 1, right]
,否则在[left, mid - 1]
中寻找。
- 如果
- 时间复杂度 O(log n)
-
实现
public boolean search(int[] nums, int target) { if (nums.length == 0) { return false; } if (nums.length == 1) { return nums[0] == target; } int left = 0; int right = nums.length - 1; while (left <= right) { int mid = left + (right - left) / 2; if (nums[mid] == target) { return true; } if (nums[left] == nums[mid]) { left++; } else if (nums[mid] <= nums[right]) { // 右边是有序的 if (target > nums[mid] && target <= nums[right]) { // target落在有序区间内 left = mid + 1; } else { right = mid - 1; } } else { // 左边是有序的 if (target >= nums[left] && target < nums[mid]) { // target落在有序区间内 right = mid - 1; } else { left = mid + 1; } } } return false; }
-
要点:如何判断哪边有序?
- 因为数组存在重复数字,所以,当
nums[mid] == nums[left]
时,我们无法确定哪边是递增的,这时需要将left
左移一个位置
- 因为数组存在重复数字,所以,当
3.5 基础练习
154. 寻找旋转排序数组中的最小值 II(困难)
-
分析
- 旋转数组在旋转前,我们可以表示为
[小数区间][大数区间]
,那么旋转之后,就是[大数区间][小数区间]
。我们要找的这个最小值,就是大数区间变成小数区间的转折点- 哪怕数组中有重复元素也不例外,这个转折点必然是数组的最小值(想一想,为什么?)
- 记这个元素下标为
i
[0, i-1]
中的所有元素全部大于等于该元素i
小于等于[i+1, ... ,n-1]
中的所有元素
- 参考3.4,制定策略
- 如果
nums[mid] < nums[right]
,那么转折点在[left, mid-1]
范围内 - 如果
nums[mid] == nums[right]
,无法确定转折点所在的区间,right
左移一位 - 如果
nums[mid] > nums[right]
,那么转折点在[mid + 1, right]
范围内
- 如果
- 为什么要使用
right
进行判断而不是使用left
?- 如果整个数组都是有序的,使用
left
无法正确得出结果。(经过多次尝试后发现,百思不得其解,参考了一下官方解法才发现关键在这里)
- 如果整个数组都是有序的,使用
- 旋转数组在旋转前,我们可以表示为
-
代码
public int findMin(int[] nums) { if (nums.length <= 2) { return nums.length == 1 ? 0 : Math.min(nums[0], nums[1]); } int left = 0; int right = nums.length - 1; int mid = 0; while (left < right) { mid = left + (right - left) / 2; if (nums[mid] < nums[right]) { right = mid; } else if (nums[right] == nums[mid]) { right--; } else { left = mid + 1; } } return nums[left]; }
-
注意
- 根据分析,当二分查找结束后,剩下的那个元素就是我们要找的中间点,于是我们采用
left < right
作为循环结束的条件
- 根据分析,当二分查找结束后,剩下的那个元素就是我们要找的中间点,于是我们采用
540. 有序数组中的单一元素(中等)
-
分析
- 考虑单一元素出现前后,数组奇数和偶数下标对应的元素发生的变化
- 在单一元素出现之前,第
i
对元素的下标是2i-2
和2i-1
- 在单一元素出现之后,第
i
对元素的下标是2i-1
和2i
- 在单一元素出现之前,第
- 也就是说,
- 在单一元素出现之前,对于奇数下标的元素,和它一样的元素在它的前面
- 在单一元素出现之后,对于奇数下标的元素,和它一样的元素在它的后面
- 根据这样的规律,即可推断出单一元素是在
mid
的 左边还是右边- 如果
nums{mid]
和前后元素都不相同,则nums{mid]
就是单一元素 - 如果
mid
是奇数,且nums{mid]
和后面元素相同,单一元素在[left, mid-1]
范围内,不然就在[mid+1, right]
范围内 - 如果
mid
是偶数,且nums{mid]
和后面元素相同,单一元素在[mid+1, right]
范围内,不然就在[left, mid-1]
范围内
- 如果
- 考虑单一元素出现前后,数组奇数和偶数下标对应的元素发生的变化
-
代码
public int singleNonDuplicate(int[] nums) { if (nums.length == 0) { return 0; } if (nums.length == 1 || nums[0] != nums[1]) { return nums[0]; } if (nums[nums.length - 1] != nums[nums.length -2]) { return nums[nums.length - 1]; } int left = 0; int right = nums.length - 1; while (left <= right) { int mid = left + (right - left) / 2; if (nums[mid] != nums[mid-1] && nums[mid] != nums[mid+1]) { return nums[mid]; } if (mid % 2 != 0) { // mid是奇数 if (nums[mid] == nums[mid + 1]) { right = mid - 1; } else { left = mid + 1; } } else { // mid是偶数 if (nums[mid] == nums[mid + 1]) { left = mid + 1; } else { right = mid - 1; } } } return 0; }
-
注意
- 因为涉及到了取
mid-1
和mid+1
的操作,所以要对首尾的元素进行特殊操作,避免数组越界
- 因为涉及到了取
3.6 进阶练习
4. 寻找两个正序数组的中位数(困难)
-
分析
- 如果对时间复杂度没有要求
- 那么将两个数组合并之后再查找中位数即可,这样的话时间复杂度为 O(m+n) 。
- 那么可不可以不合并数组来进行查找呢?
- 双指针,谁小移谁,一共移动
(m+n)/2
次后即可得到中位数,不过这样并没有优化时间复杂度,只是把空间复杂度从 O(m+n) 降低到了 O(1)
- 双指针,谁小移谁,一共移动
- 如果对时间复杂度的要求有 log,通常都需要用到二分查找,这里我是没想出来有什么好方法,后来看了官方题解后恍然大悟
- 根据中位数的定义,当
m+n
是奇数时,中位数是两个有序数组中的第(m+n)/2
个元素,当m+n
是偶数时,中位数是两个有序数组中的第(m+n)/2
个元素和第(m+n)/2+1
个元素的平均值。因此,这道题可以转化成寻找两个有序数组中的第k
小的数,其中 k 为(m+n)/2
或(m+n)/2+1
- 假设两个有序数组分别是 A 和 B。要找到第
k
个元素,我们可以比较A[k/2−1]
和B[k/2−1]
。由于A[k/2−1]
和B[k/2−1]
的前面分别有A[0..k/2−2]
和B[0..k/2−2]
,即k/2-1
个元素,对于A[k/2−1]
和B[k/2−1]
中的较小值,最多只会有(k/2−1)+(k/2−1)≤k−2
个元素比它小,那么它就不能是第k
小的数了。 - 因此我们可以归纳出三种情况:
- 如果
A[k/2−1]<B[k/2−1]
,则比A[k/2−1]
小的数最多只有A
的前k/2-1
个数和B
的前k/2−1
个数,即比A[k/2−1]
小的数最多只有k-2
个,因此A[k/2−1]
不可能是第k
个数,A[0]
到A[k/2−1]
也都不可能是第k
个数,可以全部排除。 - 如果
A[k/2−1]>B[k/2−1]
,则可以排除B[0]
到B[k/2−1]
。 - 如果
A[k/2−1]=B[k/2−1]
,则可以归入第一种情况处理。
- 如果
- 对于某些情况,需要特殊处理
- 如果
A[k/2−1]
或者B[k/2−1]
越界,那么我们可以选取对应数组中的最后一个元素。在这种情况下,我们必须根据排除数的个数减少k
的值,而不能直接将k
减去k/2
。 - 如果一个数组为空,说明该数组中的所有元素都被排除,我们可以直接返回另一个数组中第
k
小的元素。 - 如果
k=1
,我们只要返回两个数组首元素的最小值即可。
- 如果
- 根据中位数的定义,当
- 总结
- 核心思想:找到第k个小的数
- 在两个数组中依次比较第 k / 2 个数字,谁小,就说明该数组的前 k / 2 个数字都不是第 k 个小的数,然后缩减该数组,直到 k = 1,然后取两个中最小的那一个
- 每次缩减数组之后,k要减去数组减少的元素个数
- 如果对时间复杂度没有要求
-
代码
public double findMedianSortedArrays(int[] nums1, int[] nums2) { int len = nums1.length + nums2.length; int k = len / 2; if (len % 2 == 0) { int r1 = findKth(nums1, nums2, k); int r2 = findKth(nums1, nums2, k + 1); return (r1 + r2) / 2.0; } else { return findKth(nums1, nums2, k+1); } } private int findKth(int[] nums1, int[] nums2, int k) { int length1 = nums1.length; int length2 = nums2.length; // index表示的是排除元素之后,“新数组” 的开始位置 int index1 = 0; int index2 = 0; while (true) { // 如果其中一个数组为空,则返回另外一个数组的中位数 if (index1 == length1) { return nums2[index2 + k - 1]; } if (index2 == length2) { return nums1[index1 + k - 1]; } // 当 k=1 时退出循环 if (k == 1) { return Math.min(nums1[index1], nums2[index2]); } int half = k / 2; // 这步操作保证了newIndex不会超出数组长度 int newIndex1 = Math.min(index1 + half, length1) - 1; int newIndex2 = Math.min(index2 + half, length2) - 1; int pivot1 = nums1[newIndex1], pivot2 = nums2[newIndex2]; // k要减去每次排除的元素个数 if (pivot1 <= pivot2) { k -= (newIndex1 - index1 + 1); index1 = newIndex1 + 1; } else { k -= (newIndex2 - index2 + 1); index2 = newIndex2 + 1; } } }