【坐在馬桶上看演算法】演算法12:堆——神奇的優先佇列(下)

edithfang發表於2014-06-21

接著上一Pa說。就是如何建立這個堆呢。可以從空的堆開始,然後依次往堆中插入每一個元素,直到所有數都被插入(轉移到堆中為止)。因為插入第i個元素的所用的時間是O(log i),所以插入所有元素的整體時間複雜度是O(NlogN),程式碼如下。

n=0;
for(i=1;i<=m;i++)
{
    n++;
    h[ n]=a[ i];  //或者寫成scanf("%d",&h[ n]);
    siftup();
}

其實我們還有更快得方法來建立堆。它是這樣的。

直接把99、5、36、7、22、17、46、12、2、19、25、28、1和92這14個數放入一個完全二叉樹中(這裡我們還是用一個一維陣列來儲存完全二叉樹)。


在這個棵完全二叉樹中,我們從最後一個結點開始依次判斷以這個結點為根的子樹是否符合最小堆的特性。如果所有的子樹都符合最小堆的特性,那麼整棵樹就是最小堆了。如果這句話沒有理解不要著急,繼續往下看。

首先我們從葉結點開始。因為葉結點沒有兒子,所以所有以葉結點為根結點的子樹(其實這個子樹只有一個結點)都符合最小堆的特性(即父結點的值比子結點的值小)。這些葉結點壓根就沒有子節點,當然符合這個特性。因此所有葉結點都不需要處理,直接跳過。從第n/2個結點(n為完全二叉樹的結點總數,這裡即7號結點)開始處理這棵完全二叉樹。注意完全二叉樹有一個性質:最後一個非葉結點是第n/2個結點。

以7號結點為根的子樹不符合最小堆的特性,因此要向下調整。

同理以6號、5號和4結點為根的子樹也不符合最小對的特性,都需要往下調整。

下面是已經對7號、6號、5號和4結點為根結點的子樹調整完畢之後的狀態。

當然目前這棵樹仍然不符合最小堆的特性,我們需要繼續調整以3號結點為根的子樹,即將3號結點向下調整。

同理繼續調整以2號結點為根的子樹,最後調整以1號結點為根的子樹。調整完畢之後,整棵樹就符合最小堆的特性啦。

小結一下這個建立堆的演算法。把n個元素建立一個堆,首先我可以將這n個結點以自頂向下、從左到右的方式從1到n編碼。這樣就可以把這n個結點轉換成為一棵完全二叉樹。緊接著從最後一個非葉結點(結點編號為n/2)開始到根結點(結點編號為1),逐個掃描所有的結點,根據需要將當前結點向下調整,直到以當前結點為根結點的子樹符合堆的特性。雖然講起來起來很複雜,但是實現起來卻很簡單,只有兩行程式碼如下:

for(i=n/2;i>=1;i--)
    siftdown(i);

用這種方法來建立一個堆的時間複雜度是O(N),如果你感興趣可以嘗試自己證明一下,嘿嘿。

堆還有一個作用就是堆排序,與快速排序一樣堆排序的時間複雜度也是O(NlogN)。堆排序的實現很簡單,比如我們現在要進行從小到大排序,可以先建立最小堆,然後每次刪除頂部元素並將頂部元素輸出或者放入一個新的陣列中,直到堆為空為止。最終輸出的或者存放在新陣列中數就已經是排序好的了。

//刪除最大的元素
int deletemax()
{
    int t;
    t=h[ 1];//用一個臨時變數記錄堆頂點的值
    h[ 1]=h[ n];//將堆得最後一個點賦值到堆頂
    n--;//堆的元素減少1
    siftdown(1);//向下調整
    return t;//返回之前記錄的堆得頂點的最大值
}
建堆以及堆排序的完整程式碼如下:
#include <stdio.h>
int h[ 101];//用來存放堆的陣列
int n;//用來儲存堆中元素的個數,也就是堆的大小
  
//交換函式,用來交換堆中的兩個元素的值
void swap(int x,int y)
{
    int t;
    t=h[ x];
    h[ x]=h[ y];
    h[ y]=t;
}
  
//向下調整函式
void siftdown(int i) //傳入一個需要向下調整的結點編號i,這裡傳入1,即從堆的頂點開始向下調整
{
    int t,flag=0;//flag用來標記是否需要繼續向下調整
    //當i結點有兒子的時候(其實是至少有左兒子的情況下)並且有需要繼續調整的時候迴圈窒執行
    while( i*2<=n && flag==0 )
    {        
        //首先判斷他和他左兒子的關係,並用t記錄值較小的結點編號
        if( h[ i] > h[ i*2] )
            t=i*2;
        else
            t=i;
        //如果他有右兒子的情況下,再對右兒子進行討論
        if(i*2+1 <= n)
        {
            //如果右兒子的值更小,更新較小的結點編號  
            if(h[ t] > h[ i*2+1])
                t=i*2+1;
        }
        //如果發現最小的結點編號不是自己,說明子結點中有比父結點更小的  
        if(t!=i)
        {
            swap(t,i);//交換它們,注意swap函式需要自己來寫
            i=t;//更新i為剛才與它交換的兒子結點的編號,便於接下來繼續向下調整
        }
        else
            flag=1;//則否說明當前的父結點已經比兩個子結點都要小了,不需要在進行調整了
    }
}
  
//建立堆的函式
void creat()
{
    int i;
    //從最後一個非葉結點到第1個結點依次進行向上調整
    for(i=n/2;i>=1;i--)
    {
        siftdown(i);
    }  
}
  
