邏輯之美(5)_優先隊列、二叉堆和堆排序 開篇 正文 尾巴

二叉堆其實就是一棵堆有序的二叉樹

開篇

本篇文章主要講什麼

此文是排序算法系列文章的倒數第三篇,因此本文的主要意圖還是講排序算法,這次我們一起聊聊堆排序

在正式開始之前,我們先要花些篇幅聊兩種很重要的基礎數據結構——優先隊列二叉堆

正文

優先隊列(PriorityQueue)

有時我們需要處理一組有序數據時,並不需要它們整體有序。設想這樣一種情況,對於一組數據,每次我們都只處理其中鍵值最大的元素,我們可能會把這組數據的規模擴大,往裏面放更多新元素,但仍是每次挑鍵值最大的元素來處理。對於這種情況我們當然可以使整組數據一直保持整體有序,但這不是必要的——我們只需保證第一個元素始終是鍵值最大那個就行。

這就需要引入了一種新的數據結構——優先隊列,它應該高效地實現兩種基本操作,訪問最大元素和插入元素。

一種優先隊列的經典實現方式就是使用二叉堆這種更低級點的數據結構。

二叉堆(BinaryHeap)

接着來詳細瞭解下二叉堆(下簡稱堆)這種數據結構。

看到這個名字你是不是想到了二叉樹?堆其實就是一棵堆有序的二叉樹(二叉樹這種更低級的數據結構非本文重點,此處略過不表,還請讀者自行復習二叉樹相關知識)。

何爲堆有序?

當一顆二叉樹的每個結點其鍵值都大於等於它的兩個子結點時,我們稱其是堆有序的。

也即在一顆堆有序的二叉樹中,每個結點的鍵值都小於等於它的父結點。很容易確定,根結點是一棵堆有序的二叉樹中鍵值最大的結點。

這是一個堆:

             11
         /        \
       9           10
   /     \      /     \
   5      6     7      8
  / \    / \
 1   2   3   4

具體我們如何在程序中表示一個堆呢?用數組即可。

就是將堆(二叉樹)的結點按層級順序依次放入數組中。爲了方便後面算法的表示,我們不使用數組的第一個位置,即從數組下標爲 1 的位置開始存儲一個堆。把上面的堆放入數組中我們可以得到:

[-1,11, 9, 10, 5, 6, 7, 8, 1, 2, 3, 4]//把上面的二叉堆按層級順序放入數組中,注意我們沒使用數組第一個位置,我們把沒使用到的數組位置置值爲-1表示

這樣一個數組。

這樣表示的堆有幾條重要性質:

  1. 位置(也即下標) n 的結點其父結點的位置爲 n/2
  2. 位置(也即下標) n 的結點其兩個子結點的位置爲 2n 或 2n + 1(如果兩個子結點都存在的話)
  3. 一顆大小爲 N 的完全二叉樹其高度爲(lg N)

1 和 2 讓我們可以不使用指針,僅通過計算數組的索引在樹中上下移動,3 保證了我們實現的基本操作其算法僅具有對數級別的時間複雜度。

下面我們用 Java 代碼來實現一個基於二叉堆的自然數優先隊列:

/**
 *  基於二叉堆實現的正整數優先隊列
 *  注意下面註釋的表述可粗略認爲優先隊列等於二叉堆等於二叉樹
 */

public class IntPQ {
    private int[] heap;//用於存放整個二叉堆(值)的數組,不使用數組第一個位置,即 heap[0] 我們永遠也用不到

    private int size = 0;//堆當前的體積大小,二叉堆存儲於數組 heap 的[1-size] 中,heap[0] 無用

    /**
     * <p>構造函數</p>
     * @param size:需要構造的優先隊列的初始大小
     */
    public IntPQ(int size) {
        heap = new int[size + 1];
    }

    /**
     * <p>交換數組中兩個位置的值</p>
     * @param i 待交換值的位置
     * @param j 待交換值的位置
     */
    private void exch(int i, int j){
        if (i < 1 || i >= heap.length || j < 1 || j >= heap.length){
            return;
        }
        int mid = heap[i];
        heap[i] = heap[j];
        heap[j] = mid;
    }

