二分查找背模板?我选择去理解它:基础篇

二分查找背模板?我选择去理解它!:基础篇

本篇文章是本人学习二分时的一些理解,文章较为啰嗦诙谐,如果各位急于学习的话可以直接移步到第四节的部分,看完你一定会有所收获

零、序言——注意看,眼前的这个男人叫

小帅,他正打算和旁边的小美一起玩一个游戏,这个游戏叫猜数字,规则如下:一个人想一个0到100的数字,然后另一个人来猜,根据那个人猜的数字回应他大了、小了和正正好,看看谁猜出来数字所用的次数最少。

小帅是个老实人,它从0开始一个个的猜,这样肯定能猜到正确的答案,不过小美想的数字是78,小帅猜了79次才猜到。

小帅为了不输,于是他想的数字是79——比78大一点点,他以为这样就可以赢过小美了。

但是我们大女主小美一点都不care,她只用了6次就猜出了正确答案,降维打击!

QQ图片20221122204358.jpg

她会读心吗?开挂了?运气好?

我们来看看她是怎么猜的。

她第一次猜的数字是50,小帅回答小了。
她第二次猜的数字是75,小帅回答小了。
她第三次猜的数字是87,小帅回答大了。
她第四次猜的数字是81,小帅回答大了。
她第五次猜的数字是78,小帅回答小了。
她第六次猜的数字是79,小帅回答正正好。
是的,相比较小帅的老实人操作,小美用了一点点的方法。

小美的做法是有根据的,她每次都只问最中间的那个数,那么每一次,根据小帅的回答过大或过小,她都可以排除掉一半的错误答案。

比如第一次小美猜的50,小帅回答小了,那么很明显,0到50间的所有数我们都不用去猜了,回答肯定都是小了。

那么接下来我们就只用从51到100之间猜数了。

至于0到50的数,我们将它们排除了。

像这样,每次都排除掉区间内一半的答案,最坏的情况下,我们也只用8次就可以猜出正确答案了。

至于像这样这样每次查找中间的元素,再根据反馈不断收缩区间的方法,就叫做:

一、认识认识它吧——二分查找

二分查找,也叫折半查找,它被广泛的用于在特定的环境下快速找到某一元素,常见的情况就是:在一个有序数组中查找目标值。

当我们在一个区间内查找答案时,每次都查找位于区间中间的元素,然后根据查找的元素和目标值的关系逐步收缩区间。

由此可以做到,每一次查找时,都可以排除掉一半的错误答案,那么对于一个长度为n的数组来说,只需要最多

log ⁡ 2 n + 1 \log_2^{n}+1 log2n+1

次就可以找到目标值的位置了。时间复杂度就为:

O ( log ⁡ n ) O(\log^{n}) O(logn)

而正常情况下,在一个数组内查找元素,如果我们选择遍历数组的方法来查找,它的时间复杂度为:

O ( n ) O(n) O(n)

在刚刚的游戏中,小帅使用的就是遍历数组的方式,而小美使用的是二分查找法,谁的效率更高,高下立判。

好的,现在二分查找的原理你已经知道了,是不是很简单?

不过,二分也并不是万能的!它是有自己的局限性的!

那么,就让我们来看看——

二、啥时候能用二分呢

刚刚我们在介绍二分时说了:“它被广泛的用于在特定的环境下快速找到某一元素。”

这个特定要注意了。

二分不是什么时候都能用的,就比如说如果我们想要在数组中查找特定值,就必须要保证这个数组是有序的(逆序或正序无所谓,有序就行),如果数组不是有序的情况下,我们去使用二分查找,会使得我们得到的结果是错误的。

比如:

1 5 4 8 9
我想找5在哪个位置,我先枚举中间的位置,是4。
发现小了,那么我要往右边找。
但我们发现此时答案就已经不对劲了。
由此可知,这个特定的环境不能少啊!(拍桌)

那么什么时候算特定的环境呢

根据我个人的经验,如果一个题目的答案具有单调性,那么就可以使用二分查找。

什么是单调性呢?

从某一个分界点开始,答案分成了前后两个不同的部分,且这两部分的答案不会混乱,此时我们要做的就是找到这个分界点。

未命名绘图8.png

