算法竞赛基础:一种无敌的二分代码写法,C++实现,含基础方法讲解和代码示例

算法竞赛基础:二分法

如果你还在为二分查找的边界条件而纠结,那么本篇文章将会解决你困惑很久的问题,让你豁然开朗。

本篇文章将介绍一个整数二分的最优解代码模板,无需担心任何越界问题。

由于本篇文章是一篇教程,适用于初学者,因此会介绍普通的二分方法,最后才会介绍最优模板。

二分法是用来解决什么问题的?

一句话描述:在单调序列中找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) / 2left >= 0,right >=0;
left + right 无溢出
1. left + right 溢出
2. 负数情况下有向0取整问题
mid = left + (right - left) / 2left - right 无溢出若right和left都是大数且一正一负,right - left可能溢出
mid = (left + right) >> 1left + right 无溢出若left和right都是大数,那么可能溢出

left >= 0,right >= 0且没有溢出时,上面三种实现的结果相等。

综合上面表中的问题,方法二略好一些。


mid处理

代码的关键是对mid的处理,如果取值不当,while()很容易进入死循环。接下来我们仔细讨论一下这个问题:

  • 为什么会出现死循环呢? 有下面的两个原因:首先是取值,取值方式为舍弃小数点后的值取整(例如:3.5会被当成3);其次,奇数除二除不尽。那么综合这两点原因会发现:假如l + r = 奇数,此时得到的结果就会更靠近l一些,此时,如果lr两个值只差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,区间左闭右开,lr的初始值分别为1,4,那么首先加1除以2得到mid = 2,由于 1 小于 mid 得出 r = 2,此时lr已经相邻了,那么接下来,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函数有问题。


以上就是本期文章的全部内容啦~~~,创作不易!!!如果可以的话可以来个点赞+关注+收藏!!!

  • 19
    点赞
  • 19
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

若亦_Royi

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

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

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

打赏作者

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

抵扣说明:

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

余额充值