不同於前面幾篇O(n^2)或O(n*logn)排序算法,此篇文章將講解另一個排序算法——堆排序,也是此係列的第一個數據結構—–堆,需要注意的是在堆結構中排序是次要的,重要的是堆結構及衍生出來的數據結構問題,排序只是堆應用之一。
此篇涉及的知識點有:
- 堆的基本存儲
- Shift Up和Shift Down
- 基礎堆排序和Heapify
- 優化的堆排序
挖掘算法中的數據結構(一):選擇、插入、冒泡、希爾排序 及 O(n^2)排序算法思考
挖掘算法中的數據結構(二):O(n*logn)排序算法之 歸併排序(自頂向下、自底向上) 及 算法優化
挖掘算法中的數據結構(三):O(n*logn)排序算法之 快速排序(隨機化、二路、三路排序) 及衍生算法
一. 堆結構
1. 優先隊列
首先來了解堆的經典應用—–優先隊列,此概念並不陌生:
- 普通隊列:先進先出,後進後出。關鍵爲由時間順序決定出隊順序。
- 優先隊列:出隊順序和入隊順序無關,和優先級相關。
優先隊列在OS的使用
而優先隊列這種機制在計算機中被大量使用,最典型應用就是操作系統執行任務,它需要同時執行多個任務,而實際上是將CPU執行週期劃分時間片,在時間片中執行一個任務,每一個任務都有優先級,OS動態選擇優先級最高的任務執行,所以需要使用優先隊列,所有任務進行優先隊列,由隊列來進行調度需要執行哪個任務。
爲什麼使用優先隊列?
注意“動態”的重要性,如果任務是固定的話,可以將這些任務排序好安裝優先級最高到最低依次執行,可是實際處理確要複雜得多。如下圖:藍色任務處理中心就類似CPU,由它來處理所有請求(紅色代表Request)。選擇執行某個請求後,下一步不是簡單地選擇另一個請求執行,與此同時可能會來新的任務,不僅如此,舊的任務優先級可能會發生改變,所以將所有任務按優先級排序再依次執行是不現實的。
所以優先隊列模型不僅適用於OS,更存在與生活中方方面面,例如大家同時請求某個網頁,服務器端需要依次迴應請求,迴應的順序通常是按照優先隊列決定的。
優先隊列處理“靜態問題”
前面一直在強調優先隊列善於處理“動態”的情況,但其實對於“靜態”也是十分擅長,例如在1,000,000個元素中選出前100名,也就是“在N個元素中選出前M個元素”。
在前三篇博文中學習了排序算法後,很快得到將所有元素排序,選出前M個元素即可,時間複雜度爲O(n*logn)。但是使用了優先隊列,可將時間複雜度降低爲O(n *logM)!具體實現涉及到優先隊列實現,後續介紹。
優先隊列主要操作
- 入隊
- 出隊(取出優先級最高的元素)
優先隊列採用的數據結構:
- 數組:最簡單的數據結構實現方式,有兩種形式
- 普通數組:入隊直接插入數組最後一個位置,而取出優先級最高的元素需要掃描整個數組。
- 順序數組: 維護數組有序性,入隊時需要遍歷數組找到合適位置,而出隊時取出隊頭即可。
- 堆:以上兩種實現方式有其侷限性,無法很好平衡出入對操作。而使用堆這種數據結構雖然出入隊時是蠻於前兩者的,但是平均而言維持優先隊列完成系統任務所用時間大大低於使用數組。
舉個例子,對於總共N個請求:
- 使用普通數組或者順序數組,最差情況:O(n^2)
- 使用堆:O(nlgn)
2. 二叉堆(Binary Heap)的基本存儲
因此若要實現優先隊列,必須採用堆數據結構,下面介紹堆有關知識及如何實現。
(1)概念特徵
在以上了解堆中操作都是O(n *logn)級別,應當知道堆相應的是一種樹形結構,其中最爲經典的是二叉堆,類似於二叉樹,每一個節點可以有兩個子節點,特點:
- 在二叉樹上任何一個子節點都不大於其父節點。
- 必須是一棵完全的二叉樹,即除了最後一層外,以上層數的節點都必須存在並且狐妖集中在左側。
注意:第一個特徵中說明在二叉樹上任何一個子節點都不大於其父節點,並不意味着層數越高節點數越大,這都是相對父節點而言的。例如第三層的19比第二層的16大。
這樣的二叉堆又被稱爲“最大堆”,父節點總是比子節點大,同理而言“最小堆”中父節點總是比子節點小,這裏只講解“最大堆”。
(2)結構實現
對於其具體實現,熟悉樹形結構的同學可能認爲需要兩個指針來實現左、右節點,當然可以這樣實現,但是還有一個經典實現方式——通過數組實現,正是因爲堆是一棵完全的二叉樹。
將這棵二叉樹自上到下、自左到右地給每一個節點標上一個序列號,如下圖所示。對於每一個父節點而言:
- 它的左孩子序列號都是本身序列號的 2倍
- 它的右孩子序列號都是本身序列號的 2倍+1
(這裏的根節點下標是由1開始而得出以上規則,但其實由0開始也可得出相應的規則,此部分重點還是放在下標1開始)
(3)基本結構代碼實現
template<typename Item>
class MaxHeap{
private:
Item *data;
int count;
public:
// 構造函數, 構造一個空堆, 可容納capacity個元素
MaxHeap(int capacity){
data = new Item[capacity+1];
count = 0;
}
~MaxHeap(){
delete[] data;
}
// 返回堆中的元素個數
int size(){
return count;
}
// 返回一個布爾值, 表示堆中是否爲空
bool isEmpty(){
return count == 0;
}
};
// 測試 MaxHeap
int main() {
MaxHeap<int> maxheap = MaxHeap<int>(100);
cout<<maxheap.size()<<endl;
return 0;
}
以上C++代碼並不複雜,只是簡單實現了最大堆(MaxHeap)的基本結構,定義了data值,因爲不知道值的具體類型,通過模板(泛型)結合指針來定義,提供簡單的構造、析構、簡單函數方法。
3. 二叉堆中的 Shift Up 和 Shift Down
在完成代碼的二叉堆基本結構後,需要實現最重要的兩個操作邏輯,即Shift Up 和 Shift Down。
(1)Shift Up
下面就實現在二叉堆中如何插入一個元素,即優先隊列中“入隊操作”。以下動畫中需要插入元素52,由於二叉堆是用數組表示,所以相當於在數組末尾添加一個元素,相當於52是索引值11的元素。
算法思想
注意!其實整個邏輯思想完全依賴於二叉樹的特徵,因爲在二叉堆上任何一個子節點都不大於其父節點,所以需要將新插入的元素挪到合適位置來維護此特徵:
- 首先判斷新加入的元素(先歸到二叉堆中)和其父節點的大小,52比16小,所以交換位置。
- 52被換到一個新位置,再繼續查看52是否大於其父節點,發現52比41大,繼續交換。
- 再繼續判斷,52比62小,無須挪動位置,插入完成。
代碼實現
在MaxHeap中新增一個insert
方法,傳入新增元素在二叉堆中的下標
//將下標k的新增元素放入到二叉堆中合適位置
void shiftUp(int k){
while( k > 1 && data[k/2] < data[k] ){//邊界&&循環與父節點比較
swap( data[k/2], data[k] );
k /= 2;
}
}
// 像最大堆中插入一個新的元素 item
void insert(Item item){
assert( count + 1 <= capacity );
data[count+1] = item;//注意下標是從1開始,所以新增元素插入位置爲count+1,並非count
count ++;//數量增加1
shiftUp(count);
}
注意:以上代碼中嚴格需要注意邊界問題,因爲在創建MaxHeap已設置好數組個數MaxHeap<int> maxheap = MaxHeap<int>(100);
,所以在上述insert
中使用了assert函數來判斷,若超過數組長度則不插入。其實這裏有另外一種更好的解決方法,就是超過時動態增加數組長度,由於此篇重點爲數據結構,留給各位實現。
測試:
創建一個長度爲20的數組,隨機數字循環插入,最後打印出來,結果如下:(測試代碼不粘貼,詳細見源碼)
(2)Shift Down
上一部分講解了如何從二叉堆中插入一個元素,此部分講解如何取出一個元素,即優先隊列中“出隊操作”。
算法思想
- 根據二叉堆的特徵,其根節點值最大,所以直接獲取下標1的元素,但是根節點值空缺處理,需要重新整理整個二叉樹。
- 將數組中最後一個值替補到根節點,count數組總數量減1。因爲在二叉堆上任何一個子節點都不大於其父節點。所以需要調節根節點元素,相應的向下移,不同於Shift Up,它可以向左下移或右下移,這裏採用的標準是跟元素值較大的孩子進行交換:
- 根節點與16與52、30比較,將16和52進行交換。
- 將交換後的16與兩個孩子28、41比較,與41交換。
- 交換後的16此時只有一個孩子15,比其大,無需交換。Shift Down過程完成。
代碼實現
void shiftDown(int k){
while( 2*k <= count ){
int j = 2*k; // 在此輪循環中,data[k]和data[j]交換位置
if( j+1 <= count && data[j+1] > data[j] )
j ++;
// data[j] 是 data[2*k]和data[2*k+1]中的最大值
if( data[k] >= data[j] ) break;
swap( data[k] , data[j] );
k = j;
}
}
// 從最大堆中取出堆頂元素, 即堆中所存儲的最大數據
Item extractMax(){
assert( count > 0 );
Item ret = data[1];
swap( data[1] , data[count] );
count --;
shiftDown(1);
return ret;
}
測試
首先設置二叉堆長度爲20,使用MaxHeap中的insert
方法隨機插入20個元素,再調用extractMax
方法將數據逐漸取出來,取出來的順序應該是按照從大到小的順序取出來的。
// 測試最大堆
int main() {
MaxHeap<int> maxheap = MaxHeap<int>(100);
srand(time(NULL));
int n = 20; // 隨機生成n個元素放入最大堆中
for( int i = 0 ; i < n ; i ++ ){
maxheap.insert( rand()%100 );
}
int* arr = new int[n];
// 將maxheap中的數據逐漸使用extractMax取出來
// 取出來的順序應該是按照從大到小的順序取出來的
for( int i = 0 ; i < n ; i ++ ){
arr[i] = maxheap.extractMax();
cout<<arr[i]<<" ";
}
cout<<endl;
// 確保arr數組是從大到小排列的
for( int i = 1 ; i < n ; i ++ )
assert( arr[i-1] >= arr[i] );
delete[] arr;
return 0;
}
結果
二. 二叉堆優化
1. Heapify
在學習以上二叉堆實現後,發現它同樣可用於排序,不斷調用二叉堆的extractMax
方法,即可取出數據。(從大到小的順序)
// heapSort1, 將所有的元素依次添加到堆中, 在將所有元素從堆中依次取出來, 即完成了排序
// 無論是創建堆的過程, 還是從堆中依次取出元素的過程, 時間複雜度均爲O(nlogn)
// 整個堆排序的整體時間複雜度爲O(nlogn)
template<typename T>
void heapSort1(T arr[], int n){
MaxHeap<T> maxheap = MaxHeap<T>(n);
for( int i = 0 ; i < n ; i ++ )
maxheap.insert(arr[i]);
for( int i = n-1 ; i >= 0 ; i-- )
arr[i] = maxheap.extractMax();
}
(1)測試
所以以下將二叉堆和之前所學到的O(n*logn)排序算法比較測試,分別對
- 無序數組
- 近乎有序數組
- 包含大量重複值數組
以上3組測試用例進行時間比較,結果如下(測試代碼查看github源碼):
雖然二叉堆排序使用的時間相較於其它排序算法要慢,但使用時間仍在接收範圍內。因爲整個堆排序的整體時間複雜度爲O(nlogn) ,無論是創建堆的過程, 還是從堆中依次取出元素的過程, 時間複雜度均爲O(nlogn)。總共循環n此,每次循環二叉樹操作消耗O(logn),所以最後是O(nlogn)。
但是還可以繼續優化,使性能達到更優以上過程創建二叉堆的過程是一個個將元素插入,其實還有更好的方式——Heapify。
(2)Heapify算法思想
給定一個數組,使這個數組形成堆的形狀,此過程名爲Heapify。例如以下數組{15,17,19,13,22,16,28,30,41,62}:
此數組形成的二叉樹並非最大堆,不滿足特徵。但是上圖中的葉子節點,即最後一層的每個節點可看作是一個最大堆(因爲只有它一個節點)。接着再向上遞進一層:
- 由最後一個節點開始,考察父節點22是否大於孩子62,不滿足則交換位置。這樣這兩個節點組成的子樹滿足最大堆特徵。
- 再考慮父節點13是否大於孩子30、41,不滿足則與最大值的孩子交換位置。
- 依次類推,其實思想與Shift Down相似。
(3)代碼實現
所以,此堆排序的優化就是修改其創建方法,不通過一個一個元素插入來創建二叉堆,而是通過Heapify方法來完成創建,此過程消耗的時間複雜度爲O(n),性能更優。
需要修改MaxHeap中的構造函數,傳入參數爲無序的數組和數組長度,首先開闢空間,下標從1開始將數組元素值賦值到新數組中,再結合Shift Down方法層層遞進。
// 構造函數, 通過一個給定數組創建一個最大堆
// 該構造堆的過程, 時間複雜度爲O(n)
MaxHeap(Item arr[], int n){
data = new Item[n+1];
capacity = n;
for( int i = 0 ; i < n ; i ++ )
data[i+1] = arr[i];
count = n;
for( int i = count/2 ; i >= 1 ; i -- )
shiftDown(i);
}
template<typename T>
void heapSort2(T arr[], int n){
//優化後的創建二叉堆構造函數
MaxHeap<T> maxheap = MaxHeap<T>(arr,n);
for( int i = n-1 ; i >= 0 ; i-- )
arr[i] = maxheap.extractMax();
}
(4)測試
通過優化後的創建二叉堆構造函數再次測試,結果如下:
可明顯看出優化創建二叉堆構造函數後,堆排序使用時間更少
結論
將n個元素逐個插入到一個空堆中,算法複雜度是O(nlogn),而使用Heapify的過程,算法複雜度爲O(n)
2. 原地堆排序
不同於其他排序算法,在堆排序中需要將數組元素放入“堆”中,需要開闢新的數組,相當於開了額外的O(n)空間,其實可以繼續優化不適用空間原地對元素進行排序。
引出第二個優化 —— 原地堆排序,事實上,按照堆排序的思想,可以原地進行排序,不需要任何額外空間。
算法思想
其思想也很簡單,通過之前構造堆這個類的過程已知一個數組可以看成是隊列。因此將一個數組構造“最大堆”:
- 其第一個元素v就是根節點(最大值),在具體排序過程中最大值應在末尾位置w,將兩個值互換位置,此時最大值v在數組末尾。
- 那麼此時包含w在內的橘黃色部分就不是最大堆了,將w位置的值進行Shift Down操作。
- 橘黃色部分再次成爲“最大堆”,最大值仍在第一個位置,那堆末尾的元素(即倒數第二個位置)與第一個元素交換位置,再進行Shift Down操作。
- 依次類推
這樣所有的元素逐漸排序好,直到整個數組都變成藍色。使用的空間複雜度是O(1),但是這裏需要注意的是,如此一來下標是從0開始並非1,所以規則需要進行相應的調整:
代碼實現
// 優化的shiftDown過程, 使用賦值的方式取代不斷的swap,
// 該優化思想和我們之前對插入排序進行優化的思路是一致的
template<typename T>
void __shiftDown2(T arr[], int n, int k){
T e = arr[k];
while( 2*k+1 < n ){
int j = 2*k+1;
if( j+1 < n && arr[j+1] > arr[j] )
j += 1;
if( e >= arr[j] ) break;
arr[k] = arr[j];
k = j;
}
arr[k] = e;
}
// 不使用一個額外的最大堆, 直接在原數組上進行原地的堆排序
template<typename T>
void heapSort(T arr[], int n){
// 注意,此時我們的堆是從0開始索引的
// 從(最後一個元素的索引-1)/2開始
// 最後一個元素的索引 = n-1
for( int i = (n-1-1)/2 ; i >= 0 ; i -- )
__shiftDown2(arr, n, i);
for( int i = n-1; i > 0 ; i-- ){
swap( arr[0] , arr[i] );
__shiftDown2(arr, i, 0);
}
}
測試:
分別測試原始Shift Down堆排序 和 Heapify堆排序 和 原地堆排序的時間消耗。
從結構得知優化後的原地堆排序快於之前原始Shift Down堆排序和Heapify堆排序,因爲新的算法不需要額外的空間,也不需要對這些空間賦值,所以性能有所提高。
所有以上解決算法詳細代碼請查看liuyubo老師的github:
https://github.com/liuyubobobo/Play-with-Algorithms
前三篇博文介紹的排序算法及以上講解完的堆排序完成,意味着有關排序算法已講解完畢,下面篇博文對這些排序算法進行比較總結,並且學習另一個經典的堆結構,處於二叉堆優化之上的索引堆。
若有錯誤,虛心指教~