在正常的二分查找中,单调性体现在目标值和数组中数字的大小关系。

比如刚刚猜数字,他猜的是x,那么在x之前的数,答案都是小于x,在x之后的数,答案都是大于x,没有说x之后有哪个数是小于x的,这就是答案的单调性。

未命名绘图9.png

在二分答案(二分查找的另一种用法)中,单调性体现在最大、最小、恰好等字眼中。

比如去买东西,不知道要多少钱才可以买完我想要的全部东西,要求出最少需要的钱数,它就是分界点。比它大的钱数都能保证钱够买完我们想要的东西,比它小的钱数则都买不完。

而答案混乱就是指我们上面举的数组非有序的例子,分界点5之后的所有数并不都是大于5的,这样答案就失去了单调性,此时使用二分搜索是错误的!

所以切记,二分查找虽好,可不要乱用噢。

二分查找的使用条件我们已经知道了,那么接下来让我们——

三、来看看代码是怎么实现的

这里先给出二分查找的两种模板代码。

(注意,二分的模板有很多种写法,根据不同人的喜好使用不同的方法,我这里写的是我个人比较喜欢用的)
//查找数组中第一个大于x的元素
while(l<r)
{
    int mid=(l+r)/2;
    if(a[mid]<x)l=mid+1;
    else r=mid;
}
​
//查找数组中最后一个小于等于x的元素
while(l<r)
{
    int mid=(l+r+1)/2;
    if(a[mid]<=x)l=mid;
    else r=mid-1;
}

一共也就四行代码。以上变量的意思是:

l:区间的左端点。
r:区间的右端点。
mid:每次我们枚举的,区间中间的位置。
a[]:数组。
x:目标值。

就像我们说的,每次我们都枚举的是中间的元素,所以mid=(l+r)/2,然后通过if判断目标值和我们当前枚举元素的关系,来决定是修改区间的左端点l,还是修改区间的右端点r

现在感觉确实很简单有没有?

不过!正当一个小萌新信心满满的开始用它去写题时,意外发生了!

“啊?为什么超时啊?不是说这个算法很快吗?”
“啊??为什么这个while它停不下来啊?”
“啊???为什么这个答案是错的啊?”
“算啦!我还是把代码背下来吧!反正都能用!”
“嗯????怎么这里+1,这里又-1,这里又不变的?”

“搞什么鬼啊啊啊啊啊!!!!”

屏幕截图 2023-04-01 201855.png

是的!我们的小萌新惨遭滑铁卢啦(哈哈哈哈哈)!

至于让我们的小萌新感到如此抓狂的,便是令人破防的边界问题

在我们学习二分查找的过程中,多多少少都应该遇到过上面小萌新的情况,二分算法虽然是初级算法,但是是许多算法初学者的噩梦,甚至有的很多学到很后面了的,也搞不清二分的边界问题。我当时也是深受其害呀。

而当你想背下代码直接用时,发现背着背着好像有点混乱?

而且二分是很灵活的!不同的情况下,二分查找都有不同的写法,光靠背真的很难受啊!

而且对于想挑战程序设计竞赛的同学们来说,认识一个知识点,背下它的代码有时候真的不是很方便。

程序设计竞赛麻烦的不是一个知识点,而是如何把一个知识点玩出花来

有时候看看大佬们的题解,就会不由得惊叹: “啊?这个还能这么用吗?”

所以!(再次拍桌)

比起愣愣的记下代码,我选择——

四、背什么?掌握它!——这一段会有点长,但绝对简单易懂,不骗你!

我们来看看刚刚给出的两个模板。

//查找数组中第一个大于x的元素
while(l<r)
{
    int mid=(l+r)/2;
    if(a[mid]<x)l=mid+1;
    else r=mid;
}
​
//查找数组中最后一个小于等于x的元素
while(l<r)
{
    int mid=(l+r+1)/2;
    if(a[mid]<=x)l=mid;
    else r=mid-1;
}

来!我们来玩找不同!

看看这两个代码有几处不同呢?

很明显的看出,有四处:

1、当前所枚举的元素
    int mid=(l+r)/2   
          or  
    int mid=(l+r+1)/2
    
2、收缩左区间
    l=mid+1   
          or  
    l=mid
    
3、收缩右区间
    r=mid   
          or  
    r=mid-1
    
