【CSDN转载】二分查找算法 | 你真的搞懂二分了吗?

二分查找算法
前言
一、二分查找算法介绍
1.二分算法的本质
2.二分查找算法思想
二、二分查找算法模板!!!
三、力扣题目练习
704. 二分查找
35.搜索插入位置
34.在排序数组中查找元素的第一个和最后一个位置
为什么要加上1
四.浮点数二分算法模板
总结
前言
我身边的人都认为二分查找很简单,但事实真是如此吗?不,并不简单。二分算法有着许多的边界问题,当你写着代码一不小心就会陷入死循环。本篇文章会深入细节详细介绍整数二分算法以及使用二分算法步骤和力扣题目练习,并且还会给出二分查找算法模板,下面就然我们来看看吧。

一、二分查找算法介绍
1.二分算法的本质
很多人认为二分算法的本质是单调性,其实并不是,二分和单调性的关系是:有单调性的题目一定可以二分,但是我可以二分的题目不一定非得有单调性,注意这句话非常重要,就是有单调性的话我可以二分,但是没有单调性的话我也有可能可以二分。那么二分算法的本质是什么呢?,二分算法的本质其实是边界。
单调性想必大家都不陌生,给出一段有序区间,找到它的中间值mid,如果中间值小于目标值的话那么答案在右边,如果中间值大于目标值的话那么答案在左边。

那么边界又是个什么东西呢?我们给定一段区间,在这个区间上我们给定了一种性质,使得在这个区间的右半边是满足这个性质的,在这个区间的左半边是不满足这个性质的。那么此时我们就可以使用二分,来找出满足这个性质和不满足这个性质的边界点。

2.二分查找算法思想
算法思路:假设答案在闭区间[l, r]中, 每次将区间长度缩小一半,当l = r时,我们就找到了答案。

接着我们来看看二分算法的主要思想。现在给我们一个区间,在这个区间上我们给定了一种性质,使得在这个区间的右半边是满足这个性质的,在这个区间的左半边是不满足这个性质的。假设我们现在要找出左区间也就是红色区间的边界点,图中用黄色点标出了,我们应该怎样做呢?
首先我们先确定中间值mid,然后我们写一个check函数,接着根据check函数更新答案所在区间。

此时mid是满足红色性质的,所以mid落在红色区间内,所以mid是有可能为答案的,这里的答案指的是我们要而分出的边界点也就是黄色点。所以此时答案所在区间就是[mid,R];那我们要更新答案区间,就要让L=mid。

此时mid是不满足红色性质的,所以mid没有落在红色区间内,此时mid不可能为答案,所以此时答案所在区间就是[L,mid-1];那我们要更新答案区间,就要让R=mid-1。

我们每一次更新区间都会使答案落在更新的区间内,那么当区间[ L , R ]只有一个数时,也就是L==R时,那么区间[ L , R ]中的那个数就是我们要找的答案。
二、二分查找算法模板!!!
下面是二分查找算法的模板,这个模板几乎可以胜任所有的二分题,建议背过。

版本1
当我们将区间[l, r]划分成[l, mid]和[mid + 1, r]时,其更新操作是 r = mid,计算mid时不需要加1。

int bsearch_1(int l, int r)
{
while (l < r)
{
int mid = (l + r)/2;
if (check(mid)) r = mid;
else l = mid + 1;
}
return l;
}
1
2
3
4
5
6
7
8
9
10
版本2
当我们将区间[l, r]划分成[l, mid - 1]和[mid, r]时,其更新操作是l = mid;,此时为了防止死循环,计算mid时需要加1。

