二分查找是《編程珠璣》作者很喜愛的一個話題,之前我曾經專門寫了一篇博文:如何寫出正確的二分查找?——利用循環不變式理解二分查找及其變體的正確性以及構造方式,在這裏將換幾個角度,繼續探討二分查找的相關內容,以及與它聯繫緊密的分治法和排序思想。
目錄
二分思想和分治法
如果你對概念很敏感,會馬上意識到這兩者的細微不同:二分搜索每次都要捨棄一半,從留下的一半中尋找目標;而分治法把一個大問題分成兩個或多個小問題,遞歸地求這些小問題的解,最後再把它們小心謹慎的合併起來,並且要仔細考慮合併時產生的新的情況。這當然沒有錯,但你也馬上會從這裏意識到兩者的巨大聯繫。就拿選取數組中第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個元素的集合必然已經有重複元素,直接取這個集合即可。這就保證了每次元素個數減半。
排序思想
延續上一節的主題。有時當我看到O(nlogn)時間複雜度的算法,總會聯想到分治法和快速排序,這是因爲快速排序是平均O(nlogn)的時間複雜度的。其實對於很多算法,如果進行了排序特別是快速排序,能夠顯著地提高速度。甚至,排序部分是這個算法的基石。其實,對於一組無序數據,元素之間的相互關係比較相當薄弱;而在排序後,或許能將一些有近似性質的元素篩選並放在一起,以便於下一步使用,這就是我所謂的排序思想。
問題1:(第2章問題C)給定一個英語字典,找出所有變位詞集合。所謂變位詞,比如"pots"、"stop"、"tops"互爲變位詞。
分析:
檢測每對單詞是否爲變位詞需要花費大量時間。爲了將所有單詞標準化,可以先將所有單詞按字母表順序排序,比如pots變成opst,再把所有排序後的單詞再做一次排序。那麼,所有變位詞就一定是在相鄰的位置上了。爲了保存原先單詞的內容,可以使用索引來保存原單詞的位置。
問題2:(習題2.8)給定一個n元實數集合、一個實數t和一個整數k,如何快速確定是否存在一個k元子集,其元素之和不超過t?
分析:
這裏只要求不超過t,那麼把這個集合按遞增排序,如果前k個數之和小於t,那麼必然存在這樣一個k元子集。
往期回顧: