最詳細版圖解優先佇列(堆)

9龍發表於2019-04-19

一、佇列與優先佇列的區別

  1. 佇列是一種FIFO(First-In-First-Out)先進先出的資料結構,對應於生活中的排隊的場景,排在前面的人總是先通過,依次進行
  2. 優先佇列是特殊的佇列,從“優先”一詞,可看出有“插隊現象”。比如在火車站排隊進站時,就會有些比較急的人來插隊,他們就在前面先通過驗票。優先佇列至少含有兩種操作的資料結構:insert(插入),即將元素插入到優先佇列中(入隊);以及deleteMin(刪除最小者),它的作用是找出、刪除優先佇列中的最小的元素(出隊)。
優先佇列
優先佇列

二、優先佇列(堆)的特性

  • 優先佇列的實現常選用二叉堆在資料結構中,優先佇列一般也是指堆

  • 堆的兩個性質:

  1. 結構性堆是一顆除底層外被完全填滿的二叉樹,底層的節點從左到右填入,這樣的樹叫做完全二叉樹。

  2. 堆序性:由於我們想很快找出最小元,則最小元應該在根上,任意節點都小於它的後裔,這就是小頂堆(Min-Heap);如果是查詢最大元,則最大元應該在根上,任意節點都要大於它的後裔,這就是大頂堆(Max-heap)。

    結構性:

    完成二叉樹
    完成二叉樹

通過觀察發現,完全二叉樹可以直接使用一個陣列表示而不需要使用其他資料結構。所以我們只需要傳入一個size就可以構建優先佇列的結構(元素之間使用compareTo方法進行比較)。

public class PriorityQueue<T extends Comparable<? super T>> 
    public PriorityQueue(int capacity) {
        currentSize = 0;
        array = (T[]) new Comparable[capacity + 1];
    }
}
複製程式碼
完全二叉樹的陣列實現
完全二叉樹的陣列實現

對於陣列中的任意位置 i 的元素,其左兒子在位置 2i 上,則右兒子2i+1 上,父節點在 在 i/2(向下取整)上。通常從陣列下標1開始儲存,這樣的好處在於很方便找到左右、及父節點。如果從0開始,左兒子在2i+1,右兒子在2i+2,父節點在(i-1)/2(向下取整)。

堆序性:

我們這建立最小堆,即對於每一個元素X,X的父親中的關鍵字小於(或等於)X中的關鍵字,根節點除外(它沒有父節點)。

堆

如圖所示,只有左邊是堆,右邊紅色節點違反堆序性。根據堆序性,只需要常O(1)找到最小元。

三、基本的堆操作

  1. insert(插入)
  • 上濾為了插入元素X,我們在下一個可用的位置建立空穴(否則會破壞結構性,不是完全二叉樹)。如果此元素放入空穴不破壞堆序性,則插入完成;否則,將父節點下移到空穴,即空穴向根的方向上冒一步。繼續該過程,直到X插入空穴為止。這樣的過程稱為上濾。
建立空穴
建立空穴
完成插入
完成插入

圖中演示了18插入的過程,在下一個可用的位置建立空穴(滿足結構性),發現不能直接插入,將父節點移下來,空穴上冒。繼續這個過程,直到滿足堆序性。這樣就實現了元素插入到優先佇列(堆)中。

  • java實現上濾
     /**
     * 插入到優先佇列,維護堆序性
     *
     * @param x :插入的元素
     */

    public void insert(T x) {
        if (null == x) {
            return;
        }
        //擴容
        if (currentSize == array.length - 1) {
            enlargeArray(array.length * 2 + 1);
        }
        //上濾
        int hole = ++currentSize;
        for (array[0] = x; x.compareTo(array[hole / 2]) < 0; hole /= 2) {
            array[hole] = array[hole / 2];
        }
        array[hole] = x;
    }

    /**
     * 擴容方法
     *
     * @param newSize :擴容後的容量,為原來的2倍+1
     */

    private void enlargeArray(int newSize) {
        T[] old = array;
        array = (T[]) new Comparable[newSize];
        System.arraycopy(old, 0, array, 0, old.length);
    }
複製程式碼

可以反覆使用交換操作來進行上濾過程,但如果插入X上濾d層,則需要3d次賦值;我們這種方式只需要d+1次賦值。

如果插入的元素是新的最小元從而一直上濾到根處,那麼這種插入的時間長達O(logN)。但平均來看,上濾終止得要早。業已證明,執行依次插入平均需要2.607次比較,因此平均insert操作上移元素1.607層。上濾次數只比插入次數少一次。

  1. deleteMin(刪除最小元)
  • 下濾:類似於上濾操作。因為我們建立的是最小堆,所以刪除最小元,就是將根節點刪掉,這樣就破壞了結構性。所以我們在根節點處建立空穴,為了滿足結構性,堆中最後一個元素X必須移動到合適的位置,如果可以直接放到空穴,則刪除完成(一般不可能);否則,將空穴的左右兒子中較小者移到空穴,即空穴下移了一層。繼續這樣的操作,直到X可以放入到空穴中。這樣就可以滿足結構性與堆序性。這個過程稱為下濾。
刪除最小元
刪除最小元
完成刪除最小元
完成刪除最小元

如圖所示:在根處建立空穴,將最後一個元素放到空穴,已滿足結構性;為滿足堆序性,需要將空穴下移到合適的位置。

注意:堆的實現中,經常發生的錯誤是只有偶數個元素即有一個節點只有一個兒子。所以需要測試右兒子的存在性。

/**
     * 刪除最小元
     * 若優先佇列為空,丟擲UnderflowException
     *
     * @return :返回最小元
     */

    public T deleteMin() {
        if (isEmpty()) {
            throw new UnderflowException();
        }

        T minItem = findMin();
        array[1] = array[currentSize--];
        percolateDown(1);

        return minItem;
    }

     /**
     * 下濾方法
     *
     * @param hole :從陣列下標hole1開始下濾
     */

    private void percolateDown(int hole) {
        int child;
        T tmp = array[hole];

        for (; hole * 2 <= currentSize; hole = child) {
            //左兒子
            child = hole * 2;
            //判斷右兒子是否存在
            if (child != currentSize &&
                    array[child + 1].compareTo(array[child]) < 0) {
                child++;
            }
            if (array[child].compareTo(tmp) < 0) {
                array[hole] = array[child];
            } else {
                break;
            }
        }
        array[hole] = tmp;
    }
複製程式碼

這種操作最壞時間複雜度是O(logN)。平均而言,被放到根處的元素幾乎下濾到底層(即來自的那層),所以平均時間複雜度是O(logN)。

四、總結

優先佇列常使用二叉堆實現,本篇圖解了二叉堆最基本的兩個操作:插入及刪除最小元。insert以O(1)常數時間執行,deleteMin以O(logN)執行。相信大家看了之後就可以去看java的PriorityQueue原始碼了。今天只說了二叉堆最基本的操作,還有一些額外操作及分析下次再說。比如,如何證明buildHeap是線性的?以及優先佇列的應用等。

宣告:圖文皆原創,如有轉載,請註明出處。如有錯誤,請幫忙指出,歡迎討論;若覺得可以,微微一讚支援支援。

相關文章