常見基本的數據結構——優先隊列(堆)


在多用戶環境中,操作系統調度程序必須決定在若干進程中運行那個進程。一般一個進程只能被允許運用一個固定的時間片。一種算法是使用一個隊列。開始時作業被放在隊列的末尾。調度程度將反復提取隊列中的第一個作業並運行它,直到運行完畢或者該作業的時間片被用完,並在作業為被用完時將其放入隊列的末尾。但是一般來說,短的作業要盡可能快地結束,這一點很重要,因此在已經被運行的作業中這些短作業應該擁有優先權。此外,有些作業雖然短小但也很重要,也應該擁有優先權。這種特殊的應用似乎需要一些特殊的隊列,我們稱之為優先隊列。

 

模型

優先隊列應該允許至少下面的兩種操作,Insert(插入),以及DeleteMin(刪除最小值),它的作用是找出,返回和刪除優先隊列中的最小值。Insert操作相當於隊列的入隊,DeleteMin操作相當於隊列中的出隊操作。在貪婪算法中,優先隊列也是重要的,因為該算法通過反復的求出最小元來進行計算。

 

一些簡單的實現

使用簡單鏈表可以在表頭進行插入並以O(1)的時間復雜度,但是遍歷最小元的話則需要O(N)的時間復雜度。另外的一種思路是始終保持排序狀態,這使得插入的高昂代價O(N),而DeleteMin的代價是O(1),但是在實際中,DeleteMin的操作次數從不多於插入的操作次數。

還可以使用二叉查找樹來實現優先隊列,它對這兩種操作的時間都是O(logN)。反復出去左子樹的節點似乎損害樹的平衡,使得右子樹加重,最壞的情況下,左子樹為空,右子樹擁有的元素最多也就是它的兩倍,這只是在其期望深度上加上了一個小的常數。

使用查找樹可能有些過分,因為它支持的操作太多了實際上並不需要你這么多。我們使用的數據結構不需要指針,它以最壞的情形時間O(logN)支持上述的兩種操作,插入實際上將花費常數平均時間,並且能夠使用線性的時間建立具有N項的優先隊列。

 

二叉堆

二叉堆,它的使用對於優先隊列的實現是如此的普遍,以至於堆可以不加修飾使用一般是指這種數據結構的實現。和二叉查找樹相類似,堆也有兩個性質,即結構性和堆序性,堆的操作必須必須要到堆的所有性質被滿足時才能終止。

結構性質

堆是一棵被完全填滿的二叉樹,有可能的例外只在底層,在底層從左到右的填入,這樣的樹稱為完全二叉樹。容易證明,一棵高為h的完全二叉樹有2^h到2^(h+1) - 1個節點。這意味着,完全二叉樹的高是logN,顯然它是O(logN)。

一個很重要的規律是完全二叉樹是不需要指針的,它能夠使用一個數組表示而不需要指針。對於數組中的任意的位置i上面的元素,其左兒子在位置2i上,右兒子在左兒子后的單元(2i+1)中,它的父親則在位置[i/2]上。因此,這里面不需要指針,而且遍歷該樹的操作也是非常簡單。這種實現的唯一問題在於需要事先確定堆的大小。

一個堆數據結構將由一個數組,一個代表最大值的整數以及當前堆的大小組成。下面是典型的有限隊列的聲明:

struct HeapStruct;
typedef struct HeapStruct *PriorityQueue;

struct HeapStruct{
    int Capacity;
    int Size;
    ElementType *Element;
}

堆序性質

使操作被快速執行的性質是堆序性,由於我們想要找出最小元,因此最小元應該在根上面,那么我們考慮任意子樹也是一個堆,這任意節點就應該小於它的所有后裔。

由此,我們得到了堆序的性質。在一個堆中,對於每個節點X,X的父親中的關鍵字小於X中的關鍵字,根節點除外。在例子中,我們假設關鍵字是整數,雖然可能任意復雜。

根據堆序的性質,最小元總是在根處能夠找到,因而FindMin可以以常數時間完成。

PriorityQueue
Initialize(int MaxElements){
    PriorityQueue H;
    if(MaxElements < MinPQSize){
        Error("queue is too small");
    }
    H = malloc(sizeof(struct HeapStruct));
    if(H == NULL){
        FatalError("out of space");
    }
    H->Elements = malloc((MaxElements + 1) * sizeof(ElementType));
    if(H->Elements == NULL){
        FatalError("out of space");
    }
    H->Capacity = MaxElements;
    H->Size = 0;
    H->Elements[0] = MinData;
    return H;
}

