1. 程式人生 > >排序(二)鍵索引、桶排序、位示圖、敗者樹等(圖文詳解--敗者樹)

排序(二)鍵索引、桶排序、位示圖、敗者樹等(圖文詳解--敗者樹)

排序(二)

以上排序演算法都有一個性質:在排序的最終結果中,各元素的次序依賴於它們之間的比較。我們把這類排序演算法稱為比較排序

任何比較排序的時間複雜度的下界是nlgn。

以下排序演算法是用運算而不是比較來確定排序順序的。因此下界nlgn對它們是不適用的。

鍵索引計數法(計數排序)

計數排序假設n個輸入元素中的每一個都是在0到k區間的一個整數,其中k為某個整數。

思想:對每一個輸入元素x,確定小於x的元素個數。利用這一資訊,就可以直接把x放到它在輸出陣列中的位置了。

例如:

學生被分為若干組,標號為1,、2、3、4等,在某些情況下我們希望將全班同學按組序號排序分類。


1.頻率統計:

第一步就是使用int陣列cout[]計算每個鍵出現的頻率。

對於陣列中的每個元素,都使用它的鍵訪問count[]中的相應元素並將其加1。(即把鍵值作為cout[]的索引)如果鍵值為r,則將count[r+1]加1.(為什麼需要加1?稍後解釋)

for (i=0; i<N; i++)

   count[a[i].key()+1]++ ;

count[0~5]:0 0 3 5 6 6

2.將頻率轉換為索引:

接下來,我們會使用count[]來計算每個鍵在排序結果中的起始索引位置。在這個示例中,因為第一組中有3個人,第二組中有5個人,因此第三組中的同學在排序結果陣列中的起始位置為8。

對於每個鍵值r,小於r+1的鍵的頻率之和為小於r的鍵的頻率之和加上count[r],因此從左向右將count[]轉化為一張用於排序的索引表是很容易的。

for (int r=0; r<R; r++)

   count[r+1] += count[r] ;

count[0~5]:0 0 3 8 14 20

3. 資料分類:

在將count[]陣列轉換為一張索引表之後,將所有元素(學生)移動到一個輔助陣列aux[]中以進行排序。每個元素在aux[]中的位置是由它的鍵(組別)對應的count[]值決定的,在移動之後將count[]中對應元素的值加1,以保證count[r]總是下一個鍵為r的元素在aux[]中的索引位置。這個過程只需遍歷一遍資料即可產生排序結果

(這種實現方式的穩定性是很關鍵的——鍵相同的元素在排序後會被聚集到一起,但相對順序沒有變化。)

for (int i=0; i<N; i++)

   aux[count[a[i].key()]++] = a[i] ;

4. 回寫:

因此我們在將元素移動到輔助陣列的過程中完成了排序,所以最後一步就是將排序的結果複製回原陣列中。

for (int i=0; i<N; i++)

   a[i] = aux[i] ;

特點:鍵索引計數法是一種對於小整數鍵排序非常有效卻常常被忽略的排序方法。

鍵索引計數法不需要比較,只要當範圍R在N的一個常數因子範圍之內,它都是一個線性時間級別的排序方法。

基數排序

有時候,我們需要對長度都相同的字串進行排序。這種情況在排序應用中很常見——比如電話號碼、銀行賬號、IP地址等都是典型的定長字串。

將此類字串排序可以通過低位優先的字串排序來完成。如果字串的長度均為W,那就從右向左以每個位置的字元作為鍵,用鍵索引計數法(或插入排序)將字串排序W遍。

(為了確保基數排序的正確性,一位數排序演算法必須是穩定的。例如:計數排序、插入排序)

特點:基數排序是否比基於比較的排序演算法(如快速排序)更好呢?

基數排序的時間複雜度為線性級(n),這一結果看上去要比快速排序的期望執行時間代價(nlgn)更好一些。但是,在這兩個表示式中隱含在背後的常數項因子是不同的。

在處理的n個關鍵字時,儘管基數排序執行的迴圈輪數會比快速排序要少,但每一輪它所耗費的時間要長得多。且快速排序通常可以比基數排序更有效地使用硬體的快取。

此外,利用計數排序作為中間穩定排序的基數排序不是原址排序,而很多nlgn時間的比較排序是原址排序。因此,當主存的容量比較寶貴時,我們可能會更傾向於像快速排序這樣的原址排序。

桶排序

桶排序(bucket sort)假設輸入資料服從均勻分佈,其獨立分佈在[0,M)區間上。平均情況下它的時間代價為O(n)。

思想:桶排序將[0,M)區間劃分為n個相同大小的子區間,或稱為

然後,將n個輸入數分別放到各個桶中。因為輸入資料時均勻、獨立地分佈在[0,M)區間上,所以一般不會出現很多數落在同一個桶中的情況。為了得到輸出結果,我們先對每個桶中的數進行排序,然後遍歷每個桶,按照次序把各個桶中的元素列出來即可。

