堆與堆排序(一)

ARM的程式設計師敲著詩歌的夢發表於2020-04-04

堆與堆排序(一)

上一篇博文 淺談優先佇列 介紹了什麼是優先佇列,文末提到了一種資料結構——“堆”,基於“堆”實現的優先佇列,出隊和入隊的時間複雜度都為 O(logN).

這篇博文我們就走進“堆”,看看它到底是什麼結構。

此堆非彼堆

值得注意的是,這裡的“堆”不是記憶體管理中提到的“堆疊”的“堆”。前者的“堆”——準確地說是二叉堆,是一種類似於完全二叉樹的資料結構;後者的“堆”是一種類似於連結串列的資料結構。

堆的結構性質

二叉堆在邏輯結構上是一棵完全二叉樹。什麼是完全二叉樹呢?即樹的每一層都是滿的,除了最後一層最右邊的元素有可能缺位。

如下圖所示,打錯號的兩個不是完全二叉樹,其他都是。

這裡寫圖片描述

對於一個有 N 個節點的完全二叉樹,我們可以為它的每個節點指定一個索引,方法是從上至下,從左到右,從1開始連續編號,如下圖黑色數字所示。瞭解二叉樹的朋友一定看出來了,這就是二叉樹的層序遍歷。

可以看出,對於一個有 N 個節點的完全二叉樹,索引值和元素是一一對應的。所以完全二叉樹可以用一個陣列來表示而不需要指標:索引值就是陣列的下標,元素的值就是節點的關鍵字。

如下圖,是一個完全二叉樹和陣列的相互關係。

這裡寫圖片描述

如果你繼續觀察,就會發現另一個規律:對於陣列任一位置 i 上的元素,其左兒子在位置 2i 上,右兒子在2i+1上,它的父親則在位置 i/2

\lfloor i/2 \rfloor
上。

以節點 D 為例,D 的下標是 4.

  • B是它的父節點,B的下標是2(=4/2),如圖中黑色的線;
  • H是它的左孩子,H的下標是8(=4*2),如圖中藍色的線;
  • I是它的右孩子,I的下標是9(=4*2+1),如圖中紅色的線;

堆序性質

二叉堆一般分為兩種:最大堆和最小堆。

最大堆:也叫做大根堆。每一個節點的值(或者說關鍵字)都要大於或等於它孩子的值(對於任何葉子我們認為這個條件都是自動滿足的)。下圖就是一個最大堆。

這裡寫圖片描述

最小堆:也叫做小根堆。每一個節點的值(或者說關鍵字)都要小於或等於它孩子的值(對於任何葉子我們認為這個條件都是自動滿足的)。下圖就是一個最小堆。

這裡寫圖片描述

值得注意的是:以大根堆為例,在任何從根到某個葉子的路徑上,鍵值的序列是遞減的(如果允許相等的鍵存在,則是非遞增的)。然而,鍵值之間並不存在從左到右的次序。也就是說,在樹的同一層節點之間,不存在任何關係,更一般地來說,在同一節點的左右子樹之間也沒有任何關係。

堆的重要特性

大根堆為例,把堆的重要特性總結如下。

這裡寫圖片描述

  1. 只存在一棵 n 個節點的完全二叉樹。它的高度等於log2n

    \lfloor \log _2 n \rfloor

  2. 堆的根總是包含了堆的最大元素

  3. 堆的一個節點以及該節點的子孫也是一個堆

  4. 可以用陣列來實現堆,方法是用從上到下、從左到右的方式來記錄堆的元素。為了方便起見,可以在這種陣列從 1 到 n 的位置上存放堆的元素,留下H[0],要麼讓它空著,要麼在其中放一個限位器,它的值大於堆中任何一個元素

  5. 在 4 的表示法中:

    1) 父母節點的鍵將會位於陣列的前n/2

    \lfloor n/2 \rfloor
    個位置中,而葉子節點的鍵將會佔據後n/2
    \lceil n/2 \rceil
    個位置。

    2) 在陣列中,對於一個位於父母位置 i 的鍵來說,它的子女將會位於2i2i+1. 相應地,對於一個位於i的鍵來說,它的父母將會位於i/2

    \lfloor i/2 \rfloor

對於上面提到的父母節點的鍵將會位於陣列的前n/2

