1. 程式人生 > >深入淺出數據結構C語言版(15)——優先隊列(堆)

深入淺出數據結構C語言版(15)——優先隊列(堆)

turn github png 操作 pri 整數 過程 不難 nbsp

  在普通隊列中,元素出隊的順序是由元素入隊時間決定的,也就是誰先入隊,誰先出隊。但是有時候我們希望有這樣的一個隊列:誰先入隊不重要,重要的是誰的“優先級高”,優先級越高越先出隊。這樣的數據結構我們稱之為優先隊列(priority queue),其常用於一些特殊應用,比如操作系統控制進程的調度程序。

  那麽,優先隊列該如何實現呢?我們可以很快給出三種解決方案。

  1.使用鏈表,插入操作選擇直接插入到表頭,時間復雜度為O(1),出隊操作則遍歷整個表,找到優先級最高者,返回並刪除該結點,時間復雜度為O(N)。

  2.使用鏈表,鏈表中元素按優先級排序,插入操作需為插入結點找到準確位置,時間復雜度為O(N),出隊操作則直接返回並刪除表頭,時間復雜度為O(1)。

  3.使用二叉查找樹,插入操作時間復雜度為O(logN),出隊操作則返回樹中最大(或最小,取決於優先級定義)結點並刪除,時間復雜度亦為O(logN),但是出隊一定會使樹趨向於不平衡。

  如果決定使用鏈表,那麽就必須根據插入操作和出隊操作的比例,決定用方法1還是方法2。

  如果決定使用二叉查找樹,實際上有點“殺雞用牛刀”,因為它支持的操作遠不止插入和出隊(即刪除最大結點或最小結點)。而且一個有N個結點的二叉樹有2N個指針域,但只會用掉N-1個(除了根結點,每個結點必有且只有一個指向自身的指針),也就是說必然有N+1個指針域是NULL,即“浪費”掉了。當然,它的時間復雜度比較均衡。

  不過今天我們將使用一種新的數據結構來實現優先隊列,其同樣可以以O(logN)實現插入與出隊,而且不需要用到指針,這種數據結構就叫——二叉堆。

  在討論二叉堆之前,我們先決定一下我們對優先級的設定,我們假定元素的優先級為正整數,並且值越小的越優先(這對於我們之後實現二叉堆可以帶來一絲方便)

  二叉堆在邏輯結構上就是一棵完全二叉樹,而完全二叉樹即符合下述條件的二叉樹:

  1.除去最底層(即深度最大)的結點後,是一棵滿二叉樹

  2.最底層的結點必須在邏輯上“從左至右”逐一填入,不得有空

  下圖即為一棵完全二叉樹

  技術分享

  完全二叉樹在編程上最大的特點就是它可以使用數組來存儲(而且不是靠遊標數組),其原理很簡單:令根結點存儲在下標1處,則其他任一結點的父親結點均為自身下標i/2(若i為奇數,則商直接取整數部分,這在代碼上很簡單),任一結點的左孩子下標均為自身下標i*2,右孩子則是i*2+1。

  技術分享

  至此,我們確定了兩件事,一,二叉堆就是一棵完全二叉樹;二,完全二叉樹可以用數組存儲,即二叉堆可以用數組存儲。

  我們現在已經實現了說好的“不用指針”,接下來的問題就是如何滿足優先隊列的需求,並且令兩個操作均滿足O(logN)。在那之前,我們先假定好結點結構並給出二叉堆的存儲結構,初始化程序:

//二叉堆結構定義
struct BinaryHeap {
    unsigned int capacity; //capacity表示二叉堆的最大容量
    unsigned int size;   //size表示當前二叉堆的大小,即元素個數
    unsigned int *heap;  //heap即“數組”,根據初始化時給定的大小初始化
};
typedef struct BinaryHeap *PriorityQueue;   //PriorityQueue即優先隊列

