【數據結構與算法】第6章 優先隊列(堆)

目錄

二叉堆

d堆

左式堆

斜堆

二項隊列

標準庫中的優先隊列


具有特殊優先級的隊列叫做優先隊列。像操作系統中,調度算法往往會使用優先隊列結構。優先隊列至少允許下列兩種操作:insert,deleteMin(找出、返回和刪除優先隊列中的最小項)。


二叉堆

下面將討論其結構性質和堆序性質。

(1)結構性質

二叉堆是一棵被完全填滿的二叉樹(即完全二叉樹),可能的例外是在底層,底層元素從左到右填入。如下:

因爲完全二叉樹很有規律,所以可以用一個數組表示而不需要使用鏈。因爲對於數組中任意位置 i 上的元素,其左兒子在位置 2i 上,右兒子在左兒子後的單元(2i + 1)中,它的父親則在位置  \left \lfloor i/2 \right \rfloor 上。

上圖的數組存儲如下(注意是從下標爲 1 的位置存儲第一個元素):

如下是優先隊列(堆)的整體接口:

template<typename Comparable>
class BinaryHeap
{
public:
    explicit BinaryHeap(int capacity = 100);
    explicit BinaryHeap(const vector<Comparable> & items);

    bool isEmpty() const;
    const Comparable & findMin() const;

    void insert(const Comparable & x);
    void deleteMin();
    void deleteMin(Comparable & minItem);
    void makeEmpty();

private:
    int currentSize;    //堆中元素的個數
    vector<Comparable> array;   //存放堆的數組

    void buildHeap();
    void percolateDown(int hole);
};

(2)堆序性質

使操作可以快速執行的性質即是堆序性質。由於要快速的找到最小元,因此最小元應該在根上。如果任意子樹也是堆,那麼任意結點就應該小於它的所有後裔。

  • 插入操作:insert

插入操作使用一種叫做上濾的策略,新元素在堆中上濾直到找出正確的位置。如將一個元素 X 插入到堆中,我們在下一個空閒的位置創建一個空穴,因爲否則該堆將不是完全樹。如果 X 可以放在空穴中而並不破壞堆序,那麼插入完成。否則把空穴的父結點上的元素移入空穴中,這樣空穴就朝着根的方向上行一步。繼續該過程直到 X 能被放入空穴中爲止。所以最好的時間 O(1),最壞爲 O(logN)。業已證明,執行一次插入平均需要比較 2.607 次,元素平均上移 1.607 層。

    void insert(const Comparable & x)
    {
        if(currentSize == array.size()-1)
            array.resize(array.size()*2);
        
        //上濾
        int hole = ++currentSize;
        for( ; hole>1 && x<array[hole/2]; hole/=2)
            array[hole] = array[hole/2];
        
        array[hole] = x;
    }

如下是一個插入過程:

  • deleteMin操作

該操作使用一種叫做下濾的策略。當刪除一個最小元時,要在根結點建立一個空穴。由於現在堆少了一個元素,因此堆中最後一個元素 X 必須移動到該堆的某個地方。如果 X 可以被放到空穴中,那麼 deleteMin 完成。否則我們將空穴的兩個兒子中的較小者移入空穴,這樣就把空穴向下推了一層。重複該步驟直到 X 可以被放入空穴。所以最壞爲 O(logN)。

    //刪除最小元素
    void deleteMin()
    {
        if(isEmpty())
            throw UnderflowException();

        //下標爲 1 的位置是堆的起始位置
        array[1] = array[currentSize--];
        percolateDown(1);
    }
    //刪除最小元素,放入 minItem 中
    void deleteMin(Comparable & minItem)
    {
        if(isEmpty())
            throw UnderflowException();

        minItem = array[1];
        array[1] = array[currentSize--];
        percolateDown(1);
    }

    //下濾, hole 是下濾的起始位置
    void percolateDown(int hole)
    {
        int child;
        Comparable tmp = array[hole];
        
        for( ; hole*2 <= currentSize; hole = child)
        {
            child = hole * 2;
            if(child != currentSize && array[child + 1] < array[child])
                child++;    //換成右子樹
            
            if(array[child] < tmp)
                array[hole] = array[child];
            else
                break;
        }
        
        array[hole] = tmp;
    }