基本的堆序操作

無論從概念上還是實際考慮,執行插入和刪除最小元都需要保持堆序性。

Insert(插入)

為了將X插入到空穴中,我們在下一個空閑位置創建一個空穴,否則該堆將不再是完全二叉樹。如果X可以放在空穴並不會破壞堆序性,那么插入完成。否則,我們把父節點上面的元素移入空穴,空穴就朝着根的方向上行了一步。繼續改過程,知道空穴能夠放下X為止。

這種一般的策略叫做上慮,新元素在堆上慮直到找到正確的位置。

插入到一個二叉堆的過程:

void 
Inserti(Element X, PriorityQueue H){
    int i;
    if(IsFull(H)){
        Error("Priority queue is full");
        return;
    }
    for(i = ++H->Size; H->Element[i/2] > X; i = i / 2){
        H->Elements[i] = H->Emelemts[i/2];
    }
    H->Elements[i] = X;
}

在實現過程中,我們總可以通過進行反復交換來進行實現,這樣就需要3條賦值語句,那么上慮d層的話,交換的次數就需要3d,而在這里僅僅需要d+1次。

如果要插入的元素是性的最小值,那么它將一直被推導頂端。在某一次時,i將是1,我們就需要跳出循環,故我們可以在0位置處放置一個很小的值,來使循環終止,這個值需要小於堆中的任何值,我們稱之為標記。

如果新插入的元素是新的最小元,那么這種高度是O(logN)。但是平均來看,執行一次插入平均需要2.607次比較,因此Insert將元素平均上移1.607層。

 

DeleteMin(刪除最小元)

DeleteMin 以類似於插入的方式處理。找出最小元是容易的,問題的關鍵在於刪除它。當我們要要刪除一個最小元時,在根節點產生了一個空穴。由於現在堆缺少了一個元素,因此堆中的最后一個元素X必須要移動到該堆的某個地方。如果X可以被放到空穴中,那么DeleteMin就完成了。我們的做法是將空穴兩個兒子中的較小者移入空穴,這樣就能夠把空穴向下推進,重復該步驟直到X可以放在該空穴中。綜上,我們的做法是將X放置在從根開始包含最小兒子的一條路徑上面的正確的位置。我們將這種策略叫做下慮,並且也可以通過覆蓋來避免交換,下面是下慮的過程示意圖:

在堆中經常發生的錯誤是當堆中存在偶數個元素的時候,此時將遇到一個節點只有一個兒子的情況,所以我們必須要考慮節點只有一個兒子的情況,當堆的元素個數是偶數的時候,在每個開始下慮的時候,可將其值大於堆中任何元素的標記放到堆的終端的后面的位置上。下面具體的代碼實現:

ElementType
DeleteMin(PriorityQueue H){
    int i, Chid;
    ElementType MinElement, LastElement;
    if(IsEmpty(H)){
        return H->Element[0];
    }
    MinElement = H->Elements[1];
    LastElement = H->Element[H->Size--];
    for(i = 1; i*2 < H->Size; i = Chid){
        Chid = i * 2;
        if(Chid != H->Size && H->Elements[Chid+1] < H->Elements[Chid])
            Chid++;
        if(LastElement > H->Elements[Chid])
            H->Elements[i] = H->Elements[Chid];
        else
            break;
    }
    H->Elements[i] = LastElement;
    return MinELement;
}

平均運行時間為O(logN)。

 

其他的堆操作

雖然求最小值的操作可以在常數時間完成,但是按照最小元設計的堆對於求最大元方面卻是無任何幫助。事實上,一個堆蘊含的關於序的信息很少,若不會整個堆進行線性所受搜索,是不能找出特定元素的,雖然我們知道最大元在樹葉上面,但是半數的元素在樹葉上面。

 

構建堆

BuildHeap操作把N個關鍵字作為輸入並把它們放在堆中,可以通過Insert操作進行完成,由於每個Insert操作花費的時間是O(1),總的時間是O(N)。一般的算法是將N個關鍵字以任意的順序放入樹中,保持結構特性,然后再開始對父節點進行下慮操作,遍歷所有的父節點之后,堆將會滿足堆序性。

 

定理

