0%

二分查找-力扣题解

第一部分介绍了二分查找的定义和实现原理(时间复杂度的计算),并给出适合一切二分查找题型的zui强模板,对的你没看错就是zui强!

第二部分一些常用的简单(也不简单)二分查找题,第三部分是较困难的题(后面做不出来就放弃了),第四部分是总体的回顾和总结。

[1]文章组织结构和题目均来自力扣二叉查找小卡片
[2]我用的zui强模板来源于开源模板
[3]找最接近的k个元素,参考了cur的题解
原创、保留权力;

1 二分查找的定义以及模板

1.1 定义

​ 二分查找是计算机科学中最基本、最有用的算法之一。 它描述了在有序集合中搜索特定值的过程[1]。

​ 二分查找的应用遍地都是,数值分析课上也讲过了是最基础的搜索算法并有一些针对的改进。这里只研究基础的二分算法:有序队列查找,找队首、队中、队尾,如果队中值大于中间值说明解在后半段、反之说明解在前半段,一次比较减少了一半的查找范围,继续循环二分,直到找到结果。

​ 时间复杂度:查找0~k次之后,有序队列还剩下的元素个数为 $ n,\frac{n}{2},\frac{n}{4},…,\frac{n}{2^k}$ ,假设查找k次之后只剩下1个元素(表示找到了结果),即:
$$
\frac{n}{2^k} = 1, k = \log _2^n
$$
故其**时间复杂度为 $O(\log _2^n)$**。

1.2 最强模板[2]

​ 可以找第一个、最后一个位置,可以处理有重复元素的有序队列。四点要素如下:

  • 1、初始化:start=0、end=len-1
  • 2、循环退出条件:start + 1 < end
  • 3、比较中点和目标值:A[mid] ==、 <、> target
  • 4、判断最后两个元素是否符合:A[start]、A[end] ? target
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 二分搜索最常用模板
public int search(int[] nums, int target) {
// 1、初始化 left right
int left = 0, right = nums.length-1;
// 2、处理while循环
while(left+1<right){
int mid = left+(right-left)/2;
// 3、比较nums[mid]和target值
if(nums[mid]<target){
left = mid;
}else if(nums[mid]>target){
right =mid;
}else{
//这里找第一个元素
right = mid;
}
}
// 4、最后剩下两个元素,手动判断
if(nums[left]==target) return left;
if(nums[right]==target) return right;
return -1;
}

​ 我也看了liweiwei1914大佬写的推荐用模板一二的文章、但我还是觉得这个更好用一些,不用想直接上手就可以套模板、基本问题都能解决。

2 没那么简单的力扣题

69. x 的平方根

实现 int sqrt(int x) 函数。

计算并返回 x 的平方根,其中 x 是非负整数。

由于返回类型是整数,结果只保留整数的部分,小数部分将被舍去。

考虑到结果位于0和x之前,用二分算法,判断条件是mid^2是否<=x

代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Solution {
public int mySqrt(int x) {
//二分查找 k^2<=x ,,找到第一个小于等于x的mid^2
int left =0, right=x;
while(left+1<right){
int mid = left+(right-left)/2;
if((long)mid*mid<=x){
left = mid;
}else{
right = mid;
}
}
if((long)right*right<=x) return right;
if((long)left*left<=x) return left;
return -1;
}
}

2.2 374. 猜数字大小

猜数字游戏的规则如下:

每轮游戏,我都会从 1 到 n 随机选择一个数字。 请你猜选出的是哪个数字。
如果你猜错了,我会告诉你,你猜测的数字比我选出的数字是大了还是小了。

返回我选出的数字。

思路:还是套模板,==时,左右都可

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class Solution extends GuessGame {
public int guessNumber(int n) {
int left=0, right=n;
while(left+1<right){
int mid = left+(right-left)/2;
if(guess(mid)==-1){
right=mid;
}else if(guess(mid)==1){
left=mid;
}else{
left=mid;
}
}
if(guess(left)==0) return left;
if(guess(right)==0) return right;
return -1;
}
}

2.3 33. 搜索旋转排序数组

整数数组 nums 按升序排列,数组中的值 互不相同

