前言:关于二分查找的定义、优点等我就不在这里赘述了,这篇文章主要目的是带你们真正搞懂二分,遇见二分类型的题型会做。
目录
细节2:m是否始终处于[0,N)以内,不会出现溢出问题吗??(m就是mid)
细节3:更新指针时,能不能写成l=m+1,或者r=m-1???
1.首先来解决一道模板题----(数的范围---来自AcWing)
2.也还是一道比较简单的题--查找(来自洛谷)【深基13.例1】查找 - 洛谷
一、以一个例子开始讲解细节问题
例子:下面有八个元素:
1 2 3 5 5 5 8 9
对于二分算法来说,最复杂的事就是处理一些很刁钻的问题,有时候会出现无限循环等一些问题。
就比如一下四个问题:
他们的问题是非常相似的,但是细节不同,答案也完全不同,所以这就是二分算法容易出错的地方所在。
首先我先用一段代码将一些细节问题:(下面的细节问题都是针对这一小段代码来解答的!!!)
-
细节1:为什么l的初始值为-1,r 的初始值为N??
我可以举个例子:看上面五个元素:5 5 5 8 9,我们现在要找第一个“<=4”的元素,(但是作为上帝视角来看,我们所要寻找的元素不存在),假设我们将L的初始值指向第一个元素5,r的初始值指向最后一个元素9,最终的结果是这样的:
我们永远也找不到正确的分界线。(这仅仅是5个元素,未来的二分题中数组的大小不仅仅是5个,所以这种错误要避免)
同理:如果将r初始化n-1,那么还是会产生以上错误。
总结:其实可以将数组分为蓝红区域,就像我画的框一样,我们是上帝视角来看这道所以很容易发现如果将l的初始值为第一个元素,r 的初始值为最后一个元素,他们的区域都是红色或者都是蓝色,我们便找不到最后的边界线:
-
细节2:m是否始终处于[0,N)以内,不会出现溢出问题吗??(m就是mid)
我们可以计算一下m的最大值和最小值,m=(l+r)/2;
m的最小值:当l=-1,r=1时m取最小值为0;
m的最大值:当l=N-2,r=N时m取最大值为N-1
注意:一定要满足----(l+1!=r)这个条件进行取值
总结:所以m就在[0,N)之间,所以不会溢出
-
细节3:更新指针时,能不能写成l=m+1,或者r=m-1???
还是用画图来解释一下:
我们来看这张图,如果我们将r=m-1,就会让r指向蓝色区域,造成错误,所以为了不必要的错误我们也出于正确性和易懂性的双重考虑,我们就写成l=m,r=m
-
细节4:会不会陷入死循环???
我们思考:我们要用二分查找一个数,最终的结果是为了得到这个数的位置,所以要返回l或者是r,也就是返回分界线两侧的数,所以我们最终要产生一条分界线:
所以除了第一种情况,下面的所有情况都会回到第一种情况。
上面的细节问题都解决之后,我们就可以思考一下一开始提的四个问题如何解决了!
二、解决例子中的四个问题(细讲):
我还是用画图的方式进行讲解,我也想强调一句,画图是必备的技能,可以帮助我们很好的分析题目,清晰明了
强调一点:题目的前提是已经保证数组中有5这个元素的存在(还是以上帝视角来讲解题目),如果在真正解题的过程中,最后还是要判断一下分界线左右是否有5
1.找到第一个“>=5”的元素
2.找到最后一个“<5”的元素
3.找到第一个“>5”的元素
4.找到最后一个“<=5”的元素
1.其实通过画图分析不难发现,第一题和第二题是一种类型,第三题和第四题是一种类型,所以画的图和代码几乎是一样的,但是不一样的地方在于返回值(是返回 L还是返回R??)这是要根据题意的。
2.我不知道你们有没有发现,其实上面我所写的代码中isBlue函数其实可有可无。熟练之后可以直接在if语句中判断即可,分装一个isBlue函数是为了正好的理解二分。
三、通过习题进行练习以上所讲
1.首先来解决一道模板题----(数的范围---来自AcWing)
提醒:这道题属于模板题,所以比较简单,希望大家读完题之后先自己试着解决之后再看答案。
题目:给定一个按照升序排列的长度为n的整数数组,以及q个查询。对于每一个查询,返回一个元素的起始位置和终止位置(位置从0开始计数),如果数组中不存在该元素,则返回-1 -1
输入格式:第一行包含整数n和q,表示数组长度和询问个数。第二行包含n个整数(均在1--10000范围之内),表示完整数组。接下来q行,每行包含一个整数k,表示一个询问元素
输出格式:共q行,每行包含两个整数,表示所求元素的起始位置和终止位置。如果数组中不存在该元素,则返回-1 -1
数据范围:n:[1,100000]
q:[1,10000]
k:[1,10000]
直接上代码:
#include<iostream>
using namespace std;
const int N = 1e5 + 10;
int arr[N];
int n, q;
//第一个二分查找找的是:被询问数的第一次出现位置(下标)
int binary1(int a)
{
int l = -1, r = n;
while (l + 1 != r)
{
int mid = (l + r) / 2;
if (arr[mid] < a)l = mid;
else r = mid;
}
if (arr[r] == a)return r;
else return -1; // 找不到就返回 - 1
}
//第一个二分查找找的是:被询问数的最后一次出现位置(下标)
int binary2(int a)
{
int l = -1, r = n;
while (l + 1 != r)
{
int mid = (l + r) / 2;
if (arr[mid] <= a)l = mid;
else r = mid;
}
if (arr[l] == a)
{
return l;
}
else return -1;//找不到就返回-1
}
int main(void)
{
cin >> n >> q;
for (int i = 0; i < n; i++)
{
cin >> arr[i];
}
while (q--)
{
int k;
cin >> k;
int x = binary1(k);
int y = binary2(k);
cout << x << " " << y << endl;
}
return 0;
}
2.也还是一道比较简单的题--查找(来自洛谷)【深基13.例1】查找 - 洛谷
题目描述
输入 n 个不超过 1e9的单调不减的(就是后面的数字不小于前面的数字)非负整数 a1,a2,…,an,然后进行 m 次询问。对于每次询问,给出一个整数 q,要求输出这个数字在序列中第一次出现的编号,如果没有找到的话输出 −1 。
输入格式
第一行 2 个整数 n 和 m,表示数字个数和询问次数。
第二行 n个整数,表示这些待查询的数字。
第三行 m 个整数,表示询问这些数字的编号,从 1 开始编号。
输出格式
输出一行,m 个整数,以空格隔开,表示答案。
看完题应该感觉比第一个还简单一点,咱们废话不多说,直接解题,上代码(可以着重看一看我的注释)
#include<iostream>
using namespace std;
const int N = 1e8;
int arr[N];
int n, m;
int binary(int x)
{
int l = -1;
int r = n;
while (l + 1 != r)
{
int mid = (l + r) / 2;
if (arr[mid] < x)l=mid;
else r=mid;
}
if (arr[r] == x)
{
return r + 1;//为什么要返回r+1呢?? 因为题目说了数字编号从1开始,而我们的下标是从0开始的
}
else return -1;
}
int main(void)
{
cin >> n >> m;
for (int i = 0; i < n; i++)cin >> arr[i];
while (m--)
{
int x;
cin >> x;
cout << binary(x) << " ";
}
return 0;
}
3.上难度的一道题:数对 ---(来自洛谷)
题目描述
给出一串正整数数列以及一个正整数 C,要求计算出所有满足 A−B=C 的数对的个数(不同位置的数字一样的数对算不同的数对)。
输入格式
输入共两行。
第一行,两个正整数 N,C。
第二行,N个正整数,作为要求处理的那串数。
输出格式
一行,表示该串正整数中包含的满足 A−B=C 的数对的个数。
- 首先想到暴力方法解决---两层循环枚举:
#include<iostream>
using namespace std;
const int N = 200010;
int n, c;
int q[N];
int main(void)
{
scanf_s("%d %d", &n, &c);
for (int i = 1; i <= n; i++)
{
scanf_s("%d", &q[i]);
}
int cnt = 0;
for (int a = 1; a <= n; a++)
{
for (int b = 1; b <= n; b++)
{
if (q[a] - q[b] == c)
{
cnt++;
}
}
}
printf("%d\n", cnt);
return 0;
}
很清楚,时间超限了,想通过所有的测试用例就要想别的办法。
注意:c++ 1秒钟算1e9比较吃力,基本上1e9就跑不出来了,那么最好是把运算数量控制在1e7--1e8左右。上面代码一层循环是2e5,显然两层循环远超过1e9
- 其实几乎所有的优化都是在暴力方式的基础上进行的,这道题也是这样。
提示:我们可以想一下题目是A-B=C,我们可以将B移到右侧,变成A=B+C,C是已知的,我们先枚举B,然后在数组中查找A,查找A时用二分,这样就可以将2e5的规模缩小到不超过20(大家可以搜一搜log2e5是多少以2为底),这样就大大缩短时间。
代码如下:
#include<iostream>
#include<algorithm>
using namespace std;
const int N = 2e5 + 10;
int arr[N];
int n, c;
int binary1(int x)
{
int l = 0, r = n + 1;
while (l + 1 != r)
{
int mid = (l + r) / 2;
if (arr[mid] < x)l = mid;
else r = mid;
}
if (arr[r] == x)return r;
else return -1;
}
int binary2(int y)
{
int l = 0, r = n + 1;
while (l + 1 != r)
{
int mid = (l + r) / 2;
if (arr[mid] <= y)l = mid;
else r = mid;
}
if (arr[l] == y)return l;
else return -1;
}
int main(void)
{
cin >> n >> c;
long long cnt = 0;//注意计数器范围
for (int i = 1; i <= n; i++)cin >> arr[i];
sort(arr+1,arr+1+n);//一定提前排序
for (int i = 1; i <= n; i++)
{
int a = arr[i] + c;
int x = binary1(a);
int y = binary2(a);
if (x == -1)continue;
else cnt += (y - x + 1);
}
cout << cnt << endl;
return 0;
}
四、浮点二分
还是用一道例题开始讲解:(该题出自AcWing---数的三次方根)
题目:给定一个浮点数n,求它的三次方根
输入格式:共一行,包含一个浮点数n
输出格式:共一行,包含一个浮点数,表示问题的解。注意:保留6位小数。
数据范围:-10000<=n<=10000
1.首先我们来思考这个问题如何解:先用暴力方法进行解答
大家都会想到:如果用for循环从1开始枚举来解答直到一个数x*x*x==n 就得到了答案。OK我们就先用这种方法试一试之后发现:
for循环中进行枚举时,一般是for(int i=-100,i<=100;i++)后面那个i++是加1,只能加整数,如果题目有个测试用例n=2.666541,有6位小数,我们就要让i+=0.000001,这样,我们发现其实用枚举法根本行不通
#include<iostream>
double n;
using namespace std;
bool check(double x)
{
if (x * x * x <= n)
{
return true;
}
else return false;
}
int main(void)
{
cin >> n;
double l = -100, r = 100;
for (double i = -27; i <= 27; i += 0.00000001)//边界选27是因为10000的三次方根大约不超过25
{
if (check(i))
{
l = i;
}
else r = i;
}
cout << l << " " << r << endl;
return 0;
}
所以我们要想办法优化这段代码
2.其次进行二分优化
以时间复杂度角度来分析上面那段代码:估计为1e9,但是我们如果用二分的方式来做的话,可以将这段代码的时间复杂度优化到:log1e9,也就是30之内,这就是二分查找的伟大之处。下面我们试着用二分来做:
#include<iostream>
using namespace std;
double n;
bool check(double x)
{
if (x * x * x <= n)return true;
return false;
}
int main(void)
{
scanf_s("%lf", &n);
double l = -27, r = 27;
while (l + 1e-8 < r)
{
double mid = (l + r) / 2;
if (check(mid))
{
l = mid;
}
else r = mid;
}
printf("%lf\n%lf\n", l, r);
return 0;
}
最后运行结果你会发现:l和r的值是一样的,为什么呢??
其实精度为1e-8的情况下,边界的左右两侧在6位小数下值其实是相同的,所以浮点二分中不用再去费力去考虑是取左边还是取右边。
3.趁着刚讲完练习一道小题
最后的最后:对于想学好编程来讲,算法是至关重要的模块,真正搞懂每一类算法需要我们踏踏实实坐下来一步步分析,耐得住寂寞,新的一年祝大家AC多多!!!享受AC的快感!!!
该篇文章也是自己通过一些视频进行总结,希望能真正帮助到大家对于二分的理解!