看懂堆排序——堆與堆排序(三)

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

看懂堆排序——堆與堆排序(三)

有了前面兩篇文章的鋪墊,

堆與堆排序(一)

堆與堆排序(二)

我們終於可以學習“堆排序”了。

假使給定一個陣列a[N],其包含元素a[0],a[1],a[2],…,a[N-1],現要將其中元素按升序排列。如果利用堆這種資料結構,你會怎麼做?

堆排序的基本思想

很自然地想到,首先把此陣列構造成一個小根堆(利用原來的陣列,原地構造),然後依次刪除最小元素,直至堆為空。為了儲存每次刪除的最小元素,我們需要一個額外的陣列,待堆為空的時候,把額外陣列的內容拷貝到原來的陣列。

這種方法可行嗎?當然可行。但是需要一個額外的陣列,當陣列很大時,這個空間開銷是非常可觀的。避免使用額外的陣列的聰明做法是意識到這樣一個事實:在每次刪除最小元素之後,堆的規模縮小了1. 因此,位於堆中最後的那個單元可以用來存放剛剛刪去的元素。具體來說,堆排序的步驟如下:

  1. 為給定的序列建立一個堆(本文以最大堆為例)
  2. 交換堆的第一個元素a[0]和最後一個元素a[n-1]
  3. 堆的大小減1--n)。如果 n==1,演算法停止;否則,對a[0]進行下濾
  4. 重複2~3步

程式碼詳解

父親下標和孩子下標的關係

因為待排序的陣列一般都是從0開始,而不是從1開始,所以之前討論的父節點和孩子節點之間的關係需要修改。

之前的是:

對於陣列任一位置 i 上的元素,其左兒子在位置 2i 上,右兒子在2i+1上,它的父親則在位置 i/2

\lfloor i/2 \rfloor
上。

現在的是
這裡寫圖片描述

對於陣列任一位置 i 上的元素,其左兒子在位置 2i+1 上,右兒子在2i+2上,它的父親則在位置 (i1)/2

\lfloor (i-1)/2 \rfloor
上。

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

  • B是它的父節點,B的下標是 1(= (31)/2
    \lfloor (3-1)/2 \rfloor
    ),如圖中黑色的線;
  • H是它的左孩子,H的下標是 7(= 23+1
    2*3+1
    ),如圖中藍色的線;
  • I是它的右孩子,I的下標是 8(= 23+2
    2*3+2
    ),如圖中紅色的線;

所以,我們的巨集定義是:

#define LEFT(i)    (2*i+1)
#define RIGHT(i)   (2*i+2)
#define PARENT(i)  ((i-1)/2)

列印陣列的函式

void print_array_debug(int a[], int len, int pos, char token)
{
    for(int i=0; i<len; ++i)
    {
        if( i == pos )
        {
            printf("%c %d ", token, a[i]); //列印元素值和記號
        }
        else
        {
            printf("%3d ",a[i]); //正常列印
        }
    }
    printf("\n\n");
}

為了展示出排序的過程,我設計了這個函式。其實這個函式和普通的列印函式差不多,無非就是多了一個在某個元素前面列印一個標記的功能。比如要在a[0]的前面列印一個'*',那麼可以這樣呼叫(假設陣列長度是10):

print_array_debug(a, 10, 0, '*');

如果不想用它的列印標記功能,則可以給pos傳一個負數,給token隨便什麼值都行。比如

#define DUMMY_POS           -1
#define DUMMY_TOKEN         '\0'

然後呼叫

print_array_debug(a, 10, DUMMY_POS, DUMMY_TOKEN);

下濾函式

對於給定的數列,我們首先要對其進行“堆化”,堆化的方法如下:

  1. 在初始化一棵包含 n 個節點的完全二叉樹時,按照給定的順序來放置鍵;

  2. 從最後一個父母節點開始,到根為止,檢查這些父母節點的鍵是否滿足父母優勢。如果該節點不滿足,就把該節點的鍵 K 和它子女的最大鍵進行交換,然後再檢查在新的位置上,K 是否滿足父母優勢。這個過程一直繼續到 K 滿足父母優勢為止(最終它必須滿足,因為對每個葉子中的鍵來說,這條件是自動滿足的)。

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

// 下濾函式(遞迴解法)
// 假定以 LEFT(t) 和 RIGHT(t) 為根的子樹都已經是大根堆
// 調整以 t 為根的子樹,使之成為大根堆。
// 節點位置為 [0], [1], [2], ..., [n-1]
void percolate_down_recursive(int a[], int n, int t) 
{   
    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被換到了max位置
        percolate_down_recursive(a, n, max); // 遞迴,繼續考察t
    }
}

構造堆的函式

