《資料結構與演算法分析》學習筆記-第六章-優先佇列

CrazyCatJack發表於2021-02-20


佇列中的某些成員有更高的優先順序,需要優先執行或者儘快執行完畢

6.1 模型

優先佇列允許至少有兩種操作的資料結構:

  1. Insert: 插入元素,相當於入隊
  2. DeleteMin: 找出、返回和刪除優先佇列中最小的元素,相當於出隊

6.2 簡單實現

  1. 連結串列實現:
    1. 單連結串列,Insert:從頭插入,DeleteMin:遍歷整個連結串列找到最小單元返回並刪除
    2. 單連結串列,Insert:按大小順序插入,DeleteMin: 刪除連結串列第一個元素(最小)即可。
      一般來講,第一個方案實際上要比第二個方案效率高。因為DeleteMin操作不多於刪除操作次數的事實
  2. 二叉查詢樹:Insert & DeleteMin 平均執行時間都是O(logN),如果一直刪除最小元素,那麼右子樹會明顯加重,變得不平衡。而且查詢樹支援許多優先佇列並不需要的操作
  3. 二叉堆:使用基本的資料結構不需要指標,以最壞情形時間O(logN)支援Insert & DeleteMin。Insert實際花費常數平均時間,若無刪除干擾,該結構的實現將以線性時間建立一個具有N項的優先佇列。合併的實現則需要指標

6.3 二叉堆

堆具有結構性和堆序性,而對堆的一次操作可能會破壞這兩個性質,因此堆的操作必須要到堆的所有性質都被滿足時才能停止

6.3.1 結構性質

  • 堆是一棵被完全填滿的二叉樹,有可能的例外是在底層,底層上的元素從左到右填入。這樣的樹又叫完全二叉樹
  • 一棵高為h的完全二叉樹有2h到2(h+1) - 1個節點,這意味著,完全二叉樹的高是logN,顯然它是O(logN)
  • 完全二叉樹很有規律,可以用一個陣列表示而不需要指標。對於陣列中任一位置i上的元素,其左兒子在位置2i上,右兒子在左兒子後的單元(2i + 1)上,它的父親則在位置[i/2]上。遍歷該樹所需要的操作也極其簡單,在大部分計算機上執行很快。這種實現方法的唯一問題在於,最大的堆大小需要實現估計,但對於典型的情況這不成問題
  • 一個堆將由一個陣列(不論關鍵字型別),一個代表最大值的整數以及當前的堆大小組成

6.3.2 堆序性質

  • 在一個堆中,對於每一個節點X,X的父親中的關鍵字小於(或等於)X中的關鍵字,根節點除外(它沒有父親)。
  • 根據堆序性質,最小元總可以在根處找到

6.3.3 實現

  • 節點定義
typedef struct PriorityQueueNode {
	int Capacity;
	int Size;
	ElementType *Elements;
} PriorityQueueNode_T;

typedef PriorityQueueNode_T *PtrToPriorityQueue;
  • Initialize
PtrToPriorityQueue
Initialize(int MaxElements)
{
    PtrToPriorityQueue Q = NULL;
    Q = (PtrToPriorityQueue)malloc(sizeof(struct PriorityQueueNode));
    if (Q == NULL) {
		printf("Q malloc failed\n");
        return NULL;
    }
    memset(Q, 0, sizeof(struct PriorityQueueNode));
    
    Q->Capacity = MaxElements;
    Q->Size = 0;
    Q->Elements = (ElementType *)malloc(sizeof(ElementType) * (Q->Capacity + 1));
    if (Q->Elements == NULL) {
        if (Q != NULL) {
			printf("Q->Elements malloc failed\n");
            free(Q);
        }
    }
	memset(Q->Elements, 0, sizeof(ElementType) * (Q->Capacity + 1));
	Q->Elements[0] = MINKEYVALUE;
	
	return Q;
}
  • Insert
void
Insert(PtrToPriorityQueue Q, ElementType X)
{
	if (IsFull(Q)) {
		printf("PtrToPriorityQueue is full\n");
		return;
	}

	int cnt;
	for (cnt = ++Q->Size; Q->Elements[cnt / 2] > X; cnt /= 2) {
		Q->Elements[cnt] = Q->Elements[cnt / 2];
	}
	Q->Elements[cnt] = X;
}

  • DeleteMin,時間複雜度平均為O(logN)
