手擼優先佇列——二叉堆

牛初九發表於2024-10-28

在資料結構中,佇列可以對應我們生活中的排隊現象,像買早點排隊,上公共汽車排隊等。但是有一些情況,我們要優先某些元素,比如:上公共汽車時,雖然在排隊,還是要優先老幼病殘孕先上車;當有多個電腦向印表機傳送列印請求時,一臺電腦要列印100頁,而其他電腦都是單頁列印,此時更合理的做法時,優先列印單頁的請求,最後再列印100頁的請求。雖然我們都向往公平,在排隊時也講究先來後到,但是在某些特殊的情況下,還是要允許加塞現象的。這就是今天要給大家講的——優先佇列

優先佇列也是佇列,那麼最基本的兩個操作是必須有的,那就是入隊和出隊操作。我們能想到的幾種簡單的實現方法有,比如一個簡單的連結串列,入隊時就在連結串列的最後新增元素,那麼出隊時就要遍歷整個連結串列,找出最小元素,這顯然不是一個好的方案。或者我們直接使用AVL平衡二叉樹,最小元素就是最左側的子節點,很容易找到,但是在入隊和出隊的過程中,涉及到了節點的增加和刪除,那麼就要進行樹的旋轉而維持樹的平衡,這額外花費了很多開銷。那麼有沒有相對廉價一點的方案呢?這就是二叉堆的方案。

二叉堆

優先佇列的實現使用二叉堆是相當普遍的,二叉堆是一棵二叉樹,但是是有要求的二叉樹,它是一棵完全二叉樹。完全二叉樹就是樹的節點都是從上到下,從左到右去排列的,而且中間不能隔有空節點。我們看下圖中的兩個例子:

左圖中,J節點並沒有按照從左到右依次排列,所以不是完全二叉樹,而右圖中,滿足完全二叉樹的特點,是一棵完全二叉樹。

二叉堆有連個性質,一個是結構性質,一個是堆序性質。我們先來看結構性質,堆是一棵完全二叉樹,是非常有規律的。我們可以直接用陣列去表示二叉堆,而不使用鏈的結構,看下圖:

陣列中第0個元素我們空著不用,第1個元素是根節點,後面的順序就是按照完全二叉樹的順序去排。透過觀察,我們驚奇的發現瞭如下的規律,陣列中第i個元素的左子節點的位置是2i,右子節點的位置是2i+1,父節點的位置是i/2(根節點除外)。我們可以使用陣列的結構表示樹,而不是使用鏈的結構,這使得我們在遍歷樹的時候操作非常簡單。但是陣列的結構也有一個問題,那就是陣列的長度需要預先估算出來,然後隨著陣列長度的增加我們還要對其進行擴容操作。這就是二叉堆的結構性質,我們可以使用陣列去表示。

接下來我們再看看堆序性質,由於我們快速的找到最小元素,那麼最小元素我們要放到根節點上。同理,我們考慮到任意子樹也是一個二叉堆,那麼子樹中的最小元素應當在子樹的根節點。那麼也就是任意節點都應該小於它的後代節點。所以二叉堆的堆序性質就是,對於二叉堆中的任意節點,它的父節點要小於或等於該節點。我們再看下面兩個例子:

左圖中節點6的父節點是21,小於6,不滿足堆序性質,所以左圖不是二叉堆。右圖滿足堆序性質,是二叉堆。

插入

當我們向二叉堆中插入一個新的元素時,為了滿足二叉堆從上到下,從左到右的性質,我們先確定插入元素的位置,然後再和該位置的父節點作比較,如果大於父節點,那麼是滿足二叉堆性質的,直接插入就好了;如果不滿足,則需要交換兩個元素的位置,交換後再去和父節點作比較,就這樣一直遞迴下去,直到滿足二叉堆性質為止,或者交換到了根節點,是二叉堆中的最小元素。還使用上面的例子,比如我們要插入新的元素14,

由於14小於21,需要繼續向上調整,

調整到這個位置時,滿足了二叉堆的性質,我們把14插入。這樣的一個整個過程就做上濾。下面我們編寫程式實現這一過程。

/**
 * 二叉堆
 * @param <T>
 */
public class BinaryHeap<T extends Comparable<T>> {