给你 旋转后 的数组 nums 和一个整数 target ,如果 nums 中存在这个目标值 target ,则返回它的下标,否则返回 -1

fig1

​ 这题一定要画图解答,图来源力扣官方题解,其实只用花两条线就ok,因为不确定旋转元素的数量,所以mid可能出现在左边那条线、也可能出现在右边那条线,分开考虑即可。

  • 一共有四种可能的情况
    • mid位于左边,因target的大小取左边或右边集合
    • mid位于右边,因target的大小取左边或右边集合
    • (注意if判断中我们写更简单的那种情况、复杂的留给else)
  • 模板就是一阵猛套,等不等于的没太大关系

代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
class Solution {
public int search(int[] nums, int target) {
int left=0, right = nums.length-1;
while(left+1<right){
int mid = left+(right-left)/2;
//因为有两段 mid在左右要分开判断
if(nums[mid]>nums[left]){ //mid位于左边
//if判断简单的情况
if(target<=nums[mid]&&target>=nums[left]){
right = mid;
}else{
left = mid;
}
}else if(nums[mid]<nums[right]){ //mid位于右边
//if判断简单的情况
if(target>=nums[mid]&&target<=nums[right]){
left = mid;
}else{
right =mid;
}
}
}
if(nums[left]==target) return left;
if(nums[right]==target) return right;
return -1;
}
}

2.4 278. 第一个错误的版本

假设你有 n 个版本 [1, 2, ..., n],你想找出导致之后所有版本出错的第一个错误的版本。

你可以通过调用 bool isBadVersion(version) 接口来判断版本号 version 是否在单元测试中出错。实现一个函数来查找第一个错误的版本。你应该尽量减少对调用 API 的次数。

思路:找一个出现的元素,直接上模板。while结束之后,因为是要找第一个出现的,注意先判断left再判断right

代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class Solution extends VersionControl {
public int firstBadVersion(int n) {
//就是找第一个满足条件的值呗 最强模板搞起来
int left=1, right = n;
while(left+1<right){
int mid = left+(right-left)/2;
if(isBadVersion(mid)){
right=mid;
}else{
left = mid;
}
}
if(isBadVersion(left)) return left;
if(isBadVersion(right)) return right;
return -1;
}
}

2.5 162. 寻找峰值

峰值元素是指其值大于左右相邻值的元素。

给你一个输入数组 nums,找到峰值元素并返回其索引。数组可能包含多个峰值,在这种情况下,返回 任何一个峰值 所在位置即可。

官方题解:

  • 首先从数组 nums 中找到中间的元素 mid。若该元素恰好位于降序序列或者一个局部下降坡度中(通过将 nums[i]与右侧比较判断),则说明峰值会在本元素的左边。于是,我们将搜索空间缩小为 mid的左边(包括其本身),并在左侧子数组上重复上述过程。
  • 若该元素恰好位于升序序列或者一个局部上升坡度中(通过将 nums[i]与右侧比较判断),则说明峰值会在本元素的右边。于是,我们将搜索空间缩小为 mid 的右边,并在右侧子数组上重复上述过程。

代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Solution {
public int findPeakElement(int[] nums) {
//比较mid 与 mid+1处的值,,将上升段全部覆盖掉
int left=0,rigth =nums.length-1;
while(left+1<rigth){
int mid = left+(rigth-left)/2;
if(nums[mid]<nums[mid+1]){
left = mid;
}else{
rigth =mid;
}
}
if(nums[rigth]>=nums[left]) return rigth;
else return left;
}
}

2.6 153. 寻找旋转排序数组中的最小值

给你一个元素值 互不相同 的数组 nums ,它原来是一个升序排列的数组,并按上述情形进行了多次旋转。请你找出并返回数组中的 最小元素

思路:同样是两条直线,但因为最小值必然位于第二段直线的开头,所以选择判断的情况只有两种:

  • mid位于左边时,left=mid
  • mid位于右边时,right=mid
  • 还要注意只有一条直线的特殊情况(就是没有旋转时),只有第二条直线,应该用right=mid。所以这里建议先判断mid位于右边的情况!!