// 自底向上建堆,下濾法
void build_max_heap(element_t a[], int n) 
{   
    int i;
    // 從最後一個父母節點開始下濾,一直到根節點
    for(i = PARENT(n); i >= 0; --i)
    {       
        percolate_down_recursive(a, n, i);
    }
}

刪除最大元函式

//把最大元素和堆末尾的元素交換位置,堆的規模減1,再下濾根節點
void delete_max_to_end(int heap[], int heap_size)
{
    if(heap_size == 2) // 當剩下2個節點的時候,交換後不用下濾
    {
        swap( heap + 0, heap + 1 );     
    }
    else if(heap_size > 2)
    {
        swap( heap + 0, heap + heap_size - 1 );
        percolate_down_recursive(heap, heap_size-1, 0);
    }
    return;
}

排序主函式

void heap_sort(int a[], int length)
{
    build_max_heap(a,length); //堆的構造
#ifdef PRINT_PROCEDURE
    printf("build the max heap:\n");
    print_array_debug(a,ELMT_NUM, DUMMY_POS, DUMMY_TOKEN);
#endif
    for(int size=length; size>=2; --size) //當堆的大小為1時,停止
    {
        delete_max_to_end(a,size);
#ifdef PRINT_PROCEDURE      
        print_array_debug(a, ELMT_NUM, size-1, '|');
#endif
    }
}

完整程式碼及執行截圖

#include <stdio.h>

#define LEFT(i)             (2*i+1)
#define RIGHT(i)            (2*i+2)
#define PARENT(i)           ((i-1)/2)
#define ELMT_NUM            10
#define DUMMY_POS           -1
#define DUMMY_TOKEN         '\0'

typedef int element_t;

void print_array_debug(int a[], int len, int pos, char token)
{
    for(int i=0; i<len; ++i)
    {
        if( i == pos )
        {
            printf("%c %d ", token, a[i]); //列印元素值和記號
        }
        else
        {
            printf("%3d ",a[i]); //正常列印
        }
    }
    printf("\n\n");
}

//交換*a和*b
void swap(int* a, int* b) 
{
    int temp = *a;
    *a = *b;
    *b = temp;
}

// 下濾函式(遞迴解法)
// 假定以 LEFT(t) 和 RIGHT(t) 為根的子樹都已經是大根堆
// 調整以 t 為根的子樹,使之成為大根堆。
// 節點位置為 [0], [1], [2], ..., [n-1]
void percolate_down_recursive(int a[], int n, int t) 
{   
    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被換到了max位置
        percolate_down_recursive(a, n, max); // 遞迴,繼續考察t
    }
}

// 自底向上建堆,下濾法
void build_max_heap(element_t a[], int n) 
{   
    int i;
    // 從最後一個父母節點開始下濾,一直到根節點
    for(i = PARENT(n); i >= 0; --i)
    {       
        percolate_down_recursive(a, n, i);
    }
}

//把最大元素和堆末尾的元素交換位置,堆的規模減1,再下濾根節點
void delete_max_to_end(int heap[], int heap_size)
{
    if(heap_size == 2) // 當剩下2個節點的時候,交換後不用下濾
    {
        swap( heap + 0, heap + 1 );     
    }
    else if(heap_size > 2)
    {
        swap( heap + 0, heap + heap_size - 1 );
        percolate_down_recursive(heap, heap_size-1, 0);
    }
    return;
}

void heap_sort(int a[], int length)
{
    build_max_heap(a,length);
#ifdef PRINT_PROCEDURE
    printf("build the max heap:\n");
    print_array_debug(a,ELMT_NUM, DUMMY_POS, DUMMY_TOKEN);
#endif
    for(int size=length; size>=2; --size)
    {
        delete_max_to_end(a,size);
#ifdef PRINT_PROCEDURE      
        print_array_debug(a, ELMT_NUM, size-1, '|');
#endif
    }
}

int main(void)
{
    int a[ELMT_NUM]={4,1,3,2,16,9,10,14,8,7}; //10個
    printf("the array to be sorted:\n ");   
    print_array_debug(a,ELMT_NUM, DUMMY_POS, DUMMY_TOKEN);

    heap_sort(a,ELMT_NUM);

    printf("sort finished:\n ");
    print_array_debug(a,ELMT_NUM, DUMMY_POS, DUMMY_TOKEN);
}

假設檔名為heap_sort.c,編譯:

gcc heap_sort.c -DPRINT_PROCEDURE

執行結果如下圖:
這裡寫圖片描述

圖中豎線右邊的是已經有序的元素,豎線左邊是堆。

【本系列完】

參考資料

【1】《資料結構與演算法分析(原書第2版)》(機械工業出版社,2004)
【2】《演算法設計與分析基礎(第3版)》(清華大學出版社,2015)

相關文章