二分

2020/04/01 14:41
阅读数 22

问题:我们对于一个已经排好序的数组,查找某个值的位置(一般为从小到大排序,如为从大到小排序代码会有变动,以下代码均为从小到大排序的代码),怎么查找?

                    首先最简单的思想肯定是一个for循环扫一遍数组,找到就跳出。因为这样效率太低了,所以就有了二分查找。假定我们查询的范围在a数组(假设a数组中的数值不重复),我们定义一个左端点为L,右端点为R,M为L,R中点,那么对于查找关键字Key,我们用M位置的对应值来跟他作比较,会有三种情况

   (1)a[M]<key,那么我们查找的Key肯定在M右半边区域,所以此时我们将L=M+1

   (2)a[M]>key,那么我们查找的Key肯定在M左半边区域,所以此时我们将R=M-1

   (3)a[m]=key,那么我们找到了,直接跳出,此时M就是key值所在的位置

我们对于这个新的区间再次进行查找,直到找到我们要的答案;

如果一直没找到答案,我们的循环应该何时结束呢,应该很容易想到,就是当L>R的时候结束。

原来算法复杂度为N,二分算法复杂度 。对一个的数据原来的方法有可能需要次的查询,而二分只需要最多60多次的查询就可以找到,优势显而易见。

二分的写法很多,但是思想都一样!!!而会有一些细节上的差别,我会在最下面的注释中讲到。

对于这个思想我们直接写成代码也就是下面这个(这里我为了下面的讲解所以将数组中的值设置的有重复的):

#include<iostream>
using namespace std;
int main()
{
	int a[16]={1,1,2,2,2,2,3,4,7,8,9, 12,14,1000,9999},len=14;
	//下标     0 1 2 3 4 5 6 7 8 9 10 11 12 13   14
	int key;
	while(~scanf("%d",&key))//输入要查找的值 --- 大家尝试查找1,2,5会发现一些问题 
	{
		int l = 0,r = len,m = l + (r - l) / 2;//注释1 
		while(l < r)//注释2
		{
			if(a[m] < key)
				l = m + 1;
			else if(a[m] > key) 
				r = m - 1;
			else if(a[m] == key)
				break;
			m = l + (r - l) / 2;//注释1
		}
		if(a[m] == key) printf("%d\n",m);//输出他在数组中的下标 //注释3 
		else printf("没有符合要求的值\n");
	} 
	return 0;
}

如果你尝试了1,2我们会发现,你无法确定他们输出的下标是哪个,有可能是最后一个,有可能是第一个,也有可能是中间某一个,所以就出现了下面的两个问题:

1.查找下界

//返回这样一个下标i:在 i 位置插入key(原来的元素a[i],a[i+1],...全部往后移动一个位置)后序列仍然有序的第一个位置

//所以当key存在时,返回第一个等于key的位置

我们怎么对原来的代码进行改进呢,首先对于原来的代码我们原来找到这个key的时候就跳出,所以这样有可能忽略掉前面的key数字,所以当m位置=当前这个数字的时候我们也不跳出这个循环因为m位置之前还可能会有key这个数字,所以我们继续往左区间寻找,R=m;为什么是R=m而不是R=m-1呢,因为有可能只有这一个key值,如果写R=m-1可能就正好错过这个值了。而当m位置的值小于key的时候,继续往右区间寻找的时候就不用考虑m位置了,所以L=m+1。所以总结一下也就是下面这两种情况

(1)a[M]<key,那么我们查找的Key肯定在M右半边区域(不包括m位置[m+1,R]),所以此时我们将L=m+1

(2)a[M]>=key,那么我们查找的Key肯定在M左半边区域(包括m位置[L,m]),所以此时我们将R=m

代码如下:

//STL库中的lower_bound
#include<iostream>
using namespace std;
int main()
{
    int a[16] = {1,1,2,2,2,2,3,4,7,8,9, 12,14,1000,9999}, len = 15;
    //           0 1 2 3 4 5 6 7 8 9 10 11 12 13   14
    int key;
    while(~scanf("%d",&key))
    {
        int l = 0,r = len;
        while(l < r) //注释2
        {
            int m = l + (r - l) / 2;//注释1
            if(a[m] < key) l = m + 1;
            else r = m;
        }
        printf("%d\n",l);//注释3
    }
    return 0;
}