如下是一個刪除最小值過程:

  • 初始操作:buildHeap

這種操作有兩種方法,一種是對每個元素進行 insert 操作,因爲插入最好的時間 O(1),最壞爲 O(logN),所以這種最好時間爲 O(N),最壞 O(NlogN)。一種是將 N 個元素以任意順序放入樹中,然後下濾非葉結點。由於非葉結點一般爲  \left \lfloor N/2 \right \rfloor ,所以最壞的情況下下濾總次數爲:\sum_{i=0}^{h-1}2^{i}*(h-i),h 爲高度。可以知道這個值爲 S=2^{h+1}-1-(h+1),而 N 的最大值:N=2^{h+1}-1,所以這種構造的時間爲 O(N)。

    explicit BinaryHeap(const vector<Comparable> & items)
        : array(items.size()+10), currentSize(items.size())
    {
        for(int i=0; i<items.size(); i++)
            array[i+1] = items[i];
        buildHeap();
    }

    void buildHeap()
    {
        for(int i=currentSize/2; i>0; i--)
            percolateDown(i);
    }

d堆

d 堆與二叉堆很像,但其所有的結點都有 d 個兒子,因此二叉堆記爲 2 堆。

因爲有很多情形是插入比刪除操作多得多,這種樹就派上用場了。d 堆將 insert 操作運行時間改爲 O(log_{d}N),然而對於 deleteMin 操作,因爲要進行 d-1 次比較,所以時間爲 O(d*log_{d}N)。而且找到兒子和父親的乘法和除法都有個因子 d ,除非 d 是 2 的冪,不然不能通過二進制的移位來實現除法而導致運行時間急劇增加。

如下是一個 3 堆:


左式堆

由於二叉堆的合併操作是比較困難的操作,所以這是一種方便合併操作的堆。我們把任意一個結點 X 的零路徑長(null path length)npl(X) 規定爲從 X 到一個不具有兩個兒子的結點的最短路徑的長。具有 0 個或 1 個兒子結點的 npl 爲 0,而 npl(NULL) = -1 ,如下爲兩棵樹的 npl 情況。

   

可以發現,任意結點的零路徑長比它的諸兒子結點的零路徑長的最小值多 1。這也適用於少於兩個兒子的結點,因爲 null 的零路徑長是 -1。

左式堆:對於堆中的每一個結點 X,左兒子的零路徑長至少與右兒子的零路徑長一樣大。對於左式堆,X 結點的零路徑長度等於右兒子的零路徑長度加 1。

因爲左式堆趨向於加深左路徑,所以右路徑應該短,事實上沿左式堆右側的右路徑確實是該堆中最短路徑。否則,就會存在一條路徑通過某個結點 X ,取得左兒子(可能爲空)的零路徑長度小於右兒子的零路徑長度。

定理:在右路徑上有 r 個結點的左式樹必然至少有 2^{r}-1 個結點。

該定理說明,N 個結點的左式樹有一條右路徑最多含有 \left \lfloor log(N+1) \right \rfloor 個結點。

下面是是實現:

方法一:合併操作(merge)採用遞歸操作

#ifndef LeftistHeap_H
#define LeftistHeap_H

#include <queue>
using namespace std;

template<typename Comparable>
class LeftistHeap
{
public:
    explicit LeftistHeap()
    {
        root = NULL;
    }
    explicit LeftistHeap(const vector<Comparable> & items)
    {
        for(int i=0; i<items.size(); i++)
        {
            LeftistHeap *heap = new LeftistHeap();
            heap->insert(items[i]);
            que.push(heap);
        }
        buildHeap();
    }

    bool isEmpty() const
    {
        if(root == NULL)
            return true;
        else
            return false;
    }

    //C++中千萬不能返回局部對象的引用,因爲返回引用之前已被析構
    const Comparable & findMin() const
    {
        if(isEmpty())
            throw UnderflowException();
        return root->element;
    }

