看動畫輕鬆理解「 堆 」

程式設計師吳師兄發表於2018-12-20

堆(heap)又被為優先佇列(priority queue)。儘管名為優先佇列,但堆並不是佇列。

因為佇列中允許的操作是先進先出(FIFO),在隊尾插入元素,在隊頭取出元素。

而堆雖然在堆底插入元素,在堆頂取出元素,但是堆中元素的排列不是按照到來的先後順序,而是按照一定的優先順序排列的。

本文通過堆的實現、最小堆(最大堆)、堆的時間複雜度、優先佇列的實現、堆排序來介紹「 堆 」。

堆的實現

堆的一個經典的實現是完全二叉樹(complete binary tree),這樣實現的堆稱為二叉堆(binary heap)。

這裡來說明一下滿二叉樹的概念與完全二叉樹的概念。

滿二叉樹:除了葉子節點,所有的節點的左右孩子都不為空,就是一棵滿二叉樹,如下圖。

看動畫輕鬆理解「 堆 」
可以看出:滿二叉樹所有的節點都擁有左孩子,又擁有右孩子。

完全二叉樹:不一定是一個滿二叉樹,但它不滿的那部分一定在右下側,如下圖

看動畫輕鬆理解「 堆 」

堆的特性:

  • 必須是完全二叉樹
  • 任一結點的值是其子樹所有結點的最大值或最小值
  • 最大值時,稱為“最大堆”,也稱大頂堆;
  • 最小值時,稱為“最小堆”,也稱小頂堆。

看動畫輕鬆理解「 堆 」

堆的基礎實現

只要謹記堆的定義特性,實現起來其實是很容易的。

  • 特性1. 維持完全二叉樹
  • 特性2. 子類數字總是大於父類數字
public class MinHeap <E extends Comparable<E>> {
    private Array<E> data;

    public MinHeap(int capacity){
        data = new Array<>(capacity);
    }

    public MinHeap(){
        data = new Array<>();
    }

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

    // 返回一個布林值, 表示堆中是否為空
    public boolean isEmpty(){
        return data.isEmpty();
    }

    // 返回完全二叉樹的陣列表示中,一個索引所表示的元素的父親節點的索引
    private int parent(int index){
        return (index - 1) / 2;
    }

    // 返回完全二叉樹的陣列表示中,一個索引所表示的元素的左孩子節點的索引
    private int leftChild(int index){
        return index * 2 + 1;
    }

    // 返回完全二叉樹的陣列表示中,一個索引所表示的元素的右孩子節點的索引
    private int rightChild(int index){
        return index * 2 + 2;
    }
}

複製程式碼

最小堆的插入(ADD)

看動畫輕鬆理解「 堆 」

假設現有元素 5 需要插入,為了維持完全二叉樹的特性,新插入的元素一定是放在結點 6 的右子樹;同時為了滿足任一結點的值要小於左右子樹的值這一特性,新插入的元素要和其父結點作比較,如果比父結點小,就要把父結點拉下來頂替當前結點的位置,自己則依次不斷向上尋找,找到比自己大的父結點就拉下來,直到沒有符合條件的值為止。

動畫講解:

  1. 在這裡先將元素 5 插入到末尾,即放在結點 6 的右子樹。
  1. 然後與父類比較, 6 > 5 ,父類數字大於子類數字,子類與父類交換。

  2. 重複此操作,直到不發生替換。

Show me the code:

新增一個輔助函式,用來交換傳入的索引兩個位置的元素值

/**
     * 交換傳入的索引兩個位置的元素值
     *
     * @param i
     * @param j
     */
    public void swap(int i, int j) {
        if (i < 0 || i >= size || j < 0 || j >= size)
            throw new IllegalArgumentException("Index is illegal.");

        E temp = data[i];
        data[i] = data[j];
        data[j] = temp;
    }

複製程式碼