(桶平排序演算法還需要一個臨時陣列B[0..n-1]來存放連結串列(即桶),並假設存在一種用於維護這些連結串列的機制)

(有點像雜湊表的拉鍊法的處理方式。)



位示圖

思想:用位元位的相對位置(索引)來表示一個數值。

即就像用陣列的下標來表示一個數值那樣,只不過為了節省記憶體我們用一個bit的位置來標記一個數。

例如:我們可以將集合{1, 2, 3, 5,8, 13}儲存在下面這個字串中:0 1 1 1 0 10 0 1 0 0 0 0 1 0 0 0 0 0 0 集合中代表數字的各個位設定為1,而其他的位全部都設為0。

特點:位示圖法適用的問題是(該情況在排序問題中不太常見):

輸入的範圍相對要小些,並且還不包含重複資料,且沒有資料與記錄相關聯。

【應用舉例】

考慮這樣一個問題:給一個磁碟檔案排序。(具體描述如下)

輸入

所輸入的是一個檔案,至多包含n個不重複的正整數,每個正整數都要小於n,這裡n=10^7. 這些整數沒有與之對應的記錄相關聯。(即僅對這些整數排序)

輸出

以增序形式輸出經過排序的整數列表。

約束

至多隻有1MB的可用主存,但是可用磁碟空間非常充足。10秒鐘是最適宜的執行時間。

看到磁碟檔案排序,我們首先想到經典的多路歸併排序。(後面會講到)

一個整數為32位,我們可以在1MB空間中儲存250000個數。因此,我們將使用一個在輸入檔案中帶有40個通道的程式。在第一個通道中它將249999之間的任意整數讀到記憶體中,並(至多)對250000個整數進行排序,然後將它們寫到輸出檔案中。第二個通道對250000到499999之間的整數進行排序,依此類推,直到第40個通道,它將排序9750000到9999999之間的整數。在記憶體中,我們用快速排序,然後把排序的有序序列進行歸併,最終得到整體有序。

但是,此方式的效率較低,光是讀取輸入檔案就需要40次,還有外部歸併的IO開銷。

怎樣降低IO操作的次數,來提高程式的效率?一次把這一千萬個數字全部讀入記憶體?

用點陣圖的方式,我們將使用一個具有一千萬個bit位來表示該檔案,在該bit位串中,當且僅當整數i在該檔案中時,第i位才打開(設為1)。

給定了表示檔案中整數集合的點陣圖資料結構後,我們可以將編寫該程式的過程分為三個自然階段,第一個階段關閉所有的位,將集合初始化為空集。第二個階段讀取檔案中的每個整數,並開啟相應的位,建立該集合。第三個階段檢查每個位,如果某個位是1,就寫出相應的整數,從而建立已排序的輸出檔案。

內部排序方法總結

穩定性

如果一個排序演算法能夠保留陣列中重複元素的相對位置則可以被稱為是穩定的

這個性質在許多情況下很重要。

例如

考慮一個需要處理大量含有地理位置和時間戳的事件的網際網路商業程式。

首先,我們在事件發生時將它們挨個儲存在一個數組中,這樣在陣列中它們已經是按時間排序好了的。現在再按照地理位置切分,如果排序演算法不是穩定的,排序後的每個城市的交易可能不會再是按照時間順序排序的了。

演算法         是否穩定

選擇排序         否

插入排序     

希爾排序         否

快速排序         否

三向快速排序 否

歸併排序         

堆排序             否

鍵索引計數   

基數排序         

快速排序是最快的通用排序演算法。

快速排序之所以快是因為它的內迴圈中的指令很少(而且它還能利用快取,因為它總是順序地訪問資料),所以它的執行時間的增長數量級為~cNlgN,而這裡的c比其他線性對數級別的排序演算法的相應常數都要小。

且,在使用三向切分之後,快速排序對於實際應用中可能出現的某些分佈的輸入變成線性級別的了,而其他的排序演算法仍然需要線性對數時間。

如果穩定性很重要而空間又不是問題,歸併排序可能是最好的。

----------------------------------------------------------------------外部排序---------------------------------------------------------------------

(我們為什麼要進行外部排序?為什麼不在插入資料時就按照某種資料結構組織,方便查詢且有序。這就像靜態查詢樹那樣,沒什麼實用功能)

外部排序基本上由兩個相對獨立的階段組成。

首先,按可用記憶體大小,將外存上含有n個記錄的檔案分成若干長度為l的子檔案,依次讀入記憶體並利用有效的內部排序方法對它們進行排序,並將排序後得到的有序子檔案重新寫入外存,通常稱這些有序子檔案為歸併段

然後,對這些歸併段進行逐趟歸併,使歸併段逐漸由小至大,直到得到整個有序檔案為止。