ElementType
DeleteMin(PtrToPriorityQueue Q)
{
	if (IsEmpty(Q)) {
		printf("PtrToPriorityQueue is empty\n");
		return Q->Elements[0];
	}

	int cnt, Child;
	ElementType MinKeyValue = Q->Elements[1];
	ElementType LastKeyValue = Q->Elements[Q->Size--];

	for (cnt = 1; 2 * cnt <= Q->Size; cnt = Child) {
		Child = 2 * cnt;
		if (Child < Q->Size && Q->Elements[Child] > Q->Elements[Child + 1]) {
			Child++;
		}

		if (LastKeyValue > Q->Elements[Child]) {
			Q->Elements[cnt] = Q->Elements[Child];
		} else {
			break;
		}
	}
	Q->Elements[cnt] = LastKeyValue;

	return MinKeyValue;
}

6.3.4 其他的堆操作

  • 按照最小元設計的堆在求最大元方面沒有任何幫助
  • 一個堆所蘊含的關於序的資訊很少
  • 假設通過某種方法得知每個元素的位置,那麼有幾種其他操作的開銷將變小
    • DecreaseKey降低關鍵字的值: DecreaseKey(P, X, H)操作降低在位置P處的關鍵字的值。降值的幅度為正的量X。由於這可能破壞堆的序,必須通過上濾堆進行調整。例如系統管理程式能夠使它們的程式以最高優先順序進行
    • IncreaseKey增加關鍵字的值:IncreaseKey(P, X, H)操作增加位置P處關鍵字的值,增加的幅度為量X。可以用下濾來完成。許多排程程式自動的降低過多消耗CPU時間的程式的優先順序
    • Delete刪除:Delete(P, H)操作刪除堆中位置P上的節點。通過首先執行DecreaseKey(P, 無窮, H),然後再執行DeleteMin(H)來完成。當一個程式被使用者終止(而不是正常終止)時,它必須從優先佇列中除去
    • BuildHeap構建堆:N個關鍵字作為輸入並把它們放入空堆中。可以使用N個Insert操作來完成,每個Insert將花費O(1)平均時間,以及O(NlogN)最壞情形時間,因此該演算法的總的執行時間則是O(N)平均時間而不是O(NlogN)最壞情形時間。該指令能夠以線性平均時間實施。
    for (i = N / 2; i > 0; i--) {
        PercolateDown(i);
    }
    
    • 定理:包含2^(b + 1) - 1個節點的高為b的理想二叉樹的節點的高度的和為2^(b + 1) - 1 - (b + 1)

6.4 優先佇列的應用

優先佇列可以有效地用於幾個圖論演算法的實現中

6.4.1 選擇問題

  1. 演算法6A:求N個輸入元素中,第k個最小的元素。首先以O(N)時間建立堆,隨後,執行k-1次DeleteMin,第k次取出的元素即為第k小的元素。由於每次DeleteMin用時O(logN),有k次DeleteMin,因此總的執行時間是O(N + klogN)。如果k = O(N/logN),那麼執行時間取決於BuildHeap操作,即O(N)。對於大的k值,執行時間為O(klogN),如果k = [N/2],那麼執行時間則為Θ(NlogN)。如果對k = N執行該程式並在元素離開堆時記錄它們的值,那麼實際上已經對輸入檔案以O(NlogN)做了排序,即堆排序
  2. 演算法6B:在任一時刻,我們都將維持k個最大元素的集合S。在前k個元素讀入以後,當再讀入一個新元素時,該元素將與第k個最大元素進行比較,記這第k個最大的元素為Sk,注意S0是S中最小的元素,如果新的元素更大,那麼用心元素代替S中的Sk。此時,S將有一個新的最小元素,它可能就是新新增進的元素,也可能不是。在輸入終了時,我們找到S中的最小元素將其返回,就是答案。前k個元素,呼叫一次BuildHeap以總時間O(k)被置入堆中。處理每個其餘的元素的時間為O(1),即檢測元素是否進入S。再加上時間O(logk)(在必要時刪除Sk並插入新元素),因此總的時間是O(k + (N-k)logk) = O(Nlogk)。該演算法也給出中位數的時間界Θ(NlogN)

