堆、優先級隊列和堆排序


導言

“聚沙成塔,集腋成裘”,使我們非常熟悉的名言警句,其中“聚沙成塔意思”是聚細沙成寶塔,原指兒童堆塔游戲,后比喻積少成多。如果我們把這座塔抽象成一個數據結構的話,那么每一粒沙子都是結構中的元素,而這些元素不斷地往上堆積,最終形成了沙堆,對於這個沙堆來說,如果我們把沙堆按高度分成多層,那么每一層的沙子數量都各不相同,上層的沙子數小於下層的沙子數。這樣的描述就和能夠大致地理解我們要提的堆結構。

二叉堆

完全二叉樹

完全二叉樹的特點在於,二叉樹生成結點的順序必須嚴格按照從上到下,從左往右的順序來生成結點,例如下面3張圖片都是完全二叉樹。



而這種二叉樹就不屬於完全二叉樹。

一個數據結構是堆結構,需要滿足兩個條件:第一,結構是一個完全二叉樹;第二,每個父結點的值都不小於其子結點的值,這樣的堆結構被稱為大頂堆,而每個父結點的值都不大於其子結點的值的堆為小頂堆。例如圖一是大頂堆,圖二是小頂堆:


這樣的結構又叫做二叉堆,其根結點叫做堆頂,無論是大頂堆還是小頂堆,都決定了堆頂元素的值是整個堆中的最大或最小元素。

堆的存儲方式

我們在描述二叉堆時,雖然它是完全二叉樹,但它適合的的存儲方式並不是鏈式存儲,而是順序存儲,我們可以用一個數組來組織。這是因為如果我們按照從上到下,從左到右的順序遍歷完全二叉樹時,順序是這樣的:

那么我們就會發現,設父結點的序號為 k,則子結點的序號會分別為 2k 和 2k + 1,子結點的序號和父結點都是相互對應的,因此我們可以用順序存儲結構來描述二叉堆,例如上文的大頂堆:

調整堆

操作解析

首先我們先來解決數據調整的問題,也就是說當我們去掉堆頂的元素之后,我們需要保證剩下的元素能夠重構成一個新的堆。直接放個例子,假設有如圖所示大頂堆:

下面我們把堆頂貶到最底層,然后根據插入的規則從上到下,從左到右來選擇替換的結點,得到這樣的狀態:

此時我們就需要進行堆的調整,由於此時除根結點外,其余結點均滿足堆的兩個條件,由此僅需由上向下調整一條路徑的結點即可。首先以堆頂元素 5 和其左、右子樹根結點的值進行比較,由於左子樹根結點的值 8 大於右子樹根結點的值 7 且大於根結點的值,因此進行操作使 5 下沉,8上浮。由於 8 替代了 5 之后破壞了左子樹的子堆,因此需從上向下進行和上述相同的調整:

我們需要重復執行直至葉子結點,調整后得到新的堆:

現在請你自行模擬一遍上述大頂堆堆頂退出的過程,得到的新堆為:

在模擬中,我們使用了從上到下,層層篩選,合適的元素上浮,不合適的元素下沉,就像篩子一樣把合適的數據篩選出來一樣,這種調整堆的方式被稱為篩選法

偽代碼

代碼實現

void Heapify(SqList &L,int s,int m)
{          //假設線性表的 data 成員中,data[s + 1…m] 已經是堆,現將 data[s…m] 調整為以 data[s] 為根的大頂堆
    data_root = L.data[s];
    for(i = 2*s; i <= m; i *= 2)    //沿着 key 較大的子結點向下篩選
    {
        if(i < m && L.data[i].key < L.data[i].key)    //i 記錄 key 較大的下標
            i++;
        if(data_root.key >= L.data[i].key)    //找到 data_root 的插入位置
            break;
        L.data[s] = L.data[i];
        s = i;
    }
    L.data[s] = data_root;
}

時間復雜度

調整堆需要比較 log2n 次,因此時間復雜度為 O(logN)

建初堆

操作解析

