算法与acm

漫谈二分查找-Binary Search

2009年1月4日 阅读(362)

历史上,Knuth在其<<Sorting and Searching>>一书的第6.2.1节指出:尽管第一个二分搜索算法于1946年就出现,然而第一个完全正确的二分搜索算法直到1962年才出现。

而不经仔细斟酌而写出的一个二分查找经常遭遇off by one或者无限循环的错误。下面将讨论二分查找的理论基础,实现应用,及如何采用何种技术保证写出一个正确的二分程序,让我们免于思考麻烦的边界及结束判断问题。

在c++的stl中有如下函数 lower_bound, upper_bound, binary_search and equal_range,而这些函数就是我们要考虑如何实现的那些。通过实现这些函数,可以检查你是否真的掌握了二分查找。

理论基础:
当我们碰到一个问题,需要判断它是否可以采用二分查找来解决。对于最一般的数的查找问题,这点很容易判断,然而对于某些比如可以采用二分+贪心组合,二分解方程,即某些具有单调性的函数问题,也是可以利用二分解决的,然而有时它们表现的不那么显然。

考虑一个定义在有序集合S上的断言,搜索空间内包含了问题的候选解。在本文中,一个断言实际上是一个返回布尔值的二值函数。这个断言可以用来验证一个候选解是否是所定义的问题合法的候选解。

我们把下面的一条定理称之为main theorem: binary search can be used if and only if for all x in S, p(x) implies p(y) for all y > x. 实际上通过这个属性,我们可以将搜索空间减半,也就是说如果我们的问题的解应用这样的一个验证函数,验证函数的值可以满足上述条件,这样这个问题就可以用二分查找的方法来找到那个合适的解,比如最左侧的那个合法解。以上定理还有一个等价的说法 !p(x) implies !p(y) for all y < x 。这个定理很容易证明,这里省略证明。

实际上如果把这样的一个p函数应用于整个序列,我们可以得到如下的一个序列
fasle false false ……true true….
如果用01表示,实际上就是如下这样的一个序列0 0 0 0……1 1 1 1…….
而所有的二分查找问题实际都可以转化为这样的一个01序列中第一个1的查找问题,实际上我们就为二分查找找到了一个统一的模型。就像排序网络中利用的01定理,如果可以对所有的01序列排序,则可以为所有的序列排序。实际上二分查找也可以用来解决true true….fasle false false ……即1 1 1 1…… 0 0 0 0…..序列的查找问题。当然实际如果我们把p的定义变反,这个序列就变成了上面的那个,也就是可以转化为上面的模型。

这样我们就把所有问题转化为求0011模式序列中第一个1出现的位置。当然实际中的问题,也可能是求1100模式序列最后一个1的位置。同时要注意对应这两种情况下的实现有些许不同,而这个不同对于程序的正确性是很关键的

下面的例子对这两种情况都有涉及,一般来说具有最大化要求的某些问题,它们的断言函数往往具有1100模式,比如poj3258 River Hopscotch;而具有最小化要求的某些问题,它们的断言函数往往具有0011模式,比如poj3273 Monthly Expense。