int bsearch_2(int l, int r)
{
while (l < r)
{
int mid =(l + r + 1)/2;
if (check(mid)) l = mid;
else r = mid - 1;
}
return l;
}
1
2
3
4
5
6
7
8
9
10
二分模板一共有两个,分别适用于不同情况。但其本质的区别就是mid = ( l + r ) / 2时需不要加上1。注意:当更新操作为其更新操作是 r = mid,计算mid时不需要加1。其更新操作是l = mid;,此时为了防止死循环,计算mid时需要加1。
加上1的目的是为了防止死循环,这个我们会在后面的题目解释。
那么这个两个模板要怎么使用呢? 我们来做几道题目,从这几道题目中我来给你们解释。

三、力扣题目练习
704. 二分查找
–>题目链接

这道题目的前提是数组为有序数组,同时题目还强调数组中无重复元素,因为一旦有重复元素,使用二分查找法返回的元素下标可能不是唯一的,这些都是使用二分法的前提条件,当大家看到题目描述满足如上条件的时候,就要考虑是不是可以用二分法了。

这里我们看了题目,发现可以使用二分法。那么我们就用刚刚的模板来看看吧,那要怎么用呢?

首先我们先确定中间值mid
然后我们写一个check函数
接着根据check函数更新答案所在区间。
根据更新操作判断需不需要加上1
首先我们先确定中间值mid,现在有一区间[-1,12],我们定义左端点和右端点,接着我们计算出mid的值为(0+5)/2=2;
接着我们写一个check函数,可以看到target为9,那么此时区间是不是满足一个性质,这个性质把区间分为两部分,蓝色部分的值小于9,绿色区间的值大于等于9。 此时我们这个check函数可以定义为if(mid的值>=9),就是判断mid是不是满足大于等于9这个性质。
如果mid满足这个特性就说明它落在绿色区域,此时我们就要更新答案所在区间,此时答案在[L,mid]中,我们的更新操作为R=mid;由于更新操作是R=mid,所以这里的mid = ( l + r ) / 2时不需要加上1!!!,我们自然而然使用第一个模板。

但此时mid的下标为2所对应的值时3,它是不满足>=9这个性质的,所以mid此时落在蓝色区域,此时我们就要更新答案所在区间,此时答案在[mid,R]中,我们的更新操作为L=mid+1;
然后我们不断缩小答案所在范围,直到这个区间只有一个数时也就是L==R,那此时区间内唯一的那个数就是我们要找的答案,也就是这个性质的边界点。
看看代码实现

	int l=0,r=nums.size()-1;
    while(l < r)
    {
        int mid = (l + r) /2;
        if(nums[mid] >= target) r = mid;
        else l = mid + 1;
    }

     return l;

1
2
3
4
5
6
7
8
9

但是当我们提交答案后,发现这个测试样例出错了。

target为2,但是这个区间内并没有2,那我们此时二分查找出来的边界点是什么呢?

我们再回到刚刚的样例,我们知道二分查找的本质是处理边界问题,我们给这段区间一个性质,那么这个性质是一定有边界的,所以二分是一定有解的。这里我们找到的性质是target右边是大于等于9的,target左边是小于9的,这个性质把区间分为了两部分,绿色部分是满足这个性质的,蓝色部分是不满足这个性质的。那么我们这里的二分查找出来的这个值就是这个性质的边界点。

现在我们回到刚刚报错的测试样例,那如果这个区间里没有这个target呢?我们二分查找出来的这个值就是这个性质的边界点,此时我们的的性质是target右边是大于等于2的,target左边小于2的,也就是说我们会找到这个区间里从左往右第一个大于等于2的点。 这句话很重要,请重复理解。因此我们找到的值为3,它的下标为2,与预期结果-1不符,所以错误。

对此我们可以加一个判断条件,如果我们二分出来的值不等于target的话就return-1;

完整代码

 int l=0,r=nums.size()-1;
    while(l < r)
    {
        int mid = (l + r) /2;
        if(nums[mid] >= target) r = mid;
        else l = mid + 1;
    }

    if(nums[l] != target) return -1;
     return l;

1
2
3
4
5
6
7
8
9
10
11
35.搜索插入位置
–题目链接