包含2^(b+1) - 1個節點,高為h的理想二叉樹的節點的高度為2^(b+1) - 1 - (b+1)。

證明:高度為b上面的節點有1個,高度b-1上面的節點2個,高度為b-2上面的節點2^2個.....一般的高度b-i上的節點2^i個,所以所有節點的高度的和是:

S = sum(2^i)(b-i)

通過左乘2相減相消就能夠求和。

完全樹不是理想二叉樹,一個完全二叉樹節點樹是2^b和2^(b+1)之間。

雖然我們得到的結果對證明BulidHeap是線性的而言是充分的,但是高度的和的界卻是盡可能強的。

下面是一個完整例程:

#include<stdio.h>
#include<stdlib.h>
using namespace std;


typedef int ElementType;
typedef struct HeadStruct *PriorityQueue;
#define MinPQSize 10
#define MinData 0
 


struct HeadStruct{
    int Capacity;
    int size;
    ElementType *Elements;
};


// 優先隊列的初始化 
PriorityQueue Initialze(int MaxElements){
    PriorityQueue H;
    if(MaxElements < MinPQSize){
        printf("Priority QUeue is too small\n");
    }
    H = new HeadStruct();
    H->Elements = new ElementType[MaxElements + 1];
    H->Capacity = MaxElements;
    H->size = 0;
    H->Elements[0] = MinData;
    return H;
}


//優先隊列插入元素 
void insert(ElementType x, PriorityQueue H){
    int i;
    if(H == NULL){
        printf("Priority queue is Full \n");
        return;
    }
    for(i=++H->size; H->Elements[i/2]>x; i/=2){
        H->Elements[i] = H->Elements[i/2];
    }
    H->Elements[i] = x;
} 


//優先隊列刪除最小元
ElementType DeletMin(PriorityQueue H){
    int i, child;
    ElementType MinElement, LastElement;
    if(H == NULL){
        printf("PriorityQueue is null\n");
        return H->Elements[0];
    }
    MinElement = H->Elements[1];
    LastElement = H->Elements[H->size--];
    for(i=1; 2*i<=H->size; i=child){
        child = i*2;
        if(child != H->size && H->Elements[child +1] < H->Elements[child]){
            child++;
        }
        if(LastElement > H->Elements[child]){
            H->Elements[i] = H->Elements[child];
        }else{
            break;
        }
    }
    H->Elements[i] = LastElement;
    return MinElement;
} 


void printfPriorityQueue(PriorityQueue H){
    for(int i=1; i<=H->size; i++){
        printf("%d ", H->Elements[i]);
    }
}


int main(){
    PriorityQueue H = Initialze(50);
    int list[10] = {13,21,16,24,31,19,68,65,26,32};
    for(int i=0; i<10; i++){
        insert(list[i], H);
    }
    insert(14, H);
    printfPriorityQueue(H);
    int min = DeletMin(H);
    printf("min: %d\n", min);
    printfPriorityQueue(H);
} 

d-堆

d堆是二叉堆的推廣,它恰像一個二叉堆,只是所有的節點都有d個兒子(因此,二叉堆是2堆)。d堆顯然要比二叉堆淺的多,它將Insert時間改進為O(logdN)。然而,對於大的d,DeleteMin操作費時用的多,因為雖然樹淺了,但是d個兒子中的最小者是必須要找出的,使用標准的算法,這會花費d-1次。雖然仍然是一個數組,找出兒子和父親都有個因子d,除非d是2的冪,否則將會大大的增加運行時間,因為我們不能時間二進制的移位計算了。當優先隊列太大不能完全裝入主存時,d堆也是很有用的。在這種情況下,d堆能夠以於B樹大致相同的方式發揮作用。在實踐過程中,4-堆可以勝過二叉堆。

除了不能執行Find外,堆的實現的最明顯的缺點是:將兩個堆合並成一個堆是困難的,這種附加的操作叫做Merge,存在許多的實現堆的方法使得Merge操作的運行時間是O(logN)。

左式堆

設計一種堆結構像二叉樹那樣高效地支持合並操作(即以O(N)時間處理一次Merge)而且只使用一個數組似乎很困難。原因在於,合並似乎需要把一個數組拷貝到另外一個數組中去,對於相同大小的堆這將花費時間O(N),正是因為如此,所有支持合並的高級數據結構都需要使用指針。

