數據結構之優先隊列和堆

什麼是優先隊列

咱們都知道隊列是一種先進先出、後進後出的數據結構,就如同平常生活中的排隊同樣,先到先得。而優先隊列則是一種特殊的隊列,優先隊列與普通隊列最大的不一樣點就在於出隊順序不同。java

由於優先隊列的出隊順序與入隊順序無關,和優先級有關。也就是按元素的優先級決定其出隊順序,優先級高的先出隊,優先級低的後出隊,這也是爲何這種數據結構叫優先隊列的緣由。算法

這就比如現實生活中在銀行排隊辦理業務,持有金卡的客戶能夠優先於普通卡的客戶被接待,而鑽石卡的客戶又優先於金卡的客戶,以此類推。這就是一種優先隊列。api

應用場景:數組

  • 優先隊列的應用場景很是多,好比,任務調度器、赫夫曼編碼、圖的最短路徑、最小生成樹算法等等。不只如此,不少語言中,都提供了優先級隊列的實現,好比,Java 的 PriorityQueue,C++ 的 priority_queue 等。

堆的基礎表示

堆(Heap)簡單來講是一種特殊的樹,那麼什麼樣的樹纔是堆呢?我羅列了兩點要求,只要知足這兩點,它就是一個堆:bash

  • 堆是一個徹底二叉樹。徹底二叉樹:把元素順序排列成樹的形狀
  • 堆中每個節點的值都必須大於等於(或小於等於)其子樹中每一個節點的值

第一點,堆必須是一個徹底二叉樹。還記得咱們以前講的徹底二叉樹的定義嗎?徹底二叉樹要求,除了最後一層,其餘層的節點個數都是滿的,最後一層的節點都靠左排列。數據結構

第二點,堆中的每一個節點的值必須大於等於(或者小於等於)其子樹中每一個節點的值。實際上,咱們還能夠換一種說法,堆中每一個節點的值都大於等於(或者小於等於)其左右子節點的值。這兩種表述是等價的。框架

對於每一個節點的值都大於等於子樹中每一個節點值的堆,咱們叫作「大頂堆」。對於每一個節點的值都小於等於子樹中每一個節點值的堆,咱們叫作「小頂堆」。dom

清楚了定義以後,咱們來直觀的看一下什麼是堆:
數據結構之優先隊列和堆ide

在上圖中,第 1 個和第 2 個是大頂堆,第 3 個是小頂堆,第 4 個不是堆。除此以外,從圖中還能夠看出來,對於同一組數據,咱們能夠構建多種不一樣形態的堆。測試

如何實現一個堆?

堆的實現並不侷限於某一種特定的方式,可使用鏈式樹形結構(節點有左右指針)實現,也可使用數組實現,由於徹底二叉樹的特性是一層一層按順序排列的,徹底能夠緊湊地放在數組中。並且基於數組實現堆是一種比較巧妙且高效的方式,也是最經常使用的方式。

用數組來存儲徹底二叉樹是很是節省存儲空間的。由於咱們不須要存儲左右子節點的指針,單純地經過數組的下標,就能夠找到一個節點的左右子節點和父節點。以下圖所示:
數據結構之優先隊列和堆

從圖中咱們能夠看到節點的存放規律就是:數組中下標爲 $i$ 的節點的左子節點,就是下標爲 $2∗i$ 的節點,右子節點則是下標爲 $2∗i+1$ 的節點。因此反過來,其父節點也就是下標爲 $\frac{i}{2}​$ 的節點。

parent(i) = i / 2
left child(i) = 2 * i
right child(i) = 2 * i + 1

經過這種方式,咱們只要知道根節點存儲的位置,這樣就能夠經過下標計算,把整棵樹都串起來。通常狀況下,爲了方便計算子節點,根節點會存儲在下標爲 1 的位置。

若是從 0 開始存儲,實際上處理思路是沒有任何變化的,惟一變化的就是計算子節點和父節點的下標的公式改變了:若是節點的下標是 $i$,那左子節點的下標就是 $2∗i+1$,右子節點的下標就是 $2∗i+2$,父節點的下標就是 $\frac{i-1}{2}​$​。以下圖所示:
數據結構之優先隊列和堆