//刪除最大的元素
int deletemax()
{
    int t;
    t=h[ 1];//用一個臨時變數記錄堆頂點的值
    h[ 1]=h[ n];//將堆得最後一個點賦值到堆頂
    n--;//堆的元素減少1
    siftdown(1);//向下調整
    return t;//返回之前記錄的堆得頂點的最大值
}
  
int main()
{
    int i,num;
    //讀入數的個數
    scanf("%d",&num);
  
    for(i=1;i<=num;i++)
        scanf("%d",&h[ i]);
    n=num;   
  
    //建堆
    creat();
  
  
    //刪除頂部元素,連續刪除n次,其實夜就是從大到小把數輸出來
    for(i=1;i<=num;i++)
        printf("%d ",deletemax());
  
    getchar();
    getchar();
    return 0;
}

可以輸入以下資料進行驗證

14

99 5 36 7 22 17 46 12 2 19 25 28 1 92

執行結果是

 1 2 5 7 12 17 19 22 25 28 36 46 92 99

當然堆排序還有一種更好的方法。從小到大排序的時候不建立最小堆而建立最大堆。最大堆建立好後,最大的元素在h[ 1]。因為我們的需求是從小到大排序,希望最大的放在最後。因此我們將h[ 1]和h[ n]交換,此時h[ n]就是陣列中的最大的元素。請注意,交換後還需將h[ 1]向下調整以保持堆的特性。OK現在最大的元素已經歸位,需要將堆的大小減1即n--,然後再將h[ 1]和h[ n]交換,並將h[ 1]向下調整。如此反覆,直到堆的大小變成1為止。此時陣列h中的數就已經是排序好的了。程式碼如下:

//堆排序
void heapsort()
{
    while(n>1)
    {
        swap(1,n);
        n--;
        siftdown(1);
    }
}
完整的堆排序的程式碼如下,注意使用這種方法來進行從小到大排序需要建立最大堆。
#include <stdio.h>
int h[ 101];//用來存放堆的陣列
int n;//用來儲存堆中元素的個數,也就是堆的大小
  
//交換函式,用來交換堆中的兩個元素的值
void swap(int x,int y)
{
    int t;
    t=h[ x];
    h[ x]=h[ y];
    h[ y]=t;
}
  
//向下調整函式
void siftdown(int i) //傳入一個需要向下調整的結點編號i,這裡傳入1,即從堆的頂點開始向下調整
{
    int t,flag=0;//flag用來標記是否需要繼續向下調整
    //當i結點有兒子的時候(其實是至少有左兒子的情況下)並且有需要繼續調整的時候迴圈窒執行
    while( i*2<=n && flag==0 )
    {        
        //首先判斷他和他左兒子的關係,並用t記錄值較大的結點編號
        if( h[ i] < h[ i*2] )
            t=i*2;
        else
            t=i;
        //如果他有右兒子的情況下,再對右兒子進行討論
        if(i*2+1 <= n)
        {
            //如果右兒子的值更大,更新較小的結點編號  
            if(h[ t] < h[ i*2+1])
                t=i*2+1;
        }
        //如果發現最大的結點編號不是自己,說明子結點中有比父結點更大的  
        if(t!=i)
        {
            swap(t,i);//交換它們,注意swap函式需要自己來寫
            i=t;//更新i為剛才與它交換的兒子結點的編號,便於接下來繼續向下調整
        }
        else
            flag=1;//則否說明當前的父結點已經比兩個子結點都要大了,不需要在進行調整了
    }
}
  
//建立堆的函式
void creat()
{
    int i;
    //從最後一個非葉結點到第1個結點依次進行向上調整
    for(i=n/2;i>=1;i--)
    {
        siftdown(i);
    }  
}
  
//堆排序
void heapsort()
{
        while(n>1)
    {
                swap(1,n);
        n--;
        siftdown(1);
    }
}
  
int main()
{
    int i,num;
    //讀入n個數
    scanf("%d",&num);
  
    for(i=1;i<=num;i++)
        scanf("%d",&h[ i]);
    n=num;   
  
    //建堆
    creat();
  
    //堆排序
    heapsort();
  
    //輸出
    for(i=1;i<=num;i++)
        printf("%d ",h[ i]);
  
    getchar();
    getchar();
    return 0;
}

可以輸入以下資料進行驗證

14

99 5 36 7 22 17 46 12 2 19 25 28 1 92

執行結果是

1 2 5 7 12 17 19 22 25 28 36 46 92 99

OK,最後還是要總結一下。像這樣支援插入元素和尋找最大(小)值元素的資料結構稱之為優先佇列。如果使用普通佇列來實現這個兩個功能,那麼尋找最大元素需要列舉整個佇列,這樣的時間複雜度比較高。如果已排序好的陣列,那麼插入一個元素則需要移動很多元素,時間複雜度依舊很高。而堆就是一種優先佇列的實現,可以很好的解決這兩種操作。

另外Dijkstra演算法中每次找離源點最近的一個頂點也可以用堆來優化,使演算法的時間複雜度降到O((M+N)logN)。堆還經常被用來求一個數列中第K大的數。只需要建立一個大小為K的最小堆,堆頂就是第K大的數。如果求一個數列中第K小的數,只最需要建立一個大小為K的最大堆,堆頂就是第K小的數,這種方法的時間複雜度是O(NlogK)。當然你也可以用堆來求前K大的數和前K小的數。你還能想出更快的演算法嗎?有興趣的同學可以去閱讀《程式設計之美》第二章第五節。

堆排序演算法是由J.W.J. Williams在1964年發明,他同時描述瞭如何使用堆來實現一個優先佇列。同年,由Robert W.Floyd提出了建立堆的線性時間演算法。

本文轉載自:http://ahalei.blog.51cto.com/4767671/1427156

相關閱讀
評論(0)

相關文章