    void insert(const Comparable & x)
    {
        //插入操作變爲一個結點和該堆的合併
        root = merge(new LeftNode(x), root);
    }

    //刪除最小元素
    void deleteMin()
    {
        if(isEmpty())
            throw UnderflowException();

        LeftNode *oldRoot = root;
        root = merge(root->left, root->right);
        delete oldRoot;
    }
    //刪除最小元素,放入 minItem 中
    void deleteMin(Comparable & minItem)
    {
        if(isEmpty())
            throw UnderflowException();

        minItem = findMin();
        deleteMin();
    }
    void makeEmpty();

    //合併 rhs堆 到本堆
    void merge(LeftistHeap & rhs)
    {
        if(this == &rhs)
            return;

        root = merge(root, rhs.root);
        rhs.root = NULL;
    }

    const LeftistHeap & operator=( const LeftistHeap & rhs);

private:
    struct LeftNode
    {
        Comparable  element;
        LeftNode *  left;
        LeftNode *  right;
        int         npl;
        LeftNode( const Comparable &theElement, LeftNode *lt = NULL,
                  LeftNode *rt = NULL, int np = 0)
            :element(theElement), left(lt), right(rt), npl(np){}
    };

    LeftNode *root;
    queue<LeftistHeap *> que;   //存放堆的隊列

    LeftNode * merge(LeftNode *h1, LeftNode *h2)
    {
        if(h1 == NULL) return h2;
        if(h2 == NULL) return h1;
        if(h1->element < h2->element)
            return merge1(h1, h2);
        else
            return merge1(h2, h1);
    }

    //內部合併函數,h1->element < h2->element
    LeftNode * merge1(LeftNode *h1, LeftNode *h2)
    {
        if(h1->left == NULL)    //一個結點的堆的合併
            h1->left = h2;
        else
        {
            h1->right = merge(h1->right, h2);   //h1 左結點非空就合併右結點和 h2
            if(h1->left->npl < h1->right->npl)  //每層遞歸操作完成後檢查該層根結點孩子的零路徑長度
                swapChildren(h1);   //不符合左式堆的時候就交換左右孩子
            h1->npl = h1->right->npl + 1; //根結點 npl = min_npl(h1->left, h1->right) + 1,
                                          //但是左式堆的左結點的npl一定大於或等於右結點
        }
    }

    void swapChildren(LeftNode *t)
    {
        LeftNode *tmp = t->left;
        t->left = t->right;
        t->right = tmp;
    }
    void reclaimMemory(LeftNode *t)
    {
        if(t)
            delete t;
    }
    LeftNode * clone(LeftNode *t) const
    {
        LeftNode *node = new LeftNode(t->element,
                                      t->left, t->right, t->npl);
        return node;
    }

    void buildHeap()
    {
        while (que.size() > 1) {
            LeftistHeap *h1 = que.front();
            que.pop();
            LeftistHeap *h2 = que.front();
            que.pop();
            h1->merge(h2);
            que.push(h1);
        }
        LeftistHeap *heap = que.front();
        que.pop();
        this->merge(heap);
    }
};

#endif // LeftistHeap_H

步驟如下:

方法二:合併操作(merge)非遞歸

先通過合併兩個堆的右路徑建立一棵新的樹,如下

我們可以看到右路徑以排序的方式安排,且保持它們各自的左兒子不變。然後交換該路徑上左式堆性質被破壞的結點的兩個兒子。這就是非遞歸實現的方法。因爲可能由於結點很多,而導致遞歸實現缺乏棧空間,所以該方法適合於大型數據堆合併。

遞歸的方法的合併操作的時間與右路徑的長成正比,合併兩個左式堆的時間界爲 O(logN)。同理插入,刪除都是 O(logN)。


斜堆

