1. 程式人生 > >資料結構與演算法之美-堆和堆排序

資料結構與演算法之美-堆和堆排序

堆和堆排序


如何理解堆

堆是一種特殊的樹,只要滿足以下兩點,這個樹就是一個堆。

①完全二叉樹,完全二叉樹要求除了最後一層,其他層的節點個數都是滿的,最後一層的節點都靠左排列。

②樹中每一個結點的值都必須大於等於(或小於等於)其子樹中每個節點的值。大於等於的情況稱為大頂堆,小於等於的情況稱為小頂堆。

 


如何實現堆


如何儲存一個堆

完全二叉樹適合用陣列來儲存,因為陣列中對於下標從1開始的情況,下標為i的節點的左子節點就是下標為i*2的節點,右子節點就是i下標為i*2+1的節點,其父節點時下標為i/2的節點


堆支援哪些操作

往堆中插入一個元素

把新插入的元素放到堆的最後就不符合第二個特性了,所以我們需要進行調整,讓其重新滿足堆的特性,這個過程我們起了一個名字,就叫作堆化(heapify)。

堆化就是順著節點所在的路徑,向上或者向下,對比,然後交換。我們先使用從下往上的堆化方法。

讓新插入的節點與父節點對比大小。如果不滿足子節點小於等於父節點的大小關係,我們就互換兩個節點。一直重複這個過程,直到父子節點之間滿足剛說的那種大小關係。

public class Heap{
    private int[] data;//陣列,從下標1開始儲存
    private int maxNum;//陣列容量
    private int count;//當前陣列成員數量
    //構造器初始化陣列,大小和數量
    public Heap(int size){
        data 
= new int[size + 1]; maxNum = size; count = 0; } public void Insert(int item){ //堆滿返回 if (count >= maxNum) return; //先將節點插入堆尾 data[count++] = item; int i = count; //再自下向上堆化,直到堆頂或者父節點比子節點大為止 while (i / 2 > 0 && data[i] > data[i / 2
]){ //交換位置 int temp = data[i]; data[i] = data[i / 2]; data[i / 2] = temp; //更新下標 i = i / 2; } } }

刪除堆頂元素

根據對的第二條定義,堆頂元素儲存的就是堆中的最大值或最小值。

這裡我們使用從上往下的堆化方法。將最後一個節點放到堆頂,然後利用同樣的父子節點對比法,進行互換節點直到父子節點之間滿足大小關係為止。

這樣移除的就是陣列中的最後一個元素,不會破環完全二叉樹的定義。

public void RemoveMax(){
    //堆空返回
    if (count == 0) return;
    //將最後一個節點提到堆頂
    data[1] = data[count--];
    //進行堆化
    Heapify(data,count,1);
}
public static void Heapify(int[] data,int n,int i){
    while (true){
        //記錄更大節點的位置,初始化為當前節點的位置
        int maxPos = i;
        //如果其左右子節點存在,且比當前節點大,就將左右節點下標設為更大的節點
        if (i * 2 <= n && data[i] < data[i * 2]) maxPos = i * 2;
        if (i * 2 + 1 <= n && data[maxPos] < data[i * 2 + 1]) maxPos = i * 2 + 1;
        //否則就結束迴圈,堆化結束
        if (maxPos == i) break;
        //節點交換位置
        int temp = data[i];
        data[i] = data[maxPos];
        data[maxPos] = temp;
        //更新當前節點的下標,迴圈繼續與下一個左右子節點比較
        i = maxPos;
    }
}

 


如何基於堆實現排序

我們藉助於堆這種資料結構實現的排序演算法,就叫作堆排序。

我們可以把堆排序的過程大致分解成兩個大的步驟,建堆和排序。


建堆

首先將陣列原地建成一個堆。藉助另一個數組,就在原陣列上操作。我們要實現從後往前處理陣列,並且每個資料都是從上往下堆化的建堆方法。

public static void BuildHeap(int[] data, int n){
    //從下標n/2到1開始進行堆化,n/2就是最後一個葉子節點的父節點。
    for (int i = n / 2; i >= 1; --i)
        Heapify(data,n,i);
}

我們對下標從n/2開始到 111 的資料進行堆化,下標是n/2+1到n的節點是葉子節點,我們不需要堆化。

建堆操作的時間複雜度

排序的建堆過程的時間複雜度是 O(n)。


排序

建堆結束之後,陣列中的資料已經是按照大頂堆的特性來組織的。陣列中的第一個元素就是堆頂,也就是最大的元素。我們把它跟最後一個元素交換,那最大元素就放到了下標為n的位置。

這個過程有點類似刪除堆頂元素的操作,當堆頂元素移除之後,我們把下標為n的元素放到堆頂,然後再通過堆化的方法,將剩下的n-1個元素重新構建成堆。

堆化完成之後,我們再取堆頂的元素,放到下標是的位置,一直重複這個過程,直到最後堆中只剩下標為1的一個元素,排序工作就完成了。

public static void Sort(int[] data,int n){
    //將陣列建造為堆
    BuildHeap(data, n);
    //獲取堆尾的下標
    int k = n;
    //迴圈直到k為1
    while (k > 1){
        //交換堆頂和堆尾的元素
        int temp = data[k];
        data[k] = data[1];
        data[1] = temp;
        //將堆尾的下標遞減並對1到k的下標的陣列成員進行堆化
        Heapify(data,--k,1);
    }
}

堆排序的時間複雜度、空間複雜度以及穩定性

堆排序是原地排序演算法。堆排序包括建堆和排序兩個操作,建堆過程的時間複雜度是O(n),排序過程的時間複雜度是O(nlogn)所以,堆排序整體的時間複雜度是O(nlogn)。

堆排序不是穩定的排序演算法,因為在排序的過程,存在將堆的最後一個節點跟堆頂節點互換的操作,所以就有可能改變值相同資料的原始相對順序。

測試 

//Main方法
int[] data = new int[] {0,3,5,2,9,4,7 };
Heap.Sort(data,data.Length-1);
for (int i=0;i<data.Length;i++)
    Console.Write(data[i]+",");
//測試結果
0,2,3,4,5,7,9,

陣列的第1個成員,即下標0的資料是不作為資料的一部分的,這是為了演算法上的方便,如果下標是從0開始,那麼左右子節點的下標公式就是i*2+1和i*2+2。

 


思考

在實際開發中,為什麼快速排序要比堆排序效能好?

對於快速排序來說,資料是順序訪問的而對於堆排序來說,資料是跳著訪問的。這樣對 CPU 快取是不友好的。

對於同樣的資料,在排序過程中,堆排序演算法的資料交換次數要多於快速排序。堆排序的第一步是建堆,建堆的過程會打亂資料原有的相對先後順序,導致原資料的有序度降低。比如,對於一組已經有序的資料來說,經過建堆之後,資料反而變得更無序了。