这道题目的前提是数组为有序数组,同时题目还强调数组中无重复元素,因为一旦有重复元素,使用二分查找法返回的元素下标可能不是唯一的,这些都是使用二分法的前提条件,当大家看到题目描述满足如上条件的时候,就要考虑是不是可以用二分法了。 这里我们再次提起这句话,什么时候考虑使用二分。

可以看到这一题和上一题基本一致,唯一的区别就是这题如果我们的target不在这个区间内,我们就需要返回它将会被按顺序插入的位置的下标。
仔细想想,二分查找是一定有解的,这个解就是区间内性质的边界点。
那么如果这个区间内没有这个target这个值,我们是不是二分出来的是这个区间里从左往右第一个大于等于target的点呢,这不正好就是我们要插入的位置吗。
解题过程

首先我们先确定中间值mid
然后我们写一个check函数
接着根据check函数更新答案所在区间。
根据更新操作判断需不需要加上1
看看代码

	int l=0,r=nums.size()-1;
    while(l<r)
    {
        int mid=l+r>>1;
        if(nums[mid]>=target) r=mid;
        else l=mid+1;
    }
    return l;

1
2
3
4
5
6
7
8
但是当我们提交后,没错,又有错误!!

此时target值为7,区间内没有7这个值,那我们二分到的应该是大于等于7的第一个位置,但此时我们发现区间内最大的数为6,6是这个区间的最后一个位置,也就是说我们的L和R不能再往后更新了,此时L和R落在6的这个位置,循环结束,我们返回的是6的下标也就是3,但我们的7应该插入到6的后一个位置,也就是下标为4的位置。
解决方法,我们做一个特殊判断,如果target的值大于区间最后一个元素的值那我们直接返回数组最后一个元素的下标加1;
完整代码
int l=0,r=nums.size()-1;
if(nums[r]<target)
return r+1;

    while(l<r)
    {
        int mid=l+r>>1;
        if(nums[mid]>=target) r=mid;//此时更新方式是r=mid,所以求mid不需要加上1
        else l=mid+1;
    }
        
    return l;

1
2
3
4
5
6
7
8
9
10
11
12
34.在排序数组中查找元素的第一个和最后一个位置
题目链接

这道题目是什么意思呢?假设我们有一组数据,[5,7,7,8,8,10],此时target=8,那我们就返回数据中8的开始下标和结束下标。若数据内不存在target则返回[-1,-1]。

老规矩,看做题步骤。
解题过程

首先我们先确定中间值mid
然后我们写一个check函数
接着根据check函数更新答案所在区间。
根据更新操作判断需不需要加上1
由于我们要寻找开始下标和结束下标两个位置,所以我们肯定需要俩个二分算法,一个寻找开始下标,一个寻找结束下标,这里我们分开来分析。
首先是寻找开始下标,就拿上面图片的样例来分析,此时这个区间是不是被一个性质分成了两部分,这个性质是什么呢,可以看到开始下标左边的数都是小于8的,开始下标右边的数都是大于等于8的,这个性质把区间分为了两个部分,我们用二分就可以找出这个性质的边界点,也就是这里的开始下标的位置。

那么具体代码是什么呢?

int l=0,r=nums.size()-1;
while(l<r)
{
int mid=(l+r)/2;
if(nums[mid]>=target) r=mid;
else l=mid+1;
}
1
2
3
4
5
6
7

接着是寻找结束下标,此时这个区间是不是被一个性质分成了两部分,这个性质是什么呢,可以看到结束下标左边的数都是大于等于8的,结束下标右边的数都是大于8的,这个性质把区间分为了两个部分,我们用二分就可以找出这个性质的边界点,也就是这里的结束下标的位置。

下面是具体代码
可以看到更新方式为L=mid;所以这里的mid = ( l + r ) / 2时需要加上1,这里我们就使用了第二个模板。

