关于二分搜索的写法以及正确性证明的思考
今晚看到算法引论关于二分搜索的相关问题,想起了当年看编程珠玑的“无处不在的二分搜索”那章,记得作者说过能完全写对二分搜索的程序员寥寥无几,当时自己也写了下,确实不容易写,主要的难点在于写对,大致的框架可能大家都非常熟悉,但是里面的下标怎么确定是正确的呢?不对的下标很有可能造成死循环。不过,算法引论所推崇的数学归纳法的思想还是很普适的,反应在程序上就是先写n=1的情况,再写归纳阶段的代码,这样的方法用在写二分搜索感觉很有效,例如书中最普通的二分搜索代码如下:
int BinarySearch(int* A, int l, int r, int z)
{
if(l == r)
{
if(A[l] == z)
return l;
else
return -1;
}
else
{
int middle = (l + r + 1)/2;
if(z < A[middle])
{
BinarySearch(A,l, middle-1,z);
}
else
{
BinarySearch(A,middle,r,z);
}
}
}
这里对程序稍微做了点修改,但是大意是一样的,可以看到,程序书写的逻辑上,先处理n=1的情况,即l==r,这时候只有1个元素,对于1个元素的判断是trivial的,然后在else里面,算出middle并递归判断,这里是上取整,那么为什么要上取整?
要明白这个问题就是要理解,如果下取整会出现什么情况?死循环!让我们来验证下,假如某个时刻l=r-1,且A[l]<=z,这个时候就是死循环的时候,因为每次middle都会等于l(下取整)而A[l]<=z所以会走else那个分支,又继续递归l(此时middle是等于l的)和r,从而一直死循环下去。
仔细分析上面的情况不难发现,关键的地方在middle是下取整的情况,如果下取整会出现一个关键的问题就是l有可能等于middle,我们如果抓住这个问题去分析,就会很容易发现在else分支会出现死循环。下面用同样的方法分析下上取整,如果上取整的话,middle则可能会等于r,如果A[middle]>z,会直接导致r等于l,即在下次进入n=1的判断;如果A[middle]<=z,则同样会有middle等于r进入n=1的分支,这也证明了这个程序一定会在n=1的时候退出。其实在下取整和上取整发生不同的地方就是临界的位置,这也是容易造成死循环的时候。
通过以上分析,我们总结下如何快速判断一个二分搜索程序是否会出现死循环:
- 查看middle的选取是那种取整类型,是上取整还是下取整
- 根据类型,假定临界条件的发生,对上取整就是middle等于r了,对下取整就是middle等于l了
- 用临界的情况去检验if的两个分支,看看会不会出现循环调用,如果会则一定会有死循环,否则可大致判断程序是正确的
其实,编程珠玑中也介绍了程序验证学的方法,即assert,这个方法也是很好的一种方法,特别的是写短小程序的时候。
用这个方法,我发现图6.3中,二叉搜索的特殊下标问题中的程序是错误的,即middle的取法应该是下取整,书上是上取整,我用程序跑书的例子,果然华丽的堆栈溢出了,看来这种方法还是挺有作用的,不知道有人和我有同样的疑问吗,勘误上并没有说程序的问题。
[Update] 最近做Leetjob 的online judegment又遇到类似的,问题,继续总结,二分查找可以在有序数组中找任意满足某些特定条件的一个数,先根据条件,确定middle比较的形式,之后就可以确定是用上取整还是下取整,上取整,上限j必须要变,下取整,下限i必须要变,例如第一个找某个数x使得x>=target,那么我们就要写A[middle]<target,i=middle+1。例子可见https://oj.leetcode.com/problems/search-for-a-range/
(完)