1. 程式人生 > >深入淺出數據結構C語言版(19)——堆排序

深入淺出數據結構C語言版(19)——堆排序

-- 解決辦法 訪問 nsf 可能 bre 操作 數據塊 src

  在介紹優先隊列的博文中,我們提到了數據結構二叉堆,並且說明了二叉堆的一個特殊用途——排序,同時給出了其時間復雜度O(N*logN)。這個時間界是目前我們看到最好的(使用Sedgewick序列的希爾排序時間復雜度為O(N4/3),下圖為兩者函數圖像對比,但是註意,這並不是希爾排序與堆排序的對比,只是兩個大O階函數的對比)。這篇博文,我們就是要細化用二叉堆進行排序的想法,實現堆排序。

       技術分享

  在介紹優先隊列的博文中(http://www.cnblogs.com/mm93/p/7481782.html)所提到的用二叉堆排序的想法可以簡單地用如下代碼表示:

void HeapSort(int *src,int
size) { //BuildHeap即根據所給數組建立一個二叉堆並返回 struct BinaryHeap *h = BuildHeap(a, 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的下濾是一樣的。

  這個方法實現起來非常簡單:

//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);
}

  解決了最難的改造二叉堆後,堆排序的剩余操作也就不難實現了:

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],而它們很可能不在緩存中,因此對它們的訪問會比訪問緩存中的數據更慢,並且我們訪問其孩子後,並不一定會與父結點進行交換,如果是這樣,那此次訪問就可以說是“花了大代價確定了這件事不需要做”。而在快速排序中相鄰的兩次訪問一般是相鄰的,進行遠距離訪問時都是需要進行交換操作的時候,也就是說快速排序可以比堆排序更好的利用CPU緩存

  第三,在堆排序中,DeleteMax函數的無效比較與無效交換比例很高,怎麽說呢?因為我們在拿走原堆根後,是拿原堆尾到根處,然後進行下濾的,但是直觀的說,原堆尾作為“堆中較小元素”,其比原堆根的孩子要大的概率是很低的,也就是說原堆尾拿到根處幾乎不用比就知道要下濾,然而我們還是得進行比較、交換。從這個角度來說,將原堆尾拿到根處下濾是做了很多無效工作的,但這又是不得不為之的,因為我們必須得保持堆的完全二叉樹性質。也就是說,為了保持堆的特性,我們做了不少額外的操作。

  關於第三點,我們可以看看介紹優先隊列的博文中關於堆刪除操作的例子,不難看出,將原堆尾元素31從根處進行下濾,最後其還是下濾到了原有深度:

  技術分享

  最後,對大小為10000,元素隨機的數組進行模擬測試顯示,快速排序執行的交換操作次數比堆排序要少很多很多:

    技術分享

  

  不過,雖然我們將堆排序“狠狠地”批判了一番,其時間界依然是不錯的,畢竟最壞情況也就是O(N*logN),當然,這個最壞情況恐怕也是個平均情況(註意,這一點並沒有被證明),因為在實際使用中,面對大量數據時堆排序往往是遠不如快速排序的。此外,據稱堆排序的實際效果甚至不如使用Sedgewick序列的希爾排序。基於上述種種原因,一般來說,我們還是按照介紹希爾排序的博文中所說的:將插入排序作為“初級排序”,希爾排序作為“中級排序”,快速排序作為“高級排序”。

  那麽,作為“高級排序”的快速排序究竟是怎樣的呢?我們下一篇博文將會介紹。

深入淺出數據結構C語言版(19)——堆排序