\lfloor n/2 \rfloor
個位置中,而葉子節點的鍵將會佔據後n/2
\lceil n/2 \rceil
個位置。
這一點我覺得很有意思,我們們不嚴格證明,僅簡單分析一下為什麼會這樣。

設一個堆共有N個元素。判斷一個索引為 i 的節點是不是父母節點,可以看它有沒有孩子。如果它有孩子,那麼2i一定小於等於N,換句話說,如果2i大於N,則可以斷定它是葉子節點,在它位置之後的節點(如果有的話)也一定是葉子節點,因為從 2i > N 可以推出 2(i+1) > N2(i+2) > N,

所以,只要求解不等式 2i > N, 取i的最小值,就得到第一個葉子節點的位置。

經過演算,i 的最小值是 i=N/2+1

i=\lfloor N/2 \rfloor +1
,所以,最後一個父母節點的位置是 n/2
\lfloor n/2 \rfloor

如何構造一個堆

針對給定的一列鍵值,如何構造一個堆呢?

方法一:自底向上堆構造

假設要構造一個大根堆,步驟如下:

  1. 在初始化一棵包含 n 個節點的完全二叉樹時,按照給定的順序來放置鍵;
  2. 按照下面的方法,對樹進行“堆化”
  3. 從最後一個父母節點開始,到根為止,該演算法檢查這些節點的鍵是否滿足父母優勢的要求。如果該節點不滿足,就把該節點的鍵 K 和它子女的最大鍵進行交換,然後再檢查在新的位置上,K 是否滿足父母優勢要求。這個過程一直繼續到對 K 的父母優勢要求滿足為止(最終它必須滿足,因為對每個葉子中的鍵來說,這條件是自動滿足的)。
  4. 對於以當前父母節點為根的子樹,在完成它的“堆化”以後,對該節點的直接前趨(陣列中此節點的前一個節點)進行同樣的操作。在對樹的根完成這種操作以後,該演算法就停止了。

如果該節點不滿足父母優勢,就把該節點的鍵 K 和它子女的最大鍵進行交換,然後再檢查在新的位置上,K 是否滿足父母優勢要求。這個過程一直繼續到對 K 的父母優勢要求滿足為止——這種策略叫做下濾(percolate down)。

假設有一列鍵(共10個):4,1,3,2,16,9,10,14,8,7

那麼,按照上面給定的鍵值順序,對應的完全二叉樹如下圖。

這裡寫圖片描述

最後一個父母節點是5(=10/2),我們從5號節點開始對這個二叉樹進行堆化。

這裡寫圖片描述
這裡寫圖片描述

這裡寫圖片描述

這裡寫圖片描述

這裡寫圖片描述

這裡寫圖片描述

看完這些圖,相信你已經知道如何構建大根堆了。下面就用C語言來實現。

遞迴解法

根據上文的演算法描述,很容易想到用遞迴來實現。我們先設計一個函式——下濾函式。

先寫幾個巨集。給定一個位置為 i 的節點,很容易算出它的左右孩子的位置和父母的位置。

#define LEFT(i)    (2*i)   // i 的左孩子
#define RIGHT(i)   (2*i+1) // i 的右孩子
#define PARENT(i)  (i/2)   // i 的父節點

假定以 LEFT(t)RIGHT(t) 為根的子樹都已經是大根堆,下面的函式調整以 t 為根的子樹,使之成為大根堆。

// 下濾函式(遞迴解法)
// 假定以 LEFT(t) 和 RIGHT(t) 為根的子樹都已經是大根堆
// 調整以 t 為根的子樹,使之成為大根堆。
// 節點位置為 1~n,a[0]不使用
void percolate_down_recursive(int a[], int n, int t) 
{
#ifdef PRINT_PROCEDURE
    printf("check %d\n", t);
#endif
    int left = LEFT(t);
    int right = RIGHT(t);   
    int max = t; //假設當前節點的鍵值最大

    if(left <= n)  // 說明t有左孩子    
    {
        max = a[left] > a[max] ? left : max;
    }

    if(right <= n)  // 說明t有右孩子  
    {
        max = a[right] > a[max] ? right : max;
    }

    if(max != t)
    {   
        swap(a + max, a + t); // 交換t和它的某個孩子,即t下移一層
#ifdef PRINT_PROCEDURE
        printf("%d NOT satisfied, swap it and %d \n",t, max);
#endif
        percolate_down_recursive(a, n, max); // 遞迴,繼續考察t
    }
}


