演算法與資料結構番外(1):優先佇列

Albert_Shen發表於2020-05-04

這是演算法與資料結構番外系列的第一篇,這個系列未來的主要內容是補充一些與演算法與資料結構相關的知識,這些知識比較零碎,同時也與正傳關係密切,往往需要閱讀了正傳的相關內容以後,才能較好的理解這部分內容。如果對番外系列不感興趣的話,是可以跳過此係列內容的,不會影響理解其他文章的內容。

閱讀本文前,需要首先了解佇列的相關知識。

此文優先佇列的程式碼可以在我的 github 上檢視。

優先佇列

優先佇列是一種特殊的佇列。佇列具有先進先出的特性,對於普通佇列而言,首先出隊的元素是首先入隊的元素,而優先佇列中,首先出隊的元素是目前佇列中優先順序最高的元素

優先佇列分為兩類,最大優先佇列和最小優先佇列。一個最大優先佇列的嚴格定義如下:

最大優先佇列是一種用來維護由一組元素構成的集合 S 的資料結構,其中的每一個元素都有一個相關的值,稱為關鍵字(key,也就是我們上文所說的優先順序),一個最大優先佇列應該支援以下操作:

INSERT(S, x):把元素 x 插入集合 S 中。

MAXIMUM(S):返回 S 中具有最大關鍵字的元素。

EXTRACT-MAX(S):去掉並返回 S 中的具有最大關鍵字的元素。

最大優先佇列最為著名的應用場景,是共享計算機系統的作業排程。最大優先佇列將會記錄將要執行的各個作業以及它們之間的相對優先順序。當一個作業完成或者被中斷後,排程器呼叫 EXTRACT-MAX 從所有的等待作業中,選出具有最高優先順序的作業來執行,在任何時候,排程器都可以呼叫 INSERT 把一個新作業加入到佇列中來。

相應的,最小優先佇列則是選擇集合中具有最小關鍵字的元素。最小優先佇列經常被用於模擬。佇列中儲存要模擬的事件,每個事件都有一個發生時間作為其關鍵字,事件將會按照時間先後順序依次發生。模擬程式呼叫 EXTRACT-MIN (即與 EXTRACT-MAX 正好相反的功能:去掉並返回 S 中的具有最小關鍵字的元素)選擇下一個要模擬的事件,當一個新事件產生時,模擬器通過呼叫 INSERT 將其插入最小優先佇列。

優先佇列可以使用堆來實現,最大優先佇列對應最大堆,最小優先佇列對應最小堆。下面我們將以最大優先佇列為例進行介紹。

優先佇列的實現

優先佇列的定義

優先佇列的定義與佇列幾乎完全一樣:

// 優先佇列定義
typedef struct PriorityQueue{
    int* array;
    int max_size;
}PriorityQueue;

輔助函式

這些輔助函式我們在堆排序的那一章中已經寫好,這裡可以直接使用。

獲取堆中節點的父節點,左孩子和右孩子節點下標

#define PARENT(i) (i / 2)
#define LEFT(i) (2 * i)
#define RIGHT(i) (2 * i + 1)

交換陣列兩個元素的值:

// 交換陣列 下標i 和 下標j 對應的值
int swap(int *array, int i, int j){
    int temp;
    temp = array[i];
    array[i] = array[j];
    array[j] = temp;
    return 0;
}

遞迴維護最大堆:

// 遞迴維護最大堆
int MaintainMaxHeap(int *heap, int i){
    int largest;
    int left = LEFT(i);
    int right = RIGHT(i);
    if(left <= heap[0] && heap[left] > heap[i]){
        largest = left;
    } else{
        largest = i;
    }
    if(right <= heap[0] && heap[right] > heap[largest]){
        largest = right;
    }
    if(largest != i){
        swap(heap, largest, i);
        MaintainMaxHeap(heap, largest);
    }
    return 0;
}

這些輔助函式是直接採用的堆排序所用程式碼,由於篇幅有限,故不再重複解釋,可以點此檢視相關解釋。

初始化函式

// 初始化優先佇列
PriorityQueue* PriorityQueueInit(int max_size){
    PriorityQueue* priority_queue = (PriorityQueue*)malloc(sizeof(PriorityQueue));
    priority_queue->array = (int*)malloc(sizeof(int) * (max_size + 1));
    priority_queue->array[0] = 0;
    priority_queue->max_size = max_size;
    return priority_queue;
}

我們在這裡,依然使用堆排序中的陣列解釋方法:array[0] 用於儲存堆中的有效資料個數,故陣列的實際長度為 max_size + 1,堆頂元素是 array[1]。

入隊函式

// 優先佇列入隊
int PriorityQueueEnqueue(PriorityQueue *priority_queue, int number_to_enqueue){
    int i;
    priority_queue->array[0] += 1;
    i = priority_queue->array[0];
    priority_queue->array[priority_queue->array[0]] = number_to_enqueue;
    while(i > 1 && priority_queue->array[PARENT(i)] < priority_queue->array[i]){
        swap(priority_queue->array, PARENT(i), i);
    }
    return 0;
}

整個最大優先佇列本質上是一個最大堆,當我們插入一個資料時,首先將其插入至堆的尾部,此時可能會違背最大堆的性質,故我們將此元素不斷與其父節點的值進行比較,若其小於父節點的值,說明此時整個堆已經是一個最大堆了;若其大於父節點的值,則將此節點與父節點交換,重複此步驟,直到此元素小於其父節點的值或此元素成為了堆頂節點。

顯然,入隊操作的時間複雜度是 \(O(lgn)\) ,因為整個函式中影響其時間複雜度的過程為 while 迴圈,其最差情況是將此元素從葉節點一步一步交換至根節點,而樹的高度為 \(O(lgn)\)

整個過程如下圖所示:

出隊函式

// 優先佇列隊首元素
int PriorityQueueHead(PriorityQueue *priority_queue){
    return priority_queue->array[1];
}

// 優先佇列出隊
int PriorityQueueDequeue(PriorityQueue *priority_queue){
    int return_number = priority_queue->array[1];
    priority_queue->array[1] = priority_queue->array[priority_queue->array[0]];
    priority_queue->array[0] -= 1;
    MaintainMaxHeap(priority_queue->array, 1);
    return return_number;
}

在理解了堆排序以後,優先佇列的出隊操作很簡單了。

一個最大優先佇列出隊時返回的值為佇列中的最大值,即 array[1],那麼我們只需要像堆排序時那樣,將堆中最後一個有效資料複製到堆頂(array[1]),此時新的堆頂可能會違反最大堆的性質,為此我們只需對堆頂元素呼叫一次 MaintainMaxHeap() ,即可保證出隊後,此堆依然是一個最大堆。

關於此過程的時間複雜度和正確性分析,我們已經在堆排序一章中介紹過了,在此就不贅述了,直接給出結果 \(O(lgn)\)

總結

在理解了堆排序以後,優先佇列就非常簡單了,幾乎連程式碼都不需要太多修改。從上文的分析,我們也能得出一個結論,優先佇列最為關鍵的兩個步驟(入隊、出隊)的時間複雜度均為 \(O(lgn)\)

原文連結:albertcode.info

個人部落格:albertcode.info

相關文章