PriorityQueue Initialize(unsigned int capacity)
{
    PriorityQueue pPQueue = (PriorityQueue)malloc(sizeof(struct BinaryHeap));
    pPQueue->heap = (unsigned int *)malloc(sizeof(int)*capacity);
    pPQueue->capacity = capacity;
    pPQueue->size = 0;
    pPQueue->heap[0] = 0;  //令heap[0]為0可以避免插入時新元素上濾過頭,習至插入時就明白

    return pPQueue;
}

  那麽,二叉堆是如何滿足優先隊列需求的呢?這就得從二叉堆對結點的要求說起,在二叉堆中結點有且只有兩個要求:

  1.根結點優先級最高

  2.任一結點優先級高於其孩子

  下圖中,只有左側的完全二叉樹符合二叉堆要求,右側結點6不符合二叉堆要求

  技術分享

  接下來帶著這兩個要求,我們看看該如何實現對二叉堆的插入。現在,假設我們已經有了如下二叉堆及一個新結點14。

  技術分享

  數組存儲如下

  技術分享

  首先,我們要確保新結點插入後二叉堆依然是完全二叉樹,保證這一點的方法很簡單,就是讓新結點暫時先插入到完全二叉樹的最後一層最右元素的右邊,直接的說,就是插入到當前數組最後元素的後一個位置。

  然後,我們要讓新結點去往它應在的位置,或者準確點說是應在的層次,這一點的實現非常簡單:令新結點不斷與父結點比較,若新結點優先級更大,則其與父結點交換位置,直到新結點優先級不高於父結點為止。這種策略我們稱之為“上濾”(下圖中空結點即新結點14

  技術分享

  技術分享

  插入過程數組的示意如下:

  技術分享

  知道了插入的思路後,插入的代碼也就不難寫出了:

bool Insert(PriorityQueue pPQueue, unsigned int x)
{
    //由於二叉堆的heap[0]是放棄不用的,所以size最大為capacity-1
    if (pPQueue->size == pPQueue->capacity - 1 || x == 0 || x > INT_MAX)
        return false;

    //CurPos即當前位置,初始化為插入後的二叉堆size,即表尾
    unsigned int CurPos = ++pPQueue->size;

    //不斷地令CurPos對應的父結點與x比較,若大於x則令父結點下濾,等價於令x上濾
    //若小於x則退出循環,此時CurPos即x應處的位置
    for (;pPQueue->heap[CurPos / 2] > x;CurPos /= 2)
    {
        pPQueue->heap[CurPos] = pPQueue->heap[CurPos / 2];
    }
    pPQueue->heap[CurPos] = x;
    return true;
}

  註意到若CurPos為1,即根,則heap[0]將與x比較,為了避免x上濾過頭至heap[0],我們在前面要求了x必須為正整數,而heap[0]則在初始化時設為0,這樣一來heap[0]必小於任一插入元素

  稍加分析就可以看出,插入時的最壞情況也只是新結點上濾到根,此時新結點上濾的路徑就跟向二叉樹中插入了一個葉子結點是類似的,時間復雜度為O(logN)

  現在我們來看看二叉堆是如何實現出隊操作的。在二叉堆中要找優先級最高的結點非常簡單,根結點即是。但是取走了根結點後,該處就成了一個“空結點”,這個“空結點”又該如何處理?簡單的想法是不斷地令“空結點”的孩子中優先級更高者與“空結點”交換,直至“空結點”到最底層。但這個想法容易出錯,如下圖,空結點最後導致了完全二叉樹屬性的破壞

  技術分享

  那麽該如何保證二叉堆的完全二叉樹屬性呢?解決方法就是對上述想法稍加改進:根結點刪除後,令二叉堆最後一個結點頂替其位置,而後逐層“下濾”至其優先級大於其所有孩子為止。這樣一來,二叉堆的完全二叉樹屬性就可以保住。因為這麽做的話,即使“新根結點”下濾到了最底層也不會導致“空結點”的出現從而破壞完全二叉樹屬性。(下圖中空結點即原表尾結點31

  技術分享

  技術分享

  技術分享

  (出隊操作的數組變化略)

  知道了出隊的思路後,出隊的代碼也就不難寫出了:

unsigned int Dequeue(PriorityQueue pPQueue)
{
    //若堆已空則返回0,0必不為表中元素
    if (pPQueue->size == 0)
        return 0;
    
    unsigned int root = pPQueue->heap[1];   //root保存了原堆根,即需要返回的值
    unsigned int LastElement = pPQueue->heap[pPQueue->size--];  //LastElement即表尾元素

    //令LastElement從根開始下濾,所以CurPos初始化為1,child用於指出CurPos兩個孩子中優先級更高的那個
    unsigned int CurPos = 1;
    unsigned int child = CurPos * 2;
    while (child <= pPQueue->size)
    {
        //若child不是最後一個元素,且其兄弟(CurPos的右孩子)優先級更高,則令child指向CurPos右孩子
        if (child != pPQueue->size&&pPQueue->heap[child] > pPQueue->heap[child + 1])
            child += 1;
        //比較LastElement與CurPos最優先的孩子,若LastElement更優先,則循環結束
        //否則令CurPos最優先孩子上濾,等價於令LastElement下濾
        if (pPQueue->heap[child] < LastElement)
        {
            pPQueue->heap[CurPos] = pPQueue->heap[child];
            CurPos = child;
            child = CurPos * 2;
        }
        else
            break;
    }
    //跳出循環後的CurPos即LastElement該處的位置
    pPQueue->heap[CurPos] = LastElement;

    return root;
}

  出隊的時間復雜度與入隊(插入)相同,為O(logN)。

  有了上述代碼,二叉堆就算是基本實現了(Destroy的代碼沒有給出,但實現並不難)。那麽二叉堆,或者說優先隊列(即堆,但不只是二叉堆,還有別的實現方式,均稱為堆或優先隊列)還有什麽別的用處嗎?

  試想一下如果我們將一組需要排序的數據插入到二叉堆去,然後再不斷Dequeue並將得到的元素(即二叉堆的根)插入到普通隊列中,我們是否會得到一個有序的隊列?也就是說,二叉堆可以用來完成排序工作!那麽二叉堆完成排序需要的時間是多少呢?大致是插入時間+出隊時間,即O(N*logN+N*logN),O(N*logN)。這個時間比我們大多數人知曉的冒泡排序、選擇排序要好得多。我們將在之後的博文中完善堆排序的實現方法。

  下面的地址有著二叉堆的簡單實現與試驗,同時展示了二叉堆的排序效果

  https://github.com/nchuXieWei/ForBlog-----BinaryHeap

深入淺出數據結構C語言版(15)——優先隊列(堆)