邏輯之美(5)_優先隊列、二叉堆和堆排序

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

開篇

本篇文章主要講什麼

此文是排序算法系列文章的倒數第三篇,所以本文的主要意圖仍是講排序算法,此次咱們一塊兒聊聊堆排序算法

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

正文

優先隊列(PriorityQueue)

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

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

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

二叉堆(BinaryHeap)

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

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

何爲堆有序?排序

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

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

這是一個堆:

11
         /        \
       9           10
   /     \      /     \
   5      6     7      8
  / \    / \
 1   2   3   4
複製代碼

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

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

[-111, 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)。

尾巴

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

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

完。

相關文章
相關標籤/搜索