6.4.2 事件模擬

如果有C個顧客(2C個事件)和k個出納員,那麼模擬的執行時間將會是O(Clog(k + 1)),因為計算和處理每個事件花費O(logH),其中H = K + 1為堆的大小

6.5 d-堆

  • d-堆是二叉堆的簡單推廣,它像一個二叉堆,只是所有的節點都有d個兒子(因此,二叉堆是2-堆)。d-堆將Insert操作的執行時間改進為O(log(d)N),然而對於大d,DeleteMin操作費時得多,因為雖然樹淺了,但是d個兒子中的最小者是必須要找出的,如使用標準的演算法,會花費d - 1次比較,於是將次操作的用時提高到O(dlog(d)N)
  • 因為存在許多演算法,其插入次數比DeleteMin的次數多很多,因此理論上的加速是可能的。當優先佇列太大不能完全裝入貯存的時候,d-堆也是有用的,這種情況下d-堆能夠以與B-樹大致相同的方式發揮作用。實踐中4-堆可以勝過二叉堆。d-堆將兩個堆合成一個堆是困難的操作(Merge)

6.6 左式堆

  • 設計一種堆結構像二叉堆那樣高效的支援合併操作,即以O(N)時間處理一次merge。而且只使用一個陣列很困難,因為合併似乎需要把一個陣列拷貝到另一個陣列中去,對於相同大小的堆將花費時間Θ(N)。
  • 所有支援高效合併的高階資料結構都需要使用指標,預計這將使得所有其他的操作變慢,處理指標一般比用2做乘法和除法更耗費時間
  • 左式堆也具有結構特性和有序性。左式堆具有相同的堆序性質。左式堆也是二叉樹,左式堆和二叉樹唯一的區別是:左式堆不是理想平衡的,而實際上是趨於非常不平衡

6.6.1 左式堆的性質

  • 我們把任一節點X的零路徑長(NPL)Npl(X)定義為從X到一個沒有兩個兒子的節點的最短路徑的長。因此,具有0個或1個兒子的節點的Npl為0,而Npl(NULL) = -1
  • 任一節點的零路徑長比它的諸兒子節點的零路徑長的最小值多1,這個結論也適用於少於兩個兒子的節點,因為NULL的零路徑長是-1
  • 左式堆的性質是:對於堆中每一個節點X,左兒子的零路徑長至少與右兒子的零路徑長一樣大。顯然更偏重於使樹向左增加深度。確實有可能存在由左節點形成的長路徑構成的樹(而且實際上更便於合併操作),因此,我們有了左式堆這個名稱
  • 因為左式堆趨向於加深左路徑,所以右路徑應該短。事實上,沿左式堆的右路徑確實是該堆中最短的路徑。否則就會存在一條路徑通過某個節點X並取得左兒子,此時X破壞了左式堆的性質
  • 定理6.2:在右路徑上由r個節點的左式樹,必然至少有2^r - 1個節點
  • N個節點的左式樹有一條右路徑最多含有log(N+1)個節點,對左式堆操作的一般思路是將所有的工作放到右路徑上進行,它保證樹深短。對右路徑的Insert和Merge可能會破壞左式堆性質,但是易於回覆該性質

6.6.2 左式堆的操作

對左式堆的基本操作是合併。插入只是合併的特殊情形,因此我們可以把插入看成是單節點堆一個大的堆的Merge。

  • 節點定義
struct TreeNode {
    ElementType Element;
    PriorityQueue Left;
    PriorityQueue Right;
}

