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

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

開篇

本篇文章主要講什么

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

在正式開始之前,我們先要花些篇幅聊兩種很重要的基礎數(shù)據(jù)結(jié)構(gòu)——優(yōu)先隊列二叉堆

正文

優(yōu)先隊列(PriorityQueue)

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

這就需要引入了一種新的數(shù)據(jù)結(jié)構(gòu)——優(yōu)先隊列,它應該高效地實現(xiàn)兩種基本操作,訪問最大元素和插入元素。

一種優(yōu)先隊列的經(jīng)典實現(xiàn)方式就是使用二叉堆這種更低級點的數(shù)據(jù)結(jié)構(gòu)。

二叉堆(BinaryHeap)

接著來詳細了解下二叉堆(下簡稱堆)這種數(shù)據(jù)結(jié)構(gòu)。

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

何為堆有序?

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

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

這是一個堆:

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

具體我們?nèi)绾卧诔绦蛑斜硎疽粋€堆呢?用數(shù)組即可。

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

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

這樣一個數(shù)組。

這樣表示的堆有幾條重要性質(zhì):

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

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

下面我們用 Java 代碼來實現(xiàn)一個基于二叉堆的自然數(shù)優(yōu)先隊列:

/**
 *  基于二叉堆實現(xiàn)的正整數(shù)優(yōu)先隊列
 *  注意下面注釋的表述可粗略認為優(yōu)先隊列等于二叉堆等于二叉樹
 */

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

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

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

    /**
     * <p>交換數(shù)組中兩個位置的值</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 優(yōu)先隊列是否是空的
     */
    public boolean isEmpty(){
        return size == 0;
    }

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

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

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

    /**
     * <p>二叉堆中指定位置結(jié)點的降級操作,如果此結(jié)點的值比它的兩個自結(jié)點中較大那個小,就和此子結(jié)點交換位置,如此往復直至樹的葉子結(jié)點那層</p>
     * @param i 待躍遷結(jié)點位置
     */
    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>結(jié)點插入操作,注意這里我偷懶沒寫給數(shù)組擴容的邏輯,因這不是重點略過沒寫,讀者可自行改進
     * 此方法具有對數(shù)級別的平均時間復雜度</p>
     * @param value 待插入的結(jié)點
     */
    public void insert(int value){
        //將結(jié)點插入當前二叉樹的最后面,同時使當前二叉樹的大小加一
        heap[++ size] = value;
        //新插入的結(jié)點可能會破壞二叉樹的堆有序,然后對其執(zhí)行躍遷操作,以讓其歸入正確位置,確保二叉樹的堆有序
        up(size);
    }

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

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

堆排序

經(jīng)過上面那么多鋪墊,一種新的排序算法已呼之欲出。

是滴,利用二叉堆這種數(shù)據(jù)結(jié)構(gòu),我們可以實現(xiàn)一種新的排序算法——堆排序。

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

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

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

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

/**
     * <p>堆排序的 Java 實現(xiàn)</p>
     * @param array: 待排序數(shù)組,我們采用原地排序
     */
    public static void sortHeap(int[] array){
        //第一步,構(gòu)建堆
        int start = array.length/2 - 1;//構(gòu)建堆的開始遍歷下標,因為數(shù)組是從下標 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>交換數(shù)組中兩個位置的值</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>調(diào)整后的堆結(jié)點降級操作</p>
     * @param heap 待操作數(shù)組,存放堆
     * @param index 待降級操作的結(jié)點位置
     * @param bound 待操作結(jié)點所在子堆(此子堆根結(jié)點目前不在應在位置,我們對其根結(jié)點執(zhí)行降級操作使其歸位)的最后一個結(jié)點位置(下標)
     */
    private static void down(int[] heap, int index, int bound){
        //注意對比原始的 down 方法中操作下標的地方,這里多了很多 + 1 操作,原因就是我們從數(shù)組中第一個位置開始存放堆
        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 方法。

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

總結(jié)

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

尾巴

關(guān)于優(yōu)先隊列這種數(shù)據(jù)結(jié)構(gòu),它的應用場景可不止排序,本系列后續(xù)文章會再單獨聊聊這種數(shù)據(jù)結(jié)構(gòu),你會看到更多優(yōu)先隊列大顯身手的應用場景,敬請期待。

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

完。

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容