在介紹優先隊列的博文中,咱們提到了數據結構二叉堆,而且說明了二叉堆的一個特殊用途——排序,同時給出了其時間複雜度O(N*logN)。這個時間界是目前咱們看到最好的(使用Sedgewick序列的希爾排序時間複雜度爲O(N4/3)),下圖爲二者函數圖像對比,可是注意,這並非希爾排序與堆排序的對比,只是兩個大O階函數的對比。這篇博文,咱們就是要細化用二叉堆進行排序的想法,實現堆排序。html
在介紹優先隊列的博文中(http://www.cnblogs.com/mm93/p/7481782.html)所提到的用二叉堆排序的想法能夠簡單地用以下代碼表示:算法
void HeapSort(int *src,int size) { //BuildHeap即根據所給數組創建一個二叉堆並返回 struct BinaryHeap *h = BuildHeap(src, size); //有了二叉堆後,只需不斷DeleteMax獲得根結點,而後輸出到目標數組便可 //此循環結束後,src數組中就有了從小到大的順序 for (int i = size-1;i >=0 ;--i) { src[i] = DeleteMax(h); } }
雖然介紹優先隊列的博文中沒有BuildHeap和DeleteRoot函數,但學會了二叉堆的話,這兩個函數不難寫出,BuildHeap其實就是Initialize函數與Insert函數的結合,而DeleteMax也和Dequeue思路相同,即刪除並返回堆的根,前提是創建的堆知足任一結點均大於其孩子,即Max型堆,與介紹二叉堆時實現的Min型堆剛好相反。編程
至此,堆排序的實現就算是完成了,可是不難發現上述實現方法有一個缺陷,就是原數組src佔用了空間N,創建的堆h又佔用了空間N,也就是說該實現耗費的空間是插入排序、希爾排序的兩倍。那麼是否存在解決這個空間問題的辦法呢?答案是有,解決的辦法就是:直接將原數組src改爲一個二叉堆,然後每次DeleteMax,將所得原堆根放置在原堆尾,size次DeleteMax後src就會變爲從小到大的順序(執行DeleteMax後,原堆尾對於堆來講就是「廢棄」的,能夠用於存儲「刪掉的根」。但願最後順序爲有小到大是咱們將DeleteMin改成DeleteMax的緣由,若是須要從大到小的順序,則應爲DeleteMin)數組
上述解決辦法中最難的一環可能就是「將src改成一個二叉堆」。BuildHeap的實現簡單,只須要創建一個足夠大的空堆,然後不斷將數據Insert便可,而Insert的思路就是「將新元素從堆尾開始進行上濾」。那麼Insert的這個思路是否能夠用於將數組直接改造爲二叉堆呢?好比先讓src[size-1]上濾,而後src[size-2]上濾……答案是不行!由於Insert的上濾前提是向「已存在的堆」插入數據,「已存在的堆」要麼爲空,要麼到處符合堆的要求。而src數組是「一片亂」的,這個想法是不行的。緩存
闡述正確作法以前,咱們先要明確一個不容易注意到的點:在二叉堆中,能夠捨棄掉數組[0]的位置,這樣可使編程更加方便,即任一數組[i]的孩子就是數組[i*2]和數組[i*2+1],而數組[i]的父親則是數組[i/2]。可是若是是將src直接改造爲二叉堆,則不能捨棄src[0],由於咱們認爲src是一個「裝滿了」的數組。所以,將src改造爲二叉堆後,任一src[i]的孩子應爲src[i*2+1]與src[i*2+2],而src[i]的父親則應爲src[(i-1)/2]數據結構
接下來咱們說說將src直接改造爲二叉堆的方法:令i=(size-1)/2,即src[i]爲數組最後元素src[size-1]的父親,而後令src[i]下濾,src[i]下濾結束後,令i--,重複此過程直至i爲-1。函數
上述方法之因此可行,是由於按i初始值爲(size-1)/2,然後i--的順序執行下濾的話,每一個以src[i]爲根的堆都只有src[i]是不符合堆要求的,此時只須要讓src[i]下濾便可,根本思路與普通二叉堆的DeleteRoot中的下濾是同樣的。測試
這個方法實現起來很是簡單:ui
//cur即當前進行下濾的元素的下標,FilterDown即下濾之意 void FilterDown(int *src, int cur, unsigned int size) { //先暫存下濾元素的值,避免實際交換 int temp = src[cur]; unsigned int child; //child初始值爲src[cur]的左孩子下標 for (child = cur * 2 + 1;child < size;child = child * 2 + 1) { //若src[cur]存在右孩子,且右孩子比左孩子大,則令child爲右孩子下標,即令child爲src[cur]更大的孩子的下標 if (child < size - 1 && src[child] < src[child + 1]) child++; //比較下濾元素與src[child],若小於,則令src[child]上移,不然下濾結束 if (temp < src[child]) src[(child - 1) / 2] = src[child]; else break; } //下濾結束後的child對應的父親即下濾元素應處的位置 src[(child - 1) / 2] = temp; } void TransformToHeap(int *src, unsigned int size) { for (int i = (size - 1) / 2;i >= 0;--i) FilterDown(src, i, size); }
解決了最難的改造二叉堆後,堆排序的剩餘操做也就不難實現了:spa
void HeapSort(int *src, unsigned int size) { TransformToHeap(src, size); //不斷地將堆的根與堆的尾(最後一個葉子)交換,交換後新的堆根爲原堆尾,令新堆根下濾。 //此操做與堆的DeleteRoot本質相同,只是將所得原堆根放在了原堆尾處,從而利用了廢棄空間 for (int oldTail = size - 1;oldTail > 0;--oldTail) { int temp = src[0]; src[0] = src[oldTail]; src[oldTail] = temp; FilterDown(src, 0, oldTail - 1); } }
至此,堆排序算是改善好了。接下來要討論的問題就是,爲何堆排序時間複雜度那麼好,卻不如快速排序?(快速排序最壞狀況爲O(N2),平均爲θ(N*logN))
這個問題很難解答,由於隨着DeleteMax操做,堆的內部結構一直是不穩定的。但咱們能夠分紅三個方面來試着解釋一下:
第一,咱們要明白大O階只是一個簡寫的時間界,即便是1000000000*N*logN+100000000000,咱們依然是寫做O(N*logN),所以兩個同爲O(N*logN)的算法並不意味着二者時間上會很接近。套用到堆排序與快速排序中,就是堆排序雖然也是O(N*logN),可是其實際時間可能比快速排序的平均界θ(N*logN)要大得多,大多少,我不知道╮(╯_╰)╭。
第二,從計算機的底層來講,CPU與內存之間存在緩存,緩存通常存儲着最近訪問的數據所在的數據塊,假設來講,由於咱們訪問了內存中的src[100],因此CPU將src[80]到src[120]都放入了緩存,這以後若是咱們訪問src[80]到src[120]之間的數據就會很快,由於它們在緩存之中。可是,堆排序中相鄰操做所訪問的數據「距離太遠了」,好比咱們訪問了src[100]後要訪問其孩子進行比較,則咱們須要訪問src[201]或src[202],而它們極可能不在緩存中,所以對它們的訪問會比訪問緩存中的數據更慢,而且咱們訪問其孩子後,並不必定會與父結點進行交換,若是是這樣,那這次訪問就能夠說是「花了大代價肯定了這件事不須要作」。而在快速排序中相鄰的兩次訪問通常訪問的元素在位置上也是相鄰的,進行遠距離訪問時都是須要進行交換操做的時候,也就是說快速排序能夠比堆排序更好的利用緩存
第三,在堆排序中,DeleteMax函數的無效比較與無效交換比例很高,怎麼說呢?由於咱們在拿走原堆根後,是拿原堆尾到根處,而後進行下濾的,可是直觀的說,原堆尾做爲「堆中較小元素」,其比原堆根的孩子要大的機率是很低的,也就是說原堆尾拿到根處幾乎不用比就知道要下濾,然而咱們仍是得進行比較、交換。從這個角度來講,將原堆尾拿到根處下濾是作了不少無效工做的,但這又是不得不爲之的,由於咱們必須得保持堆的徹底二叉樹性質。也就是說,爲了保持堆的特性,咱們作了很多額外的操做。
關於第三點,咱們能夠看看介紹優先隊列的博文中關於堆刪除操做的例子,不難看出,將原堆尾元素31從根處進行下濾,最後其仍是下濾到了原有深度:
最後,對大小爲10000,元素隨機的數組進行模擬測試顯示,快速排序執行的交換操做次數比堆排序要少不少不少:
不過,雖然咱們將堆排序「狠狠地」批判了一番,其時間界依然是不錯的,畢竟最壞狀況也就是O(N*logN),可是在實際使用中,面對大量數據時堆排序每每是遠不如快速排序的。此外,據稱堆排序的實際效果甚至不如使用Sedgewick序列的希爾排序。基於上述種種緣由,通常來講,咱們仍是按照介紹希爾排序的博文中所說的:將插入排序做爲「初級排序」,希爾排序做爲「中級排序」,快速排序做爲「高級排序」。
那麼,做爲「高級排序」的快速排序到底是怎樣的呢?咱們下一篇博文將會介紹。