    /**
     *
     * @return 優先隊列是否是空的
     */
    public boolean isEmpty(){
        return size == 0;
    }

    /**
     *
     * @return 優先隊列當前大小
     */
    public int size(){
        return size;
    }

    /**
     * 注意下面兩個方法極爲重要,即二叉堆結點的躍遷和降級操作,
     * 什麼意思呢?
     * 首先我們要知道數據結構的樹和現實世界中的樹有點不一樣,數據結構的樹樹根在上樹葉在下,現實世界反之,我們這裏討論數據結構的樹
     * 所謂躍遷操作,就是這個樹下面的某個結點的鍵值比它的父結點要大,那它肯定要躍遷到它父結點上面才能使整棵樹堆有序
     * 反之,就是所謂的降級操作
     * 就是堆中某個結點不在它該在的位置,打破了二叉樹的堆有序時,我們將其歸位讓二叉樹重新堆有序的操作
     * 這兩個方法是 insert 和 delMax 方法的基礎*/

    /**
     * <p>二叉堆中指定位置結點的躍遷操作,如果此結點的值比它的父結點大,就和父結點交換位置,如此往復直至樹的根部</p>
     * @param i 待躍遷結點位置
     */
    private void up(int i){
        while (i > 1 && heap[i] > heap[i/2]){//需要上浮的結點位置在根結點下面且值大於它的父結點
            //交換 i 和 i/2 兩個位置的值
            exch(i, i/2);
            i /= 2;
        }
    }

    /**
     * <p>二叉堆中指定位置結點的降級操作,如果此結點的值比它的兩個自結點中較大那個小,就和此子結點交換位置,如此往復直至樹的葉子結點那層</p>
     * @param i 待躍遷結點位置
     */
    private void down(int i){
        while (i * 2 <= size){
            int j = i * 2;
            if (j < size && heap[j] < heap[j + 1]){
                j += 1;
            }
            if (heap[i] < heap[j]){
                //交換 i 和 j 兩個位置的值
                exch(i, j);
                i = j;
            }else {
                break;
            }
        }
    }

    /**
     * <p>結點插入操作,注意這裏我偷懶沒寫給數組擴容的邏輯,因這不是重點略過沒寫,讀者可自行改進
     * 此方法具有對數級別的平均時間複雜度</p>
     * @param value 待插入的結點
     */
    public void insert(int value){
        //將結點插入當前二叉樹的最後面,同時使當前二叉樹的大小加一
        heap[++ size] = value;
        //新插入的結點可能會破壞二叉樹的堆有序,然後對其執行躍遷操作,以讓其歸入正確位置,確保二叉樹的堆有序
        up(size);
    }

    /**
     * <p>刪除並返回隊列中值最大的那個元素(其實就是當前樹根),也就是樹根結點,此方法具有對數級別的平均時間複雜度</p>
     * @return
     */
    public int delMax(){
        int max = heap[1];//從二叉樹的根結點得到鍵值最大的元素

        //將根結點的最後一個葉子結點交換位置,並將樹的大小減一,
        // 此時我們相當於把根結點刪除了,但新的根結點鍵值不一定是最大的,
        // 所以我們需要降級歸位,使二叉樹重新堆有序
        exch(1, size --);
        down(1);//根結點降級歸位,使二叉樹重新堆有序
        return max;
    }
}

堆排序

經過上面那麼多鋪墊,一種新的排序算法已呼之欲出。

是滴,利用二叉堆這種數據結構,我們可以實現一種新的排序算法——堆排序。

終於說到堆排序了!你可以暫緩幾分鐘,接着我們來好好聊聊什麼是堆排序,準備好~

堆排序的思路是這樣的,利用優先隊列(基於二叉堆)的兩個基本操作(插入元素和刪除最大元素),我們可以寫出對數組原地排序的更高效算法,其最壞時間複雜度僅爲線性對數級別的(n log n)。具體算法實現共分兩步,構造堆和銷燬堆。

  1. 構造堆:即先對數組進行原地調整,讓整個數組堆化。基於堆元素的躍遷和降級操作,你或許會有多種思路來將一個數組堆化,這裏我們使用一種最經典高效的方法來將一個數組堆化。即使用下沉操作遍歷樹中的所有非葉子結點,遞歸地給數組構造出堆的秩序。爲什麼要這樣來構造堆?因爲這是對數組操作最少,最節省成本的堆構造方式。這個問題其實很有意思,值得好好思考。之所以只遍歷所有非葉子結點,是因爲我們可以跳過所有大小爲 1 的子堆,因爲大小爲 1 的子堆(子樹)已經是一個堆了(已經堆有序了),而如果一個結點的兩個子結點已經是堆了,那在此結點上調用降級操作可將它們變成一個整體的堆,就是如此,遞歸地給數組建立起堆的秩序。這裏有個問題,堆中的非葉子結點分佈在數組中的什麼位置區間?如果現在的數組規模爲 n ,那答案就是從堆第一個結點的位置到數組下標 n/2,即[1, n/2],這點很容易自證。如果用來給數組原地排序的話,那堆在數組中存放時就不能跳過第一個位置了,這樣當前堆所有非葉子結點所在區間就是[0, n/2 - 1]。
  2. 銷燬堆:將數組構建出堆秩序後,我們已經得到了一個堆。再進一步,使數組達到整體有序,接下來要做的就是一個結點一個結點地銷燬掉整個堆。你應該很容易想到,所用方法正是遞歸地刪除當前二叉堆的樹根,將其跟最後一個結點交換位置,然後堆的規模減一,此時新的根結點可能會破壞堆有序狀態,我們對其進行降級操作使新的規模縮減的堆重新變成堆有序,如此遞歸,直至堆的規模縮減爲一,此時整個數組達到整體有序,排序完成。

OK 捋完整體邏輯我們來擼一下代碼:

/**
     * <p>堆排序的 Java 實現</p>
     * @param array: 待排序數組,我們採用原地排序
     */
    public static void sortHeap(int[] array){
        //第一步,構建堆
        int start = array.length/2 - 1;//構建堆的開始遍歷下標,因爲數組是從下標 0 開始存放堆的,所以減一,此下標在遍歷中遞減
        int bound = array.length - 1;//待操作子堆最後一個下標,也可看做當前堆長度,不過此長度從 0 開始計
        for (; start >= 0; start --){
            down(array, start, bound);
        }
        //第二步,銷燬堆,這個過程跟選擇排序有點類似
        while (bound > 0){
            exch(array, 0, bound);
            down(array, 0, -- bound);
        }
    }

    /**
     * <p>交換數組中兩個位置的值</p>
     * @param i 待交換值的位置
     * @param j 待交換值的位置
     */
    private static void exch(int[] array, int i, int j){
        if (i < 0 || i >= array.length || j < 0 || j >= array.length){
            return;
        }
        int mid = array[i];
        array[i] = array[j];
        array[j] = mid;
    }

    /**
     * <p>調整後的堆結點降級操作</p>
     * @param heap 待操作數組,存放堆
     * @param index 待降級操作的結點位置
     * @param bound 待操作結點所在子堆(此子堆根結點目前不在應在位置,我們對其根結點執行降級操作使其歸位)的最後一個結點位置(下標)
     */
    private static void down(int[] heap, int index, int bound){
        //注意對比原始的 down 方法中操作下標的地方,這裏多了很多 + 1 操作,原因就是我們從數組中第一個位置開始存放堆
        while (index * 2 + 1 <= bound){
            int j = index * 2 + 1;
            if (j < bound && heap[j] < heap[j + 1]){
                j += 1;
            }
            if (heap[index] < heap[j]){
                //交換 i 和 j 兩個位置的值
                exch(heap, index, j);
                index = j;
            }else {
                break;
            }
        }
    }

代碼主要看 sortHeap 方法。

我的天花這麼多篇幅終於聊完本文主角堆排序了!

總結

堆排序的效率比 冒泡排序、選擇排序、插入排序和希爾排序都要高,其最差時間複雜度僅爲線性對數級別的O(n log n)。

尾巴

關於優先隊列這種數據結構,它的應用場景可不止排序,本系列後續文章會再單獨聊聊這種數據結構,你會看到更多優先隊列大顯身手的應用場景,敬請期待。

下篇,我們聊聊歸併排序。

完。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章