堆的基本操作及堆排序

bmilk發表於2022-01-23

堆的定義

任意一個子節點總是大於等於或者小於等於父節點的完全二叉樹稱之為堆,根據位元組點和父節點的大小關係,堆又分為大頂堆小頂堆

  • 大頂堆: 父節點的值總是大於等於子節點的值的堆稱之為大頂堆,大頂堆的最大值總是在堆頂
  • 小頂堆: 父節點的值總是小於等於子節點的值的堆稱之為小頂堆,小頂堆的最小值總是在堆頂
堆的基本操作及堆排序
圖1:大頂堆(圖左)和小頂堆(圖右)

堆的儲存結構

堆本質是一棵完全二叉樹,則完全二叉樹的所有性質都適合於堆。
對於二叉樹我們一般定義節點的資料結構如下:

public class Node<T> {
    public T data;
    public Node left;   //左孩子節點
    public Node right;  //右孩子節點  
    public Node parent; //父節點
}

但是對於完全二叉樹,將其按照層序遍歷得到包含二叉樹中所有元素的序列(\({k_1,k_2,...,k_i,...k_n}\)),總是有給定一個節點\(k_i\),那麼其左孩子節點為\(k_{2i+1}\)右孩子\(k_{2i+2}\),其父節點\(k_{(i-1)/2}\),那麼就可以完全省略掉樹節點中左孩子、右孩子以及父節點的相關定義,使用一維陣列來儲存堆。圖1所示的堆使用陣列表示後如圖2所示

堆的基本操作及堆排序
圖2:大頂堆(圖左)和小頂堆(圖右)

堆的基本操作

以圖1表示的大頂堆為例,將其寫成陣列形式為{70,58,5,34,20,4,3,13,4},如果此時將陣列尾部的4換為60,那麼此時如何將其調整一個最大堆呢?

堆的基本操作及堆排序
圖3:尾部換為60後側二叉樹

堆的上浮操作

如果堆的有序性因為某個節點的變化變得比其父節點更大(小),那麼可以通過交換它與它的父節點來修復堆,如果交換後這個節點比它的兩個子節點都大(小)(其中一個節點是它之前的兄弟節點,一個節點是交換之前的父節點),那麼它有可能比它新的父節點還大(小),那麼可以使用相同的辦法一遍一遍交換使其恢復堆得秩序,直到碰到一個比其更大(小)的父節點或者至堆頂(圖4),這個操作稱之上浮

堆的基本操作及堆排序
圖4:堆的上浮操作

程式碼實現:

private void siftUp(int k) {
    while (k > 0) {
        int parent = (k - 1) >>> 1;
        if (!less(parent, k)) break;
        swap(parent, k);
        k = parent;
    }
}

堆的下沉操作

如果堆的有序性因為某個節點的變化變得比其兩個子節點(或其中一個)更小(大),那麼可以通過互動它與它子節點中較大的一個來修復堆,交換後有可能在子節點處繼續打破堆得有序性,那麼便需要繼續這一過程使堆恢復有序性(圖5)。這個操作稱之為下沉操作。
圖5顯示了根節點由70變為30後的整理堆的一個過程,使用的就是下沉操作

堆的基本操作及堆排序
圖5:堆的下沉操作
private void siftDown(int k) {
    int mid = queue.length >>> 1;

    while (k < mid) {
        int child = (k << 1) + 1;
        int r = child + 1;
        if (r < queue.length && less(child, r)) child = r;
        if (!less(k, child)) break;
        swap(k, child);
        k = child;
        
    }
}

堆的構建

如何將給定的的\(N\)個元素構造成一個堆呢?

  • 方法一:一種辦法是從左至右遍歷陣列,將一個個元素插入到堆裡面去(原地建堆,無需新的空間),保證在插入第\(i+1\)個元素之前,前\(i\)個元素已經是一個堆,插入第\(i+1\)個元素後,再使用上浮siftUp的方法將其放到合適的位置
  • 方法二:另一個更有效的方式是從右往左掃面每個元素,保證以當前被掃描的元素為根節點的子樹滿足堆的特性,當新掃描到一個元素時,使用下沉siftDown的方式使的新的子樹滿足堆得有序性
    方法二程式碼示例:
protected void buildHeap() {
    int i = (queue.size() >>> 1) - 1;
    while (i >= 0) {
        siftDown(i);
        i--;
    }
}

堆排序

先說一個結論:

  • 從小到大排序需要構建大頂堆
  • 從大到小排序需要構建小頂堆
  • 如果頭鐵想從大到小排序用大頂堆也可以,就是算父節點和邊界可能會崩潰

下文將按照從大到小排序來分析堆排序

  1. 對於一個小頂堆一定有堆的堆頂元素是最小的
  2. 從大到小排序最小的元素一定是在最尾部
  3. 堆的最後一個元素一定是待排序列的最後一個元素

那麼我們可以嘗試將一個小頂堆的堆頂和堆最後一個元素(待排序列的最後一個元素)交換,再將除最後一個元素之外的堆通過下沉或者上浮的方式重新構造成一個小頂堆,再將堆頂元素與倒數第二個元素交換,依次遞迴,得到一個有序的序列。

堆的基本操作及堆排序
圖6:堆從小到大排序過程

程式碼示例:

public void sort() {
    int N = this.size() - 1;
    for (int i = N ; i >= 0; i--) {
        swap(0, i);
        siftDown(0, i);
    }
}

private void siftDown(int k, int N) {
    int mid = N >>> 1;

    while (k < mid) {
        int child = (k << 1) + 1;
        int r = child + 1;
        if (r < N && less(child, r)) child = r;
        if (!less(k, child)) break;
        swap(k, child);
        k = child;
    }
}

堆的使用範圍

  • 優先順序佇列(補充完優先順序佇列 這裡上鍊接)

參考資料:

[1] 演算法第4版

相關文章