現在我們有個順序表,這個順序表的數據是無序的,因此要把這個順序表描述為堆結構,就必須令其滿足上述兩個條件,即完全二叉樹中的每一個結點的子樹都要是一個堆結構。對於一個完全二叉樹來說,所有序號大於 n / 2 的結點都是子葉,而只有 1 個結點的樹顯然是堆,因此建初堆的操作本質上就是一個所有非葉子節點依次下沉的過程。例如有如圖順序表:

首先我們需要操作的是 1 號結點,1 號結點小於它的子結點,因此它需要下沉:

接下來是 7 號結點,7 號結點小於它的子結點,因此它需要下沉:

接下來是 9 號結點,7 號結點不小於它的子結點,因此它不需要下沉。接下來是 4 號結點,4 號結點小於它的子結點,因此它需要下沉:

此時 4 號結點仍小於它的子結點,因此它需要繼續下沉:

現在我們就把一個無序的完全二叉樹整理為一個堆結構了。

偽代碼

其實思路是很明確的,我們只需要吧序號為 n / 2、n / 2 - 1、…、1 的結點作為根的子樹統統搞成堆即可。加上我們在剛才已經寫了篩選法的代碼,現在這件事情就變得簡單了。

代碼實現

void CreatHeap(SqList &L)
{              //現以無序序列 data[1…n] 建立大頂堆
    for(int i = L.length / 2; i > 0; i--)
    {
        Heapify(L,i,L.length);
    }
}

時間復雜度

此處時間復雜度可以用級數來推導。我的能力有限,這里引用 “知乎@吳獻策” 的推導過程,原文鏈接

優先級隊列

按照優先級出隊列

對於一個隊列結構而言,隊列中的元素遵循着先進先出,后進后出的規則,元素只能隊列尾進入,出隊列則是隊列頭的元素。而我們現在要談的優先級隊列則是隊列不再遵循先入先出的原則,而是分為兩種情況:最大優先隊列,無論入隊順序,當前最大的元素優先出隊。最小優先隊列,無論入隊順序,當前最小的元素優先出隊。例如這個隊列,我們設這個隊列是個最小優先隊列,因此當這個隊列執行出隊操作的時候,出隊的元素為 1。要滿足如此的需求,我們利用線性表的基本操作同樣可以實現,但是這么做最壞時間復雜度O(n),也就是我們要遍歷這個隊列,顯然這並不是最佳的方式。

我們來回憶一下二叉堆,對於一個二叉堆而言,大頂堆的堆頂是整個堆中的最大元素,小頂堆的堆頂是整個堆中的最小元素。因此,當我們使用大頂堆來實現最大優先隊列時,入隊列操作就是堆的插入操作,出隊列操作就是刪除堆頂節點。假設我們有如圖所示大頂堆:

優先級隊列結構體定義

同順序表,不過我們的目的是用順序存儲結構描述堆結構。

typedef struct HeapStruct
{
    int size;
    ElemType data[MAXSIZE];
}*PriorityQueue;

入隊列操作

上濾