首先,斜堆是具有堆序性質的二叉樹,但是沒有樹的結構限制。而左式堆也是具有堆序性質的二叉樹,但是要求 npl(leftChild) >= npl(rightChild)。所以不用保留結點的 npl 信息。除此之外,和左式堆沒有區別。斜堆的右路徑可以任意長。斜堆的基本操作也是合併(merge),只不過交換孩子結點是每次遞歸操作後都有的。看下面的代碼

    //內部合併函數,h1->element < h2->element
    LeftNode * merge1(LeftNode *h1, LeftNode *h2)
    {
        if(h1->left == NULL)    //一個結點的堆的合併
            h1->left = h2;
        else
        {
            h1->right = merge(h1->right, h2);   //h1 左結點非空就合併右結點和 h2
            swapChildren(h1);  //每層遞歸操作完成後就交換左右孩子
        }
    }

我們會發現,每個子樹的右路徑的所有結點的最大者不交換左右兒子,這是因爲它的右兒子必定爲空。這也是由這段代碼決定的,因爲我們的合併操作,總是先考慮到左孩子是否爲空,爲空就放在左邊,所以不存在這樣的結點它只有右孩子。所以右路徑的最大結點要麼沒有孩子,要麼只有左孩子。沒有孩子時最後執行 “h1->left = h2” 跳出遞歸,有左孩子時,交換後,那麼右路徑最大結點變爲其原來的左孩子,它是沒有孩子結點的,當然也符合上面的結論。

 

斜堆的插入、刪除、合併也是 O(logN)。


二項隊列

雖然左式堆和斜堆都以每次操作花費 O(logN) 時間有效地支持合併、插入和 deletMin,二叉堆以每次操作花費常數時間支持插入。二項隊列支持所有這三種操作,每次操作的最壞情形運行時間爲 O(logN),而插入操作平均花費常數時間。

二項隊列不是一顆堆序的樹,而是堆序的樹的集合,稱爲森林。這個集合中的每一棵樹都是有約束的形式,他們叫做二項樹。

下面定義二項樹:高度爲 0 的二項樹是一顆單結點樹;高度爲 k 的二項樹 B_{k} 通過將一顆二項樹 B_{k-1} 附接到另一顆二項樹 B_{k-1} 的根上而構成。下圖顯示二項樹 B_{0},B_{1},B_{2},B_{3},B_{4} :

可見,二項樹 B_{k} 由一個帶有兒子 B_{0},B_{1},...,B_{k-1} 的根組成。高度爲 k 的二項樹恰好有 2^{k} 個結點,其在深度 d 處的結點數是二項係數 C_{k}^{d}

二項隊列要求其集合中的二項樹在任意高度上最多隻有一顆,並且這些二項樹是有堆序要求的。我們表示大小爲 13 的優先隊列可以用森林  \begin{Bmatrix} B_{3},&B_{2}, & B_{0} \end{Bmatrix} 表示。我們把這種表示寫成 1101,它不僅以二進制表示了 13,而且也表示這樣的事實:在上述表示中,B_{0},B_{2},B_{3} 出現,而 B_{1} 則沒有出現。

如下是具有 6 個元素的優先隊列 H_{1}

  • 二項隊列操作

(1)最小元可以通過搜索所有樹的根找出。由於最多有 log(N+1) 棵不同的樹,因此最小元可以以 O(log(N+1)) 時間找到。

(2)合併操作也很容易,基本上是通過兩個隊列加到一起來完成的。下面通過一個例子介紹:

如下兩個二項隊列合併,二項隊列最好是按照高度排序好的,這樣更有效:

首先,令 H_{3} 是新的二項隊列,由於 H_{1} 中沒有高度爲 0 的二項樹而 H_{2} 有,因此將該二項樹作爲 H_{3}  的一部分。然後將兩個高度爲 1 的二項樹相加(大根成爲小根的子樹)從而建立了一個高度爲 2 的二項樹,如下

這樣,H_{3} 中將沒有高度爲 1 的二項樹。現在存在 3 棵高度爲 2 的樹:我們將一顆高度爲 2 的二項樹放到 H_{3} 中併合並其他兩個二項樹。由於 H_{1},H_{2} 中沒有高度爲 3 的二項樹,因此該二項樹就成爲 H_{3} 的一部分,合併結束。如下