類似二叉堆那樣,左式堆也具有結構性和有序性。事實上,和所有使用的堆一樣,左式堆具有時間的堆序性質。左式堆也是二叉樹,左式堆和二叉樹唯一的區別是:左式堆不是理想平衡的,而實際上是趨向於非常不平衡。

左式堆的性質

我們把任一節點的零路徑長Npl定位為從X到一個沒有兩個兒子的節點的最短路徑長。因此,具有0個或者1個兒子的節點Npl為0,而Npl(NULL)=-1。任一節點的零路徑長比它的諸兒子節點的零路徑長的最小值多1,這個結論也適合用少於兩個兒子的節點。

左式堆性質是:對於堆中的每一個節點X,左兒子的零路徑長至少與右兒子的零路徑長一樣大。這個性質實際上超出了平衡的要求,因為它顯然更偏向於使樹向左增加深度。確實有可能存在左節點形成的長路徑構成的樹,因此,我們就有了左式堆。左式堆趨向於加深左路徑,所以右路徑應該短。否則就會存在一條路徑上通過某個節點X並取得做兒子,此時X則破壞了左式堆的性質。

 

在右路徑上有r個節點的左式樹必然至少有2^r - 1 個節點。

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

下面是具體的代碼實現:

struct TreeNode;
typedef struct TreeNode *PriorityQueue;

struct TreeNode{
    ElementType Element;
    PriorityQueue Left;
    PriorityQueue Right;
    int Npl;    
}

合並左式堆的驅動例程

PriorityQueue
Merge(PriorityQueue H1, PriorityQueue H2){
    if(H1 == NULL)
        return H2;
    if(H2 == NULL)
        return H1;        
    if(H1->Element < H2->Element)
        Merge1(H1, H2);
    else
        Merge1(H2, H1);
}

static PriorityQueue
Merge1(PriorityQueue H1, PriorityQueue H2){
    if(H1->Left == NULL)
        H1->Left = H2;
    else{
        H1->Right = Merge(H1->Right, H2);
        if(H1->Left->Npl < H1->Right->Npl)
            SwapChildren(H1);
        H1->Npl = H1->Right->Npl + 1;
    }
    return H1;
}

左式堆的插入例程

PriorityQueue
Insert1(ElementType X, PriorityQueue H){
    PriorityQueue SingleNode;
    SingleNode = malloc(sizeof(struct TreeNode));
    if(SingNode == NULL){
        FatalError("out of space");
    }else{
        SingleNode->Element = X;
        SingleNode->Npl = 0;
        SingleNode->Left = SingleNode->Right = NULL;
        H = Merge(SingleNode, H);
    }  
    return H;
}

PriorityQueue
DeleteMin1(PriorityQueue H){
    PriorityQueue LeftHeap, RightHeap;
    if(IsEmpty(H)){
        Error("empty");
        return H;
    }
    LeftHeap = H->Left;
    RRightHeap = H->Right;
    free(H);  
    return Merge(LeftHeap, RightHeap);
}

斜堆

斜堆是左式堆的自調節形式,實現起來及其簡單,斜堆和左式堆的關系類似於伸展樹和AVL樹間的關系。斜堆是具有堆序的二叉樹,但是不存在堆樹的結構的限制。不同於左式堆,關於任意節點的零路徑長的任何信息都不保存。斜堆的左路徑在任意時刻都可以任意長,因此所有操作的最壞情形運行時間均為O(N)。然而,正如伸展樹一樣,任意M次操作,總的最壞運行時間是O(MlogN)。因此斜堆每次操作的攤還時間是O(logN)。

 

二項隊列

雖然左式堆和斜堆每次操作花費O(logN)時間,這有效地支持了合並,插入和DeleteMin。但是還有改進的余地,因為二叉堆每次操作花費常數平均時間支持插入。二項隊列支持所有這三種操作,每次操作的最壞情形運行時間是O(logN),而插入操作平均花費常數時間。

 

二項隊列不同於我們已經看到的所有優先隊列的實現之處是,一個二項隊列不是一顆堆序的樹,而是堆序樹的集合,稱為森林。堆序樹中的每一個樹都是有約束的形式,叫二項樹。每個高度上至多存在一顆二項樹。高度為0的二項樹是一顆單節點樹;高度為k的二項樹Bk通過一顆二項樹Bk-1附接到另一顆二項樹Bk-1的根上。下圖顯示了二項樹B0,B1,B2,B3。

 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM