大家也许玩过这样一个游戏:A在心中想一个一百以内的数,B猜一个数后A来回答大了还是小了,直到B猜出这个数。那么,应怎样尽快结束这个游戏?聪明的你想到了使用一半一半地猜的方法。即先猜50再靠对方的回答来决定猜25或者75,并以此类推直到猜出这个数。而这正是二分查找的应用。
一、二分查找
二分查找是一种非常高效的搜索方法,主要原理是每次搜索可以抛弃一半的值来缩小范围,一般用于对普通搜索方法的优化。(时间复杂度为 (long2n) )。但是,使用此方法有一个前提,有序性。也就是说,在进行查找之前,要对数组进行排序。
基本思路
(一)先对n个数进行排序。
(二)再有顺序的n个数中查找x。
①先让x与区间中间值mid比较,等于就结束
②若x<a[mid],则说明x应在a[0]到a[mid]之间,调整区间右端点
③若x>a[mid],则说明x应在a[mid]到a[n-1]之间,调整区间左端点
循环重复进行,每次查找都拿新的区间中间值与x做比较
代码实现
cpp
int l; //区间左端点
int r; //区间右端点
while(l<=r) { //循环进行查找
int mid=(l+r)/2; //求区间中间值
if (mid==答案) { //将中间值与答案进行比较
求出结果了,可进行下一步操作
}
else if (mid<答案) l=mid+1; //mid小于答案,调整左端点
else r=mid-1; //mid大于答案,调整右端点
}
题目详解
A-B 数对
题面
思路
本题最重要的思路即将题面中的A-B=C转换为A+C=B,通过循环来确定和更换A的值,并将A与C相加得到希望能找到的B的值。再通过二分来确定是否存在与个数。
其中值得注意的是,在n个正整数中,可能会有重复,且不同位置的数字一样的数对算不同的数对。所以应该找的是某个值所有的个数。
那么,怎么求某个值所有的个数呢?
在将这n个数排序后,值相等的数会形成一个区间。所以,只需要找到该区间的左端点与右端点取其之差就可以了。
寻找左端点(即找大于等于的数,其输出的位置是同类型中最靠左的):
其与上述代码大体相似,但mid的判断与while循环条件等不太一样。
cpp
int l; //区间左端点
int r; //区间右端点
while(l<r) { //区间内至少有一个元素时循环
int mid=(l+r)/2; //求区间中间值
if (mid<答案) l=mid+1; //mid小于答案,调整左端点
else if (mid>=答案) r=mid; //mid大于等于答案,调整右端点(可以只写一个else)
}
将循环条件改为l<r可以避免死循环,例如当l=3,r=4时,mid=3。若此时mid>=答案,则r=mid=3,若此时循环条件为l<=r则会死循环,但将其改为l<r就可以避免。
将r=mid-1改为r=mid可以保留mid,因为在求大于等于的数时,mid就算大于答案也可能是我们要求的值。
寻找右端点(即找小于等于的数,其输出的位置是同类型中最靠右的):
此与上文找大于等于的数基本相同,不再赘述。
cpp
int l; //区间左端点
int r; //区间右端点
while(l<r) { //区间内至少有一个元素时循环
int mid=(l+r+1)/2; //求区间中间值,+1目的为向上取整
if (mid<=答案) l=mid; //mid小于等于答案,调整左端点
else if (mid>答案) r=mid-1; //mid大于答案,调整右端点
}
向上取整的目的是避免死循环。比如说当l与r相邻时,若向下取整(mid=(l+r)/2),那么mid就等于l。之后若mid<=答案成立,则l=mid=l,l的值没有变,区间大小也没有变,陷入了死循环。
代码
cpp
#include <bits/stdc++.h> //万能头文件
using namespace std;
long long n,c,a[200005],ans,sum; //要求处理的数字个数n,数对结果c,要求处理的数字a数组,每一次目标要求的b的值ans,满足a-b=c的数对个数sum
int main() {
cin>>n>>c; //输入n与c
for (int i=1;i<=n;i++) { //进行n次循环
cin>>a[i]; //输入a数组
}
sort(a+1,a+n+1); //对a数组进行排序
long long l,r,l1,r1,mid; //定义求左端点和右端点会用到的变量
for (int i=1;i<=n;i++) { //进行n次循环,n个数轮流当A
ans=c+a[i]; //将已知的A与C相加得到目标要求的ans的值
l=1,r=n; //将求左端点用的变量初始化
l1=1,r1=n; //将求右端点用的变量初始化
while (l<r) { //求左端点
mid=(l+r)/2; //求区间中间值
if (a[mid]<ans) { //当中间值小于目标要求的ans的值时
l=mid+1; //调整l
}
else { //当中间值大于等于目标要求的ans的值时
r=mid; //调整r
}
}
while (l1<r1) { //求右端点
mid=(l1+r1+1)/2; //求区间中间值,向上取整
if (a[mid]>ans) { //当中间值大于目标要求的ans的值时
r1=mid-1; //调整r1
}
else { //当中间值小于等于目标要求的ans的值时
l1=mid; //调整l1
}
}
if (a[l]==ans&&a[l1]==ans) {//因为a[l]与a[l1]有可能不等于目标要求的ans的值,所以需要再判断一下
sum+=abs(l1-l+1); //计算与ans相等的数的个数
}
}
cout<<sum; //输出总数
return 0;
}
查找
题面
思路
本题相较于上文中所述的A-B数对来说更简单,仅求出左端点(在序列中第一次出现的位置)就可以了。求出左端点的方法前文已叙述,这里不多赘述。
且本题输入的序列是从大到小的有序数列,符合使用二分查找的条件。
代码
cpp
#include <bits/stdc++.h> //万能头文件
using namespace std;
long long n,m,a[1000005],b; //序列数字个数n,询问次数m,序列数字a数组,m次询问中所询问的数字b
int main() {
cin>>n>>m; //输入序列数字个数与询问次数
for (int i=1;i<=n;i++) { //进行n次循环
cin>>a[i]; //输入序列
}
long long l,r,mid; //定义寻找左端点所用的变量
for (int i=1;i<=m;i++) { //进行m次循环
cin>>b; //输入所询问数字
l=1,r=n; //初始化变量
while (l<r) { //用while循环进行查找
mid=(l+r)/2; //计算区间中间值
if (a[mid]<b) { //当中间值小于所询问数字时
l=mid+1; //调整l
}
else { //当中间值大于等于所询问数字时
r=mid; //调整r
}
} //使用此方法求的是大于等于的值,有不相等的风险,因此在查找后仍需判断一下
if (a[l]==b) { //当查找出来的数与所询问数字相等时
cout<<l<<" "; //输出答案位置
}
else { //当查找出来的数与所询问数字不相等时
cout<<"-1"<<" "; //输出-1
}
}
return 0;
}
二、二分答案
当答案属于一个区间,且区间很大(容易超时),并且对题目中的某个量有单调性(最重要)时,就会使用二分答案。
与二分查找的异同
二分查找是在一个已知的有序数对上进行查找。二分答案则是在最优答案所在区间进行查找,直到找到它。
它们进行二分的写法差不多,仅仅只是if语句判断条件的不同。在二分答案里,我们需要依照题意写一个检查函数(check函数,可缩写成c函数),并将中间值带入来判断是舍弃左区间还是右区间。
题目详解
跳石头
题面
https://www.luogu.com.cn/problem/P2678
思路
通过读题可知,本题有答案区间,可使用二分答案。本体题面中"使得选手们在比赛过程中的最短跳跃距离尽可能长"告诉了我们本题是求最小值的最大值,所以说是套上文中求小于等于的公式。
代码
cpp
#include <bits/stdc++.h> //万能头文件
using namespace std;
long long l,m,n,d[50005]; //起点到终点的距离l,组委会至多移走的岩石数m,起点和终点之间的岩石数n,第i块岩石与起点的距离d数组
bool c(long long x) { //检查函数,布尔值类型
int s=0,z=0; //移走的岩石数s,上一块石头位置z
for (int i=1;i<=n+1;i++) { //进行(n+1)次循环,因为最后一块石头也要跳
if (d[i]-z<x) { //如果这一次跳跃距离小于最短跳跃距离
s++; //说明需要移走当前石头
if (s>m) { //移走的石头数量超过了至多移走的岩石数
return 1; //此x不是答案
}
}
else { //如果这一次跳跃距离大于等于最短跳跃距离
z=d[i]; //跳一下,记录位置
}
}
return 0; //此x可能是答案
}
int main() {
cin>>l>>n>>m; //输入起点到终点的距离,起点和终点之间的岩石数和至多移走的岩石数
for (int i=1;i<=n;i++) { //进行n次循环
cin>>d[i]; //输入第i块岩石与起点的距离
}
d[n+1]=l; //最后一块石头也要跳,记录一下距离
long long le=1,r=l,mid; //定义变量
while (le<r) { //二分答案分的是最短跳跃距离
mid=(le+r+1)/2; //计算中间值,向上取整防死循环
if (c(mid)) { //进行判断,发现此mid不是答案
r=mid-1; //说明mid大了,调整r
}
else { //进行判断,发现此mid可能是答案
le=mid; //保留一下,继续二分
}
}
cout<<le; //输出答案
return 0;
}
砍树
题面
P1873 [COCI 2011/2012 #5] EKO / 砍树 - 洛谷
思路
本题与上题较相似,本题二分的是锯片的最高高度。从题面中"如果再升高 1 米,他将得不到 M 米木材。"可知本题是求最小值的最大值,所以说是套上文中求小于等于的公式。
代码
cpp
#include <bits/stdc++.h> //万能头文件
using namespace std;
long long n,m,a[1000005],l,r,mid; //树木的数量n,需要的木材总长度m,每棵树的高度a数组,后面二分锯片的最高高度所要用到的l,r,mid
bool c (long long x) { //检查函数
long long s=0; //得到的木材总长度
for (int i=1;i<=n;i++) { //砍了n棵树,所以进行n次循环
if (a[i]>x) { //如果这棵树会被锯片砍到,即它的高度高于锯片高度
s+=a[i]-x; //将砍下的木材加入得到的木材总长度
}
}
if (s<m) { //如果根本满足不了目标
return 0; //此为错误答案
}
return 1; //此可能为最优答案
}
int main() {
cin>>n>>m; //输入树木的数量与需要的木材总长度
for (int i=1;i<=n;i++) { //进行n次循环
cin>>a[i]; //输入每棵树的高度
if (r<a[i]) { //区间右端点的值是树的高度的最大值
r=a[i];
}
}
sort(a+1,a+n+1); //将树按高度从小到大进行排序
while (l<r) { //进行二分答案
mid=(l+r+1)/2; //计算区间中间值
if (!c(mid)) { //如果连目标都满足不了
r=mid-1; //说明锯片高了,调整r
}
else { //如果此可能为最优答案
l=mid; //保留mid并调整l
}
}
cout<<l; //输出答案
return 0;
}
三、总结
二分算法使用起来较简单,不是很灵活,记住公式在题目中灵活应变即可。
P.S.在读题时一定要读明白,知道是求最小值的最大值(用找小于等于的数的公式)还是最大值的最小值(找大于等于的数的公式)。
在最后,给大家分享个口决吧。
二分最怕死循环,mid计算是关键。
l=mid必须上取整,否则相邻就卡死。
r=mid必须下取整,对称规则要记全。
检查函数写清晰,贪心模拟莫跑偏。