【例】假設有一個含有10000個記錄的檔案,首先通過10次內部排序得到10個初始歸併段R1~R10,其中每一段都含有1000個記錄。然後對它們作兩兩歸併,直至得到一個有序檔案為止。


每一趟歸併從m個歸併段得到m/2個歸併段。這種歸併方法稱為2-路平衡歸併。

若對上例中所得的10個初始歸併段進行5-路平衡歸併,則從下圖可見,僅需進行二趟歸併,外排時總的IO讀/寫次數顯著減少。


一般情況下,m個初始歸併段進行k-路平衡歸併時,歸併的趟數s = logkm

可見,若增加k或減少m便能減少s。

一般的歸併merge,每得到歸併後的有序段中的一個記錄,都要進行k-1次比較。顯然,為得到含u個記錄的歸併段需進行(u-1)(k-1)次比較。

內部歸併過程中總的比較次數為:

logkm (k-1) (u-1)tmg  =( log2m/ log2k)(k-1) (u-1)tmg

所以,要單純地增加k將導致內部歸併的時間,這將抵消由於增大k而減少外存資訊讀寫時間所得效益。

然而,若在進行k-路歸併時利用敗者樹(Tree of Loser),則可使在k個記錄中選出關鍵字最小的記錄時僅需進行log2k次比較。則總的歸併時間變為log2m (u-1)tmg此式與k無關,它不再隨k的增長而增長。

敗者樹

它是樹形選擇排序的一種變型。每個非終端結點均表示其左、右孩子結點中的“敗者”。而讓勝者去參加更高一層的比賽,便可得到一顆“敗者樹”(所謂“勝者”就是你想選出來的元素)。

以一顆實現5-路(k=5)歸併的敗者樹為例:

陣列ls[0…k-1]表示敗者樹中的非終端結點。敗者樹中根結點ls[1]的雙親結點ls[0]為“冠軍”,其他結點記錄的是其左右子樹中的“敗者”的索引值。b[0…k-1]是待比較的各路歸併序列的首元素。

ls[]中除首元素外,其他元素表示為完全二叉樹。


那表示葉子結點的b[]該如何與之對應?

葉結點b[x]的父結點是ls[(x+k)/2]。


敗者樹的建立:

1、  初始化敗者樹:把ls[0..k-1]中全設定為MINKEY(可能的最小值,即“絕對的勝者”)


//我們設一個b[k]= MINKEY,ls[]中記錄的是b陣列中的索引值。故初始為5.

2、從各葉子結點溯流而上,調整敗者樹中的值。

拿勝者s(初始為葉結點值)與其父結點中值比較,誰敗(較大的)誰上位(留著父結點中),勝者被記錄在s中。(決出勝者,記錄敗者,勝者向上走)

//對於葉結點b[4],調整的結果如下:


//對於葉結點b[3],調整的結果如下


//同理,對於葉結點b[2],調整的結果如下


//同理,對於葉結點b[1],調整的結果如下


//同理,對於葉結點b[0],調整的結果如下


[cpp] view plaincopyprint?在CODE上檢視程式碼片派生到我的程式碼片
  1. void CreateLoserTree(LoserTree &ls)  
  2. {  
  3.     b[k].key = MINKEY ;  
  4.     //設定ls中“敗者”的初值
  5.     for (i=0; i<k; ++i)  
  6.         ls[i] = k ;  
  7.     //依次從b[k-1]...b[0]出發調整敗者
  8.     for (i=k-1; i>=0; --i)  
  9.         Adjust(ls, i) ;  
  10. }  
  11. void Adjust(LoserTree &ls, int m)  
  12. {  
  13.     //沿從葉節點b[m]到根結點ls[0]的路徑 調整敗者樹
  14.     for (i = (m + k)/2; i>0; i=i/2)  //ls[i]是b[m]的雙親結點
  15.     {  
  16.         if (b[m].key > b[ls[i]].key)   
  17.             exch(m, ls[i]) ;         //m儲存新的勝者的索引
  18.     }  
  19.     ls[0] = m ;  
  20. }  

【後記】

從n個數中選出最小的,我們為什麼要用敗者樹?

首先,我們想到用優先佇列,但其應對這種多路歸併的情況,效率並不高。

堆結構:其待處理的元素都在樹結點中(在葉節點和非葉子節點中)

敗者樹:其待處理的元素都在樹的葉子結點上,其非葉子結點上記錄上次其子結點比較的結果。

這樣的話,堆結構的某個葉子結點不是對應固定的某個待歸併序列。一次選出最值之後,還得取出各歸併序列的首元素,重建堆再調整,不能利用之前比較的結果。

而敗者樹,一個葉結點固定地對應一個歸併序列,這樣,若其序列的首元素被選出,則序列的下個元素可以直接增補進入結點,然後沿樹的路徑向上比較。

總結:堆結構適用於插入是無規則的,選出最值。

敗者樹適用於多路序列的插入,選出最值。