編程珠璣第一個case是有關一個技巧性解決外部排序問題的。問題很巧妙的解決了,但一開始提到的利用歸併排序進行外部排序的演算法仍值得仔細探究一下,畢竟本科時學的不是很深入。
先來看內部排序中最簡單的2路歸併排序演算法。
演算法核心操作是將一維數組中前後相鄰的兩個有序序列歸併為一個有序序列,給定數組中序列界限i、m、n,用2個下標變數分別從i和j=m+1開始逐個往後處理,先比較,小的寫到結果序列的當前遍曆下標k中,相應下標自增繼續比較直到某個序列的下標走到邊界,再將另外一個序列的剩餘元素拷貝到結果序列中。
演算法可用遞迴或遞推實現,從相鄰的兩兩元素開始不斷調用上面的核心操作組成較長有序序列直到完成整個序列。
演算法進行一趟歸併就得到一個局部有序的完整新序列,n個元素共需要log2n趟歸併,每趟完成比較操作n次(1次得到序列的1個值),得到的新序列寫到結果序列空間中,下一趟之前要先將結果序列複製一份到臨時空間,下一趟歸併在臨時空間上進行。因此時間複雜度nlog2n,空間上除了原始序列空間n、結果序列空間n,還需要輔助臨時空間n。
接下來看外部排序。外部排序指的是大檔案的排序,即待排序的記錄儲存在外儲存空間上,待排序的檔案無法一次裝入記憶體,需要在記憶體和外部儲存空間之間進行多次資料交換,以達到排序整個檔案的目的。外部排序最常用的演算法是多路歸併排序,即將原檔案分解成多個能夠一次性裝入記憶體的部分,分別把每一部分調入記憶體完成排序。然後,對已經排序的子檔案進行多路歸併排序。
多路歸併排序演算法在常見資料結構書中都有涉及。從2路到多路(k路),增大k可以減少外存資訊讀寫時間,但k個歸併段中選取最小的記錄需要比較k-1次,為得到u個記錄的一個有序段共需要(u-1)(k-1)次,若歸併趟數為s次,那麼對n個記錄的檔案進行外排時,內部歸併過程中進行的總的比較次數為s(n-1)(k-1),也即(向上取整)(logkm)(k-1)(n-1)=(向上取整)(log2m/log2k)(k-1)(n-1),而(k-1)/log2k隨k增而增因此內部歸併時間隨k增長而增長了,抵消了外存讀寫減少的時間,這樣做不行,由此引出了“敗者樹”tree
of loser的使用。在內部歸併過程中利用敗者樹將k個歸併段中選取最小記錄比較的次數降為(向上取整)(log2k)次使總比較次數為(向上取整)(log2m)(n-1),與k無關。
敗者樹是完全二叉樹,因此資料結構可以採用一維數組。其元素個數為k個葉子結點、k-1個比較結點、1個冠軍結點共2k個。ls[0]為冠軍結點,ls[1]--ls[k-1]為比較結點,ls[k]--ls[2k-1]為葉子結點(同時用另外一個指標索引b[0]--b[k-1]指向)。另外bk為一個附加的輔助空間,不屬於敗者樹,初始化時存著MINKEY的值。
多路歸併排序演算法的過程大致為:首先將k個歸併段中的首元素關鍵字依次存入b[0]--b[k-1]的葉子結點空間裡,然後調用CreateLoserTree建立敗者樹,建立完畢之後最小的關鍵字下標(即所在歸併段的序號)便被存入ls[0]中。然後不斷迴圈:把ls[0]所存最小關鍵字來自於哪個歸併段的序號得到為q,將該歸併段的首元素輸出到有序歸併段裡,然後把下一個元素關鍵字放入上一個元素本來所在的葉子結點b[q]中,調用Adjust順著b[q]這個葉子結點往上調整敗者樹直到新的最小的關鍵字被選出來,其下標同樣存在ls[0]中。迴圈這個操作過程直至所有元素被寫到有序歸併段裡。
虛擬碼如下:
void Adjust(LoserTree &ls, int s)
/*從葉子結點b[s]到根結點的父結點ls[0]調整敗者樹*/
{ int t, temp;
t=(s+K)/2; /*t為b[s]的父結點在敗者樹中的下標,K是歸併段的個數*/
while(t>0) /*若沒有到達樹根,則繼續*/
{ if(b[s]>b[ls[t]]) /*與父結點指示的資料進行比較*/
{ /*ls[t]記錄敗者所在的段號,s指示新的勝者,勝者將去參加更上一層的比較*/
temp=s;
s=ls[t];
ls[t]=temp;
}
t=t/2; /*向樹根退一層,找到父結點*/
}
ls[0]=s; /*ls[0]記錄本趟最小關鍵字所在的段號*/
}
void K_merge( int ls[K])
/*ls[0]~ls[k-1]是敗者樹的內部比較結點。b[0]~b[k-1]分別儲存k個初始歸併段的目前記錄*/
/*函數Get_next(i)用於從第i個歸併段讀取並返回目前記錄*/
{ int b[K+1),i,q;
for(i=0; i<K;i++)
{ b[i]=Get_next(i); /*分別讀取K個歸併段的第一個關鍵字*/ }
b[K]=MINKEY; /*建立敗者樹*/
for(i=0; i<K ; i++) /*設定ls中的敗者初值*/
ls[i]=K;
for(i=K-1 ; i>=0 ; i--) /*依次從b[K-1]……b[0]出發調整敗者*/
Adjust(ls , i); /*敗者樹建立完畢,最小關鍵字序號存入ls[0]
while(b[ls[0]] !=MAXKEY )
{ q=ls[0]; /*q為當前最小關鍵字所在的歸併段*/
prinftf("%d",b[q]);
b[q]=Get_next(q);
Adjust(ls,q); /*q為調整敗者樹後,選擇新的最小關鍵字*/
}
}
最後,對使用多路歸併排序來進行外部排序的過程大致描述一下:根據有限的記憶體資源將大檔案分為L個段,然後依次將這L個段讀入記憶體並利用高效的內部排序演算法對每個段進行排序,排序後的結果即為初始有序歸併段直接寫入外存檔案。內部排序時要選擇合適的排序演算法,並且要考慮到內部排序需要的輔助空間以及有限的記憶體空間來決定究竟要把大檔案分為幾個段。接下來選擇合適的路數k對這L個歸併段進行多路歸併排序,每一趟歸併使k個歸併段變為1個較大歸併段寫入檔案,反覆幾趟歸併後得到整個有序的檔案。在多路歸併過程中,記憶體空間只需要維護一個大小為2k的敗