int l=0,r=nums.size()-1;
while(l<r)
{
int mid=(l+r+1)/2;
if(nums[mid]<=target) l=mid;
else r=mid-1;
}
1
2
3
4
5
6
7
下面是完整代码

vector res;
//如果我们的数据为空,直接返回[-1,-1]
if(nums.size()==0)
{
res.push_back(-1);
res.push_back(-1);
return res;
}

    //二分开始下标
    int l=0,r=nums.size()-1;
    while(l<r)
    {
        int mid=(l+r)/2;
        if(nums[mid]>=target) r=mid;
        else l=mid+1;
    }
    //当target值不存在时,return[-1,-1]
    if(nums[l]!=target) 
    {
         res.push_back(-1);
         res.push_back(-1);
        return res;
    }
    res.push_back(l);
	
	//二分结束下标
    l=0,r=nums.size()-1;
    while(l<r)
    {
        int mid=(l+r+1)/2;
        if(nums[mid]<=target) l=mid;
        else r=mid-1;
    }

    res.push_back(l);

    return res;

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
38
为什么要加上1
好了,三道题目做完了,不知道你还记得那两个模板吗,一个模板在求mid是需要加上一,一个不需要。但是你知道为什么吗?其实在前面我已经提到过了,是为了防止死循环所以我们加上了一个1,下面我们来分析下;

我们知道当更新方式为L=mid时,这里的mid = ( l + r ) / 2时需要加上1。
如果此时L=R-1,那么mid算出来的结果应该为L;这里我们可以带入数据去计算,当L=3,R=4,此时mid=(3+4)/2等于3,然后我们更新答案区间,L=mid,我们就又把3赋值给了L,此时就陷入了死循环。
所以我们需要在这里加上1,此时mid的值就为(3+4+1)/2等于4,然后我们更新答案区间,L=mid,此时就把4赋值给了L,这时候L==R,循环就终止了,我们就找到了边界点。
四.浮点数二分算法模板
以上我们介绍的都是整数二分算法,整数二分算法需要考虑许多边界问题,因此细节比较多,但浮点数二分简单多了。这里我们就去介绍了,同样的我会给出浮点数二分算法的模板。

double bsearch_3(double l, double r)
{
const double eps = 1e-6; // eps 表示精度,取决于题目对精度的要求
while (r - l > eps)
{
double mid = (l + r) / 2;
if (check(mid)) r = mid;
else l = mid;
}
return l;
}
1
2
3
4
5
6
7
8
9
10
11
这里有两道题目,大家可以自己去尝试做一做。
69.x 的平方根
367.有效的完全平方数
记住我们的做题步骤,然后套用上面的模板。
解题过程

首先我们先确定中间值mid
然后我们写一个check函数
接着根据check函数更新答案所在区间。
根据更新操作判断需不需要加上1
总结
本篇文章介绍了整数二分算法,二分算法的本质不是单调性,二分和单调性的关系是:有单调性的题目一定可以二分,但是我可以二分的题目不一定非得有单调性,二分算法的本质是处理边界。
算法思路:假设答案在闭区间[l, r]中, 每次将区间长度缩小一半,当l = r时,我们就找到了答案。
然后我们给出了整数二分算法的模板和浮点数二分算法的模板,整数二分的模板是由两个的,其本质的区别就是mid = ( l + r ) / 2时需不要加上1,这个模板几乎可以用来解决所有的二分题,建议背过。
接着我们做了几道力扣题,还记得我们的做题步骤吗。
首先我们先确定中间值mid
然后我们写一个check函数
接着根据check函数更新答案所在区间。
根据更新操作判断需不需要加上1
然后我们分析了整数模板为什么需要加上1,其原因是为了防止死循环。

好了,这篇文章就分享到这,如果对你有帮助的话,请点赞关注,支持一下吧!
————————————————

                        版权声明:本文为博主原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。

原文链接:https://blog.csdn.net/2301_77412625/article/details/129950099