//交換*a和*b, 內部函式
static void swap(int* a, int* b) 
{
    int temp = *a;
    *a = *b;
    *b = temp;
}

有了上面的函式,我們就可以從最後一個父母節點開始,到根為止,逐個進行“下濾”。

非遞迴解法

以上程式碼是用“交換法”(第26行)對節點進行下濾。一次交換需要3條賦值語句,有沒有更好的寫法呢?有,就是“空穴法”(我自己起的名字)。我們先說明空穴法的原理,然後附上程式碼。

以上圖中“檢查1號節點,不滿足”這個地方開始,對1號節點進行下濾。

這裡寫圖片描述

這裡寫圖片描述

這裡寫圖片描述

這裡寫圖片描述

這裡寫圖片描述

// 非遞迴且不用交換
void percolate_down_no_swap(int a[], int n, int t) 
{
    int key = a[t];  // 用key記錄鍵值
    int max_idx;
    int heap_ok = 0;  // 初始條件是父母優勢不滿足
#ifdef PRINT_PROCEDURE      
    printf("check %d\n", t);
#endif  
    // LEFT(t) <= n 成立則說明 t 有孩子
    while(!heap_ok && (LEFT(t) <= n))
    {           
        max_idx = LEFT(t); // 假設左右孩子中,左孩子鍵值較大
        if(LEFT(t) < n)    // 條件成立則說明有2個孩子
        {
            if(a[LEFT(t)] < a[RIGHT(t)])
                max_idx = RIGHT(t); //說明右孩子的鍵值比左孩子大

        }//此時max_idx指向鍵值較大的孩子

        if(key >= a[max_idx])
        {
            heap_ok = 1; //為 key 找到了合適的位置,跳出迴圈
        }
        else
        {   
            a[t] =  a[max_idx]; //孩子上移一層,max_idx 被空出來,成為空穴  

#ifdef PRINT_PROCEDURE                  
            printf("use %d fill %d \n", max_idx, t);
            printf("%d is empty\n", max_idx);
#endif              
            t = max_idx; //令 t 指向空穴     
        }               
    }

    a[t] = key; // 把 key 填入空穴
#ifdef PRINT_PROCEDURE                  
    printf("use value %d fill %d \n", key, t);

#endif      

    return;
}

如果在編譯的時候定義巨集PRINT_PROCEDURE,則可以看到堆化過程和上文的六張圖相符。假設原始檔名是 max_heap.c,在編譯的時候用-D巨集名稱可以定義巨集。

gcc max_heap.c -DPRINT_PROCEDURE

方法二:自頂向下堆構造

除了上面的演算法,還有一種演算法(效率較低)是通過把新的鍵連續插入預先構造好的堆,來構造一個新堆。有的人把它稱作自頂向下堆構造。

  1. 首先,把一個鍵值為 K 的新節點附加在當前堆的最後一個葉子後面;
  2. 然後,拿 K 和它父母的鍵做比較。如果 K 小於等於它的父母,那麼演算法停止;否則交換這兩個鍵,並把 K 和它的新父母做比較。
  3. 重複2,一直持續到 K 不大於它的父母,或者 K 成為樹根為止。

這種策略叫做上濾(percolate up)

依然以4,1,3,2,16,9,10,14,8,7這列鍵為例,用圖說明上濾的過程。

這裡寫圖片描述

這裡寫圖片描述

這裡寫圖片描述

這裡寫圖片描述

這裡寫圖片描述

這裡寫圖片描述

這裡寫圖片描述

細心的讀者應該已經看出來了:下濾法構造的堆,其對應的陣列是

[16,14,10,8,7,9,3,2,4,1]

而上濾法構造的堆,其陣列是

[16,14,10,8,7,3,9,1,4,2]

所以得出結論:對於同一列鍵,用下濾法和上濾法構造出來的堆,不一定完全相同。

囿於篇幅,“堆”就說到這裡,上濾法的程式碼,我們們下次說。

參考資料

https://blog.csdn.net/guoweimelon/article/details/50904346

相關文章