有了以上的認知後,接下來,咱們就能夠先編寫一個堆的基礎框架代碼了:

package heap;

import java.util.ArrayList;
import java.util.Collections;

/**
 * 基於數組實現的最大堆
 * 堆中的元素須要具備可比較性,因此須要實現Comparable
 * 在此實現中是從數組的下標0開始存儲元素,由於使用ArrayList做爲數組的角色
 *
 * @author 01
 * @date 2021-01-19
 **/
public class MaxHeap<E extends Comparable<E>> {

    /**
     * 使用ArrayList的目的是無需關注動態擴縮容邏輯
     */
    private final ArrayList<E> data;

    public MaxHeap(int capacity) {
        this.data = new ArrayList<>(capacity);
    }

    public MaxHeap() {
        this.data = new ArrayList<>();
    }

    /**
     * 返回對中的元素個數
     */
    public int size() {
        return data.size();
    }

    /**
     * 判斷堆是否爲空
     */
    public boolean isEmpty() {
        return data.isEmpty();
    }

    /**
     * 根據傳入的index,計算其父節點所在的下標
     */
    private int parent(int index) {
        if (index == 0) {
            throw new IllegalArgumentException("index-1 doesn't have parent.");

        }
        return (index - 1) / 2;
    }

    /**
     * 根據傳入的index,計算其左子節點所在的下標
     */
    private int leftChild(int index) {
        return index * 2 + 1;
    }

    /**
     * 根據傳入的index,計算其右子節點所在的下標
     */
    private int rightChild(int index) {
        return index * 2 + 2;
    }
}

向堆中添加元素和Sift Up

往堆中添加一個元素後,咱們須要繼續知足堆的兩個特性。若是咱們把新添加的元素放到數組的最後,以下圖,是否是就不符合堆的特性了?
數據結構之優先隊列和堆

因而,咱們就須要進行調整,讓其從新知足堆的特性,這個過程就叫作堆化(heapify)。堆化實際上有兩種,從下往上(Sift Up)和從上往下(Sift Down)。這裏我先講從下往上的堆化方法。堆化很是簡單,就是順着節點所在的路徑,向上或者向下,對比,而後交換。

看下面這張使用Sift Up方式的堆化過程分解圖。咱們可讓新插入的節點與父節點對比大小。若是不知足子節點小於等於父節點的大小關係,咱們就互換兩個節點。一直重複這個過程,直到父子節點之間知足剛說的那種大小關係:
數據結構之優先隊列和堆

將這個流程翻譯成具體的實現代碼以下:

/**
 * 向堆中添加元素 e
 */
public void add(E e) {
    data.add(e);
    siftUp(data.size() - 1);
}

/**
 * 從下往上調整元素的位置,直到元素到達根節點或小於父節點
 */
private void siftUp(int k) {
    while (k > 1 && isParentLessThan(k)) {
        // 交換 k 與其父節點的位置
        Collections.swap(data, k, parent(k));
        k = parent(k);
    }
}

/**
 * 判斷 k 的父節點是否小於 k
 */
private boolean isParentLessThan(int k) {
    return data.get(parent(k)).compareTo(data.get(k)) < 0;
}

從堆中取出元素和Sift Down

從堆的定義的第二條中,任何節點的值都大於等於(或小於等於)子樹節點的值,咱們能夠發現,堆頂元素存儲的就是堆中數據的最大值或者最小值。

而從堆中取出元素其實就是取出堆中最大或最小的元素,而且取出後會刪除,因此也能夠理解爲刪除堆頂元素。堆頂也就是堆的根節點,或者說是數組下標爲0或1的元素。

假設咱們構造的是大頂堆,堆頂元素就是最大的元素。當咱們刪除堆頂元素以後,就須要把最後一個節點放到堆頂,而後利用一樣的父子節點對比方法。對於不知足父子節點大小關係的,互換兩個節點,而且重複進行這個過程,直到父子節點之間知足大小關係爲止。這就是從上往下(Sift Down)的堆化方法。以下圖:
數據結構之優先隊列和堆