4、判断条件
    a[mid]<x  
          or  
    a[mid]<=x

总的就是这么四种情况,确实,有点乱,不过好在其他的地方都没有问题啊。

去掉那几个情况后,代码的模板就变成了:

while(l<r)
{
    
    if()
    else 
}

虽然好像也不剩啥了,但是不急!

从现在开始,我们一步一步的还原这个代码!

在我们还原代码时,我们要先记下一个操作:

我们要始终保证正确的答案在我们的区间[l,r]中,(包括l和r的位置)

好,现在我们要想一个使用二分的环境,就选择:在升序数组中找第一个大于等于x的值的位置。

那么首先的,我们肯定是要枚举区间中间的元素

未命名绘图.png

我们加上:

while(l<r)
{
    //先不管这里+不+1,反正我们都是要枚举中间元素的,至于+1的问题我们后面讲
    int mid=(l+r)/2
    if()
    else 
}

好的,现在我们已经枚举了一个元素,这个元素只可能有两种情况:大于等于x小于x

随便选一种情况放入 if 中,就当他大于等于x吧,我们把这个写上:

while(l<r)
{
    int mid=(l+r)/2
    if(a[mid]>=x)
    else 
}

我们要找的是第一个大于等于x的值,而现在,我们枚举的元素确实是大于等于x的。

但是我们并不知道这个元素是不是第一个

那么很显然的,想知道是不是第一个,我们就去它的左边看看有没有比他更早大于等于x的元素。

如果有,它就不是第一个了;反之,它就是第一个,也就是我们要找的答案。

但不管怎么样,它右边的元素我们肯定不用找了,他右边的元素怎么可能比他还早大于等于x呢?

所以我们这里要做的是收缩右端点,即修改r的值

问题来了!这里是写 r=mid ?还是 r=mid-1

虽然我们现在不知道枚举的这个元素是不是答案,但它也许是对的。(毕竟如果右边没有比他更早一步的元素,它就是答案了)

那么我们称它是一个:可能的答案。

未命名绘图1.png

如果我们选择了 r=mid-1,会发生什么事?我们把这个元素去掉了!

未命名绘图2.png

回想我一开始说的:

我们要始终保证正确的答案在我们的区间[l,r]中,(包括l和r的位置)

如果我们选择了r=mid-1,那就会去掉一个可能正确的答案,所以我们不要这么干。

所以很显然,这里我们要写的当然是r=mid:

未命名绘图3.png

while(l<r)
{
    int mid=(l+r)/2
    if(a[mid]>=x)r=mid;
    else 
}

如果我们枚举的这个元素是小于x呢?

它不满足大于等于x,是一个错误的答案

未命名绘图4.png

并且它左边的所有元素都是错误的答案,此时我们就要收缩左端点,即修改l的值,来排除错误答案。

那么这里我们是选择 l=mid ?还是 l=mid+1

再次看我们上面的那句话。

我们只要保证正确的答案在我们的区间中就可以了,而这个答案明显是一个错误的答案,那么我们当然可以顺手把他去掉啦。

未命名绘图5.png

所以我们这里写的就是l=mid+1.

while(l<r)
{
    int mid=(l+r)/2
    if(a[mid]>=x)r=mid;
    else l=mid+1
}

那么现在,有问题的地方就剩下一处了,就是mid那里到底要不要+1?

关于这个问题,我们首先想到的应该是:为什么会有+1这个操作?

这里先说一下,这个+1,其实是一个手动四舍五入的过程

我们这里进行的是整数除法,而我们知道,整数的除法会自动去掉尾部的小数,而不是帮你四舍五入,比如:(3+4)/2得到的是3而不是4。

而当我们+1后,原来偶数的运算变成奇数了,但是结果没有变化;原来奇数的运算变成偶数了,结果+1。

如果把它放在我们的程序里,表示的意义就是我们枚举元素时,更容易枚举到一个偏向右边的元素。

  • 如果区间端点相加(l+r)是偶数,那么枚举的位置就是正中间,没有问题。
  • 但当区间端点相加是奇数,那么枚举的位置就和我们是否四舍五入有关系了

如果我们没有四舍五入,枚举的元素就是一个偏左边的元素;

