代码改变世界

Binary Search[翻译]

2007-03-05 13:42  老博客哈  阅读(2808)  评论(5编辑  收藏  举报

                                                              Binary Search
       
                             【原文见:http://www.topcoder.com/tc?module=Static&d1=tutorials&d2=binarySearch
                                                                                                         作者 By lovro
                                                                                                         TopCoder Member
                                                                                                          翻译    农夫三拳@seu
                                                                                                           drizzlecrj@gmail.com
    二分查找是计算机科学中的一个基础算法。为了能够探究它,我们首先建立起理论支柱,然后使用它来正确的实现算法并且避免人人谈到的差1的错误。

Finding a value in a sorted sequence
    二分查找最简单的形式是在一个有序的序列里面快速的查找到一个值(暂时考虑一个普通的数组序列)。我们为了更清楚的说明问题,将待查找的值称为目标值。二分查找不断维护着目标值必定存在的开始序列的子序列,这个被称为搜索空间。搜索空间初始时是整个序列。在每一步中,算法将搜索空间的中间值与目标值进行对比。由于序列已经有序再加上比较,我们可以排除一半的搜索空间。通过重复做以上的操作,最后将剩下只包含目标值一个元素的搜索空间。

   例如,考虑一个已经进行升序排列的整数序列,并且我们将要查找数字55:

0 5 13 19 22 41 55 68 72 81 98

    我们对序列中目标值的位置感兴趣,因此我们把搜索空间看成序列的索引。初始时,搜索空间包含从索引1到11.由于搜索的空间是一个区间,它足够用来存储最低和最高两个索引。正如上面所描述的,我们现在选择中间值,也就是索引为6 的值(1和11之间的中间点):
这个值是41,比目标值要小。从这点中我们可以总结出不仅索引6的元素不是目标值,还能得到在索引1到索引5之间的元素不可能是目标值,因为这些索引处的元素都比41要小。这个就使得搜索的空间降到了7到11:

55 68 72 81 98

    继续进行上面的操作,我们再次舍掉了搜索空间的一半空间,得到:

55 68

    依照我们在这个相等数目的元素中如何选取中间数,我们下一步既可以找到55或者舍掉68而得到一个仅有一个元素的搜索空间。不管哪种方法,我们都能得到目标值的位置为7。

    如果目标值不在序列中,二分查找将会清空搜索空间。这种清空很容易检查出来并进行处理,下面是描述相关的一些代码:

binary_search(A, target):
   lo 
= 1, hi = size(A)
   
while lo <= hi:
      mid 
= lo + (hi-lo)/2
      
if A[mid] == target:
         
return mid            
      
else if A[mid] < target: 
         lo 
= mid+1
      
else:
         hi 
= mid-1
            
   
// target was not found

Complexity
    由于二分查找过程中的每次比较都能使得搜索空间减半,因此我们可以断言并且轻松的证明二分查找将不会使用超过(大O表示法)O(log N)次比较来找到目标值。

    对数函数是一个增长非常缓慢的函数。如果你没有意识到二分查找是多么高效的话,那么考虑在一个包含一百万个人名的电话簿中找一个名字。二分查找可以让你\有条理的找到指定的名字,而使用的次数不会超过21次。如果你能够将世界上所有的人按照姓名排序,按么你可以在35步以内找到任何人。现在这个看起来并不是很可行和可用,稍后我们会进行修改。

   注意这里我们假设了能够随机访问这个序列。试图将二分查找用在链表中没有什么意义,最好还是用一个线性查找替代比较好。

Binary search in standard libraries
   C++标准库中依照我们的需要在lower_bound,upper_bound, binary_search和equal_range算法实现了二分查找。Java为数组内建了Arrays.binary_search方法而.Net Framework有Array.BinarySearch。

    你最好在可以使用的任何情况下使用库函数,因为,正如你所知道的,自己实现一个二分查找非常容易出错。

Beyond arrays: the discrete binary search
    这里我们将对二分查找进行抽象。一个序列(数组)可以看成将数字与相应的值关联的函数。尽管如此,没有理由可以限制我们在实体序列中使用二分查找。事实上,我们可以把上面描述的算法用在任何定义域是一系列整数的单调函数f上。仅有的不同之处在于我们将一个数组替换成了一个函数值:我们现在查找一些x,满足f(x)等于目标值。现在的搜索空间成为了函数定义域的一个子区间,而目标值是陪域中的一个元素。二分查找现在开始显示了它的威力:我们不仅仅最多需要O(logN)次比较来找到目标值,而且省去了许多次的去评估函数。此外,在这种情况下我们没有受到像数组一样可用内存的实际问题约束。

Taking it further: the main theorem
    当你碰到一个你认为可以使用二分查找来解决的问题时,你需要使用某种方法来证明它是正确的做法。我现在将要展示二分查找更加抽象的一个级别,它将允许我们解决更多的问题,并且使得能够用二分查找的问题的证明变得简单而且可以帮助实现它们。这个部分有点正式了,但不要泄气,并没有那么糟糕。

    考虑已经有在某个排好序的集合S(搜索空间)上的谓词p。搜索空间包含了解决问题的候选方案。在这篇文章中,谓词是一个返回值为bool,真或假的一个函数(我们也使用yes和no作为bool值)。我们根据问题的定义使用谓词来判断一个待选方案是合法的(不违反一些约束)。

    被我们称为的主定理(main theorem)陈述到:当且仅当对于所有S中的x,对所有的y>x,p(x)包含p(y)。这个属性就是我们在丢弃一半的搜索空间时所使用到的。对于所有的y<x,¬p(x) 包含 ¬p(y),这与前面的说法是等价的(¬代表逻辑而不是运算符),这个定理可以很容易的被证明,为了减少混乱,这里略去证明。

    在神秘的数学背后,我真正想说的是如果你有一个是或者不是的问题(谓词),对于某些潜在方案x得到了是的回答就意味着你对于x之后的任何元素也能得到一个是的回答。类似的,如果你得到了一个否的回答,那么对于x之前的任何元素会得到一个否的回答。作为结果,如果你要对于搜索空间(已经有序)的每个元素进行询问的话,你将会得到一系列否的回答,随后的是一些是的回答。

    细心的读者可能注意到二分查找同样可以在当谓词产生一系列是的回答,而随后是一些否的回答的情况。对谓词求反将会满足原先的条件。为了简化,我们仅处理前面定理谈到的谓词。

    如果主定理中的条件满足,那么我们可以使用二分查找来找到最小的合法解决方案,也就是使得p(x)为真的最小的x。基于二分查找来设计的解决方案的第一部分是涉及一个可以用来评估并且对二分查找有意义的谓词:我们需要选择算法应该找到的元素。我们应该找到要么一个使得p(x)为真的第一个x,要么最后一个使得p(x)为假的x。正如你所看到的,两者之间的差距很小,但是有必要解决其中的一个。刚开始我们寻找第一个答案为是的x(第一个选项)。
 
    第二部分是证明二分查找可以被用在谓词上。这里我们使用主定理来验证主定理中的条件满足。证明不需要完全的数学化,你只需要说服你自己,对于所有的y>x,p(x)包含p(y)或者对于所有的y<x,¬p(x)包含¬p(y)。这个可以根据不同的情况选择第一个或者第二个句子。

    当谓词的作用域是整数,那么可以证明p(x)包含p(x+1)或者¬p(x)包含¬p(x-1),余下的判断同上。

    这两部分经常会交错在一起:当我们考虑一个可以使用二分查找的问题时,我们的目标是设计一个满足主定理中的条件的谓词。

    你可能很想知道我们为什么使用抽象的描述而不是讨论到目前为止使用的算法。这是因为很多问题不能够被建模成查找指定值的问题,但是它能够在当我们寻找具有最低消费的一些分配方案时定义和评估一个像“有花费小于等于x的分配方案吗?”的谓词,例如,旅行商问题(TSP)寻找最便宜的旅费来不重复的遍历每一个城市。这里,目标值不是像上面定义的那样,但是我们可以定义一个谓词“有旅费小于等于x的情况吗?”并且将它应用在二分查找上来搜索满足谓词的最小的x。这个就叫做将原始问题转换为选择(是/否)的问题。不幸的是,我们不知道用来评估这个特定谓词的有效方法,因此TSP问题并不能简单的用二分查找解决,但是对大多数的优化问题是可以这样的。

    现在让我们将原来介绍的在有序数组上进行的简单二分查找变成这个抽象的定义。首先,让我们改述这个问题:“给定一个数组A和一个目标值,返回第一个大于或者等于目标值的第一个元素的索引。”lower_bound或多或少在C++中就是这么工作的。

    我们现在想要找到目标值的索引,因此数组中的任何索引都是一个候选的答案。搜索空间S就是所有候选答案的集合,也就是一个包含所有索引的区间。考虑谓词“A[x]大于或者等于目标值吗?”。如果你要找到第一个使得谓词的答案为真的x,我们其实已经在前一个小节中决定了要找的东西。

    主定理的条件是满足的因为数组是按照升序进行排列的。如果A[x]大于等于目标值,那么所有的它之后的元素肯定大于等于目标值。

    考虑前面样例的序列:

0 5 13 19 22 41 55 68 72 81 98

    搜索空间(索引)为:
1 2 3 4 5 6 7 8 9 10 11

    在它之上使用我们的谓词(目标值为55):
no no no no no no yes yes yes yes yes

    正如我们所期望的,在一系列的是的答案之后是一些否定答案。注意索引7(目标值所在地)是第一个使得谓词为真的,因此这是我们的二分查找所要找的。

Implementing the discrete algorithm
    在你准备编码之前需要记住一件很重要的事情就是你所维护的两个数(最低和最高范围)是什么意思。一个可能的回答是一个包含使得p(x)为真的第一个x的闭区间。你所有的代码都要维护这个不变量:它能够告诉你怎样正确的移动范围,如果你不注意,很容易产生bug。

    另外一个你需要知道的事情是该把范围设置多高。我用“高”其实意思是“宽”,因为你需要担心两个范围。经常会发生一个程序员在编码的过程中推断他或她设置的宽度足够了,但是结果确在一个很短的时间内发现了一个反例(这个时候太晚了)。
    
    不幸的是,除了一而再再而三的检查范围之外没有什么更好的建议了!同样的,由于执行时间是随范围的指数增长的,因此你可以将它们设置的高一点,只要它们在评估谓词的时候不中断就可以了。整个过程,尤其是计算中间数的时候,要格外的注意溢出错误。

    现在我们终于完成了实现这段和上段小节的二分查找的代码了:

binary_search(lo, hi, p):
   
while lo < hi:
      mid 
= lo + (hi-lo)/2
      
if p(mid) == true:
         hi 
= mid
      
else:
         lo 
= mid+1
          
   
if p(lo) == false:
      complain                
// p(x) is false for all x in S!
      
   
return lo         // lo is the least x for which p(x) is true

    两个至关重要的行数是hi=mid和lo=mid+1。当p(mid)为真的时候,我们可以丢弃后一半的搜索空间,因为谓词对于它其中的元素都是正确的(根据主定理)。尽管如此,我们不可以丢弃mid本身,因为它可能是第一个使得p为真的元素。这样移动上界范围到mid和不引进错误是一样的道理。

    和上面类似,当p(mid)为false时,我们可以丢弃搜索空间的前一半,但是这次p(mid)是false,因此我们不需要将它放到搜索空间中 。这就意味着我们可以将下界范围移动到mid+1。

    如果我们想要找到最后一个使得p(x)为假的x,我们可以(使用上面的基本原理)设计如下:

// warning: there is a nasty bug in this snippet!
binary_search(lo, hi, p):
   
while lo < hi:
      mid 
= lo + (hi-lo)/2    // note: division truncates
      if p(mid) == true:
         hi 
= mid-1
      
else:
         lo 
= mid
          
   
if p(lo) == true:
      complain                
// p(x) is true for all x in S!
      
   
return lo         // lo is the greatest x for which p(x)

    你可以验证这个满足我们的条件,我们所要找的元素总是在区间(lo,hi)中。尽管如此,这里有另外一个问题。考虑当你在如下谓词给定的搜索空间中运行上述代码会得到什么。

no yes


    这段代码将陷入死循环。它总是选择第一个元素作为mid,然后它并没有移动下界范围因为它想要保持no在搜索空间中。解决方案是将mid=lo+(hi-lo)/2变为mid=lo+(hi-lo+1)/2也就是说让它上升而不是下降。也有其他的方法来处理这个问题,但是这个是最清楚的。只要记住测试在两个元素的情况下,第一个元素对于谓词是false,而第二个是true的情况就行了。

    你也许想知道为什么mid使用lo+(hi-lo)/2来计算而不使用mid=(lo+hi)/2。这个是为了避免另外一个潜在的近似错误:在第一个例子中我们想要除法朝向下界范围向下近似。但是除法会截断小数,所以lo+hi将会是负数,它会朝向上界范围。这样的编码计算能够确保除法所得到的数总是正数并且总是可以近似成我们所要的数。为了保持连贯性我下面将使用这种编码方式。

Real numbers
    二分查找也可以用在值域为一系列实数的单调函数上。在实数上实现二分查找通常要比正数的简单,因为你不需要关注怎样移动范围:

binary_search(lo, hi, p):
   
while we choose not to terminate:
      mid 
= lo + (hi-lo)/2
      
if p(mid) == true:
         hi 
= mid
      
else:
         lo 
= mid
          
   
return lo // lo is close to the border between no and yes

    由于实数集合是密集的,因此我们通常得不到准确的目标值。我们有两种方法来决定何时终止:当搜索空间比预定义的范围小的时候(比如10^(-12))或者做一定数量的迭代。在TopCoder中,最好的方法是使用数百次的迭代,这将给你一个不用考虑太多精度的结果100次迭代将会使搜索空间减少大约原大小的10^(-30),这对于大多数(不是所有的)问题足够了。

    如果你需要做尽可能少的迭代,你可以当区间变小的时候,但是试着对范围做一个相对比较,而不是一个绝对比较。原因是double最多只能给你15个十进制数字的精度,所以如果搜索空间包含大数的时候(比如百万相似的数),你不可能得到一个低于10^(-7)的绝对误差。
   
Example
    这边我将要展示如何将上面所说的用在解决TopCoder问题上。为了举例,我挑选了一个适当难度的问题,SRM169 division1 level2的问题FairWorkload。

    在这个问题中,一些工人需要检查一些橱柜,这些橱柜并不是相同的大小并且已知每一个橱柜包含多少个抽屉。我们现在需要找到一个分配方案,使得每一个工人得到连续的的橱柜检查并且要求工人需要检查的最大抽屉的数量最小。

    熟悉了问题之后,需要一定程度的创造力。假定我们需要处理无限数目的工人。一个很重要的观察是,对于某些数MAX,我们可以计算出每个工人需要检查不多于MAX个(如果这个可能的话)抽屉的最少工人数目。让我们看看该怎么去做。一些工人需要检查第一个橱柜,因此我们可以为它分配任何工人。但是,由于橱柜必须被连续的分配(一个工人不能检查橱柜1和3而不检查橱柜2),因此,如果这个没有使得他超过我们所介绍的(MAX),分配给它第二个橱柜总是最优的。而如果超过了限制大小。我们可以推断出他的工作已经完成并且可以将一个新的工人分配第二个橱柜。我们已知这样处理下去直到所有的橱柜都被分配到了,可以断言在我们规定的限制下,我们使用了最少数量的工人。注意这里工人的数量与MAX成反比:限制设置的越高,需要越少的工人。

    现在,让我们回来并仔细的检查在问题中我们要求做的,可以看到我们是要求最小的MAX,满足所需工人的数量小于或者等于可用工人的数量。把这个记下来,我们基本上完成了任务,我们仅仅需要怎样将框架中的要点联系起来,我们使用二分查找来解决问题。

    问题改述之后更好的满足了我们的需求,我们现在检查谓词在工人数量有限的情况下,每个工人检查的不超过x个抽屉的工作量能够可行吗?我们可以使用上面描述的贪心算法高效的对任意x进行评估。这个是构建一个二分查找解决方案的第一个部分,我们现在需要证明主定理的条件满足。观察可以看出x的增长减少了最大工作量的限制,因此我们需要同样数量或者更少的工人,而不是更多。因此,一旦谓词对某些x正确,那么它对所有大于x的元素都是正确的。

   把上面的封装起来,这里是一个使用STL的解决这个问题的代码片段:

int getMostWork( vector  folders, int workers ) {
   
int n = folders.size();
   
int lo = *max_element( folders.begin(), folders.end() );
   
int hi = accumulate( folders.begin(), folders.end(), 0 );

   
while ( lo < hi ) {
      
int x = lo + (hi-lo)/2;

      
int required = 1, current_load = 0;
      
for ( int i=0; i<n; ++i ) {
         
if ( current_load + folders[i] <= x ) {
            
// the current worker can handle it
            current_load += folders[i];
         }

         
else {
            
// assign next worker
            ++required;
            current_load 
= folders[i];               
         }

      }


      
if ( required <= workers )
         hi 
= x;
      
else
         lo 
= x+1;
   }


   
return lo;
}


注意小心的选择下界和上界范围:你可以使用一个相当大的数作为上界,但是下界范围不能小于最大的橱柜,这样是为了避免出现单个橱柜对于任何工人都太大的情况,这种情况在处理谓词时将不正确。另外一方法是将下界范围设置为0,然后将比较小的x作为一个特例在谓词中处理。
  
    为了验证这个解决方案不会死循环,我使用了一个小的no/yes例子,抽屉={1,1}并且工人数目为1。

    这个解决方案总的复杂度为O(nlogSIZE),这里SIZE是搜索空间的大小。这个非常的快。

    正如你所看到的,我们使用了一个贪心算法来评估谓词。在其他的问题中,谓词可能是任何东西,从一个简单的数学公式到找出二分图中的最大基数。

Conclusion
    如果你读到这而没有放弃的话,你应该准备解决任何可以使用二分查找解决的问题。将下面的东西记在脑海里: 涉及一个能被二分查找应用的高效评估的谓词 决定你所要寻找的东西并且编码,使得搜索空间中总是存在它(如果存在的话) 如果搜索空间仅包含正数的话,测试一下两个元素的集合来确保它不是死循环。
 验证下界和上界范围不被过分的约束:通常适度放宽来使得它们不破坏谓词显得更好。

这里是一些可以使用二分查找解决的问题:
Simple
AutoLoan - SRM 258
SortEstimate - SRM 230

Moderate
UnionOfIntervals - SRM 277
Mortgage - SRM 189
FairWorkload - SRM 169
HairCuts - SRM 261

Harder
PackingShapes - SRM 270
RemoteRover - SRM 235
NegativePhotoresist - SRM 210
WorldPeace - SRM 204
UnitsMoving - SRM 278
Parking - SRM 236
SquareFree - SRM 190
Flags - SRM 147