二分查找是《编程珠玑》作者很喜爱的一个话题,之前我曾经专门写了一篇博文:如何写出正确的二分查找?——利用循环不变式理解二分查找及其变体的正确性以及构造方式,在这里将换几个角度,继续探讨二分查找的相关内容,以及与它联系紧密的分治法和排序思想。
目录
如果你对概念很敏感,会马上意识到这两者的细微不同:二分搜索每次都要舍弃一半,从留下的一半中寻找目标;而分治法把一个大问题分成两个或多个小问题,递归地求这些小问题的解,最后再把它们小心谨慎的合并起来,并且要仔细考虑合并时产生的新的情况。这当然没有错,但你也马上会从这里意识到两者的巨大联系。就拿选取数组中第k个最小的数的算法来说,有一个版本便是从快速排序中修改而来:划分后,舍弃掉不存在的区间,对剩余部分迭代(后文将进行讲解),而快速排序是分治法的典型代表。
正式地把这个问题叙述为:
(习题11.9、《编程珠玑(续)》第15章)在O(n)时间内从数组x[0...n-1]中找出第k个最小的元素。可以改变原数组中元素的位置。
下面这段代码就是从快速排序中修改而来,同时考虑到了随机选择划分元素的问题。
int partition(int *array, int p, int r) { int x,i,j; x = array[r]; i = p-1; for (j=p;j<=r-1;j++) { if (array[j]<= x) { i++; swap_value(array+i,array+j); } } swap_value(array+i+1,array+r); return i+1; } int random_select(int *array, int p, int r, int i) { int q,k; if (p == r) return array[p]; q = random_partition(array,p,r); k = q-p+1; if (i == k) return array[q]; else if (i<k) return random_select(array, p, q-1, i); else return random_select(array, q+1, r, i-k); }
虽然《续》中作者用实验和统计的方式说明了对于N元数组,平均期望时间为O(n),但如果你不满足于统计而想得到理论上的证明,请参考《算法导论》9.3节。
扩展:(《续》习题15.2)如何从一个3元数组中选出第2小的?如果从1000000个中选出1000个最小元素、且输入存储在磁带上呢?
分析:前者至多只需3次比较:1和2、1和2中最大的和3、1和2中最小的和3;后者是遍历时用1000大小的最大堆保存1000个当前最小的即可。其实前者是为了说明,如果问题只有几步就可以解决,根本没必要使用复杂的递归函数,直接解就是了;而后者是因为磁带进行随机I/O不方便而已,否则,直接用K=1001划分,那么K前面的1000个就是所求的元素。
扩展:(《编程之美》2.5寻找最大的K个数)
分析:使用二分法找到了从大到小的第K个的数之后,那么比它大的和它自己就是要找的最大的K个数了。当然这个问题还有其它解法,有兴趣的读者可以参考《编程之美》原书。
如果从“二分搜索”中提炼出“二分法”,即这种舍去一半、留一半的方式,而又不用像分治法那样考虑子问题解的合并,那么我们的思路也应该更加广阔一些:能够二分的,不仅仅是数组下标。如果这样讲很抽象,那么考虑下面一个例子:
(《编程珠玑》第二章问题A)给定一个最多包含40亿个随机排列的32位整数的顺序文件,找出一个不在文件中的32位整数。
分析:32位整数一共有4294967296个,略大于40亿。即使不重复出现,它们也不可能全部放入这40亿个整数的数组中,必然有一部分不出现。根据二分思想,我们把40亿个数的集合分成两个,其中必然有一个至少缺少一个数的集合,进行递归求解。划分的依据是按数的位扫描,从第31位开始,分别统计这一位是0和1的数,把较小的那一部分用做下一次递归。扫描完第0位,必然得到一个不含元素的空集,这个集合对应的就是缺失的元素。
为了演示这一过程,我编写了相应的测试程序。由于包含大量的文件I/O操作,看上去比较复杂,但是基本的思想框架是一样的。为了简化起见,只处理30000个带符号的正数(这意味着我从每个数的第14位开始检测,最多有37628个可能),运行前需要生成一个含有30000个数的文件output.txt。
#include <stdio.h> #include <assert.h> int BitCheck(int total,int n,int last) { FILE *input,*output0,*output1; char filename[10] = ""; int mask,value,num0 = 0,num1 = 0; assert(n>=0); if(n==total) input = fopen("output.txt","r"); else { sprintf(filename,"%d_%d.txt",n,last); input = fopen(filename,"r"); } if(n==0) { sprintf(filename,"final_0"); output0 = fopen(filename,"w"); sprintf(filename,"final_1"); output1 = fopen(filename,"w"); } else { sprintf(filename,"%d_0.txt",n-1); output0 = fopen(filename,"w"); sprintf(filename,"%d_1.txt",n-1); output1 = fopen(filename,"w"); } assert(input!=NULL && output0!=NULL&&output1!=NULL); mask = 1<<n; while(!feof(input)) { fscanf(input,"%d\n",&value); if(value&mask) { fprintf(output1,"%d\n",value); num1++; } else { fprintf(output0,"%d\n",value); num0++; } } fflush(output0); fflush(output1); fclose(output0); fclose(output1); fclose(input); return num1<num0; } int Search(int n){ int total = n,last = 0,missing =0; while(n>=0) { last = BitCheck(total,n,last); missing |= (last<<n); n--; } printf("missing number:%d\n",missing); return 0; } int main() { Search(14); return 0; }
体验过这个思想所展示的威力之后,也难怪《编程珠玑》的作者感叹二分搜索“无所不在”了。
另外值得一提的是,虽然分治法也用到了二分思想,但具体分法是五五开还是三七开,这可就不一定了。
扩展:(习题2.2)给定包含43亿个32位整数,找出至少出现两次的整数。
分析:如果每次都保留大于数目一半的集合,原先的方案并不能保证每次减少一半元素。为了每次尽可能多地抛弃元素,在检查元素个数时,如果一个集合的元素个数已经超过了这次递归中它所能容纳不重复的元素个数m(起始时是232/2)而达到了m+1,那么剩余部分元素都没有必要再检查而直接抛弃,这m+1个元素的集合必然已经有重复元素,直接取这个集合即可。这就保证了每次元素个数减半。
(2013.8.16更新)
这三个问题来自于《算法设计手册》(The Algorithm Design Manual)4.9.2~4.9.3。
先看单侧二分查找。假设一个已排序的数组A[1...n]以0开始,并且有多个0。如何找到这些0的结尾?如果0不多,直接二分查找会导致性能退化。那么单侧二分查找就是检测A[1],A[2],A[4],A[8],A[16]...直到找出非0值,然后在最后一个区间再做二分查找。
至于求平方根和方程的解,是二分查找的应用。求n的平方根时,二分查找区间为[1,n],很快就能确定根的值。而对于在[l,r]上与x轴只有一个交点的连续函数,且它满足f(l)与f(r)一正一负,那么也可以用二分法求出一个根x使f(x)=0。
延续上一节的主题。有时当我看到O(nlogn)时间复杂度的算法,总会联想到分治法和快速排序,这是因为快速排序是平均O(nlogn)的时间复杂度的。其实对于很多算法,如果进行了排序特别是快速排序,能够显著地提高速度。甚至,排序部分是这个算法的基石。其实,对于一组无序数据,元素之间的相互关系比较相当薄弱;而在排序后,或许能将一些有近似性质的元素筛选并放在一起,以便于下一步使用,这就是我所谓的排序思想。
问题1:(第2章问题C)给定一个英语字典,找出所有变位词集合。所谓变位词,比如"pots"、"stop"、"tops"互为变位词。
分析:
检测每对单词是否为变位词需要花费大量时间。为了将所有单词标准化,可以先将所有单词按字母表顺序排序,比如pots变成opst,再把所有排序后的单词再做一次排序。那么,所有变位词就一定是在相邻的位置上了。为了保存原先单词的内容,可以使用索引来保存原单词的位置。
问题2:(习题2.8)给定一个n元实数集合、一个实数t和一个整数k,如何快速确定是否存在一个k元子集,其元素之和不超过t?
分析:
这里只要求不超过t,那么把这个集合按递增排序,如果前k个数之和小于t,那么必然存在这样一个k元子集。
往期回顾: