手把手教你写出正确的二分搜索!

写出正确的二分搜索知易行难,原理好像都懂,但是实际上手就出各种错误,例如如何确定循环终止条件、区间搜小判断条件等。这里就手把手教你写出正确的二分检索!

二分法共有下面7种变式。

①是否存在数字t                                   ——返回下标或者-1

②找到大于t的第一个数                        ——返回下标或者-1

③找到大于等于t的第一个数                 ——返回下标或者-1

④找到小于t的最后一个数                     ——返回下标或者-1

⑤找到小于等于t的最后一个数              ——返回下标或者-1

⑥是否存在数字t,返回 第一个t            ——返回下标或者-1

⑦是否存在数字t,返回 最后一个t        ——返回下标或者-1

首先说一点个人思考,情况1(查找target位置)完全就是抖了个机灵,用情况1来当作二分查找的基本情况是非常非常错误的,从情况1完全不能推导出其他情况下二分查找该如何写。看了很多篇博客,没有一篇兼顾了正确性证明和归纳性。下面是我阅读过《Elements of programming》了解二分法原理后,自己扩展出的二分法各种变式。每一种情况都有正确性证明,且条件归纳统一,归纳性强,很好理解。

一、二分法的适用条件——P划分区间

想要用好二分法,就要先搞清楚什么是二分法及其适用条件,即什么样的问题可用二分法解决,如何解决。答案是二分法的每一种变式,都是在P划分区间上查找划分点。下面介绍P划分区间和划分点的概念。

  • P划分区间:给定一个元素为s_i的闭区间S和一个对s的判断条件P(即P(s)返回true或者false),如果S中存在某个元素s_x使得区间上在s_x之前的元素(不包括s_x)都不满足P,而s_x及其之后的元素(包括s_x)都满足P,那么就称区间S是P划分的,且s_x是划分点,下面简写为划分点x。

直白形象的说,S是一个区间,x∈S,x之前的元素(不包括x)都不满足P,而x及其之后的元素(包括x)都满足P。就像这样

此例中,s4即为划分点。

  • 划分点能带给我们什么信息呢?

1)对于S中任意一个元素s_i,若s_i满足P,那么s_i后面的元素都满足条件P。

2)对于S中任意一个元素s_i,若s_i不满足P,那么s_i前面的元素都不满足条件P。

二、情况2、3,第一个满足条件的元素

大于或大于等于target,就是一个条件P。二分查找中,区间S中元素是升序的,显然S满足P划分。所以我们的任务就是找到第一个满足P的元素,也就是划分点。先给出代码

int findPartition(vector<int> nums,bool (*P)(int)) {
    if(P(nums[nums.size() - 1]) == false) return -1; //there no partition point             
    int left = 0,right = nums.size() - 1,mid;                                     
    while(left < right){                           
        mid = left + ((right - left) >> 1);                                 
        if(P(nums[mid])){                           
            right = mid;                           
        }else{                                     
            left = mid + 1;                        
        }                                          
    }                                              
    return left;                                   
}                                                  

我们从以下三方面证明算法的正确性:

1)若划分点不存在,函数返回 -1

2)若划分点存在,算法必然终止。

3)若划分点存在,算法终止时,left所指元素即为划分点。

证明:

1)如果不存在划分点,那说明区间中的元素都不满足条件P,此时只需要用区间最后一个元素对P进行检查即可。算法第2行代码如果最后一个元素都不满足P,那么区间中所有元素都不满足P,也就没有划分点,返回-1,算法结束。

2):mid = (left + right) / 2,即left和right的中位数向下取整,可见mid  >= left。又因为while循环条件为left不等于right,故而循环体中mid < right。所以每次迭代,区间长度至少减1。当区间长度为1时,算法终止,此时left 等于right。

3):观察while循环体中内容,如果第6行判断mid位置元素满足断言P,那么划分点x要么位置是mid,要么在mid前面,但肯定不会在mid后面了,故而我们把right指向mid(第7行),排除掉mid后面的元素(不包括mid)。如果第6行判断条件为false,那么说明mid位置上的元素不满足P,且mid之前的元素都不满足P,故而我们把left指向mid + 1(第9行),排除掉mid及其前面的元素(包括mid)。熟悉不变式的同学肯定明白了,每次迭代,都满足划分点一定在闭区间[left,right],且这个区间一直在缩小,当区间长度为1,left等于right,此时区间内的唯一元素就必然是划分点。

三、情况4、5,最后一个满足条件的元素

小于或小于等于target,就是一个条件P,我们要找到满足条件P的最后一个元素。此时有两种方法,第一种是找满足!P的第一个元素,然后返回(其下标-1)。第二种是灵活修改一下划分区间和划分点的定义。

此时S3是划分点,即最后一个满足条件P的元素,代码如下

int findPartition(vector<int> nums,bool (*P)(int)) {
    if(P(nums[0]) == false) return -1; //there no partition point             
    int left = 0,right = nums.size() - 1,mid;                                     
    while(left < right){                           
        mid = left + ((right - left + 1) >> 1); //attention,up[(left + right) / 2]                              
        if(P(nums[mid])){                           
            left = mid;                          
        }else{                                     
            right = mid - 1;                        
        }                                          
    }                                              
    return left;                                   
}                                                  

正确性证明同理情况2、3,有两点注意

1)代码第2行,利用区间第一个元素来判断是否有划分点的存在。如果第一个元素都不满足P,那么区间中所有元素都不满足P,返回-1。

2)代码第5行,计算中位数时候,要取left和right的向上取整,这样就有left < mid,mid <=right,才能保证区间长度一直在减小,算法必然终止。

四、情况6、7,在情况2、3、4、5基础上加入等于条件

要找是否存在某数,返回其第一个或是最后一个的位置,这可以当作之前情况的扩展,感谢这篇博客给我的思路。以下两点即可解决此种情况

  • 查找第一个t,即查找第一个大于等于t的元素,还要专门考虑此数存不存在。
  • 查找最后一个t,即查找最后一个小于等于t的元素,还要专门考虑此数存不存在。

代码在之前的基础上,return时增加判断条件nums[left] == target即可。

return nums[left] == target ? left : -1;

OK,这就是我归纳的二分搜索写法,欢迎交流。

 


版权声明:本文为qwe1173529071原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接和本声明。