由於咱們移除的是數組中的最後一個元素,而在堆化的過程當中,都是交換操做,不會出現數組中的「空洞」,因此這種方法堆化以後的結果,確定知足徹底二叉樹的特性。

具體的實現代碼以下:

/**
 * 獲取堆頂元素
 */
public E findMax() {
    if (isEmpty()) {
        throw new IllegalArgumentException("Can't find max when heap is empty.");
    }

    return data.get(0);
}

/**
 * 從堆中取出元素,也就是取出堆頂元素
 */
public E extractMax() {
    E ret = findMax();
    // 交換根節點與最後一個節點的位置
    Collections.swap(data, 0, data.size() - 1);
    // 刪除最後一個節點
    data.remove(data.size() - 1);
    siftDown(0);

    return ret;
}

/**
 * 從上往下調整元素的位置,直到元素到達葉子節點或大於左右子節點
 */
private void siftDown(int k) {
    // 左子節點大於size時就證實到底了
    while (leftChild(k) < data.size()) {
        int leftChildIndex = leftChild(k);
        int rightChildIndex = leftChildIndex + 1;
        int maxChildIndex = leftChildIndex;

        // 左右子節點中最大的節點下標
        if (rightChildIndex < data.size() &&
                isGreaterThan(rightChildIndex, leftChildIndex)) {
            maxChildIndex = rightChildIndex;
        }

        // 大於最大的子節點證實 k 已經大於左右子節點,無需再繼續下沉了
        if (data.get(k).compareTo(data.get(maxChildIndex)) >= 0) {
            break;
        }

        // 不然,交換 k 與其最大子節點的位置,繼續下沉
        Collections.swap(data, k, maxChildIndex);
        k = maxChildIndex;
    }
}

/**
 * 判斷右子節點是否大於左子節點
 */
private boolean isGreaterThan(int rightChildIndex, int leftChildIndex) {
    return data.get(rightChildIndex).compareTo(data.get(leftChildIndex)) > 0;
}

到此爲止,咱們就已經實現了堆的核心操做。接下來咱們使用一個簡單的測試用例,測試下這個堆的行爲是否符合預期。測試代碼以下:

/**
 * 測試堆的行爲是否符合預期
 */
private static void testAddAndExtractMax() {
    int n = 1000000;
    // 隨機往堆裏添加n個元素
    MaxHeap<Integer> maxHeap = new MaxHeap<>();
    Random random = new Random();
    for (int i = 0; i < n; i++) {
        maxHeap.add(random.nextInt(Integer.MAX_VALUE));
    }

    // 取出堆中的全部元素,放到arr中
    int[] arr = new int[n];
    for (int i = 0; i < n; i++) {
        arr[i] = maxHeap.extractMax();
    }

    // 因爲堆的特性,此時arr中的元素理應是有序的
    // 因此這裏校驗一下arr是不是有序的,若是無序則表明堆的實現有問題
    for (int i = 1; i < n; i++) {
        if (arr[i - 1] < arr[i]) {
            throw new IllegalArgumentException("Error");
        }
    }

    System.out.println("Test MaxHeap completed.");
}

public static void main(String[] args) {
    testAddAndExtractMax();
}

Heapify 和 Replace

堆的 Heapify 和 Replace 也是比較常見的操做,雖然使用以前所編寫的代碼也能實現,但並非那麼好使,例如實現 Replace 須要兩次$O(logn)$的操做。因此在本小節就爲這兩個操做,單獨編寫相應的代碼。

Replace

  • Replace:取出最大元素後,放入一個新元素
  • 使用已有代碼的實現:能夠先extractMax,再add,兩次$O(logn)$的操做
  • 新的實現:能夠直接將堆頂元素替換之後進行Sift Down,只須要一次$O(logn)$的操做

有了以前的代碼基礎,實現 Replace 就很是簡單了,只須要幾行代碼。以下:

/**
 * 取出堆中的最大元素,而且替換成元素e
 */