代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class Solution {
public int findMin(int[] nums) {
int left=0, right=nums.length-1;
while(left+1<right){
int mid = left+(right-left)/2;
if(nums[mid]<=nums[right]){
right =mid;
}else{
left = mid;
}
//下面这个判断不适用于全升序的情况 错误
// if(nums[mid]>=nums[left]){
// left = mid;
// }else{
// right =mid;
// }
}
if(nums[left]<nums[right]) return nums[left];
return nums[right];
}
}

2.7 34. 在排序数组中查找元素的第一个和最后一个位置

给定一个按照升序排列的整数数组 nums,和一个目标值 target。找出给定目标值在数组中的开始位置和结束位置。

如果数组中不存在目标值 target,返回 [-1, -1]。

思路:

​ 套模板两次,第一次找开始位置,第二次找结束位置。注意第一次要判断是否存在target,如果不存在直接返回[-1.-1]

代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
class Solution {
public int[] searchRange(int[] nums, int target) {
int[] res = new int[]{-1,-1};
if(nums.length==0) return res;
//先找开始位置
int left = 0, right = nums.length-1;
while(left+1<right){
int mid = left+(right-left)/2;
if(nums[mid]<target){
left =mid;
}else if(nums[mid]>target){
right = mid;
}else{
right = mid;//找第一个
}
}
if(nums[left]==target||nums[right]==target){
res[0] = (nums[left]==target? left:right);
}else{
return res;
}
//找最后一个元素
left = 0; right = nums.length-1;
while(left+1<right){
int mid = left+(right-left)/2;
if(nums[mid]>target){
right = mid;
}else if(nums[mid]<target){
left = mid;
}else{
left = mid;
}
}
res[1] = (nums[right]==target? right:left);
return res;
}
}

658. 找到 K 个最接近的元素

给定一个排序好的数组 arr ,两个整数 k 和 x ,从数组中找到最靠近 x(两数之差最小)的 k 个数。返回的结果必须要是按升序排好的。

整数 a 比整数 b 更接近 x 需要满足:

|a - x| < |b - x| 或者
|a - x| == |b - x| 且 a < b

示例 1:

输入:arr = [1,2,3,4,5], k = 4, x = 3
输出:[1,2,3,4]

解法一:二分法找最左节点

解题思路来源于**[3]**:

​ 我们要从数组中找k个连续的最靠近x的数字,如果我们找到x再向左右伸缩,那么边界问题将会很复杂;但是我们只考虑要取k个数字的左边界,问题就会变得相对简单。时间复杂度:O(logN+K)

1.确定左边界的范围
要取k个数字在数组中,那么左边界范围的最左边可以取到:0,左边界范围的最右边可以取到:数组长度-k;
这个很好理解,如果k与数组长度相等,那么左边界此时取到最小值0,如果x大于数组最后一个数字,此时全部从右侧取,左边界取到最大值:数组长度-k

2.二分法确定固定左边界
这里我自由发挥、直接用最强模板、while循环结束后,多一步判断应该取left还是right

  • 判断mid的移动时,比较当前mid对应区间的[mid, mid+k-1]两个左右端点的值,如果mid处小就二分选左边right=mid;反之二分选右边
  • 跳出循环之后判断取left还是right时,如果left对应区间的左端点|arr[left]-x|小于 下一个区间的右端点|arr[left+k]-x|,说明left合适,否者用right;

代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
class Solution {
public List<Integer> findClosestElements(int[] arr, int k, int x) {
//这题挺难顶的,二分法找左结点!!一个伟大的思路
//左节点的范围在[0,length-k]之间
int left =0, right = arr.length-k;
while(left+1<right){
int mid = left+(right-left)/2;
if(x-arr[mid]>arr[mid+k-1]-x){
left = mid;
}else if(x-arr[mid]<arr[mid+k-1]-x){
right = mid;
}else{
right = mid;//要找第一个小值
}
}
//判断选left还是right
if(left+k<arr.length){ //防止数组长度为1时越界
if( x-arr[left]<=arr[left+k]-x) left =left;
else left =right;
}
//保存结果
List<Integer> res = new ArrayList<>();
for(int i=0;i<k;i++){
res.add(arr[left+i]);
}
return res;
}
}

