小帅,他正打算和旁边的小美一起玩一个游戏,这个游戏叫猜数字,规则如下:一个人想一个0到100的数字,然后另一个人来猜,根据那个人猜的数字回应他大了、小了和正正好,看看谁猜出来数字所用的次数最少。
小帅是个老实人,它从0开始一个个的猜,这样肯定能猜到正确的答案,不过小美想的数字是78,小帅猜了79次才猜到。
小帅为了不输,于是他想的数字是79——比78大一点点,他以为这样就可以赢过小美了。
但是我们大女主小美一点都不care,她只用了6次就猜出了正确答案,降维打击!
她会读心吗?开挂了?运气好?
我们来看看她是怎么猜的。
她第一次猜的数字是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。
发现小了,那么我要往右边找。
但我们发现此时答案就已经不对劲了。
那么什么时候算特定的环境呢?
根据我个人的经验,如果一个题目的答案具有单调性,那么就可以使用二分查找。
什么是单调性呢?
在正常的二分查找中,单调性体现在目标值和数组中数字的大小关系。
比如刚刚猜数字,他猜的是x,那么在x之前的数,答案都是小于x,在x之后的数,答案都是大于x,没有说x之后有哪个数是小于x的,这就是答案的单调性。
在二分答案(二分查找的另一种用法)中,单调性体现在最大、最小、恰好等字眼中。
比如去买东西,不知道要多少钱才可以买完我想要的全部东西,要求出最少需要的钱数,它就是分界点。比它大的钱数都能保证钱够买完我们想要的东西,比它小的钱数则都买不完。
而答案混乱就是指我们上面举的数组非有序的例子,分界点5之后的所有数并不都是大于5的,这样答案就失去了单调性,此时使用二分搜索是错误的!
二分查找的使用条件我们已经知道了,那么接下来让我们——
这里先给出二分查找的两种模板代码。
//查找数组中第一个大于x的元素
while(l
一共也就四行代码。以上变量的意思是:
l:区间的左端点。
r:区间的右端点。
mid:每次我们枚举的,区间中间的位置。
a[]:数组。
x:目标值。
就像我们说的,每次我们都枚举的是中间的元素,所以mid=(l+r)/2,然后通过if判断目标值和我们当前枚举元素的关系,来决定是修改区间的左端点l,还是修改区间的右端点r。
不过!正当一个小萌新信心满满的开始用它去写题时,意外发生了!
是的!我们的小萌新惨遭滑铁卢啦(哈哈哈哈哈)!
至于让我们的小萌新感到如此抓狂的,便是令人破防的边界问题!
在我们学习二分查找的过程中,多多少少都应该遇到过上面小萌新的情况,二分算法虽然是初级算法,但是是许多算法初学者的噩梦,甚至有的很多学到很后面了的,也搞不清二分的边界问题。我当时也是深受其害呀。
而且二分是很灵活的!不同的情况下,二分查找都有不同的写法,光靠背真的很难受啊!
而且对于想挑战程序设计竞赛的同学们来说,认识一个知识点,背下它的代码有时候真的不是很方便。
程序设计竞赛麻烦的不是一个知识点,而是如何把一个知识点玩出花来。
有时候看看大佬们的题解,就会不由得惊叹: “啊?这个还能这么用吗?”
比起愣愣的记下代码,我选择——
我们来看看刚刚给出的两个模板。
//查找数组中第一个大于x的元素
while(l
来!我们来玩找不同!
看看这两个代码有几处不同呢?
很明显的看出,有四处:
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]
总的就是这么四种情况,确实,有点乱,不过好在其他的地方都没有问题啊。
去掉那几个情况后,代码的模板就变成了:
while(l
虽然好像也不剩啥了,但是不急!
在我们还原代码时,我们要先记下一个操作:
好,现在我们要想一个使用二分的环境,就选择:在升序数组中找第一个大于等于x的值的位置。
那么首先的,我们肯定是要枚举区间中间的元素。
我们加上:
while(l
好的,现在我们已经枚举了一个元素,这个元素只可能有两种情况:大于等于x 和 小于x。
随便选一种情况放入 if 中,就当他大于等于x吧,我们把这个写上:
while(l=x)
else
}
我们要找的是第一个大于等于x的值,而现在,我们枚举的元素确实是大于等于x的。
但是我们并不知道这个元素是不是第一个。
那么很显然的,想知道是不是第一个,我们就去它的左边看看有没有比他更早大于等于x的元素。
如果有,它就不是第一个了;反之,它就是第一个,也就是我们要找的答案。
但不管怎么样,它右边的元素我们肯定不用找了,他右边的元素怎么可能比他还早大于等于x呢?
所以我们这里要做的是收缩右端点,即修改r的值。
问题来了!这里是写 r=mid ?还是 r=mid-1 。
虽然我们现在不知道枚举的这个元素是不是答案,但它也许是对的。(毕竟如果右边没有比他更早一步的元素,它就是答案了)
如果我们选择了 r=mid-1,会发生什么事?我们把这个元素去掉了!
回想我一开始说的:
如果我们选择了r=mid-1,那就会去掉一个可能正确的答案,所以我们不要这么干。
所以很显然,这里我们要写的当然是r=mid:
while(l=x)r=mid;
else
}
如果我们枚举的这个元素是小于x呢?
它不满足大于等于x,是一个错误的答案。
并且它左边的所有元素都是错误的答案,此时我们就要收缩左端点,即修改l的值,来排除错误答案。
那么这里我们是选择 l=mid ?还是 l=mid+1 。
再次看我们上面的那句话。
所以我们这里写的就是l=mid+1.
while(l=x)r=mid;
else l=mid+1
}
那么现在,有问题的地方就剩下一处了,就是mid那里到底要不要+1?
关于这个问题,我们首先想到的应该是:为什么会有+1这个操作?
这里先说一下,这个+1,其实是一个手动四舍五入的过程。
我们这里进行的是整数除法,而我们知道,整数的除法会自动去掉尾部的小数,而不是帮你四舍五入,比如:(3+4)/2得到的是3而不是4。
而当我们+1后,原来偶数的运算变成奇数了,但是结果没有变化;原来奇数的运算变成偶数了,结果+1。
如果把它放在我们的程序里,表示的意义就是我们枚举元素时,更容易枚举到一个偏向右边的元素。
如果我们没有四舍五入,枚举的元素就是一个偏左边的元素;
如果四舍五入,枚举的元素就是一个偏右边的元素。
那么我们什么时候+1,什么时候不+1呢?
这一点,要看我们要找的答案偏向哪个方向。
比如我们这里要找的是第一个大于等于x的元素,注意这个第一个。
既然说是第一个了,那么对于我们来说,这个元素肯定越往左越好。因为只要尽可能的往左找,要是找到了大于等于x的元素,那不就是答案了吗?如果我们尽可能往右找,那么有可能这个元素的左边有比它先一步大于等于x的。
所以,在现在这个情况,答案是偏向左边的。
比如:
1 3 5 7 9 11 13
我们想在这个数组找第一个大于等于10的元素,那么很明显答案是11,但对于其它大于等于10的元素来说,11确实是 “偏向左边” 的。
所以我们就不需要给它手动的四舍五入了。
while(l=x)r=mid;
else l=mid+1
}
//if和else是可以互相替换的,无所谓
while(l
怎么样?有没有恍然大悟的感觉??
我们再来试一试:查找升序数组中最后一个小于x的元素
首先还是空模板:
while(l
枚举中间元素:
while(l
如果我们枚举的元素小于x,说明是可能正确的答案,我们收缩左区间,即:l=mid.
while(l
如果我们枚举的元素大于等于x,说明是错误的答案,我们收缩右区间,同时排除掉他,即:r=mid-1.
while(l
然后再想一想,答案要找的是最后一个小于x的元素,那么对于我们来说,这个元素肯定越往右越好,我们在mid处手动四舍五入,即:mid=(l+r+1)/2.
//升序数组内查找最后一个小于x的元素
while(l=x)r=mid-1;
else l=mid;
}
以上就是我们的全部内容了。
什么?你还有点懵懵的?没事,多看几遍!
那么最后,就是我们的——
总结一下内容就是:
分析代码的四个步骤:
其实这么看下来,二分查找并不苦难呀,但就像我之前说的:
最重要的还是多写题积累经验,所以学习完后建议多去找找相关知识点的题来写噢,感受AC的快感。
那么我们在下一个知识点再见啦!拜拜!