public E replace(E e) {
    E ret = findMax();
    // 替換堆頂元素
    data.set(0, e);
    siftDown(0);

    return ret;
}

Heapify

  • Heapify:將任意數組整理成堆的形狀,也就是對一個數組進行堆化,或者說是建堆
  • 使用已有代碼的實現:遍歷數組,調用add將每一個元素添加到堆裏。時間複雜度是$O(nlogn)$
  • 新的實現:從後往前處理數組,而且每一個數據都是從上往下堆化。由於葉子節點往下堆化只能本身跟本身比較,因此咱們直接從最後一個非葉子節點開始,依次堆化就好了。這樣至關於只須要對數組中一半的元素進行Sift Down操做。時間複雜度是$O(n)$

建堆分解步驟圖以下:
數據結構之優先隊列和堆
數據結構之優先隊列和堆

一樣,基於以前已有的代碼,Heapify 實現起來也很是的簡單,咱們能夠選擇在構造器中提供這個功能。具體的實現代碼以下:

public MaxHeap(E[] arr) {
    this.data = asArrayList(arr);
    // 最後一個非葉子節點的下標
    int lastNode = parent(data.size() - 1);
    for (int i = lastNode; i >= 0; i--) {
        // 從後往前依次堆化
        siftDown(i);
    }
}

/**
 * 將數組轉換爲ArrayList
 */
private ArrayList<E> asArrayList(E[] arr) {
    ArrayList<E> ret = new ArrayList<>();
    Collections.addAll(ret, arr);

    return ret;
}

基於堆的優先隊列

如今咱們已經瞭解了優先隊列和堆,而且本身動手實現了一個堆,所以,不難看得出來,堆和優先隊列很是類似。一個堆其實就能夠看做是一個優先隊列。Java中的優先隊列也是基於堆實現的,是一個小頂堆。

不少時候,它們只是概念上的區分而已。往優先隊列中插入一個元素,就至關於往堆中插入一個元素;從優先隊列中取出優先級最高的元素,就至關於取出堆頂元素。因此,堆和優先隊列在基本行爲上是等價的。

咱們以前也提到了優先隊列可使用不一樣的方式進行實現,但使用堆這種數據結構來實現優先隊列是最高效也最符合直覺的,由於堆自己就是一個優先隊列。

從下圖中能夠看到使用不一樣數據結構實現優先隊列的時間複雜度:
數據結構之優先隊列和堆

接下來,咱們就實現一個基於堆的優先隊列。首先,定義一個隊列接口:

package queue;

/**
 * 隊列數據結構接口
 *
 * @author 01
 **/
public interface Queue<E> {
    /**
     * 新元素入隊
     *
     * @param e 新元素
     */
    void enqueue(E e);

    /**
     * 元素出隊
     *
     * @return 元素
     */
    E dequeue();

    /**
     * 獲取位於隊首的元素
     *
     * @return 隊首的元素
     */
    E getFront();

    /**
     * 獲取隊列中的元素個數
     *
     * @return 元素個數
     */
    int getSize();

    /**
     * 隊列是否爲空
     *
     * @return 爲空返回true,不然返回false
     */
    boolean isEmpty();
}

而後實現接口中的方法,因爲咱們以前已經實現了一個堆,因此這個優先隊列實現起來就很是簡單了:

package queue;

import heap.MaxHeap;

/**
 * 基於堆實現的優先隊列
 *
 * @author 01
 * @date 2021-01-19
 */
public class PriorityQueue<E extends Comparable<E>> implements Queue<E> {

    private final MaxHeap<E> maxHeap;

    public PriorityQueue() {
        maxHeap = new MaxHeap<>();
    }

    @Override
    public int getSize() {
        return maxHeap.size();
    }

    @Override
    public boolean isEmpty() {
        return maxHeap.isEmpty();
    }

    @Override
    public E getFront() {
        return maxHeap.findMax();
    }

    @Override
    public void enqueue(E e) {
        maxHeap.add(e);
    }

    @Override
    public E dequeue() {
        return maxHeap.extractMax();
    }
}
相關文章
相關標籤/搜索