巧用循环不变式书写正确的二分查找算法(看不懂我撞墙)

1.二分查找介绍

在进行开始之前,我们县要来正确的认识一下什么是二分查找算法
上过数值分析这门课的同学一定在迭代那一刻里面清楚的了解过一个名词叫做对分法
实际上,对分法的本质就是二分查找
下面我们来介绍一下二分查找算法
Binary-Search
是不同于顺序扫描的一种极其高效的查找算法
首先我们县要来了解一下二分查找算法相对于朴素的顺序查找算法的优劣
对于朴素的顺序查找算法(不局限于数据的逻辑存储方式,链式或者顺序都可以),我们从顺序表的头开始一直遍历到顺序表的尾知道找到我们需要查找的元素即可跳出,时间复杂度是O(n),对于数据量一旦过于庞大的话,该算法会显得力不从心
二分查找算法(受限制与数据的逻辑存储方式,仅限制于顺序存储结构),前提是被查找的内存区域数据域必须是有序的,这是二分法开始的先决条件(对于交换式排序的O(n*logn)的时间复杂度可能大家会觉得还不如朴素的算法,但是实际中我们常常会遇到类似插入排序那样的情况,我们的二分查找开始就没必要进行排序,所以说仅从查找的角度来看的话,二分还是非常高效的)
每一次,我们以中点作为分界点,明确我们的待插元素的位置,舍弃另一半区域从而将我们的算法的查找区域大大缩小
计算其时间复杂度的话
T(n)=T(n/2)+O(C)
...一共进行了k=logn次迭代
T(n)=T(1)+O(C)*logn=O(logn)
因为是对数的原因,在数据两大的情况下,我们的比较次数依然会变的无限趋近于常数阶,所以说,二分查找是非常的高效的

2.二分查找为什么难

虽然我们看着二分查找非常的简单,但是实际上,二分查找的正确书写需要一个非常精细的数学和逻辑推导过程(我在下面会用几个例题来模拟一下思考的思路)

《编程珠玑》第四章提到:提供充足的时间,仅有约10%的专业程序员能够完成一个正确的二分查找。

为什么难在于这么几点:

1.终止条件不清晰

2.不正确的转换边界容易导致死循环

3.二分查找的变体相当庞杂,死记硬背绝对不可能


既然二分查找这么难写,有没有好一点的方法可以帮助我们理解

下面我引入一种思路叫做循环不变式

3.循环不变式

什么是循环不变式,在《算法导论》和《编程之美》中大神都有队循环不变式进行了详细的讲解
个人在这里只是描述个人的见解,如有纰漏和误解,还烦请大神指出
循环不变式相当于是严谨的附加了终止条件的数学归纳法
学过数学归纳法的同学应该都知道,数学归纳法处理的问题往往都是没有上界的,但是作为一个算法,我们的第一根本原则就是有穷性

定义如下:
初始:循环第一次迭代开始之前,我们的描述和假设必须正确

维护(也有的叫保持):在某次迭代是正确的,在下次迭代还是正确的

终止:循环可以终止,并返回正确的结果

循环不变式其实不仅仅在本问题中可以大显身手,其实在 循环不变式 是一种思想,在以后的很多问题中,我们要证明算法的正确性等等都需要用到这个强有力的工具

4.若干变体描述以及代码示例

我们二分查找描述操作的区域设定为1-right的数组区域

循环不变式的初始是left-right

1.二分查找值为key的下标不存在则返回-1

初始:待查数组范围是left(1)-right(n),待查元素key如果存在必定在该范围内

保持:
if(data[mid]>key) right=mid-1;   //1
else if(data[mid]<key) left=mid-1;    //2
else if(data[mid]==key) return mid;    //3
解释:1.当data[mid]>key说明mid-right中必定没有key,那么我们的超照范围就变成了left-(mid-1),该判断语句是查找范围缩小了right-mid+1
            2.同理,查找范围缩小了mid-left+1
            3.超找到了位置,直接返回
在保持环节中,显然,我们每次都确保了key必定在我们的查找范围内,保持性证明完成
我们始终保证了待插元素落在left-right区域范围内

终止:
本例题中,因为每次只要没有找到的话我们的待查区域是必定会至少减少1个长度的,所以说,我们的程序必定会正确的终止,不会出现死循环的情况
在最后,如果left>right的话,我们返回-1就好

示例代码:
int Binary_Search(int left,int right)
{
	int mid=left+(right-left)/2;
	while(left<=right)
	{
		if(data[mid]<key) left=mid+1;
		else if(data[mid]>key) right=mid-1;
		else return mid;
	} 
	return -1;
}

2.二分查找key第一次出现的下标(可能有重复),不存在返回-1

初始: 待查数组范围是left(1)-right(n),待查元素key如果存在必定在该范围内