由於總共最多存在 O(log(N+1)) 棵二項樹,因此合併在最壞情形下花費時間爲 O(log(N+1))。

(3)插入實際上就是特殊情形的合併,只要創建一顆單結點樹並執行一次合併即可。

最壞情形運行時間爲 O(logN),由於二項隊列中每棵樹出現的概率爲 1/2,於是我們預計插入操作在兩步之後終止,因此平均時間是常數。

T_{n} = \frac{1}{2}\times 1+\frac{1}{2}\times(\frac{1}{2}\times2+\frac{1}{2}\times(\frac{1}{2}\times3+\frac{1}{2}\times(\frac{1}{2}\times...)))

T_{n} = 2-n\cdot (\frac{1}{2})^{n}

(4)deleteMin 可以通過首先找出一顆具有最小根的二項樹來完成。設該樹爲 B_{k},並令原來的優先隊列爲 H,我們從 H 中去除 B_{k} 形成 H^{'}。再去除 B_{k} 的根,得到一些二項樹 B_{0},B_{1},...,B_{k-1},他們共同形成優先隊列 H^{''}。合併 H^{'}H^{''} 即可。整個花費爲 2*O(logN) 的時間。

  • 二項隊列的實現

二項樹的每一個結點包含數據、第一個兒子以及右兄弟。

以下爲二項隊列類架構及結點定義

#ifndef BINOMIALQUEUE_H
#define BINOMIALQUEUE_H

template <typename Comparable>
class BinomialQueue
{
public:
    BinomialQueue();
    BinomialQueue(const Comparable & item);
    BinomialQueue(const BinomialQueue & rhs);
    ~BinomialQueue();

    bool isEmpty() const;
    const Comparable & findMin() const;

    void insert(const Comparable & x);
    void deleteMin();
    void deleteMin(Comparable & minItem);

    void makeEmpty();
    void merge(BinomialQueue & rhs);

    const BinomialQueue & operator= (const BinomialQueue & rhs);

private:
    struct BinomialNode
    {
        Comparable element;
        BinomialNode *leftChild;
        BinomialNode *nextSibling;
        BinomialNode( const Comparable & theElement,
                      BinomialNode *lt, BinomialNode *rt)
            :element(theElement), leftChild(lt), nextSibling(rt){}
    };

    enum{ DEFAULT_TREES = 1 };

    int currentSize; // number of items in priorty queue
    vector<BinomialNode *> theTrees; // an array of tree roots

    int findMinIndex() const;
    int capacity() const;
    BinomialNode * combineTrees(BinomialNode *t1, BinomialNode *t2);
    void makeEmpty(BinomialNode *& t);
    BinomialNode * clone(BinomialNode *t) const;
};

#endif // BINOMIALQUEUE_H

合併操作只涉及到同樣高度的兩個二項樹的合併操作,所以以下代碼是合併兩個同樣大小的二項樹程序代碼:

    BinomialNode * combineTrees(BinomialNode *t1, BinomialNode *t2)
    {
        if(t2->element < t1->element)
            return combineTrees(t2, t1);
        t2->nextSibling = t1->leftChild;
        t1->leftChild = t2;
        return t1;
    }