入隊列操作一般使用的策略叫做上濾(percolate up,即新元素在堆中上濾直到找出正確的位置(設堆為 H,待插入的元素為 e,首先在 size + 1 的位置建立一個空穴,然后比較 e 和空穴的父結點的大小,把較小的父親換下來,以此推進,最后把 e 放到合適的位置,該算法時間復雜度為O(㏒n)。

模擬入隊列

假設要在上述大頂堆插入結點 10,首先我們直接將結點按照完全二叉樹的規則入堆:

接着我們將結點依次上浮到合適的位置:


偽代碼

代碼實現

void Insert( ElemType e, PriorityQueue H )
{
    if (H->Size == MAXSIZE)
    {
	cout << "Priority queue is full" ;
	return ;
    }
    for (int i = ++H->size; H->data[i / 2] < e; i /= 2)    //查找合適的位置
	H->data[i] = H->Elements[i / 2];    //上浮操作
    H->data[i] = e;    //插入元素
}

出隊列操作

下濾

出隊列的算法就是直接將堆頂元素出隊列,然后將堆頂的元素替換為在完全二叉樹中對應最后一個元素,接着使用篩選法,逐層推進把較大的子結點換到上層,該算法時間復雜度為O(㏒n)。

模擬出隊列

直接將堆頂元素出堆即可。

接下來令完全二叉樹的最后一個結點成為堆頂,即結點 4。

然后利用篩選法將結點 4 下沉到合適的位置,完成操作。

偽代碼

代碼實現

ElemType DeleteMax( PriorityQueue H )
{
	int Child;
	ElemType Max, LastElem;
 
	if ( H->size == 0 )
	{
		cout << "Priority queue is empty!";
		return H->data[0];
	}
	Max = H->data[1];    //最大元素出隊列 
	LastEleme = H->data[H->Size--];    //最后一個結點替代堆頂 
 
	for (int i = 1; i * 2 <= H->size; i = Child )
	{
		Child = i * 2;    //定位到下一層,尋找更大的子結點 
		if ( Child != H->Size && H->data[Child + 1] > H->data[Child] )
			Child++;
		if ( LastElem > H->data[Child] )    //結點下沉 
			H->data[i] = H->data[Child];
		else	//下沉結束 
			break;
	}
	H->data[i] = LastElem;
	return Max;
}

C++ STL priority_queue

STL 真是 C++ 為我們提供的神兵利器,STL 中為我們封裝好了最小優先隊列和最大優先隊列,包含於頭文件:

#include<queue>

優先隊列具有隊列的所有特性,只是在這基礎上添加了優先級出隊列的機制,它本質是一個堆實現的,不過能夠使用隊列的基本操作:

方法 操作
top 訪問隊頭元素
empty 判斷是否為空隊列
size 返回隊列內元素個數
push 元素從隊尾入隊列(並排序)
emplace 構造一個結點並入隊列
pop 隊頭元素出隊里
swap 交換元素內容

容器定義形式

priority_queue<Type, Container, Functional>
參數 作用
Type 數據類型
Container 容器類型,必須是用數組實現的容器,例如vector(默認)、deque,不能用 list
Functional 比較的方式

這些參數當我們需要用自定義的數據類型時才需要傳入,使用基本數據類型時只需要傳入數據類型,默認使用大頂堆實現優先級隊列。例如以下兩種建法:

priority_queue <int,vector<int>,greater<int> > q;    //升序隊列
priority_queue <int,vector<int>,less<int> >q;    //降序隊列

STL 庫使用例

要求構造兩個優先級隊列,分別是最小優先隊列和最大優先隊列,隨機輸入 5 個數字,分別用大頂堆和小頂堆進行組織,之后將兩個隊列輸出。

運行結果如下:

情景應用:修理牧場

情景需求

情景模擬

為了使費用最省,我們使用貪心算法的思想,每一次選擇最小的兩段木頭拼回去,直到將所有木頭拼成一段完整的木頭,每次一拼接都計算一次費用。我們發現,優先級隊列也是可以實現貪心算法的。

我們首先需要先把這個隊列修改成小頂堆,方便我們實現優先級隊列。

接下來令兩個元素出隊列,計算一次費用,然后將兩個元素之和的數字入隊列。

重復上述操作,使的隊列只剩一個元素。





解法分析

在這里我們可以看出優先級隊列是可以解決問題的,此時的問題是我該怎么控制堆中的數據元素個數?通過觀察,每一次是出堆 2 個元素,入堆 1 個元素,也就是說每次的凈出堆元素是 1 個,那么就在堆中元素為 1 時結束這個流程就行了。
接下來就是如何調整堆的問題了,比較粗暴的方式是每次更改之后都建初堆,但是這樣效率和哈夫曼樹差不多,優化不是很明顯。根據我們剛剛的分析,既然有 2 次出堆,那么在出堆之后保證剩下的元素也是堆就可以了,那么只需要 2 次調整堆。比較方便的操作是第一次出堆時,拿堆的最后一個元素到堆頂調整堆,第二次出堆時直接把入堆元素填充到堆頂調整堆。

代碼實現

#include<iostream>
using namespace std;
void heapify(int a_heap[], int idx1, int idx2);
void creatHeap(int a_heap[], int n);
int main()
{
    int a_heap[10001];
    int count;      //堆中剩余元素個數
    int heap_top;      //暫時保存第一個堆頂
    int money = 0;      //總費用

    cin >> count;
    for (int i = 1; i <= count; i++)
    {
        cin >> a_heap[i];
    }
    creatHeap(a_heap, count);    //建初堆
    while (count != 1)
    {
        heap_top = a_heap[1];    //提取第一個最小花費
        a_heap[1] = a_heap[count--];
        heapify(a_heap, 1, count);    //調整堆尋找第二個最小花費
        a_heap[1] += heap_top;    //提取第二個最小花費,將花費相加,入堆
        money += a_heap[1];    //更新總花費
        heapify(a_heap, 1, count);    // 調整堆,准備下一次拼接
    }
    cout << money;
    return 0;
}

void heapify(int a_heap[], int idx1, int idx2)    //調整堆,細節見上文
{
    int insert_node = a_heap[idx1];

    for (int i = 2 * idx1; i <= idx2; i *= 2)
    {
        if (i < idx2 && a_heap[i] > a_heap[i + 1])
        {
            i++;
        }
        if (insert_node <= a_heap[i])
        {
            break;
        }
        a_heap[idx1] = a_heap[i];
        idx1 = i;
    }
    a_heap[idx1] = insert_node;
}

void creatHeap(int a_heap[], int n)    //建初堆,細節見上文
{
    for (int i = n / 2; i > 0; i--)
    {
        heapify(a_heap, i, n);
    }
}

堆優化 Dijkstra 算法

優化前的時間復雜度

算法中添加頂點的循環執行 n - 1 次,每次執行的時間復雜度為 O(n),所以總時間復雜度為 O(n2)。如果用帶權的鄰接表來存儲,則雖然修改 D 數組的時間可以被降下來,但由於在 D 中選擇最小分量的時間不變,所以時間復雜度仍為O(n2)。我們往往只希望找到從源點到某一個特定終點的最短路徑,但是這個問題和求源點到其他所有頂點的最短路徑一樣復雜,也得用迪傑斯特拉算法來解決。

優化思路


我們先觀察下從 v0 出發的最短路徑的推導過程,我們可以觀察到其實每一次添加的點都是所謂的最短邊的點,然后在下一輪拿這個點來繼續運作算法。其實這就給了我們一個啟示——既然我需要每一輪去探測最短的點,為什么不能直接把這個點彈出來呢?
此時我們就想到了使用最棧或者優先級隊列,這樣就是直接把確定了最短路徑的點取出來就行了。那么堆中應該存儲什么樣的數據?因為每輪循環都需要根據當前頂點修正路徑,因此我們考慮讓修正后的路徑入堆,然后下一輪就可以直接在堆頂找到下一個分析的點進行操作了。除了第一輪循環需要建初堆以外,接下來的路徑引入都只需要調整堆即可。

算法實現

輔助結構

使用鄰接表實現,首先要定義兩個數據類型:

  1. 表示邊的二元組結構體
typedef struct Edge
{
      int vt;
      int cost;
}Edge;
  1. 描述堆頂邊的二元組,使用 STL 庫的 pair 容器,這個主要用在建立優先級隊列:
typedef pair<int,int> Path;      //first 為起點,second 為權值

接下來是實現算法的輔助結構:

  1. 一維數組 D[i]:記錄點 v0 到終點 vi 的當前最短路徑長度。初始化的時候若 v0 到 vi 有弧,則 D[i] 為弧上的權值,否則為 ∞;
  2. 二叉小頂堆 que:存儲所有添加入的邊,並且每次彈出最短的邊;

偽代碼

代碼實現

void ShortestPath_DIJ_HEAP(AdjGraph*& G,int v0)
{
    priority_queue<Path,vector<Path>,greater<Path>> que;      //用 STL 建優先級隊列
    Path a_path;      //存儲彈出的邊的二元組
    int v;      //存儲彈出的邊的起點
    ArcNode* ptr;

    for(int i = 0; i < G.n; i++)      //初始化數組 D
    {
         D[i] = INF;
    }
    D[v0] = 0;
    que.push(Path(0,v0));      //v0 自回路入隊列
    while(!que.empty())
    {
        a_path = que.top();      //提取堆頂
        que.pop();
        v = a_path.first;
        if (D[v] < a_path.second)    //若新的邊沒有小於當前最短距離
        {
            continue;      //跳過這條路徑,排除重復路徑的干擾
        }
        ptr = G->adjlist[v].firstarc;
	while (ptr)      //掃描對應的邊表
        {
            if (D[ptr->jvex] > D[v] + ptr->info)      //判斷是否要修正
            {
                D[ptr->jvex] = D[v] + ptr->info;
                que.push(Path(D[ptr->jvex],ptr->info));      //修正的路徑加入堆
            }
        }
    }
}

優化后的時間復雜度

每個頂點當前的最短距離加入二叉小頂堆,因此在更新最短距離時,堆結構應當自動更新當前的所有結點,使得堆頂表示的邊是最短邊。每次從堆中取出的堆頂就是下一次要用的頂點,用鄰接表存儲的話這樣堆中的元素共有 O(v)個,調整堆的操作有 e 次,結合調整堆的時間復雜度,算法的復雜度是 O(elogv)。相比原來的算法,效率的提高非常明顯!

堆排序

代碼實現

有了前面這么多鋪墊,相信理解堆排序就很容易了。所謂堆排序,就是建初堆后,反復進行交換和調整堆

void HeapSort(SqList &L)
{
      int heap_top;

      creatHeap(L);      //建初堆
      for(int i = L.length; i > 1; i--)
      {
            heap_top = L.r[1];      //拷貝堆頂元素,0 元素不使用
            L.r[1] = L.r[i];
            L.r[i] = heap_top;      //交換堆中無序部分的最后一個元素和堆頂元素
            Heapify(L, 1, i - 1);      //調整堆
      }
}

其實你也能觀察到,如果你是用大頂堆來存的,那么最后會得到一個有序小頂堆。當然你可以做一個小小的改裝,就是每次出堆過后直接用另一個線性表存起來,這樣就不用交換數據元素了,不過思想還是一樣的。

復雜度分析

時間復雜度

堆排序的時間復雜度來源於重復的建初堆操作。由於建初堆的時間復雜度為 O(n),這個操作執行一次,更改堆元素后調整堆時間復雜度 O(logn),這個操作執行 n - 1 次。合計時 O(n) + O(nlogn) ~ O(nlogn)

空間復雜度

由於只需要一個順序表,無需其他輔助空間,因此時間復雜度為 O(1)

算法特點

  1. 堆排序只應用於順序存儲結構,不適用於鏈式存儲結構;
  2. 堆排序是不穩定的排序;
  3. 數據元素較少時不適合使用,因為建初堆的比較次數較多;
  4. 最壞情況時間復雜度為 O(nlogn),相比於快速排序的最壞時間復雜度而言更佳,尤其是在數據元素較多時。

參考資料

《大話數據結構》—— 程傑 著,清華大學出版社
《數據結構教程》—— 李春葆 主編,清華大學出版社
《數據結構(C語言版|第二版)》—— 嚴蔚敏 李冬梅 吳偉民 編著,人民郵電出版社
《數據結構與算法分析 (C語言描述)》—— Mark Allen Weiss著
二叉堆
堆排序(heapsort)
c++優先隊列(priority_queue)用法詳解
使用優先隊列優化的Dijkstra算法
堆排序中建堆過程時間復雜度O(n)怎么來的?
堆排序及其時間復雜度
優先隊列(堆) - C語言實現(摘自數據結構與算法分析 C語言描述)
漫畫:什么是優先隊列?
樹、二叉樹(完全二叉樹、滿二叉樹)概念圖解
dijkstra算法詳解(普通算法和堆優化算法)


免責聲明!

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



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