保持:
if(data[mid]>key) right=mid-1;   //1
else if(data[mid]<key) left=mid+1;   //2
else right=mid;   //3
解释:
1.data[mid]>key,说明必然第一次出现的下标在left-(mid-1)范围内,该轮判断查找区域缩小了right-mid+1
2.同理,盖伦判断的查找区域缩小了mid-left+1
3.当相同的时候,我们会发现我们要找第一次出现的下表,显然第一次出现的下表必然在left-mid之间,该轮判断查找区域缩小了right-mid
其实1,3是可以合并的,合并起来我们可以减小我们的代码量和分支语句,提高判断效率,并且合并之后是不会出现错误的,毕竟按照1来说合并之后right=mid,我们的待插元素key还在left-mid范围内,只不过是收缩的精确度的问题,这个不影响结果

我们始终保证了待插元素落在left-right区域范围内

终止:
在该例题中,我们会发现,第1,3,的分支检查的区域缩小的范围是right-mid,也就是说,我们有可能会存在一次判断之后,待查区域大小没有变化的情况,即当left=right的时候,我们的待查区域缩小量始终是right-mid=right-right=0
这种情况下,会造成死循环,满足不了我们的循环不变式种植的要求,这时候,我们就要对循环的控制进入的条件进行巧妙地修改了
先来看:
left=right-1:mid始终等于left,会1,3情况缩小范围,可以正确运行
left=right:mid始终等于left和right,1,3情况缩小范围是0,回不正确终止
这时候,我们只要让终止条件是left<right就可以了
之后,我们对data[left]进行判断,等于key,返回left,否则返回-1

示例代码:
int Binary_Search(int left,int right)
{
	int mid=left+(right-left)/2;
	while(left<right)
	{
		if(data[mid]<key) left=mid+1;
		else right=mid;
	} 
	if(data[left]==key) return left;
	else return -1;
}

3.二分查找key(有可能重复)的最后一次出现的下表,没有返回-1

1.初始: 待查数组范围是left(1)-right(n),待查元素key如果存在必定在该范围内

2.保持:
if(data[mid]>key) right=mid-1;   //1
else if(data[mid]<key) left=mid+1;   //2
else left=mid;   //3
解释:
1.显然,我们的待查范围缩小了right-mid+1
2.同理,我们的待查范围缩小了mid-left+1
3.相同的时候,我们发现我们要查找最后一个元素,那么显然最后一个key的下表必然在mid-right之间,该轮缩小待查区域为mid-right
同上2,3可以合并,减少代码量
我们始终保证了待插元素落在left-right区域范围内

3.终止
我们发现left=right-1,或者left=right的时候,我们的mid始终等于left,那么对于判断3,很容易出现死循环
我们对终止条件进行修正left<right-1
最后我们对left,right进行判断就好了

代码示例:
int Binary_Search(int left,int right)
{
	int mid=left+(right-left)/2;
	while(left<right-1)
	{
		if(data[mid]>key) right=mid-1;
		else left=mid;
	} 
	if(data[right]==key) return right;
	else if(data[left]==key) return left;
	else return -1;
}

4.二分查找刚好小于key的元素的下表,不存在返回-1

1.初始: 待查数组范围是left(1)-right(n),待查元素key如果存在必定在该范围内

2.保持:
if(data[mid]<key) left=mid;    //1
else if(data[mid]>=key) right=mid-1;    //2
对于1来说,显然小于的话,我们的待插元素必定在mid-right中间,那么缩小的区域大小就是mid-left
对于2,显然缩小范围是right-mid+1
我们始终保证了待插元素落在left-right区域范围内

3.终止:
对于情况一来说,如果left=right-1/left=right的话,显然会出现死循环的情况,所以说我们就需要修改终止情况left<right-1
最后判断一下就好了

代码示例:
int Binary_Search(int left,int right)
{
	int mid=left+(right-left)/2;
	while(left<right-1)
	{
		if(data[mid]>=key) right=mid-1;
		else left=mid;
	} 
	if(data[right]<key) return right;
	else if(data[left]<key) return left;
	else return -1;
}

5.二分查找干好大于key的元素的下表不存在返回-1

1.初始:待查数组范围是left(1)-right(n),待查元素key如果存在必定在该范围内

2.维护:
if(data[mid]>key) right=mid;
else left=mid+1;

3.终止:
对于情况left=right来说,容易出现死循环的情况,我们left<right就可以了
最后判断

代码示例:
int Binary_Search(int left,int right)
{
	int mid=left+(right-left)/2;
	while(left<right)
	{
		if(data[mid]>key) right=mid;
		else left=mid+1;
	} 
	if(data[left]<key) return left;
	else return -1;
}

5.总结:

思路,利用循环不变式不断的缩小我们的待查区域
最后我们需要对left=right和left=right-1等特殊情况进行考虑,避免因为缩小的区域大小可能为0导致的死循环的情况出现,修改终止条件
最后额外判断就好了



评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值