一步步地分析排序——優先隊列和堆排序

本文框架

思維導圖

定義和使用場景

優先隊列是一個抽象數據類型,和棧、隊列類似,它們都是抽象數據類型,相當於一個Java類,有自己的屬性,並對外提供API。在瞭解它有什麼API之前,先來看看優先隊列的使用場景。

優先隊列適用於需要對集合不斷地執行插入元素、刪除最大(或最小)元素的場景。這個場景大體可以分爲兩類:
第一類是業務實際情況需要,比如CPU的任務調度,待執行的任務是一個集合,每啓動一個新程序就是在向集合裏面插入元素。當前程序執行完後,就要從集合裏面取出下一個優先級最高的程序。不斷地有程序被啓動和被執行,就像不斷地對集合執行插入、刪除最大元素的操作。

第二類場景是“從N個元素裏獲取最大的M個元素,N很大,不能一次性全部讀進內存”,比如從銀行成百上千萬條交易記錄裏面找到金額最大的10筆交易;或者從全國的手機號碼裏面找到使用年限最長的10個號碼。對於第二類場景,問題本身並不需要不斷地對集合進行插入、刪除操作。如果內存沒有限制的話,你可以一次性將數據全部裝進集合,然後隨便選擇一個排序算法對集合進行降序排列,接着輸出最前面的10個元素。但是由於待處理的數據量過大(相對內存而言),不能使用排序算法解決該類問題,以銀行交易記錄爲例子,你可以用優先隊列通過如下步驟解決:

  1. 創建一個容量爲11的集合
  2. 向集合裏插入一筆交易記錄,如果插入後集合的元素達到11個,刪除金額最小的一筆交易(需要注意的是,如果要獲取最大的M個元素,在刪除時刪除的是最小的;而如果獲取最小的M個元素,在刪除時刪除的是最大的,是反過來的
  3. 循環執行步驟2,直到所有交易記錄遍歷完畢
  4. 逐一輸出集合裏的所有交易記錄,此時輸出的便是成百上千萬條交易記錄裏面金額最大的10筆交易,而且輸出的數據是升序的

整個流程如下圖所示,爲了繪圖方便,這裏取了比較小的值,N爲9,M爲3:
優先隊列使用示例
此時的集合就像是一個過濾器,如果新插入的元素比集合裏面所有的元素都小,在下一次刪除元素時,這個元素會被刪除,就像它從來沒出現過。如果新插入的元素比集合裏原有的任一元素大,在下一次刪除元素時,集合裏面最小的元素會被“排擠”出去。

不論是哪一類場景,都是在不斷地(不一定是輪流進行,沒有必然的規律)對集合進行插入、刪除最大(或最小)的元素,這就是優先隊列的典型應用。

抽象API

通過前文的使用場景可以看到,主要的操作就是插入和刪除最小的元素,因此優先隊列的API可以這樣定義:

// 插入元素
void insert(int a);

// 刪除最大元素
int deleteMin();

以上是實現一個優先隊列必要的API,當然如果你有需要,可以定義其它的API。

優先隊列使用示例

以下代碼示例瞭如何使用優先隊列解決“從N個元素裏面獲取最大的M個元素”的問題,代碼來自《算法》第四版中文版,刪減了一些不需要的東西。

public class TopM{
    publi static void main(String[] args){
        int M = Integer.parseInt(args[0]);
        // 注意了,需要(M+1)個空間
        MinPQ<Transaction> pq = new MinPQ<Transaction>(M+1);
        
        // 循環地向優先隊列裏插入元素
        while(StdIn.hasNextLine()){
            pq.insert(new Transaction(StdIn.readLine()));
            // 當優先隊列裏已經有(M+1)個元素,刪除最小的那個
            if(pq.size() > M){
                pa.deleteMin();
            }
        }// 循環結束,最大的M個元素都在優先隊列裏面
        
        // 輸出優先隊列裏的元素
        while(!pq.isEmpty()){
            StdOut.println(pq.deleteMin());
        }
    }
}

計算API的調用次數

由於我們還沒談到具體如何實現這兩個API,所以暫時無法計算時間複雜度,但是我們可以計算從N個元素裏面獲取最大的M個元素時,插入和刪除操作執行的次數。整個過程可以分爲如下階段:

  1. 遍歷待處理的元素:就是讓待處理的元素都被集合處理一次,這一階段還可以進一步分爲兩個階段:
    1. 裝滿集合:集合裏的元素從0個到M個,這一階段共調用insert()M次。
    2. 過濾元素:集合元素達到M個後,每插入一個新元素,就要從集合裏面刪除最大的元素,該部分調用insert()和deleteMin()各(N-M)次。
  2. 輸出集合裏的元素:這一階段其實就是輸出結果,共調用deleteMin()M次,集合元素從M個到0個。

這是爲我們後續計算具體實現方案的時間複雜度作鋪墊。

傳統方式實現優先隊列

優先隊列最簡單的實現方案就是使用有序或無序的數組(或鏈表),以下列舉每個方案實現上述API的邏輯:

實現方案 insert() deleteMin()
有序數組 相當於執行插入排序的一次插入,元素按照降序排列 刪除數組尾部的元素
無序數組 向數組尾部插入一個元素 相當於執行選擇排序的一次選擇,找到最小的元素後,將它和數組尾部的元素互換位置,此時最小元素在數組尾部,直接刪除尾部元素即可
有序鏈表 相當於執行插入排序的一次插入,元素按照升序排列 刪除表頭元素
無序鏈表 向鏈表頭部插入一個元素 相當於執行選擇排序的一次選擇,找到最小的元素之後直接刪除即可

接着列舉每個方案的時間複雜度(假設元素總數爲N,需要獲取最大的M個元素,這裏的N是遠大於M的):

實現方案 一次insert() 一次deleteMin() 從N個元素裏面獲取最小的M個元素的總時間複雜度
有序數組 O(M) O(1) M² + (N-M)*M + (N-M) + M = O(NM)
無序數組 O(1) O(M) M + (N-M) + (N-M)*M + M² = O(NM)
有序鏈表 O(M) O(1) M² + (N-M)*M + (N-M) + M = O(NM)
無序鏈表 O(1) O(M) M + (N-M) + (N-M)*M + M² = O(NM)

小結:

  • 可以看出,使用數組還是鏈表差異不大,主要是有序和無序的差異。
  • 進行一次插入或刪除操作的時間複雜度和集合的大小(即M)成線性關係,解決整個問題的全部時間複雜度和待處理元素數量N和集合大小M的乘積即(NM)成線性關係。

在真實的工程應用裏,單次操作的時間複雜度爲線性是不可以接受的,單次操作一般都要求對數關係的時間複雜度,於是有了接下來要談的實現方案。

二叉堆的定義

聲明:爲了在後續二叉堆的介紹裏面,更符合直覺和習慣,前文我們分析的都是實現deleteMin(),從現在起按照實現deleteMax()來進行分析

二叉堆是一個存儲在數組裏的堆有序的完全二叉樹,第一個元素存放在數組下標1的位置。這句話有幾個關鍵詞:堆有序的完全二叉樹、存在數組裏、下標1。他們分別從順序、結構、存儲方式對二叉堆進行了約束。

順序性:堆有序的二叉樹,是指二叉樹裏的每個結點都大於等於它的兩個子結點。比如一個由1、2、3三個元素構成的二叉樹,如果要符合堆有序,根結點必須是3,至於1和2誰左誰右,沒有關係。注意它和二叉查找樹的差異,如果是兩層的二叉查找樹的話,三個元素只有一個擺放方式:2是根結點,1是左子結點,3是右子結點。然而堆有序的二叉樹只要求父結點大於等於兩個子結點,對兩個子結點的大小關係沒有約束。下圖示例了二叉堆和二叉查找樹的區別:
二叉堆和二叉查找樹的區別舉例
結構性:二叉堆是一棵完全二叉樹,也就是在堆有序的二叉樹的前提下,加上完全二叉樹的約束。

存儲方式:不論一棵二叉樹是否是完全二叉樹,用鏈式結構存儲都有一個問題:從父結點找子結點容易,從子結點找父結點不方便(除非在每個結點添加指向其父結點的指針)。而由於完全二叉樹的特殊性,可以直接使用數組存放,父子結點之間不用任何指針,它們之間有算術上的關係,可以通過算術關係找到任意結點的父、子結點。那麼爲什麼要在下標1呢?根據完全二叉樹的性質,如果第一個元素在下標0上,則第k個元素的兩個子元素爲2k+1和2k+2,父元素爲k/2(當k爲奇數)或((k/2)-1)(當k爲偶數),即父元素有兩類情況。但如果第一個元素在下標1上,則第k個元素的兩個子元素爲2k和2k+1,父元素爲k/2,即父元素只有一類情況。也就是爲了方便計算。

二叉堆的特性

除了上述提到的父子結點的算術關係,我們還可以列舉幾個二叉堆的特性,加深我們對二叉堆的理解。

  • 大頂堆、小頂堆:如果一個二叉堆,每個結點都大於或等於它的兩個子結點,這個二叉堆稱爲大頂堆;反之,如果一個二叉堆,每個結點都小於或等於它的兩個子結點,這個二叉堆稱爲小頂堆。
  • 對於大頂堆,從任一結點隨便選一條路徑往下走到葉子結點,可以得到一個降序序列;反之,從任意葉子結點往上走到根結點,可以得到一個升序序列。
  • 一個降序排列的數組其實就是一個大頂堆,一個升序排列的數組就是一個小頂堆。

恢復堆的有序性——上浮

既然二叉堆作爲一個集合(數組即集合),那麼在對集合進行增刪改的時候,可能會破壞二叉堆原有的順序性。比如直接將某個結點的值修改爲一個比它的父結點更大的值;向數組尾部插入一個新結點,新結點的值比它的父結點大;直接修改某個結點的值讓它比其中一個子結點的值小。修改集合的方式有很多,但是造成的結果可以歸爲兩類:某個結點變大或變小。那麼當二叉堆的順序被破壞後,如何恢復堆的有序性?

如果某個結點的值變得比它的父結點更大,對於以該結點爲根結點的子堆,顯然還是符合二叉堆的特性的,只是該結點和它的父結點、祖先結點的位置關係不對,此時只要將該結點和其父結點互換位置即可。接下來面臨同樣的問題,如果該結點仍然比它新的父結點更大該怎麼辦,自然是繼續互換它們的位置,直到遇到比它大的父結點,或者該結點最終變成了根結點。整個過程就像該結點沿着路經在往上爬,或者說是我們將該結點浮上去了,這個操作稱爲“上浮”,上浮操作可以在我們向二叉堆插入新元素後,恢復二叉堆的有序性。下圖示例了上浮操作的過程:
上浮操作示例圖

上浮操作示例代碼

以下是上浮操作示例代碼:

/*
 * 方法說明:“上浮”算法要實現的,是對位置k的結點執行“上浮”操作,將其“上浮”到合適的位置。
 * 對引用到的兩個方法說明如下:
 * less(int p, int q)方法,如果下標爲p的元素小於下標爲q的元素,則返回true,否則返回false。
 * exchange(int p, int q)方法,互換數組裏面下標爲p和q的兩個元素。
 */
private void swim(int k){
    // 循環判定條件:只要k還沒到達根結點,且k的子結點比他還小,k就要繼續往上浮
    while( k>1 && less(k/2,k) ){
        exchange(k/2, k);
        k = k/2;
    }
}

時間複雜度分析

我們以執行“比較操作”的次數表示時間複雜度,根據完全二叉樹的性質,如果根結點算作第一層的話,位置k的結點在⌊log2k⌋ + 1層。最壞的情況是在數組尾部插入新元素並且要上浮到根結點,如果結點總數爲N,則要爬⌊log2N⌋層,每爬一層之前都要比較一次,爬到根結點時不用再對兩個結點進行比較了,所以比較次數爲⌊log2N⌋,即時間複雜度爲O(logN)。

恢復堆的有序性——下沉

如果某個結點的值變得比它的其中一個子結點小(也有可能比它的兩個子結點都小),對於該結點往上的所有結點,顯然還是符合二叉堆的特性,只是該結點和它的子結點、子孫結點的位置不對。此時只要將該結點和它的兩個子結點裏較大的那個互換位置即可。接下來面臨同樣的問題,如果該結點的兩個新子結點裏仍然有比該結點更大的怎麼辦,自然是繼續和它的兩個子結點裏較大的那個互換位置,直到該結點比它的兩個子結點都大,或者該結點變成了葉子結點。整個過程就像該結點沿着路經往下滑,或者說是我們將該結點沉下去了,這個操作稱爲“下沉”。下圖示例了下沉操作的過程:
下沉操作示例圖

下沉操作示例代碼

以下是下沉操作示例代碼:

/*
 * 方法說明:“下沉”算法要實現的,是對位置k的結點執行“下沉”操作,將其“下沉”到合適的位置。
 * 對引用到的兩個方法說明如下:
 * less(int p, int q)方法,如果下標爲p的元素小於下標爲q的元素,則返回true,否則返回false。
 * exchange(int p, int q)方法,互換數組裏面下標爲p和q的兩個元素
 */
private void sink(int k){
    // 循環判斷條件,位置k是否還有子結點
    while(2*k<=N){
        int j = 2*k; // j指向k的第一個子結點
        // 如果位置k的元素有兩個子結點,且第二個子結點大於第一個,將j指向第二個子結點
        if( j<N && less(j, j+1) ) {
            j++;
        }
        // 當代碼走到這裏,不論k有一個還是兩個子結點,j已經指向最大的那個子結點
        // 如果結點k大於它所有的子結點,結束
        if(less(j, k)) {
            break;
        }
        // 否則,將k和其子結點位置交換
        exchange(k, j);
        k = j;
    }
}

時間複雜度分析

我們仍以執行“比較操作”的次數表示時間複雜度,根據完全二叉樹的性質,如果根結點算作第一層的話,位置k的結點在⌊log2k⌋ + 1層。最壞的情況是從根結點下沉到樹的底層,如果結點總數爲N,則要下沉⌊log2N⌋層。每下沉一層都要比較兩次,所以比較次數爲2*⌊log2N⌋次,即時間複雜度爲O(log2N)。

二叉堆實現優先隊列API

其實當我們介紹瞭如何使用上浮和下沉操作恢復二叉堆的有序性後,你大概就知道該怎麼使用二叉堆來實現優先隊列了。對於insert(),只要將元素插入到數組尾部,然後對該元素執行上浮操作即可。對於deleteMax(),只要刪除二叉堆的根結點,就能輸出集合裏最小的元素,然後,將數組尾部的元素移到根結點,對根結點執行下沉操作即可恢復堆的有序性。

示例代碼

以下代碼展示了二叉堆實現優先隊列的insert()和deleteMax()方法:

// 向優先隊列插入一個元素
public void insert(Key v){
    pq[++N] = v; // 向數組末尾插入一個元素
    swim(N);     // 將剛插入的元素上浮到正確位置
}

// 從優先隊列刪除最大元素
public Key deleteMax(){
    Key max = pq[1];    // 保存堆頂的元素
    exchange(1, N--);   // 堆尾元素移到堆頂
    pq[N+1] = null;     // 刪除堆尾的元素
    sink(1);            // 將堆頂元素下沉到正確位置
    return max;
}

時間複雜度分析

前文已經分析過單次insert()或deleteMax()的時間複雜度,均是O(logN),現在來分析解決整個“從N個元素裏面獲取最小的M個元素”問題的時間複雜度,注意現在分析的是deleteMax()所以是獲取最小的M個元素。我們按照前文計算優先隊列API調用次數時分成兩個階段來計算,先引入一個算式方便我們計算:
當N = 2k-1時(這個約束其實就是要求N的值剛好是滿二叉樹的結點數量),有⌊log21⌋ + ⌊log22⌋ + … + ⌊log2N⌋ = 0 + 1 + 1 + 2 + 2 + 2 + 2 + 3 + 3 +3 +3 +3 +3 +3 + 3 + 4…這個算式其實是有規律的,當N剛好對應滿二叉樹的結點數量時,值爲 (N+1)*log2(N+1)-2N。
這個算式是計算時間複雜度的一部分,表達的是一個增長趨勢,對於一棵完全二叉樹,底層元素個數是1個和完全鋪滿的情況,計算差異可能很大,但是底層不論有幾個元素,耗時不會超過鋪滿的情況。

最優情況是輸入是降序的:

  1. 遍歷待處理的元素:
    1. 裝滿集合:數組從0個元素增加到M個元素,每插入一個元素,只需進行一次比較,時間複雜度爲M。
    2. 過濾元素:在含有M個元素的二叉堆裏交替執行insert()和deleteMax(),注意下沉操作需要進行兩次比較,時間複雜度爲(N-M) + (N-M)2log2M。
  2. 輸出集合裏的元素:調用deleteMax(),數組元素從M個縮減爲0個,刪除第一個元素之後,是在(M-1)個元素裏執行下沉,所以執行下沉操作的總次數爲2*(⌊log2(M-1)⌋ + ⌊log2(M-2)⌋ + … + ⌊log21⌋) ,取(M-1)剛好是滿二叉樹的情況,使用前面的算式,得到(2Mlog2M) - 4(M-1)。

求和之後得到N + 2Nlog2M - 4M + 4 ≈ O(Nlog2M)

最壞的情況是輸入序列是升序的:

  1. 遍歷待處理的元素:
    1. 裝滿集合:數組從0個元素增加到M個元素,從第二個元素開始(第一個元素沒有可上浮的),每插入一個元素,都要從數組末尾上浮到根結點,時間複雜度爲⌊log21⌋ + ⌊log22⌋ + ⌊log23⌋ + … + ⌊log2(M-1)⌋ = (Mlog2M) - 2(M-1)。
    2. 過濾元素:在含有M個元素的二叉堆裏交替執行insert()和deleteMax(),時間複雜度爲(N-M)log2M + (N-M)2log2M。
  2. 輸出集合裏的元素:調用deleteMax(),數組元素從M個縮減爲0個,刪除第一個元素之後,是在(M-1)個元素裏執行下沉,所以時間複雜度爲2*(⌊log2(M-1)⌋ + ⌊log2(M-2)⌋ + … + ⌊log21⌋) = (2Mlog2M) - 4(M-1)。

求和之後得到3Nlog2M - 6M + 6 ≈ O(Nlog2M)。

堆排序

如果對一個大頂堆連續執行刪除根結點的操作,就會輸出一個降序序列,相當於排序的效果,堆排序就是基於二叉堆實現的排序。思路也很簡單,首先用N個元素構造一個二叉堆,接着連續刪除根結點,爲了實現原地排序的功能,刪除的時候不要直接輸出元素,而是將根結點和數組尾部元素互換位置,這樣就不需要使用而外的數組。

構造堆

最直觀的方法自然是從第一個待處理的元素開始遍歷數組,類似調用N次insert()向優先隊列裏插入N個元素那樣。這個方式可以稱爲上浮方式構造堆,它的邏輯和前文的基本一致,就不多說了。

一個更高效的方法是下沉方式構造堆,我們知道下沉操作可以在二叉堆的某個結點變得比它的子結點小時恢復堆的有序性,而如果我們將這個變小的結點看作它的左右兩個子堆的根結點,這個操作就像合併兩個子堆,如下圖示:
合併兩個子堆示例一
特別的,如果對三個隨機排列的元素的根結點執行下沉操作,就像將兩個大小爲一的子堆合併成一個二叉堆,如下圖示:
合併兩個子堆示例二
如果從第一個非葉子結點開始,自右向左,自下而上地對每個結點執行下沉操作,就像不斷地在合併子堆,子堆的大小從1開始遞增,整個過程有點類似歸併排序的自底向上的歸併過程,如下圖示:
下沉建堆
以上就是下沉方式構造堆的過程,由於葉子結點是大小爲1的堆,所以從第一個不是葉子的結點開始下沉,也就是從大小爲3的堆開始下沉,由於過程是自下而上的,所以每一個新處理的結點,它的兩個子堆一定都是有序的,對新結點進行下沉即可合併兩個子堆,直到遇到根結點,建堆完畢。當元素數量相同時,樹的高度也相同,上浮建堆和下沉建堆只是方向不同,它們走的高度都是一樣的,爲什麼下沉就更高效呢?因爲結點在樹的各層不是均勻分佈的,這導致了以根結點作爲終點(上浮)來建堆和以樹的底部作爲終點(下沉)來建堆的效率差異,如下圖示:
下沉建堆和上浮建堆的比較
越是接近樹的底部,結點數量越多,反之,越是接近根結點,結點的數量越少。如果以根結點作爲終點,意味着結點數量最多的那一層,走的路徑也是最長的,結點數量第二多的那一層,走的路徑是第二長的。如果以樹的底部作爲終點,則結點數量最多的那一層,走的路徑長爲0,結點數量第二多的那一層,走的路徑長爲1。上浮建堆和下沉建堆是相反的,這是效率差異的原因。以15個結點作爲例子進行計算,上浮建堆的路徑總長度爲10 + 21 + 32 +43 = 34,下沉建堆的路徑總長度爲80 + 41 + 22 + 13 = 11。這是從計算的角度說明。

下沉建堆時間複雜度分析——證明下沉建堆能在線性時間內完成
我們以下圖所示由滿二叉樹構成的二叉堆爲例進行計算和說明:
下沉建堆時間複雜度分析
計算由2k+1-1個元素構成的數組執行下沉建堆的時間複雜度,可以涵蓋由2k至2k+1-1個元素構成的數組執行下沉建堆的時間上界。例如當k取4,計算31個元素構成的數組執行下沉建堆的時間複雜度,可以涵蓋由16至31個元素構成的數組執行下沉建堆的時間上界。對於時間複雜度的計算,得到上界即可,滿二叉樹比較有規律,方便計算。

以上圖爲例,對31個元素構成的升序排列的數組進行下沉建堆,構建大頂堆,
在高度爲0那層,有16個元素,每個元素需要下沉0層;
在高度爲1那層,有8個元素,每個元素需要下沉1層;
在高度爲2那層,有4個元素,每個元素需要下沉2層;
在高度爲3那層,有2個元素,每個元素需要下沉3層;
在高度爲4那層,有1個元素,每個元素需要下沉4層;
這個算式其實是有規律的,在高度爲h那層,有2H-h個元素,每個元素需要下沉h層,則建堆需要下沉的總次數爲:
1 * 2H-1 + 2 * 2H-2 + 3 * 2H-3 + … + H*20,這是一個等差等比數列相乘求和,用錯位相減法即可化簡,最終結果爲:
2 * 2H+1 - H - 2,再根據H和N的關係,得到最終算式:
N - log2(N+1),由於下沉一層需要比較兩次,所以比較的次數爲2N - 2log2(N+1),這個算式的值顯然小於2N,所以時間複雜度爲O(N),即下沉建堆可以在線性時間複雜度完成。
重新提一下計算的前提是數量N構成滿二叉樹,這個算式僅在N = 2k+1-1的時候得到的值是剛好相等的,其它情況相當於是計算出了耗時上界。

下沉排序

下沉排序的思路類似實現優先隊列的deleteMax(),但不像deleteMax()那樣把根結點刪了就行了,爲了實現原地排序(也就是空間複雜度爲常數),在刪除第一個結點的時候,將根結點和倒數第一個元素互換位置,接着對新的根結點進行下沉操作。然後繼續將根結點和數組倒數第二個元素互換位置,再對新的根結點進行下沉操作,以此類推。這樣就能實現原地排序,最終得到一個升序數組。時間複雜度的計算和前面“輸出集合裏的元素”一致:
2 * (⌊log2(N-1)⌋ + ⌊log2(N-2)⌋ + … + ⌊log21⌋) = (2Nlog2N) - 4(N-1) = O(Nlog2N)。

將建堆和下沉排序的時間複雜度加起來,這個算式不好化簡,但是我們可以只將它們的上界加起來,即2N和2Nlog2N,則通過下沉建堆的方式實現堆排序需要不超過(2N + 2Nlog2N)次的比較(N+Nlog2N)次的互換(比較次數的一半)。

堆排序示例代碼

以下是堆排序代碼實例:

public static void heapSort(int[] a){
    int N = a.length;
    
    // 下沉建堆
    for(int k = N/2; k>=1; k--){
        sink(a, k, N);
    }
    
    // 下沉排序
    while(N > 1){
        exchange(a, 1, N--);
        sink(a, 1, N);
    }
}

全文總結

本文介紹了優先隊列的定義及典型使用場景,接着由使用場景引出了相應API的定義,並列舉了兩類實現優先隊列API的方式——數組(鏈表)和二叉堆。接着對由二叉堆實現優先隊列API和堆排序的思路進行了詳細的說明,並詳細計算了相應的時間複雜度。

發佈了43 篇原創文章 · 獲贊 20 · 訪問量 4萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章