未命名绘图6.png

如果四舍五入,枚举的元素就是一个偏右边的元素。

未命名绘图7.png

这就是我们在mid里+1的意义。

那么我们什么时候+1,什么时候不+1呢?

这一点,要看我们要找的答案偏向哪个方向

比如我们这里要找的是第一个大于等于x的元素,注意这个第一个。

既然说是第一个了,那么对于我们来说,这个元素肯定越往左越好。因为只要尽可能的往左找,要是找到了大于等于x的元素,那不就是答案了吗?如果我们尽可能往右找,那么有可能这个元素的左边有比它先一步大于等于x的。

所以,在现在这个情况,答案是偏向左边的

PS:注意!是偏向左边!而不是答案就在左边!

比如:

1 3 5 7 9 11 13

我们想在这个数组找第一个大于等于10的元素,那么很明显答案是11,但对于其它大于等于10的元素来说,11确实是 “偏向左边” 的。

所以我们就不需要给它手动的四舍五入了。

那么我们的代码就写完了,即:
while(l<r)
{
    int mid=(l+r)/2
    if(a[mid]>=x)r=mid;
    else l=mid+1
}
​
//if和else是可以互相替换的,无所谓
while(l<r)
{
    int mid=(l+r)/2
    if(a[mid]<x)l=mid+1;
    else r=mid;
}
这就是利用二分查找在升序数组中查找第一个大于等于x的值。

怎么样?有没有恍然大悟的感觉??

我们再来试一试:查找升序数组中最后一个小于x的元素

首先还是空模板:

while(l<r)
{
    
    if()
    else 
}

枚举中间元素:

while(l<r)
{
    //先不管这里+不+1,反正我们都是要枚举中间元素的,至于+1的问题我们后面讲
    int mid=(l+r)/2
    if()
    else 
}

如果我们枚举的元素小于x,说明是可能正确的答案,我们收缩左区间,即:l=mid.

while(l<r)
{
    int mid=(l+r)/2
    if(a[mid]<x)l=mid;
    else 
}

如果我们枚举的元素大于等于x,说明是错误的答案,我们收缩右区间,同时排除掉他,即:r=mid-1.

while(l<r)
{
    int mid=(l+r)/2
    if(a[mid]<x)l=mid;
    else r=mid-1;
}

然后再想一想,答案要找的是最后一个小于x的元素,那么对于我们来说,这个元素肯定越往右越好,我们在mid处手动四舍五入,即:mid=(l+r+1)/2.

那么我们的代码就写完了:
//升序数组内查找最后一个小于x的元素
while(l<r)
{
    int mid=(l+r+1)/2
    if(a[mid]<x)l=mid;
    else r=mid-1;
}
​
//if和else也可以互相调换
//升序数组内查找最后一个小于x的元素
while(l<r)
{
    int mid=(l+r+1)/2
    if(a[mid]>=x)r=mid-1;
    else l=mid;
}

要特别注意!以上我说的两种情况都是在“升序数组”中进行的,如果是逆序数组,则区间的收缩会相反。

以上就是我们的全部内容了。

什么?你还有点懵懵的?没事,多看几遍!

那么最后,就是我们的——

五、结尾啦——快乐的时光总是短暂的呀

总结一下内容就是:

  1. 二分查找的复杂度为O(logn)。
  2. 使用二分查找必须要保证答案具有单调性。
  3. 边界问题要注意,根据具体所求,逐步分析。

分析代码的四个步骤:

  1. 准备空模板。
  2. 如果当前枚举的元素满足目标值的关系,区间收缩,但要保证它在区间里呆着。
  3. 如果当前枚举的元素不满足目标值的关系,区间收缩,可以顺手把它从区间排除。
  4. 判断答案偏向左边就不变,偏向右边就+1.

其实这么看下来,二分查找并不苦难呀,但就像我之前说的:

“学习一个知识点并不太难,难的是如何能玩出花来。”

最重要的还是多写题积累经验,所以学习完后建议多去找找相关知识点的题来写噢,感受AC的快感。

最后如果本篇文章帮到了您,不知是否能点一个小小的赞呢。(拜托了!这对我真的很重要!)

QQ图片20230402115236.gif

那么我们在下一个知识点再见啦!拜拜!

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值