6.7 斜堆

  • 斜堆是左式堆的自調節形式,實現起來極其簡單。斜對是有堆序的二叉樹,但是不存在對樹的結構限制。
  • 不同於左式堆,關於任意節點的零路徑長的任何資訊都不保留。斜堆的右路徑在任何時刻都可以任意長,所有操作的最壞情形執行時間均為O(N)
  • 任意M次連續操作,總的最壞情形執行時間是O(MlogN)。因此每次操作的攤還時間為O(logN)
  • 斜堆的基本操作也是合併操作。這個Merge例程還是遞迴的,我們執行與之前完全相同的操作,只有一個例外: 對於左式堆,我們檢視是否左兒子和右兒子滿足左式堆堆序性質並交換那些不滿足該性質者;但對於斜堆,除了這些右路徑上所有結點的最大者不交換他們的左右兒子外,交換是無條件的。這個例外就是在自然遞迴實現時所發生的現象,因此它實際上根本不是特殊情形。由於該節點肯定沒有右兒子,因此執行交換是愚蠢的
  • 我們也可像左式堆那樣非遞迴地進行所有的操作:合併右路徑,除最後的節點外交換右路徑上每個結點的左兒子和右兒子。由於除去右路徑上最後節點外的所有節點都將它們的兒子交換,因此最終結果是它變成了新的左路徑

6.8 二項佇列

左式堆和斜堆每次操作花費O(logN)實踐。有效的支援了合併、插入和DeleteMin。因為二叉堆每次操作花費常數平均時間支援插入。二項佇列支援所有這三種操作,每次操作的最壞情形執行時間為O(logN),而插入操作花費常數時間

6.8.1 二項佇列結構

  • 一個二項佇列不是一棵堆序的樹,而是堆序樹集合,稱為森林。堆序樹中的每一棵都是有約束的形式,叫做二項樹。每一個高度上至多存在一棵二項樹。高度為0的二項樹是一顆單節點樹,高度為k的二項樹Bk通過將一棵二項樹Bk-1附接到另一棵二項樹Bk-1的根上而構成。高度為k的二項樹恰好有2^k個節點,而在深度為d處的節點數是二項係數。
  • 如果我們把堆序施加到二項樹上並允許任意高度上最多有一棵二項樹,那麼我們能夠用二項樹的集合唯一地表示任意大小的優先佇列。例如,大小為13的優先佇列可以用森林B3,B2,B0表示,我們可以把這種表示寫成1101,它不僅以二進位制表示了13,而且也表示這樣的事實:在上述表示中,B3,B2,B0出現,B1則沒有

6.8.2 二項佇列的操作

  • 最小元可以通過搜尋所有的樹的根來找出。由於最多有logN棵不同的樹,因此最小元可以在時間O(logN)找到。另外,如果記住最小元在其他操作期間變化時更新它,那麼我們可以保留最小元的資訊並以O(1)時間執行該操作
  • 合併操作基本上是通過將兩個佇列加到一起來完成的。兩個佇列中,相同高度的二項樹相加。高度從低到高,即從0開始。最壞情形花費時間O(logN)
  • 插入操作就是特殊情形的合併。只需建立一棵單節點樹並執行一次合併。最壞情形執行時間是O(logN)對一個初始為空的二項佇列進行N次Insert將花費的最壞情形時間為O(N).事實上,只用N-1次比較就有可能進行該操作。謹記一點:Bk樹是由兩棵Bk-1樹組成的。因此,插入一定是按照這個原則進行的。
  • DeleteMin可以通過首先找出一棵具有最小根的二項樹來完成,令該樹為Bk,並令原始的優先佇列為H。我們從H的樹的森林中除去二項樹Bk,形成新的二項樹佇列H'。Bk不要扔掉,除去Bk的根,得到一些二項樹B0, B1, ..., Bk-1,它們共同形成優先佇列H''。合併H'和H'',操作結束。為了找出含有最小元素的樹建立佇列H'和H''花費時間O(logN)。合併這連個佇列又花費O(logN)時間。整個DeleteMin操作花費時間O(logN)

6.8.3 二項佇列的實現

DeleteMin操作需要快速找出根的所有子樹的能力,因此,需要一般樹的表示方法:每個結點的兒子都存在一個連結串列中,而且每個節點都有一個指向它的第一個兒子(如果它有的話)的指標。還要求,諸兒子按照它們的子樹的大小排序。需要保證能夠很容易的合併兩棵樹。當兩棵樹被合併時,其中一棵樹作為兒子被加到另一棵樹上。由於這棵新樹將是最大的子樹,因此,以大小遞減的方式保持這些子樹事有意義的。只有這時,我們才能有效地合併兩顆二項樹,從而合併兩個二項佇列。二項佇列將是二項樹的陣列。總之,二項樹的每一個節點將包含資料、第一個兒子以及右兄弟。二項樹中的諸兒子以遞減次序排列

  • 節點定義