合併兩個優先隊列的程序代碼:

    // merge rhs into the priority queue. rhs becomes empty.
    // carry is previous step's reslut.
    void merge(BinomialQueue & rhs)
    {
        if(this == &rhs)
            return;

        currentSize += rhs.currentSize;

        if(currentSize > capacity())
        {
            int oldNumTrees = theTrees.size();
            int newNumTrees = max(theTrees.size(), rhs.theTrees.size()) + 1;
            theTrees.resize(newNumTrees);
            for(int i=oldNumTrees; i<newNumTrees; i++)
                theTrees[i] = NULL;
        }

        BinomialNode *carry = NULL;
        for(int i=0, j=1; j<=currentSize; i++, j*=2)
        {
            BinomialNode *t1 = theTrees[i];
            BinomialNode *t2 = i<rhs.theTrees.size() ? rhs.theTrees[i] : NULL;

            int whichCase = t1==NULL ? 0 : 1;
            whichCase += t2==NULL ? 0 : 2;
            whichCase += carry==NULL ? 0 : 4;
            
            switch (whichCase) {
            case 0: // no trees
            case 1: // only this
                break;
            case 2: // only rhs
                theTrees[i] = t2;
                rhs.theTrees[i] = NULL;
                break;
            case 4: // only carry
                theTrees[i] = carry;
                carry = NULL;
                break;
            case 3: // this and rhs
                carry = combineTrees(t1, t2);
                theTrees[i] = rhs.theTrees[i] = NULL;
                break;
            case 5: // this and carry
                carry = combineTrees(t1, carry);
                theTrees[i] = NULL;
                break;
            case 6: // rhs and carry
                carry = combineTrees(t2, carry);
                rhs.theTrees[i] = NULL;
                break;
            case 7: // this rhs and carry
                theTrees[i] = carry;
                carry = combineTrees(t1, t2);
                rhs.theTrees[i] = NULL;
                break;
            default:
                break;
            }
        }
        
        for(int k=0; k<rhs.theTrees.size(); k++)
            rhs.theTrees[k] = NULL;
        rhs.currentSize = 0;
    }

以下爲 deleteMin 程序代碼:

    void deleteMin(Comparable & minItem)
    {
        if(isEmpty())
            throw UnderflowException();
        
        int minIndex = findMinIndex();
        minItem = theTrees[minIndex]->element;
        
        BinomialNode *oldRoot = theTrees[minIndex];
        BinomialNode *deletedTree = oldRoot->leftChild;
        delete oldRoot;
        
        // construct H"
        BinomialQueue deletedQueue;
        deletedQueue.theTrees.resize(minIndex + 1);
        deletedQueue.currentSize = (1 << minIndex) - 1;
        for(int j=minIndex - 1; j>=0; j--)
        {
            deletedQueue.theTrees[j] = deletedTree;
            deletedTree = deletedTree->nextSibling;
            deletedQueue.theTrees[j]->nextSibling = NULL;
        }
        
        // construct H'
        theTrees[minIndex] = NULL;
        currentSize -= deletedQueue.currentSize + 1;
        
        merge(deletedQueue);
    }

    // find index of tree containing the smallest item in the priority queue.
    // the priority queue must not be empty.
    // return the index of tree containing the smallest item.
    int findMinIndex() const
    {
        int i;
        int minIndex;
        
        for(i=0; theTrees[i]==NULL; i++)
            ;
        
        for(minIndex=i; i<theTrees.size(); i++)
            if(theTrees[i] != NULL && 
                    theTrees[i]->element < theTrees[minIndex]->element)
                minIndex = i;
        
        return minIndex;
    }

標準庫中的優先隊列

在 STL 中,二叉堆是通過稱爲 priority_queue 的類模板實現的,該模板在標準頭文件 queue 中找到。只不過 STL 實現一個最大堆而不是最小堆,於是所訪問的項就是最大項。使用 greater 函數對象作爲比較器可以得到最小堆。

堆中元素重複是允許的,刪除也只刪除一個。

以下爲最大堆和最小堆的一個例程:

#include <iostream>
#include <vector>
#include <queue>
#include <functional>
#include <string>
using namespace std;

// empty the priority queue and print its contents
template <typename PriorityQueue>
void dumpContents(const string & msg, PriorityQueue & pq)
{
    cout << msg << ":" << endl;
    while (!pq.empty()) {
        cout << pq.top() << endl;
        pq.pop();
    }
}

int main()
{
    priority_queue<int> maxPQ;
    priority_queue<int, vector<int>, greater<int> > minPQ;
    
    minPQ.push(4); minPQ.push(3); minPQ.push(5);
    maxPQ.push(4); maxPQ.push(3); maxPQ.push(5);
    
    dumpContents("minPQ", minPQ); // 3 4 5
    dumpContents("maxPQ", maxPQ); // 5 4 3
    return 0;
}

 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章