2.查找上界

//返回这样一个下标i:在 i 位置插入v(原来的元素a[i],a[i+1],...全部往后移动一个位置)后序列仍然有序的最后一个位置

//所以当key存在时,返回它出现的最后一个位置后面一个位置

我们怎么对原来的代码进行改进呢,首先对于原来的代码我们原来找到这个key的时候就跳出,所以这样是不满足要求的因为k不大于k,所以当m位置=当前这个数字的时候我们继续往右区间(不包括m)寻找,l=m+1;为什么是R=m而不是R=m-1呢,因为当前这个m这个值已经满足要求了,我们写成R=m-1可能就正好错过这个值了。所以  总结一下也就是下面这两种情况

(1)a[M]<=key,那么我们查找的Key肯定在M右半边区域(不包括m位置即[m+1,R]),所以此时我们将L=m+1

(2)a[M]>key,那么我们查找的Key肯定在M左半边区域(包括m位置[L,m]),所以此时我们将R=m

代码如下:

//STL库中的upper_bound 
#include<iostream>
using namespace std;
int main()
{
	int a[16]={1,1,2,2,2,2,3,4,7,8,9, 12,14,1000,9999},len=15;
	//下标     0 1 2 3 4 5 6 7 8 9 10 11 12 13   14
	int key;
	while(~scanf("%d",&key))
	{
		int l = 0,r = len;
		while(l < r)//注释2
		{
			int m = l + (r - l) / 2;//注释1
			if(a[m] <= key) l = m + 1;
			else r = m;
		}
		printf("%d\n",l);//注释3 
	} 
	return 0;
} 


注释①:我看刘汝佳大神的《算法竞赛入门经典第二版》上把m = (l + r) / 2;的操作写成这样,m = l + (r - l) / 2;书中解释原因如下:

在数学上这两个式子是一样的,但在计算机中是有差别的。计算机中运算符“/”的“取整”是朝零方向的取整,而不是向下取整。换句话说,5/2的值是2,而-5/2的值是-2。而-5/2的向下取整应该是-3。为了方便分析,此处用m = l + (r - l) / 2;来确保分界点总是靠近区间起点。这是一个相当重要的技巧。

还有就是有人把求M这个式子写在每次循环开始,有人写在每次循环结束,这两种写法有什么区别,请看注释③。

注释②:对于这个循环条件有人可能写的是L<=R有人可能写的是L<R,其实这个是没什么大的区别的,跟自己的二分代码写的有关系。

如果你循环条件写的为L<R的话,循环结束时L=R(L==R的循环不执行)

如果你写的循环条件为L<=R的话,循环结束时L=R+1(L==R的循环执行)

注释③:有些写法可能是以左端点L作为最终答案或返回值的,有些写法是以中点M,甚至可能还有写法以右端点R。

如果你最后以M为返回值就一定要把求M这个式子写在最后且M要赋初值,因为我们最终想让最终返回M,我们得保证最终循环结束时M是(L,R变化后)正确的中值。

如果是以L为返回值,那么你只需要保证每次区间缩小前M为正确的中值,所以写在每次循环开始就可以。


其实不管对于那种写法来说只要遵循循环不变式就不会有问题,对于二分这个程序一般需要建立两个不变性:
1.当前待查找序列,肯定包含目标元素 2.每次待查找序列的规模都会变小。

二分不止用来搜索某个值,还可以用来判定某个值是否可行等等;

有二分,同理还有三分,都属于分治法;

在网上看到有人说据统计在规定时间内能写出满足要求的正确的二分程序的程序员不超10%。

个人感觉二分查找还是挺细节的一个东西,稍不注意可能就会死循环或者是得到的答案不满足要求。后面很多把N变为logN的算法思想都有二分的影子,所以要好好掌握二分。









发布了646 篇原创文章 · 获赞 57 · 访问量 14万+
展开阅读全文
加载中

作者的其它热门文章

打赏
0
0 收藏
分享
打赏
0 评论
0 收藏
0
分享
返回顶部
顶部
返回顶部
顶部