超细二分(不看血亏)

前言:关于二分查找的定义、优点等我就不在这里赘述了,这篇文章主要目的是带你们真正搞懂二分,遇见二分类型的题型会做。


目录

一、以一个例子开始讲解细节问题

​编辑

细节1:为什么l的初始值为-1,r 的初始值为N??

细节2:m是否始终处于[0,N)以内,不会出现溢出问题吗??(m就是mid)

细节3:更新指针时,能不能写成l=m+1,或者r=m-1???

细节4:会不会陷入死循环???

二、解决例子中的四个问题(细讲):

1.找到第一个“>=5”的元素

2.找到最后一个“<5”的元素

3.找到第一个“>5”的元素

4.找到最后一个“<=5”的元素

三、通过习题进行练习以上所讲

1.首先来解决一道模板题----(数的范围---来自AcWing)

2.也还是一道比较简单的题--查找(来自洛谷)【深基13.例1】查找 - 洛谷

 题目描述

输入格式

输出格式

 3.上难度的一道题:数对  ---(来自洛谷)

题目描述

输入格式

输出格式

四、浮点二分

1.首先我们来思考这个问题如何解:先用暴力方法进行解答

2.其次进行二分优化

3.趁着刚讲完练习一道小题


一、以一个例子开始讲解细节问题

例子:下面有八个元素:

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.上难度的一道题:数对  ---(来自洛谷)

A-B 数对 - 洛谷

题目描述

给出一串正整数数列以及一个正整数 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的快感!!!

该篇文章也是自己通过一些视频进行总结,希望能真正帮助到大家对于二分的理解!

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

写不出bug的小李

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值