如果你还在为二分查找的边界条件而纠结,那么本篇文章将会解决你困惑很久的问题,让你豁然开朗。
本篇文章将介绍一个整数二分的最优解代码模板,无需担心任何越界问题。
由于本篇文章是一篇教程,适用于初学者,因此会介绍普通的二分方法,最后才会介绍最优模板。
一句话描述:在单调序列中找x或者x的前驱;在单调序列中找x或者x的后继。
这里拿后继来说,在单调递增序列a[]
中,如果有x,找第一个x的位置;如果没有,则找出第一个比x大的值的位置。
例如,有如下序列
1 , 4 , 5 , 7 , 9 , 9 , 10 , 11 , 57 , 78 , 99 {1,4,5,7,9,9,10,11,57,78,99} 1,4,5,7,9,9,10,11,57,78,99
我们要在其中查找9,以后继方法查找,我们会找到从左向右第一个9的位置(划线位置):
1 , 4 , 5 , 7 , 9 ‾ , 9 , 10 , 11 , 57 , 78 , 99 {1,4,5,7,\underline {9},9,10,11,57,78,99} 1,4,5,7,9,9,10,11,57,78,99
如果我们要查找56,以后继方法查找,我们会找到第一个比56大的数:
1 , 4 , 5 , 7 , 9 , 9 , 10 , 11 , 57 ‾ , 78 , 99 {1,4,5,7,9,9,10,11,\underline {57},78,99} 1,4,5,7,9,9,10,11,57,78,99
首先我们注意到,能够解决的问题必须是单调的。这一点关乎到二分法的原理。
为什么是单调的呢?
假设你想要从一个单调递增区间中找出一个值x,或者最接近x的值,你可以每次从区间中取出中间的值,与x比较大小;
如果中间值大于x,又因为它是单调递增区间,说明带查找的值x在中间值的左边,我们需要向左边查找。
相反,则说明在右边,需要向右边查找。
这样一来,待查找的值的范围变小了,我们对新的范围重复上面所说的步骤即可。
本质上,二分法是一种无限逼近待查找值的方法
从数学界来看,这个方法是用于求解非线性方程的根的方法。
如何进行编码呢?
我们有三个变量需要操作:
每次我们缩小区间的一半,直到l == r
为止(当然,对于离散的数组来说,区间中本就没有待查找的值,那么将会发现最近的值),这样我们就找到了答案。
这里我们给出找出x或者x的后继的模板代码~~
这里的区间是左闭右开哦~~~~
int bin_search(int *a, int n, int x) {
int left = 0, int right = n;
while(left < right) {
int mid = left + (right - left)/2; // 也可以写成:int mid = (right + left) >> 1;这两种方法各有优劣,等会儿会讲
// 当a[mid] >= x时,说明x在待查找元素的左边让右区间缩小
if(a[mid] >= x) right = mid;
// 当a[mid] < x时,说明x在待查找元素的右边让左区间缩小
else left = mid + 1;
}
return left;
}
看完上面的模板,你肯定有很多问题:
int mid = left + (right - left)/2
可不可以int mid = (right + left) >> 1;
?left = mid + 1;
?可不可以写成left = mid
?right = mid
不写成right = mid - 1
?别急,这些问题等我一一解答~~
关于mid的计算方法有很多种:但是没有一种方法是完美的,但是请你记住,他们的本质都是除以2。但是会有不同的溢出问题,下面是对这些方法的说明:
实现 | 适用场合 | 可能出现的问题 |
---|---|---|
mid = (left + right) / 2 | left >= 0,right >=0; left + right 无溢出 |
1. left + right 溢出 2. 负数情况下有向0取整问题 |
mid = left + (right - left) / 2 | left - right 无溢出 | 若right和left都是大数且一正一负,right - left可能溢出 |
mid = (left + right) >> 1 | left + right 无溢出 | 若left和right都是大数,那么可能溢出 |
当left >= 0,right >= 0
且没有溢出时,上面三种实现的结果相等。
综合上面表中的问题,方法二略好一些。
代码的关键是对mid的处理,如果取值不当,while()很容易进入死循环。接下来我们仔细讨论一下这个问题:
l + r = 奇数
,此时得到的结果就会更靠近l
一些,此时,如果l
和r
两个值只差1(到达临界值),例如,l = 2,r = 3
,得到的mid
等于2,如果我们要寻找x的后继,则会让mid = l
此时就会进入死循环。l = mid + 1
。此时可能有人会有疑问,如果每次都让mid + 1
,会不会错过结果呢?例如,假设此时的l
就是答案,是否会出现 l + 1 而让查找区间错过答案的情况呢?答案是否定的,如果待查找的位置是 l 的位置,那么说明在此之前mid一定等于 l 此时会让r也指向l。例如:我们以1,2,3为例子,此时待查找的元素是1,区间左闭右开,l
和r
的初始值分别为1,4,那么首先加1除以2得到mid = 2,由于 1 小于 mid 得出 r = 2,此时l
和r
已经相邻了,那么接下来,mid = 1,并且得出r = 1。l == r 退出while循环,返回left的值。可以看出并不会有问题。int bin_search2(int *a, int n, int x) {
int left = 0, right = n;
while (left < right) {
int mid = left + (right - left + 1) / 2; //保持右中位数
if (a[mid] <= x) left = mid;
else right = mid - 1;
}
return left;
}
这段代码与上面查找后继的代码类似,不再多做解释。
值得注意的还有一点:
上面的内容如果是初学者可能难以理解,读者一定要配合纸笔在纸上进行演算,学算法是一个困难的过程,一定要掌握一个正确的学习方法。如果思路还不清晰,可以配合后面的总结,梳理思路。把求前驱或者求后继的内容分门别类的学习。
如果区间中存在负数,那么上面的代码可以使用吗?答案是肯定的。
不过,如果你的mid的计算方法是 ( l e f t + r i g h t ) / 2 (left + right) / 2 (left+right)/2的话,那么你要注意了,上面的模板就不能使用了。
为什么呢?原因是取值方向,在上面的代码中,你可以看出来,在同一个代码中,方向必须是朝向一个无穷方向的,例如 3.5 向正无穷取值就是4,向负无穷取值就是3。
那么我们上面所说的三种求mid的方式的取值方向如下:
(left + right) / 2
:向0取整left + (right - left) / 2
:向负无穷取整(left + right) >> 1
:向负无穷取整因此,只有第二和第三种方式可以担当胜任。
如果只是单纯寻找一个具有准确大小的数字的话,我们使用如上的代码就可以解决这个问题。
现在来对上面的两个模板进行一下总结,方便理解和记忆:
主要有两个:
他们返回找到元素的位置,如果未找到,那么返回end。
如果只是简单的查找x或x附近的数,这两个函数就可以解决。
适用问题如下:
简单来说:
假如有一串序列:
1 , 2 , 3 , 4 , 5 , 5 , 5 , 6 , 7 , 8 , 9 {1,2,3,4,5,5,5,6,7,8,9} 1,2,3,4,5,5,5,6,7,8,9
用 红色 \textcolor {red} {红色} 红色表示lower_bound,用 蓝色 \textcolor {blue} {蓝色} 蓝色表示upper_bound,如果让他们查找5,他们查找到的位置如下:
1 , 2 , 3 , 4 , 5 , 5 , 5 , 6 , 7 , 8 , 9 {1,2,3,4,\textcolor {red} {5},5,5,\textcolor {blue} {6},7,8,9} 1,2,3,4,5,5,5,6,7,8,9
再给出一个序列:
1 , 4 , 7 , 12 , 25 , 26 , 35 , 38 , 44 , 46 , 51 , 57 {1,4,7,12,25,26,35,38,44,46,51,57} 1,4,7,12,25,26,35,38,44,46,51,57
如果要查找24,两个函数都将查找到25,下划线表示
1 , 4 , 7 , 12 , 25 ‾ , 26 , 35 , 38 , 44 , 46 , 51 , 57 {1,4,7,12,\underline {25},26,35,38,44,46,51,57} 1,4,7,12,25,26,35,38,44,46,51,57
解释如下:
注意,在适用这个函数之前要确保,待查找的区间一定是从小到大有序的!
在算法比赛中,我们往往不会对一个数直接进行二分查找。通常情况下,给定的问题会具有二分的特征。
通常情况下,我们能使用的二分的模板如下:
while (left < right) {
int ans;
int mid = left + (right - left) / 2;
if (check(mid)) {
ans = mid; //记录答案
... //移动left
} else {
... //移动right
}
因此,二分法的关键在于,如何建模check中的内容,其中可能会套用其他的方法和数据结构。
关于建模的问题,这里会另外再写一篇文章。
int bin_search(int *a, int n, int tar) {
int l = 1, r = n;
int mid = l + (r - l) / 2;
while (l + 1!= r) {
mid = l + (r - l) / 2;
if (check(mid)) {
l = mid;
} else {
r = mid;
}
}
return ...; //返回值可以是l也可以是r,根据具体情况判断
}
这段代码解决了越界问题,不会出现死循环。
其原因很简单,l + 1!= r
这句话是精髓,二分的最终临界值一定是两个指针指向的值相邻,如果两个值相邻,就可以跳出while循环了。
返回值怎么判断呢?
很简单,对于l
,只需要永远保持a[l] < x
,对于r
,只需要永远保持a[r] >= x
这样一来,你如果想找到x,那么直接返回r
的值;
如果想找到第一个大于x的值,也是返回r
的值;
如果想找到第一个小于x的值,那么返回l
的值。
这样永远不会越界,除非,你的check函数有问题。
以上就是本期文章的全部内容啦~~~,创作不易!!!如果可以的话可以来个点赞+关注+收藏!!!