typedef struct BinNode *Position;
typedef struct Collection *BinQueue;

struct BinNode
{
    ElementType Element;
    Position LeftChild;
    Position NextSibling;
}

struct Collection
{
    int CurrentSize;
    BinTree TheTrees[MaxTrees];
}
  • CombineTree
BinTree
Merge(BinTree T1, BinTree T2)
{
    if (T1->Element > T2->Element) {
        return Merge(T2, T1);
    }
    T2->NextSibling = T1->LeftChild;
    T1->LeftChild = T2;
    return T1;
}
  • Merge
BinQueue
Merge(BinQueue H1, BinQueue H2)
{
    BinTree T1, T2, Carry = NULL;
    int i, j;
    if (H1->CurrentSize + H2->CurrentSize > Capacity) {
        printf("Merge would exceed capacity\n");
    }
    H1->CurrentSize += H2->CurrentSize;
    
    for (i = 0; j = 1; j <= H1->CurrentSize; i++, j*= 2) {
        T1 = H1->TheTrees[i];
        T2 = H2->TheTrees[i];
        
        switch(!!T1 + 2 * !!T2 + 4 * !!Carry) {
            case 0: /* No trees */
            case 1: /* Only H1 */
                break;
            case 2: /* Only H2 */
                H1->TheTrees[i] = T2;
                H2->TheTrees[i] = NULL;
                break;
            case 4: /* Only Carry */
                H1->TheTrees[i] = Carry;
                Carry = NULL;
                break;
            case 3: /* H1 and H2 */
                Carry = CombineTrees(T1, T2);
                H1->TheTrees[i] = H2->TheTrees[i] = NULL;
                break;
            case 5: /* H1 and Carry */
                Carry = CombineTrees(T1, Carry);
                H1->TheTrees[i] = NULL;
                break;
            case 6: /* H2 and Carry */
                Carry = CombineTrees(T2, Carry);
                H2->TheTrees[i] = NULL;
                break;
            case 7: /* All three */
                H1->TheTrees[i] = Carry;
                Carry = CombineTrees(T1, T2);
                H2->TheTrees[i] = NULL;
                break;
        }
    }
    return H1;
}
  • DeleteMin
ElementType
DeleteMin(BinQueue H)
{
    int i, j;
    int MinTree;
    BinQueue DeletedQueue;
    Position DeletedTree, OldRoot;
    ElementType MinItem;
    
    if (IsEmpty(H)) {
        printf("Empty binomial queue\n");
        return -Infinity;
    }
    
    MinTtem = Infinity;
    for (i = 0; i < MaxTrees; i++) {
        if (H->TheTrees[i] && H->TheTrees[i]->Element < MinItem) {
            /* Update minimum */
            MinItem = H->TheTrees[i]->Element;
            MinTree = i;
        }
    }
    
    DeletedTree = H->TheTrees[MinTree];
    OldRoot = DeletedTree;
    DeletedTree = DeletedTree->LeftChild;
    free(OldRoot);
    
    DeletedQueue = Initialize();
    DeletedQueue->CurrentSize = (1 << MinTree) - 1;
    for (j = MinTree - 1; j >= 0; j--)
    {
        DeletedQueue->TheTrees[j] = DeletedTree;
        DeletedTree = DeletedTree->NextSibling;
        DeletedQueue->TheTrees[j]->NextSibling = NULL;
    }
    
    H->TheTrees[MinTree] = NULL;
    H->CurrentSize -= DeletedQueue->CurrentSize + 1;
    
    Merge(H, DeletedQueue);
    return MinItem;
}

參考文獻

  1. Mark Allen Weiss.資料結構與演算法分析[M].America, 2007

本文作者: CrazyCatJack

本文連結: https://www.cnblogs.com/CrazyCatJack/p/13340038.html

版權宣告:本部落格所有文章除特別宣告外,均採用 BY-NC-SA 許可協議。轉載請註明出處!

關注博主:如果您覺得該文章對您有幫助,可以點選文章右下角推薦一下,您的支援將成為我最大的動力!


相關文章