算法竞赛基础:二分法
文章目录
如果你还在为二分查找的边界条件而纠结,那么本篇文章将会解决你困惑很久的问题,让你豁然开朗。
本篇文章将介绍一个整数二分的最优解代码模板,无需担心任何越界问题。
由于本篇文章是一篇教程,适用于初学者,因此会介绍普通的二分方法,最后才会介绍最优模板。
二分法是用来解决什么问题的?
一句话描述:在单调序列中找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 i f t lift lift 或者 l l l
- 区间右值(右指针): r i g h t right right 或者 r r r
- 区间中点: m i d mid mid
每次我们缩小区间的一半,直到l == r
为止(当然,对于离散的数组来说,区间中本就没有待查找的值,那么将会发现最近的值),这样我们就找到了答案。
查找后继x示例
这里我们给出找出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的计算
关于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处理
代码的关键是对mid的处理,如果取值不当,while()很容易进入死循环。接下来我们仔细讨论一下这个问题:
- 为什么会出现死循环呢? 有下面的两个原因:首先是取值,取值方式为舍弃小数点后的值取整(例如:3.5会被当成3);其次,奇数除二除不尽。那么综合这两点原因会发现:假如
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的值。可以看出并不会有问题。
查找前驱x示例
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是用的左中位数,也就是借助的C++语言中整形除法的特性,那么可以取右中位数吗?答案是不行,以上面那个2,3的例子为例,如果让mid等于3,如果取right = mid,那么会发现还是进入死循环。
- 同样的,查找前驱的代码中,mid的值必须为右中位数,这就是上面的代码中必须+1的原因。
上面的内容如果是初学者可能难以理解,读者一定要配合纸笔在纸上进行演算,学算法是一个困难的过程,一定要掌握一个正确的学习方法。如果思路还不清晰,可以配合后面的总结,梳理思路。把求前驱或者求后继的内容分门别类的学习。
关于负数二分
如果区间中存在负数,那么上面的代码可以使用吗?答案是肯定的。
不过,如果你的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
:向负无穷取整
因此,只有第二和第三种方式可以担当胜任。
代码特征总结
如果只是单纯寻找一个具有准确大小的数字的话,我们使用如上的代码就可以解决这个问题。
现在来对上面的两个模板进行一下总结,方便理解和记忆:
- 区间可以是左闭右开也可以是左闭右闭,这个不会影响代码特征
- 无论是前驱还是后继,返回值都是left
- 无论是前驱还是后继,if 条件中比较符号左边是mid值,右边是target(目标值)
- 对于前驱:if 条件中的比较符号是>=
- 对于后继:if条件中的比较符号是<=
- 对于前驱:如果满足 if 则 right = mid,否则left = mid + 1
- 对于后继:如果满足 if 则 left = mid,否则right = mid + 1
C++中STL中的二分函数
主要有两个:
- lower_bound()
- upper_bound()
他们返回找到元素的位置,如果未找到,那么返回end。
如果只是简单的查找x或x附近的数,这两个函数就可以解决。
适用问题如下:
- 查找第一个大于x的元素的位置:upper_bound()或lower_bound()
- 查找第一个大于等于x的元素:lower_bound()
- 查找第一个与x相等的元素:lower_bound()
- 查找最后一个与x相等的元素:upper_bound()返回的前一个且等于x的元素
- 查找最后一个等于或小于x的元素:upper_bound()的前一个元素
- 查找最后一个小于x的元素:lower_bound()的前一个元素
- 计算单调序列中x的个数:upper_bound() - lower_bound()
简单来说:
- lower_bound查找到的元素是第一个x,或者第一个大于x的元素
- upper_bound查找到的元素是最后一个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
解释如下:
- upper_bound查找最后一个24,发现没有,进而返回第一个比24大的值
- lower_bound查找第一个24,发现没有,返回第一个比24大的值
注意,在适用这个函数之前要确保,待查找的区间一定是从小到大有序的!
二分建模
在算法比赛中,我们往往不会对一个数直接进行二分查找。通常情况下,给定的问题会具有二分的特征。
通常情况下,我们能使用的二分的模板如下:
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函数有问题。
以上就是本期文章的全部内容啦~~~,创作不易!!!如果可以的话可以来个点赞+关注+收藏!!!