解法二:双指针的排除法

解题思路:

left、right指针分别指向数组首尾,每次比较移动指针”删除“掉离x更远的那个元素,一共需要”删除“arr.length-k次;时间复杂度:O(N)

代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class Solution {
public List<Integer> findClosestElements(int[] arr, int k, int x) {
int left = 0, right = arr.length-1;
int deleteNums = arr.length-k;
while(deleteNums>0){
if(Math.abs(x-arr[left])>Math.abs(x-arr[right])){
left++;
}else{
right--;
}
deleteNums--;
}
List<Integer> res = new LinkedList<>();
for(int i=left;i<=right;++i){
res.add(arr[i]);
}
return res;
}
}

2.9 50. Pow(x, n)

实现 pow(x, n) ,即计算 x 的 n 次幂函数(即,xn)。

示例 1:

输入:x = 2.00000, n = 10
输出:1024.00000

思路:

​ 分治的思想,要计算n次方 先计算[n/2]次方,如果n为偶数,返回平方;如果n为奇数,返回*平方x**。如果n是负数, 先计算myPow(x,-n),再取倒数。(做一遍忘一遍的题)

代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Solution {
public double myPow(double x, int n) {
//分治的思想,要计算n次方 先计算[n/2]次方,如果n为偶数,返回平方;如果n为奇数,返回平方*x
//如果n是负数, 先计算my(x,-n),再取倒数
return n>=0? powN(x,n): 1.0/powN(x,-n);
}

private double powN(double x, int n){
//递归出口 n=0 返回1.0
if(n==0) return 1.0;
double y = powN(x,n/2);
return n%2==0? y*y: y*y*x;
}
}

2.10 367. 有效的完全平方数

给定一个 正整数 num ,编写一个函数,如果 num 是一个完全平方数,则返回 true ,否则返回 false 。

进阶:不要 使用任何内置的库函数,如 sqrt 。

示例 1:

1
2
输入:num = 16
输出:true

示例 2:

1
2
输入:num = 14
输出:false

​ 思路:这题没有什么意思,比求x的平方根更容易一些,只要判断序列中存不存在就好

代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class Solution {
public boolean isPerfectSquare(int num) {
//跟求x的平方根类似 这里只需要找是否存在一个数的平方等于num
int left = 0, right = num;
while(left+1<right){
int mid = left +(right-left)/2;
if((long)mid*mid<num){
left=mid;
}else if((long)mid*mid>num){
right=mid;
}else{
right =mid;
}
}
if(left*left==num) return true;
if(right*right==num) return true;
return false;
}
}

2.11 744. 寻找比目标字母大的最小字母

给你一个排序后的字符列表 letters ,列表中只包含小写英文字母。另给出一个目标字母 target,请你寻找在这一有序列表里比目标字母大的最小字母。

在比较时,字母是依序循环出现的。举个例子:

如果目标字母 target = ‘z’ 并且字符列表为 letters = [‘a’, ‘b’],则答案返回 ‘a’

1
2
3
4
输入:
letters = ["c", "f", "j"]
target = "a"
输出: "c"

思路:

​ 因为小写字母按循环的方式比较大小,有一种特殊情况是整个列表的数都小于等于target,此时应该返回第一个元素。。排除此种情况后,字符按绝对的值大小找第一个大于的字符即可,不论第一个大字符位于数组首、中、尾部,二分搜索都可以找。

代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class Solution {
public char nextGreatestLetter(char[] letters, char target) {
//因为循环比较大小 首先要把整个列表都小于等于target的特殊情况区分出来 比如 z
//如果 按正常的值比较 target位于首、中、尾部,二分都能找到
int n = letters.length;
if(letters[0]-target<0 && letters[n-1]-target<=0) return letters[0];
int left =0, right = n-1;
while(left+1<right){
int mid = left+(right-left)/2;
if(letters[mid]-target>0){
right = mid;
}else if(letters[mid]-target<0){
left = mid;
}else{
left = mid;//找大于target的值,所以等于时要往后找
}
}
//System.out.println(left+","+right);
if(letters[left]-target>0) return letters[left];
return letters[right];
}
}

