導言
“聚沙成塔,集腋成裘”,使我們非常熟悉的名言警句,其中“聚沙成塔意思”是聚細沙成寶塔,原指兒童堆塔游戲,后比喻積少成多。如果我們把這座塔抽象成一個數據結構的話,那么每一粒沙子都是結構中的元素,而這些元素不斷地往上堆積,最終形成了沙堆,對於這個沙堆來說,如果我們把沙堆按高度分成多層,那么每一層的沙子數量都各不相同,上層的沙子數小於下層的沙子數。這樣的描述就和能夠大致地理解我們要提的堆結構。
二叉堆
完全二叉樹
完全二叉樹的特點在於,二叉樹生成結點的順序必須嚴格按照從上到下,從左往右的順序來生成結點,例如下面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 出發的最短路徑的推導過程,我們可以觀察到其實每一次添加的點都是所謂的最短邊的點,然后在下一輪拿這個點來繼續運作算法。其實這就給了我們一個啟示——既然我需要每一輪去探測最短的點,為什么不能直接把這個點彈出來呢?
此時我們就想到了使用最棧或者優先級隊列,這樣就是直接把確定了最短路徑的點取出來就行了。那么堆中應該存儲什么樣的數據?因為每輪循環都需要根據當前頂點修正路徑,因此我們考慮讓修正后的路徑入堆,然后下一輪就可以直接在堆頂找到下一個分析的點進行操作了。除了第一輪循環需要建初堆以外,接下來的路徑引入都只需要調整堆即可。
算法實現
輔助結構
使用鄰接表實現,首先要定義兩個數據類型:
- 表示邊的二元組結構體
typedef struct Edge
{
int vt;
int cost;
}Edge;
- 描述堆頂邊的二元組,使用 STL 庫的 pair 容器,這個主要用在建立優先級隊列:
typedef pair<int,int> Path; //first 為起點,second 為權值
接下來是實現算法的輔助結構:
- 一維數組 D[i]:記錄點 v0 到終點 vi 的當前最短路徑長度。初始化的時候若 v0 到 vi 有弧,則 D[i] 為弧上的權值,否則為 ∞;
- 二叉小頂堆 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)。
算法特點
- 堆排序只應用於順序存儲結構,不適用於鏈式存儲結構;
- 堆排序是不穩定的排序;
- 數據元素較少時不適合使用,因為建初堆的比較次數較多;
- 最壞情況時間復雜度為 O(nlogn),相比於快速排序的最壞時間復雜度而言更佳,尤其是在數據元素較多時。
參考資料
《大話數據結構》—— 程傑 著,清華大學出版社
《數據結構教程》—— 李春葆 主編,清華大學出版社
《數據結構(C語言版|第二版)》—— 嚴蔚敏 李冬梅 吳偉民 編著,人民郵電出版社
《數據結構與算法分析 (C語言描述)》—— Mark Allen Weiss著
二叉堆
堆排序(heapsort)
c++優先隊列(priority_queue)用法詳解
使用優先隊列優化的Dijkstra算法
堆排序中建堆過程時間復雜度O(n)怎么來的?
堆排序及其時間復雜度
優先隊列(堆) - C語言實現(摘自數據結構與算法分析 C語言描述)
漫畫:什么是優先隊列?
樹、二叉樹(完全二叉樹、滿二叉樹)概念圖解
dijkstra算法詳解(普通算法和堆優化算法)