    private static final int DEFAULT_CAPACITY = 10;
    private int currentSize;
    private T[] array;

    public BinaryHeap() {
        this(DEFAULT_CAPACITY);
    }

    @SuppressWarnings("unchecked")
    public BinaryHeap(int defaultCapacity) {
        this.currentSize = 0;
        this.array = (T[])new Comparable[defaultCapacity];
    }

    /**
     * 二叉堆是否為空
     * @return
     */
    public boolean isEmpty() {
        return this.currentSize == 0;
    }

    /**
     * 使二叉堆為空
     */
    @SuppressWarnings("unchecked")
    public void makeEmpty() {
        this.currentSize = 0;
        this.array = (T[])new Comparable[DEFAULT_CAPACITY];
    }

    /**
     * 擴充套件陣列
     * @param newSize 擴充套件陣列大小
     */
    @SuppressWarnings("unchecked")
    private void enlargeArray(int newSize) {
        if (newSize < this.array.length) {
            throw new RuntimeException("擴充套件陣列小於原始陣列");
        }

        T[] tmpArray = (T[])new Comparable[newSize];
        System.arraycopy(this.array,0,tmpArray,0,this.array.length);
        this.array = tmpArray;
    }


    /**
     * 二叉堆插入元素
     * @param element 插入元素
     */
    public void insert(T element) {
        if (currentSize == this.array.length-1) {
            enlargeArray(this.array.length * 2 - 1);
        }

        int hole = ++currentSize;
        for (this.array[0] = element;element.compareTo(this.array[hole/2]) < 0;hole /= 2) {
            this.array[hole] = this.array[hole/2];
        }
        this.array[hole] = element;
    }
}

由於二叉堆中的元素是可比較的,所以我們定義了泛型,必須實現了Comparable介面。然後我們定義陣列array,和陣列的初始長度DEFAULT_CAPACITY。最後再定義二叉堆當前的節點數currentSize。兩個建構函式和isEmptymakeEmpty方法比較簡單,這裡不做過多介紹了。接下來我們看一下資料擴容的方法enlargeArray,先比較一下新的長度和陣列當前長度,如果小於,則丟擲異常。然後就是建立新資料,資料複製,替換老資料。

接下來我們重點看一下insert方法,先判斷currentSize和陣列長度-1,這裡為什麼要減1呢?因為資料的第0個元素是不用的,二叉堆的根節點在第1個元素。如果相等,說明陣列已經用盡,需要擴容,擴容的時候也是採用2倍擴容,這裡減1還是因為不用根節點。然後先確定空穴的位置,hole=++currentSize,下面的for迴圈,就是上濾的過程,也是這一段的精華。大家在實現的時候,可能都會和父元素作比較,然後進行交換,這種方法沒有問題,但是交換兩個元素要用3行程式碼來完成,先把第一個變數賦值給臨時變數,再把第二個變數賦值給第一個變數,最後把臨時變數賦值給第二個變數。而這裡只使用了1行程式碼,這就是使用空穴位置的好處。在for迴圈中,將新元素賦值給第0個元素,這裡使用第0個元素是有用處的,我們接著看,然後新元素和父節點作比較,父節點的下標是hole/2,這個在前面介紹過,如果小於,當前空穴位置的值就是父節點的值,然後處理空穴的位置,就是父節點的位置,hole /=2。如果這樣一直到了根節點,也就是hole=1的時候,父節點是不存在的,但是程式中寫的是hole/2,那麼就是第0個元素,第0個元素就是新插入的元素,等於是自己和自己比較,是相等的,所以就跳出了迴圈。最後把空元素的值賦給空穴位置。這裡我們巧妙的使用第0個元素,實現了根節點的比較,使得跳出迴圈。

刪除最小值

刪除最小值,就是我們的出隊操作,由於我們使用二叉堆,所以最小值就在根節點,刪除之後,在根節點產生了一個空穴,我們把二叉堆的最後一個元素,也就是currentSize的元素放到空穴位置,再和兩個子節點的最小元素作比較,如果大於,則交換兩個元素,空穴位置下移,直到滿足二叉堆的性質為止。這個過程叫下濾