而对于数key的查找,我们可以利用如下一个断言使它成为上述模式。比如x是否大于等于key,这样对于一个上升序列来说它的断言函数值成为如下模式:0 0 0 0……1 1 1 1…….,而寻找最左边的key(类似stl中的lower_bound,则就是上述模型中寻找最左边的1.当然问题是寻找最后出现的那个key(类似stl中的upper_bound),只需要把断言修改成:x是否小于等于key,就变成了1 1 1 1…… 0 0 0 0…..序列的查找问题。

可见这样的查找问题,变成了如何寻找上述序列中的最左或最右的1的问题。

类似的一个单调函数的解的问题,只要设立一个断言:函数值是否大于等于0?也变成了如上的序列,如果是单调上升的,则变成了0011模式,反之则是1100模式。实际上当函数的自变量取值为实数时,这样的一个序列实际上变成了一种无穷数列的形式,也就是1111…..0000中间是无穷的,01的边界是无限小的。这样寻找最右侧的1,一般是寻找一个近似者,也就是采用对实数域上的二分(下面的源代码4),而用fabs(begin-end)来控制精度,确定是否停止迭代。比如poj 3122就是在具有1111…..0000模式的无穷序列中查找那个最右侧的1,对应的自变量值。

实现:
一个判断序列中是否存在某key的二分基本实现(返回-1表示不存在,否则代表出现位置的index):

源程序1:

int binary_search(int array[],int key){
 int begin = 0;
 int end = array.size()-1;
 while(begin < end){
  int mid = begin + (end-begin)/2;
  if(array[mid] < key)
   begin = mid+1;
  else if(array[mid] > key)
   end = mid-1;
  else return mid;
 }
 return -1;
}

 

下面的程序中我们都假设这样的1是存在的,如果不存在,可以在最后加一句验证,是否==1即可。

0011序列中最左侧的1的查找

源程序2:

 int binary_search(int array[],int key){
 int begin = 0;
 int end = array.size()-1;
 while(begin < end){
  int mid = begin + (end-begin)/2;
  if(array[mid] == 1){
   end = mid;
  }
  else{
   begin= mid+1;
  }
 }
 return begin;
}

1100序列中最右侧的1的查找

 源程序3:

 int binary_search(int array[],int key){
 int begin = 0;
 int end = array.size()-1;
 while(begin < end){
  int mid = begin + (end-begin)/2;//wrong!!!In fact it should be :int mid = begin + (end-begin+1)/2;
  if(array[mid] == 1){
   begin = mid;
  }
  else{
   end = mid-1;
  }
 }
 return begin;
}

二分浮点数值解单调函数(这种问题,比前几种简单,因为不需要过多考虑begin,end的边界问题,只要单纯的=mid就可以了),fabs(begin-end) < 0.0000000001控制精度,有时会超时,需要调整 0.0000000001的值,或者设定一个迭代计数器,在一定步数内结束:

double binary_search(double){

double begin = min;

double end = max;

while(fabs(begin-end) < 0.0000000001){

      double mid = (end-begin)/2;

     if(f(mid) > 0) begin = mid;

     else end = mid;

}

return mid;

}

 理论与实现的对应:

如果细心,仔细的观察,可以发现实现与理论中的断言是有密切的对应关系的。实际上用在我们的实现中的循环内部的if条件语句便是上面理论基础中所谓的断言的一个程序语言表述。实际上寻找这样的断言,便可以指导我们的实现。

程序正确性的保证:

程序的正确性,主要依靠循环不变式来保证。

而对于二分查找,一般需要建立两个不变性:

1.当前待查找序列,肯定包含目标元素 2.每次待查找序列的规模都会变小

1用来防止,把目标元素错过,2可以保证程序最终会终止。每次循环的分支内,保证这样的两个不变性可以满足,那么这样的二分查找程序一般不会含有逻辑错误。

观察上面的源程序2和3,其中变化的时候有的mid+-1,有的没有加,实质上就是为了保证不变式1.比如源程序2中的   end = mid;之所以不写成end = mid-1,是有原因的,因为对于0011来说,而这个mid有可能就是那个最左侧的1,如果让end = mid-1,这样这个1在下次迭代时实际上已经不在搜索序列中了,也就是不变性1不成立了。

仔细观察上面的源程序3,如果给一个序列1 0会如何?

是的,实际会进入无限循环。原因何在呢?实际上违反了不变性2,也就是没有保证序列规模下降,进一步的将这是由除法的特殊性决定的,比如3/2=1,比如begin=0 end=1,mid =0,最终不会造成序列长度的缩小。而这个问题,则是写二分查找时,最常犯的off by one错误。解决方案在这,把int mid = begin + (end-begin+1)/2;因为显然end是必然减少的,而这样也可以保证begin每次迭代后都会变大。这样就保证了不变性2.

 

可能有人会问,问什麽源程序2没有这样的问题呢,实际上是因为int mid = begin + (end-begin)/2;我们可以发现,如果begin !=end ,mid可以保证比原来的end小,而当end-begin=1是mid等于begin的。而在源程序2里,begin=mid+1,保证了begin是变化的,而mid = begin + (end-begin)/2;又刚好保证了end是必然减少的,所以合起来保证了不变性2. 

写程序的时候,考虑保证这两个不变性,可以帮助写出正确的二分查找。

基本例题

 poj 3233 3497 2104 2413 3273 3258 1905 3122

注:

poj1905 实际上解一个超越方程 L"sinx -Lx=0,可以利用源码4,二分解方程

poj3258 寻找最大的可行距离,实际上是111000序列中寻找最右侧的1,可以参考源码3

poj3273 寻找最小的可行值,实际上是000111序列中寻找最左侧的1,可以参考源码2

总结

首先寻找进行二分查找的依据,即符合main 理论的一个断言:0 0 0 ……..111…….

确定二分的上下界,尽量的让上下界松弛,防止漏掉合理的范围,确定上界,也可以倍增法

观察确定该问题属于0011还是1100模式的查找

写程序注意两个不变性的保持

注意验证程序可以处理01这种两个序列的用例,不会出错

注意mid = begin+(end-begin)/2,用mid=(begin+end)/2是有溢出危险的。实际上早期的java的jdk里的二分搜索就有这样的bug,后来java大师Joshua Bloch发现,才改正的。

转载请注明作者:phylips@bmy

出处:http://duanple.blog.163.com/blog/static/709717672009049528185/

 参考书籍:

John Bentley的经典书里有大概第4章,第9章进行了讨论

代码之美有两章涉及

topcoder 算法专题Binary Search

 

You Might Also Like