陣列中新增交換兩元素位置的方法,注意下面程式碼中註釋的描述特性位置。

    /**
     * 堆中新增元素方法。
     *
     * @param e
     */
    public void add(E e) {
        //特性1:新插入的元素首先放在陣列最後,保持完全二叉樹的特性
        data.addLast(e);
        siftUp(data.getSize() - 1);
    }

    /**
     * index 為i位置元素上浮。
     *
     * @param i
     */
    private void siftUp(int i) {
         //特性2:比較插入值和其父結點的大小關係,小於父結點則用父結點替換當前值,index位置上升為父結點
        // 當上浮元素大於父親,繼續上浮。並且不能上浮到0之上
        // 直到i 等於 0 或 比 父親節點小了。
        while (i > 0 && data.get(i).compareTo(data.get(parent(i))) > 0) {
            // 陣列Array中新增方法swap
            data.swap(i, parent(i));
            i = parent(i); // 這句話讓i來到新的位置,使得迴圈可以檢視新的位置是否還要大。
        }
    }

複製程式碼

最小堆的刪除(DELETE)

看動畫輕鬆理解「 堆 」

核心點:將最後一個元素填充到堆頂,然後不斷的下沉這個元素。

假設要從節點 1 ,也可以稱為取出節點 1 ,為了維持完全二叉樹的特性 ,我們將最後一個元素 6 去替代這個 1 ;然後比較 1 和其子樹的大小關係,如果比左右子樹大(如果存在的話),就要從左右子樹中找一個較小的值替換它,而它能自己就要跑到對應子樹的位置,再次迴圈這種操作,直到沒有子樹比它小。

通過這樣的操作,堆依然是堆,總結一下:

  • 找到要刪除的節點(取出的節點)在陣列中的位置
  • 用陣列中最後一個元素替代這個位置的元素
  • 當前位置和其左右子樹比較,保證符合最小堆的節點間規則
  • 刪除最後一個元素

Show me the code:

    public E findMin() {
        return data.get(0);
    }

    public E extractMin() {

        E ret = findMin();

        data.swap(0, data.getSize() - 1); // 0位置元素和最後一個元素互換。
        data.removeLast(); // 刪除此時的最後一個元素(最小值)
        siftDown(0); // 對於0處進行siftDown操作

        return ret;
    }

    /**
     * k位置元素下移
     *
     * @param k
     */
    private void siftDown(int k) {

         while(leftChild(k) < data.getSize()){
            int j = leftChild(k); // 在此輪迴圈中,data[k]和data[j]交換位置
            if( j + 1 < data.getSize() &&
                    data.get(j + 1).compareTo(data.get(j)) < 0 )
                j ++;
            // data[j] 是 leftChild 和 rightChild 中的最小值

            if(data.get(k).compareTo(data.get(j)) >= 0 )
                break;

            data.swap(k, j);
            k = j;
        }
    }

複製程式碼

時間複雜度

對於有 n 個節點的堆來說,其高度 d = log2n + 1。 根為第 0 層,則第 i 層結點個數為 2i, 考慮一個元素在堆中向下移動的距離。

  • 大約一半的結點深度為 d-1 ,不移動(葉)。
  • 四分之一的結點深度為 d-2 ,而它們至多能向下移動一層。
  • 樹中每向上一層,結點的數目為前一層的一半,而子樹高度加一

堆有logn層深,所以插入刪除的平均時間和最差時間都是O(logN)

優先佇列(priority_queue)

普通佇列是一種先進先出的資料結構,先放進佇列的元素取值時優先被取出來。而優先佇列是一種具有最高優先順序元素先出的資料結構,比如每次取值都取最大的元素。

優先佇列支援下面的操作:

  • a. 找出優先順序最高的元素(最大或最小元素);
  • b. 刪除一個具有最高優先順序的元素;
  • c. 新增一個元素到集合中。

程式碼實現

public class PriorityQueue<E extends Comparable<E>> implements Queue<E> {

    private 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();
    }
}
複製程式碼

堆排序

理解了優先佇列,堆排序的邏輯十分簡單。

第一步:讓陣列形成堆有序狀態;

第二步:把堆頂的元素放到陣列最末尾,末尾的放到堆頂,在剩下的元素中下沉到正確位置,重複操作即可。

堆排序動畫

看動畫輕鬆理解「 堆 」

相關文章