3 更多的练习

3.1 154. 寻找旋转排序数组中的最小值 II

思路:

​ 相比没有重复的题,这次两边都可以画成一个三折的直线。同样只需要注意mid位于左边侧还是右边侧就ok.一般要与nums[right]比较来判断位于左边还是右边

  • 当mid位于左边侧且 不等于right时 可以直接动left指针
  • 当mid位于右边侧且 不等于right时 可以直接动right指针
  • 当nums[mid] == nums[right],两边都有可能,就只前移right一格

fig1

代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class Solution {
public int findMin(int[] nums) {
int left = 0, right = nums.length-1;
while(left+1<right){
int mid = left+(right-left)/2;
if(nums[mid]>nums[right]){
//当mid位于左边侧且 不等于right时 可以直接动left指针
left = mid;
}else if(nums[mid]<nums[right]){
//当mid位于右边侧且 不等于right时 可以直接动right指针
right =mid;
}else{
//当nums[mid] == nums[right],两边都有可能,就只前移right一格
right--;
}
}
if(nums[right]<=nums[left]) return nums[right];
return nums[left];
}
}

太离谱了 后面全是困难题,,借用力扣评论的一句话, “生不出人,我很抱歉”

4 总结

​ 实践之证明、那第三个模板几乎所有的题都可以做、我愿称之为最强。不需考虑边界反正就是left=mid或者right=mid无脑莽夫就行,仅仅只是多了一个步骤需要判断最后left和right究竟谁才真正指向结果。

  • 一般要找第一个解答,就先用left尝试
  • 如果要找最后一个解答,就先用right尝试

中等难度及以下的题,只需要根据题意考虑一些细节就基本没有问题。还是总结一下所有题目的Tips


  • x的平方根:查找范围为[0,x],因为要找[sqrt(x)],所以判断条件是mid^2<x,因为要找最接近sqrt(x)的,所以等于是left前移、结果先判断right
  • 猜数字大小:简单题、大了就前移、小了就后移,等于无所谓
  • 搜索旋转排序数组:旋转之后、数字的升序变成两条线,判断条件变成4种:(注意if判断中我们写更简单的那种情况、复杂的留给else)
    • mid位于左边,因target的大小取左边或右边集合
    • mid位于右边,因target的大小取左边或右边集合
  • 第一个错误的版本:没什么特点、因为要找第一个,所以判断结果时、先判断left
  • 寻找峰值:只需要找任意一个峰值点,二分搜索,如果n[mid]<n[mid+1],说明是上坡段继续向后找;反之说明到下坡段或者平段,向前找。
  • 寻找旋转排序数组中的最小值:旋转后分为两端,但最小值永远位于第二段。所以一共只有两种情况,mid位于左边、或者右边。但要小心、如果数组没有旋转就之后第二段,所以while内、先判断第一段。
  • 在排序数组中查找元素的第一个和最后一个位置:最强模板正适合处理这种情况、没意思
  • 找到 K 个最接近的元素:这题的思路可以说是“惊才绝艳”,我只找这k个元素的最左边的元素,范围为[0,length-k]
    • 如果最左边的元素与x之差 > 大于最右边元素与x之差,,表示需要后移,反之前移
    • 当等于时,也前移(找第一小);
    • 更重要的判断结果是left还是right,需要比较 left最左端的差 与 right最右边的差的大小、谁小用谁
  • Pow(x, n)**:神nb的递归:要计算n次方 先计算[n/2]次方,如果n为偶数,返回平方;如果n为奇数,返回平方*x**。如果n是负数, 先计算myPow(x,-n),再取倒数。
  • 寻找旋转排序数组中的最小值 II:重要有重复元素了、左右两边可能都是三段的折线,除了两边重合的地方不好判断,用right–外,另外两种同样是移动指针
    • 当mid位于左边侧且 不等于right时 可以直接动left指针
    • 当mid位于右边侧且 不等于right时 可以直接动right指针
    • 当nums[mid] == nums[right],两边都有可能,就只前移right一格

**”生不出人、我很抱歉”**,出现困难题、我直接投降好吧。

-------------感谢阅读没事常来-------------