【編程珠璣】【第二章】問題A
A題
給定一個最多包含40億個隨機排列的32位整數的順序文件,找出一個不在文件中一32位整數。有三個問題:(1)在文件中至少存在這樣一個數?(2)如果有足夠的內存,如何處理?(3)如果內存不足,僅可以用文件來進行處理,如何處理?
答案:
(1)32位整數,包括-2146473648~~2146473647,約42億個整數,32bit可用表示的最大無符號整數約為43億。可見,一定存在至少一個這樣的整數不被包含在40億個整數中。
(2)如果采用位向量思想,通過建立一個大小為 2^32 的bool數組,用來表示相應的整數是否出現,進而,這種數組其實可以用第一章中學過的位圖bitmap來實現,先對各位全部初始化為0。遍歷輸入文件,若某數出現,將該位置為1。最後,遍歷一遍這個位圖,所有為0的位就表示該位對應的整數沒有出現過。時間復雜度為 O(n)+ 2*L (L為定值,表示 2^32,乘以2表示一次初始化和一次遍歷),空間上32位整數最多需要占用43億(2的32次方)個位,大約(2^32)/(8*10^6) = 512 MB的內存空間。
使用位向量方法,通常是逐位進行判斷,但是這個例子中絕大多數的位都被置為1,只有極少的位為0,所以為了加速比較可以直接粗粒度的對int進行比較而不是對bit進行test(i)比較。為-1的二進制表示是全1的,判斷每個int是否等於-1,若等於意味著該Int的32位都為1,否則說明其中某一位為0,該位對應的整數缺失。
這個方法的最大缺點,就是耗費內存。這時采用多趟算法,假如我們只有 500 B內存,就是說我們一次只能申請到 0~3999 個可用位(實際上加上其他開銷,可能申請不到這麽多),可以先遍歷一趟文件,只測試在 0~3999 之間的整數,第二趟,測試 4000~7999 ,依次類推……。(如果題目要求只找到一個數就停止,那這種方法應該很不錯。。)
(3.1)內存不足(假如只有1M內存),可以采用散列分桶的思想:輸入的40億個數
2的32次方個數(近43億)用位向量表示需要512MB的內存,可是實際上只有1MB內存,這樣不能夠直接使用位向量記錄輸入文件中的40億個整數(相應位置為0)並進而判斷沒有被記錄的那些整數(相應位為0)。我們可以使用分桶的策略,把2的32次方分成一個個能夠被裝入內存中的小桶,每個小桶進行獨立的處理,這樣處理完所有的桶之後便能夠輸出所有的不在文件中的整數。此策略的缺點是需要重復的遍歷輸入文件和重復的進行每個桶的處理,優點是以時間代價來滿足空間限制、且思路簡單易懂。1MB內存空間有2^23個bits,可以表示2^23個整數,而整個整數範圍為2^32,因此需要劃分為2^(32-23)=2^9個桶,即512個桶。每個桶內仍使用位向量方法進行判斷。
(3.2)題目中並沒有說輸入的40億個數字中有沒有重復,只說明了是32位的整數,這意味著40億的輸入數據一定不能夠覆蓋全部的2^32的範圍,無論是否有重復,如果有重復意味著40億的輸入數據所缺失的數字更多,所以也沒有影響。任意將40億個數字分為兩個集合,能確定至少有一個集合(數據量少的那個)會有缺失的項。
因此這裏考慮二分法(這裏要摒棄固定思維,二分法並不是什麽情況下都需要排序後才能使用)。二分法思想是這樣的:我們讀取輸入文件並從中取出整數,根據高位為1及高位為0將這些整數分為兩類放到不同文件裏,這個過程不需要多少工作內存,幾十個byte足夠,設高位為1的放入文件A中,為0的放入B中。
(3.2.1)當A中數據量和B一樣多時,說明兩組中都有遺漏的數據(遺漏一個或者多個,遺漏的數目相同),因為數據範圍為43億,實際數據只有40億(沒有重復的情況下,有重復的話則更少),最多每組只有20億,不可能都包含43億數據中的一半數據。此時任取其中一組進行接下來的二分即可。
(3.2.2)如果A數據量和B數據量不同時,可以肯定的是數據量少的那組一定缺少某一個或者多個數,而數據量多的那組遺漏與否是不一定的。在沒有重復的情況下可以通過比較數據量多的那組數據量和2^32/2值的大小來判斷它是否包含完整的一半數據,若有重復的情況下難以直接判斷數據量多的那組是否缺項。此時我們取數據量小的(可以使得接下來的二分需要處理的數據更少,數據量多的那個則有可能沒有不存在的數)那組進行二分——然後遞歸前面的步驟,最終會找到某個不在40億數中的數。
但是較本題目更為復雜的一個問題是,如果輸入數據量大於整數範圍的最大值,且輸入的數據中有重復數據,此時問題比較難以處理,若仍要使用二分法可能要進行精密的改動。下面將要講的是來自網上的一個例子,例子能夠很好地解釋前述的二分法過程,但是並不能正確的應用於該例子假設的所有情況,具體請詳細參閱:
假設一個文件裏頭有20個4bit的整數,需要找出其中遺漏的數字。我們一次從中取出一個數字,如果是最高位為1,那麽放到一個文件A中,否則放到另外一個文件B中。理論上最高位為1的4bit數字不重復的共有2^3=8個,最高位為0的4bit數字同樣為8個。若統計的個數少於8個肯定是這堆數中有遺漏的數。
設輸入數據文件中包含的20個整數中有大量重復數字,而且導致最高位為1的數字個數可能大於8,但這不表示它其中沒有缺少的數字。那到底如何去分辨哪個堆裏頭有缺少數字呢?在分揀到文件的過程中,程序有兩個計數器,分別記錄放入哪個文件的數字的個數,缺少的數字肯定在較小個數的那個文件裏頭。
對{1,3,4,5,3,6,3,7,9,4,9,1,2,2,11,12,15,11,14,15}共20個整數進行二分法,最高位為1的數字共有{9,11,12,15,11,14,15}七個,而最高位為0的數字共有{1,3,4,5,3,6,3,7,4,1,2,2}十二個,我們應該選擇高位為1的那組數據繼續二分法。同時我們還要設一個“標兵”,當前1 000和0 000是二分法的兩個邊界,我們取1000為此輪二分法的分界點,值為8。
對{9,11,12,15,11,14,15}繼續二分法,第二高位為1的數字共有{12,15,15},第二高位為0的數字共有{9,11,11,14}。取較小數量的{12,15,15},又因邊界為1100和1000,故“標兵”為8+4=12。
對{12,15,15}繼續二分法,第三高位為1的數字共有{15,15}兩個,第三高位為0的共有{12}一個。選擇較小數量的{12},又因邊界為1110和1100,這時“標兵”為12。
對{12}繼續二分法,第四高位為1的數字個數零個,第四高位為0的數字為{12}一個。選擇第四高位為1的空集合{},邊界為1101和1100,因此這時候“標兵”為12+1=13。
四個位判斷完畢後,我們求出上例中沒有的數字為13。
#include <stdlib.h> int getLost(unsigned char *a, unsigned char *b, unsigned char *c, int alen, int bit) { unsigned char *t; int re = 0, v = 0, biter = 0, citer, i = 0; if (!a || !b || alen >=(unsigned long)( (1<< bit))) return -1; //這規定了輸入數據量不能大過bits所能表示的最大數值。 while (bit--) { //從最高位開始逐位進行二分,直到所有的bits位處理完為止。 v = (1 << bit); //定位到當前最高位 for (biter = citer = i = 0; i < alen; ++i) { //遍歷a[len]數組 if (a[i] & (1 << bit)) //將當前最高位為1的元素存儲到b數組中。 b[biter++] = a[i]; else //將當前最高位為0的元素存儲到c數組中。 c[citer++] = a[i]; } if (biter <= citer) { //b,c數組中選擇數據量較小的數組 re += v; //如果b組數據量小,意味著當前高位為1的數組被選中, t = a; a = b; b = t; //需要更新“標兵”re,否則不需要更新re。 alen = biter; //把a指向b地址,並更新alen的長度值,以便進行下次二分 } else { t = a; a = c; c = t; alen = citer; } } return re; //所有位二分結束後,返回“標兵”的值,即為缺失的值。 } int main() { unsigned char b[20] ={0}; //b數組相當於文件a unsigned char c[20] ={0}; //c數組相當於文件b unsigned char a[] = {1,3,4,5,3,6,3,7,9,4,9,1,2,2,11,12,15,11,14,15}; //相當於輸入數據 printf("%d\n",getLost(a,b,c,20,4)); system("pause"); return 1; }
已知4bits二進制的整數值最大為15,按理說20個輸入數據中有可能沒有遺漏的數,也就是說包含所有的數,所以上述的思路實際上是錯誤的,它只能夠處理輸入數據量小於整數最大值範圍的情況,不能夠處理超過最大範圍的情況,因此代碼中用紅色註釋標紅了註意事項,這個條件限制了輸入的情況,保證了算法的適用性,足以滿足編程珠璣中的題目要求。
註意輸入的數據默認是無序的,這樣我們在將所有輸入數據根據最高位劃分成兩組時,需要依次遍歷所有的數據並將其添加到對應的數組b或者c中存儲起來。下一次二分時根據b,c的長度選擇較小的數組進行類似的操作。但是若輸入的數據是有序的,我們在使用二分法查找遺漏數據時,並不需要開辟新的數組來存儲兩組數據,只需要存儲幾個輸入數組的下標便可,這是因為通過下標變換就能夠在有序數組上隨意的定位某一段範圍內的元素。代碼如下:
#include <stdio.h> #include <stdlib.h> int getLost(unsigned char *a, int length, int bitlen){ unsigned char result = 0; int start = 0; int i,j; for( i = 1; i <= bitlen; i++){ int bit0 = 0; int bit1 = 0; int mod = 1 << (bitlen - i); int len = length/(1<<(i-1)); for( j = start; j < start + len; j++){ if((a[j] & mod) == 0) bit0++; else bit1++; } if(bit0 < bit1) result |= 0<<(bitlen - i); else{ result |= 1<<(bitlen - i); start+=len/2; } } return result; } int main(){ unsigned char b[] = {1,3,4,5,5,6,7,8,9,10,11,12,13,14,15}; printf("%d\n",getLost(b,15,4)); system("pause"); return 1; }
(4)算法分析:書中明確指出了可以利用外部臨時文件,但是內存較小,這意味著我們可以使用較小的內存和充足的外存,這給了我們什麽提示呢?
這通常意味著我們不能夠把大量數據讀入內存中處理,只能夠通過遍歷輸入文件,讀取少量數據到內存中,進行處理後存儲到臨時文件裏去,而後可能通過對臨時文件進行處理得到最終結果。
正如本例中,輸入數據量是巨大的,只能通過一條一條記錄讀取,根據當前數據記錄分到兩個臨時組文件A和B中存儲,而後轉而處理其中一個臨時組文件,其他的文件被交替使用。如此重復,最終求得結果。
復雜度分析:每次需處理的數據量都是原來的一半:n+n/2+n/4+n/8+n/2^log(2)n=2n-1;的確是O(n)的。所以不要誤以為時間復雜度正比於log2(n),但要註意一共劃分的次數是正比於 log2(n)的。為了便於理解,舉個常見的二分查找的例子,二叉樹查找目標元素,需要log2(n)次比較,每次比較的復雜度是O(1),因此二分查找的復雜度即為O(log2(n))。但是在這裏,不僅需要執行log2(n)次劃分,每次劃分的復雜度不是1而是n/2^log2(n),所以本例中的復雜度是正比於O(n)的。
(5)上面介紹了亂序輸入和排序後輸入兩種情況下的getlost算法,不過這兩種算法只是模擬算法,僅僅用於介紹二分思想,因為他們的輸入數據都是由數組保存的,在內存有限的情況下是不可能開辟出這麽大的內存數組來保存輸入數據的。因此,這裏介紹真正的利用文件實現的算法,整體思路上與之前的算法是一致的:
/* * 1.定義相關變量 * 2.如果當前探測位為最高位,意味著是第一次二分,需要讀原始數據文件。 * 如果當前探測位不是最高位,意味著需要讀上次二分後所含數據元素少的臨時文件。 * 3.如果當前探測位是最低位,意味著這次二分要寫的文件即為最終輸出文件。 * 如果當前探測位不是最低位,意味著本次二分要寫的文件為臨時輸出文件。 * 4.其他思路同上 * 5.值得註意的是curbit和totalbit之間的關系以及mask = 1<<(curbit-1)中的curbit-1的意義。 *6.參數totalbits是指需要進行二分的總位數,原則上要求輸入數據不能超過這些位所能表示的最大值,但是代碼中並沒有進行錯誤檢查等,因此該代碼並不具備較高的容錯性;而且每次二分都是新建臨時文件比較浪費磁盤空間,可以每次讀取完畢後及時刪除或者重復利用固定數目的幾個文件,等等的問題值得後續進行改進。 */ #include <stdio.h> #include <assert.h> void GetLost(int totalbits) { FILE *input,*output0,*output1; char filename[30] = ""; int mask,value,num0 = 0,num1 = 0,missing =0,curbit=totalbits; while(curbit>0) { if(curbit==totalbits){ input = fopen("source_input.txt","r"); }else if(num0<=num1){ sprintf(filename,"tmp_bit%d_0.txt",curbit+1); input = fopen(filename,"r"); }else { sprintf(filename,"tmp_bit%d_1.txt",curbit+1); input = fopen(filename,"r"); } if(curbit==1) { sprintf(filename,"final_output_0.txt"); output0 = fopen(filename,"w"); sprintf(filename,"final_output_1.txt"); output1 = fopen(filename,"w"); }else { sprintf(filename,"tmp_bit%d_0.txt",curbit); output0 = fopen(filename,"w"); sprintf(filename,"tmp_bit%d_1.txt",curbit); output1 = fopen(filename,"w"); } assert(input!=NULL && output0!=NULL&&output1!=NULL); num1=num0=0; mask = 1<<(curbit-1); while(!feof(input)) { fscanf(input,"%d\n",&value); if(value&mask) { fprintf(output1,"%d\n",value); num1++; }else { fprintf(output0,"%d\n",value); num0++; } } if(num1<=num0){ missing |= (1<<(curbit-1)); } fflush(output0); fflush(output1); fclose(output0); fclose(output1); fclose(input); curbit--; } printf("missing number:%d\n",missing); } int main() { GetLost(4); return 0; }
【編程珠璣】【第二章】問題A