刪除根節點13後,產生一個空穴,同時,整個陣列長度減1,我們用最後一個元素31,和空穴的最小子節點14作比較,31大於14,所以交換位置,如下:

繼續比較,31大於最小子節點21,空穴位置下移,

最後,31小於子節點32,那麼31就放在空穴位置,滿足了二叉堆的性質,整個下濾過程結束。我們用程式碼實現一下,

/**
 * 取出最小值
 * @return 根元素
 */
public T deleteMin() {
    if (isEmpty()) {
        throw new RuntimeException("二叉堆為空");
    }
    T minItem = this.array[1];
    this.array[1] = this.array[currentSize--];
    perlocateDown(1);

    return minItem;
}

/**
 * 下濾過程
 * @param hole
 */
private void perlocateDown(int hole) {
    int child;
    T tmp = this.array[hole];
    for (;hole * 2 <= currentSize;hole=child) {
        child = hole * 2;
        if (child != currentSize && this.array[child].compareTo(this.array[child+1]) > 0) {
            child += 1;
        }
        if (this.array[child].compareTo(tmp) < 0) {
            this.array[hole] = this.array[child];
        } else {
            break;
        }
    }
    this.array[hole] = tmp;
}

deleteMin方法很簡單,就是取根節點元素,將最後一個元素賦值給根節點,節點個數減1,然後呼叫下濾方法。我們重點要看的就是下濾方法,入參是空穴的位置,傳入的是1,也就是根節點的位置,我們將值賦給臨時變數,這裡根節點的值是二叉堆的最後一個元素。接下來我們進入迴圈,迴圈成立的條件是空穴位置有子節點,hole*2 <= currentSize。那麼左子節點的位置是hole*2,右子節點是hole*2+1。這裡我們特殊處理的是空穴是不是隻有一個子節點,只有一個子節點的情況只會發生在二叉堆的最後的位置,如果hole*2 == currentSize,說明後只有一個子節點,而且只能是左子節點,這樣,我們就能夠找出hole的最小子節點了,判斷的邏輯是:如果hole*2 == currentSize,那麼hole只有一個左子節點,最小子節點就是hole*2;其他情況就需要比較左右子節點,誰小就用誰。這就是我們for迴圈中第一個if處的邏輯。後面的邏輯就比較簡單了,如果hole的值大於最小子節點,就進行交換,hole下移,等於最小子節點的位置,直到跳出迴圈。最後將臨時值賦給空穴位置。這就是整個的刪除和下濾的過程。

構建二叉堆

最重點的插入和刪除方法我們已經講完了,那麼如果給我們一個陣列,我們怎麼去構建一個二叉堆呢?我們還是要從二叉堆的性質入手,也就是結構性質和堆序性質。結構性質比較容易我們將第0個元素空著就可以了,那麼堆序性質怎麼解決呢?由於上面我們已經將下濾過程抽象成了一個方法,這也就不難實現了。我們先將最小的子樹,透過下濾方法變成二叉堆,最小的子樹的節點就是樹中倒數第二層的節點,倒數第二層的節點中,有的節點有子節點,有的節點沒有子節點,沒有子節點的不用下濾,那麼怎麼找到有子節點的節點呢?我們之前有個變數currentSize,這是最後一個節點的位置,它的父節點是currentSize/2,也是最後一個有子節點的節點,然後我們向前迴圈,每個節點執行一遍下濾方法,直到根節點下濾完,那麼整棵樹就是一個二叉堆了。我們實現一下,

/**
 * 構建二叉堆
 * @param items
 */
@SuppressWarnings("unchecked")
public BinaryHeap(T[] items) {
    this.currentSize = items.length;
    this.array = (T[])new Comparable[this.currentSize * 2 +1];
    int i = 1;
    for (T item: items) {
        this.array[i++] = item;
    }
    buildHeap();
}

/**
 * 構建二叉堆
 */
private void buildHeap() {
    for (int i = this.currentSize / 2;i>0;i--) {
        perlocateDown(i);
    }
}

實現起來很簡單,這裡注意一下迴圈的時候,條件是i>0,不是大於等於,因為第0個元素是不用的。

總結

好了,到這裡二叉堆就介紹完了,它是實現優先佇列最基本的方法,有問題的小夥伴歡迎評論區留言~~

相關文章