前四篇博文介紹的O(n^2)或O(n*logn)排序算法及堆排序結束,意味着有關排序算法已講解完畢,此篇博文將對這些排序算法進行比較總結,並且學習另一個經典的堆結構,處於二叉堆優化之上的索引堆,最後拓展瞭解由堆衍生的一些問題。
此篇涉及的知識點有:
- 排序算法總結
- 索引堆及其優化
- 堆結構衍生的問題
挖掘算法中的數據結構(一):選擇、插入、冒泡、希爾排序 及 O(n^2)排序算法思考
挖掘算法中的數據結構(二):O(n*logn)排序算法之 歸併排序(自頂向下、自底向上) 及 算法優化
挖掘算法中的數據結構(三):O(n*logn)排序算法之 快速排序(隨機化、二路、三路排序) 及衍生算法
一. 排序算法總結
前三篇博文介紹的排序算法及以上講解完的堆排序完成,意味着有關排序算法已講解完畢,下面對這些排序算法進行簡單總結:
(1)均時間複雜度
注意,表格中強調的是“平均”時間複雜度,比如說快速排序,待排序數組已經是近乎有序,那麼其時間複雜度會退化到O(n^2),所以使用了隨機算法優化使其概率降低到0。總體而言,快速排序的性能較優,也就是說在O(n*logn)這3種算法而言有常數性的差異,但快速排序較優,所以一般系統級別的排序採用快速排序,而對於含有大量重複元素的數組可採用優化的三路快速排序。
(2)原地排序
插入排序、快速排序和堆排序可以直接在待排序數組上交換元素完成排序過程,而歸併排序無法完成,它必須開闢額外的空間來輔助完成。正因如此,若一個系統對空間使用比較敏感,並不會採用歸併排序。
(3)額外空間
- 對於插入排序和堆排序而言,使用的額外空間就是數組上交換元素,所以所耗空間爲O(1)級別,即常數級別。
- 而歸併排序需要O(n)級別空間,即數組同等長度空間來輔助完成歸併過程。
- 快速排序所需O(logn)額外空間,因爲它採用遞歸方式來進行排序,遞歸有logn層,所以需要O(logn)空間來保證每一層的臨時變量以供遞歸返回時繼續使用。
(4)穩定排序
穩定排序:對於相等的元素,在排序後,原來靠前的元素依然靠前,即相等元素的相對位置沒有發生改變,此算法纔是穩定的。
例如上圖數組中有3個相同元素3,在排序後,這分開的3個3肯定會排列在一起,但重點依舊按照原來的“紅綠藍”位置排列,這纔是穩定排序。
例如實際應用中,學生名單按照名字字典序排列,現在需要按照成績重新排列,最後幾個同分的同學之間依然還是按照字典序排列。
- 穩定排序
- 插入排序:算法中有後面元素與前面元素相比較,若小於則前移,否則不動。所以相同元素之間位置不會發生改變。
- 歸併排序:在歸併過程中,左右子數組已經有序,需要歸併到一起,其核心也是判斷當後面元素小於前面元素才前移,否則不動。所以相同元素之間位置不會發生改變。
- 不穩定排序
- 快速排序:算法核心中會隨機選擇一個標誌點來進行大於、小於判斷排序,所以很有可能使得後面相等元素到前面來。所以相同元素之間位置會發生改變。
- 堆排序:將整個數組整理成堆的過程中會破壞掉穩定性。所以相同元素之間位置會發生改變。
二. 索引堆(Index Heap)及優化
下面依然將重點放到“堆”這個數據結構,以下將介紹一個相比普通的堆更加高級的數據結構——索引堆。
1. 引出問題
首先來分析一下普通的堆有什麼問題,纔會進而衍生出索引堆:
重點查看以上舉例證明一個數組實現堆後元素的變換,可是在構建堆的過程中有侷限性:
- 如果元素是非常複雜的結構,例如字符串(一篇十萬字的文章)等等,這樣交換的代價是十分大的。不過這可以通過一些基本手段解決,
- 更加致命的是元素在數組中的位置發生改變,使得在堆中很難索引到它!例如元素下標是任務ID,元素值是優先級別。當將數組構建成堆後,下標發生改變,則意味着兩者無法產生聯繫!在原來數組中尋找任務只需O(1),但是構建成堆後元素位置發生改變後需要遍歷數組!所以纔會引入“索引堆”這個概念。
2. 結構思想
當將此數組構建成堆之前:對於索引堆來說將數據和索引兩部分內容分開存儲,而真正表示堆的數組是由索引構建成的,如下圖,每一個節點旁標記的是索引1,2,3……
當將此數組構建成堆之後:****data部分並未發生改變,真正改變的是索引index,index數組發生改變形成堆。index爲10,即真正元素值去找索引10代表的data值62,這樣去對應。
使用“索引堆”有以下兩個好處:
- 將數組構建成堆之後,只是索引index值發生改變,int型數字之間的交換而不會涉及到data數據類型,提供交換效率。
- 重要的一點,如果想對堆中數據進行操作,例如對index爲7的數據進行修改,找到對應數據值爲28更改,在修改之後需要繼續維護“堆”的性質,這時只需對data數組進行維護即可。
其實“索引堆”和之前堆的思路類似,只是在做元素值比較時是比較data數組,而做元素交換時修改的是索引值。
3. 代碼實現
(1)基本更改
在原先MaxHeap基礎上修改即可,首先改名爲IndexMaxHeap(完整代碼請查看github上源碼,這裏只提供重點部分):
- 在成員變量中需要添加一個數組來存儲索引值
- 在構造函數中需要開闢索引數組空間
- 在析構桉樹中也要相信釋放掉該空間
(2)插入和刪除函數
重點需要修改插入和刪除函數:
- 插入函數:在調用插入函數時不僅要傳數據還要傳遞索引值。注意:這裏獲取data數組的索引值是根據index數組來確定的!在
shiftUp
函數中交換的是index索引值,並非是data數組。
// 索引堆中, 數據之間的比較根據data的大小進行比較, 但實際操作的是索引
void shiftUp( int k ){
while( k > 1 && data[indexes[k/2]] < data[indexes[k]] ){
swap( indexes[k/2] , indexes[k] );
k /= 2;
}
}
// 向最大索引堆中插入一個新的元素, 新元素的索引爲i, 元素爲item
// 傳入的i對用戶而言,是從0索引的
void insert(int i, Item item){
assert( count + 1 <= capacity );
assert( i + 1 >= 1 && i + 1 <= capacity );
i += 1;
data[i] = item;
indexes[count+1] = i;
count++;
shiftUp(count);
}
- 刪除函數:
// 索引堆中, 數據之間的比較根據data的大小進行比較, 但實際操作的是索引
void shiftDown( int k ){
while( 2*k <= count ){
int j = 2*k;
if( j + 1 <= count && data[indexes[j+1]] > data[indexes[j]] )
j += 1;
if( data[indexes[k]] >= data[indexes[j]] )
break;
swap( indexes[k] , indexes[j] );
k = j;
}
}
// 從最大索引堆中取出堆頂元素, 即索引堆中所存儲的最大數據
Item extractMax(){
assert( count > 0 );
//注意:這裏獲取data數組的索引值是根據index數組來確定的
Item ret = data[indexes[1]];
swap( indexes[1] , indexes[count] );
count--;
shiftDown(1);
return ret;
}
(3)更改函數
在第一點“引出問題”中提到一個問題,就是二叉堆中的某個節點的元素值可能會被改變,(實際應用中:OS中任務的優先級會動態改變)所以提供一個方法僅供修改元素值。
注意:修改元素值後必然還要維護索引堆的特性,所以該元素的值位置可能會有所改變,具體操作也簡單,只需分別調用shiftUp
、shiftDown
方法即可找到合適位置。
// 將最大索引堆中索引爲i的元素修改爲newItem
void change( int i , Item newItem ){
i += 1;
data[i] = newItem;
// 找到indexes[j] = i, j表示data[i]在堆中的位置
// 之後shiftUp(j), 再shiftDown(j)
for( int j = 1 ; j <= count ; j ++ )
if( indexes[j] == i ){
shiftUp(j);
shiftDown(j);
return;
}
}
4. 反向查找優化 —— 更改元素值
(1)引出問題
舉個例子,如上圖,若用戶要更改下標4所指向元素的數據,將此數據更愛以後需要維護index數組,此數組本質上是一個堆,其中存儲的元素對應着上一層索引。
所以需要做的是在index數組中找到4的位置,在下標9指向的位置,上一點的實現方法是順序遍歷查找下標9的位置,4指向的data是13,然後調用shiftUp
、shiftDown
方法維護二叉堆特徵,這樣過程的時間複雜度爲O(n)級別。
(2)思想
其實對於以上更改元素值思想還有可以優化的地方,此種思想非常經典,被稱爲“反向查找”,查看下圖:
可以看到,多了一行數組rev,rev[i]代表i這個索引在堆中的位置。舉個例子,將下標4的data13修改了,接着需要維護索引4在堆中的位置,即維護index數組,怎麼找到下標4在堆中的位置?
查看rev數組,rev數組中對應的是9,所以在index數組中第9個位置存儲的索引4。
rev數組相關性質
這樣一來只需維護rev數組,在進行元素更新時所耗時間複雜度爲O(1),來了解rev數組相關性質:
(3)代碼實現
如此一來引入了rev數組,就需要在insert
、shiftUp
、extractMax
、shiftDown
函數中進行維護,代碼如下:
(具體代碼見github源碼,以下只粘貼重點部分)
int *reverse; // 最大索引堆中的反向索引, reverse[i] = x 表示索引i在x的位置
// 索引堆中, 數據之間的比較根據data的大小進行比較, 但實際操作的是索引
void shiftUp( int k ){
while( k > 1 && data[indexes[k/2]] < data[indexes[k]] ){
swap( indexes[k/2] , indexes[k] );
reverse[indexes[k/2]] = k/2;
reverse[indexes[k]] = k;
k /= 2;
}
}
// 索引堆中, 數據之間的比較根據data的大小進行比較, 但實際操作的是索引
void shiftDown( int k ){
while( 2*k <= count ){
int j = 2*k;
if( j + 1 <= count && data[indexes[j+1]] > data[indexes[j]] )
j += 1;
if( data[indexes[k]] >= data[indexes[j]] )
break;
swap( indexes[k] , indexes[j] );
reverse[indexes[k]] = k;
reverse[indexes[j]] = j;
k = j;
}
}
// 向最大索引堆中插入一個新的元素, 新元素的索引爲i, 元素爲item
// 傳入的i對用戶而言,是從0索引的
void insert(int i, Item item){
assert( count + 1 <= capacity );
assert( i + 1 >= 1 && i + 1 <= capacity );
// 再插入一個新元素前,還需要保證索引i所在的位置是沒有元素的。
assert( !contain(i) );
i += 1;
data[i] = item;
indexes[count+1] = i;
reverse[i] = count+1;
count++;
shiftUp(count);
}
// 從最大索引堆中取出堆頂元素, 即索引堆中所存儲的最大數據
Item extractMax(){
assert( count > 0 );
Item ret = data[indexes[1]];
swap( indexes[1] , indexes[count] );
reverse[indexes[count]] = 0;
reverse[indexes[1]] = 1;
count--;
shiftDown(1);
return ret;
}
// 將最大索引堆中索引爲i的元素修改爲newItem
void change( int i , Item newItem ){
assert( contain(i) );
i += 1;
data[i] = newItem;
// 有了 reverse 之後,
// 我們可以非常簡單的通過reverse直接定位索引i在indexes中的位置
shiftUp( reverse[i] );
shiftDown( reverse[i] );
}
五. 堆衍生的問題
1. 使用堆實現優先隊列
(1)OS系統執行任務
可以使用堆來作爲優先隊列,對於OS而言,每次使用堆可找到優先級最高的任務執行,就算此時有新的任務添加進行,插入堆中即可,動態修改任務的優先級也可滿足。實現一個堆後,以上需求易簡單。
(2)在N個元素中選出前M個元素
例如在1,000,000個元素中選出前100名,也就是“在N個元素中選出前M個元素”。
按照之前學習的一些排序算法,性能最優可達到O(n*logn )但是使用了優先隊列,可將時間複雜度從O(n*logn )降低爲O(n *logM)!(若N是百萬級別數字,其實這優化的不少)使用一個最小堆,使長度維護在100,將前100個元素放入最小堆之後再插入新的元素,此時只會將堆中最小元素移出去,堆的長度不變,將這1,000,000個元素遍歷完後,最小堆中存放的100個元素就是前100名,因爲比其小的元素全都請了出去。
2. 多路歸併排序
可以使用堆來完成多路歸併排序,首先思考歸併排序思想,是將數組一分爲二,兩個子數組分別排序後進行歸併,每次歸併的過程只有兩個子數組元素比較,如下圖:
其實在歸併排序過程中可以一次分成多個(大於2,這裏是4)子數組,再進行歸併,每次比較4個元素的大小關係,理所當然想到逐個元素比較,但是可以將這4個元素放入堆中,再逐個取出來,取出來的元素屬於哪個子數組,再添加這個子數組的下一個元素進入堆中,來維護這個堆。
3. d叉堆
此部分主要講解的是二叉堆,即每個節點最多有兩個孩子,其實還有一種思想——d叉堆,下圖是三叉堆,依然滿足堆的特徵。其中d越大,層數越少,同樣在Shift Up、Shift Down時比較的次數增多,所以對於這個d的數量,也是性能之間的衡量。(二叉堆是最經典的)
4. 堆的實現細節優化
這裏這位讀者提供對細節優化的思路,切身去體會算法的“優化”,堆的實現細節優化還有:
- ShiftUp 和 ShiftDown 中使用賦值操作替換swap操作
- 表示堆的數組從0開始索引
- 沒有capacity的限制,動態的調整堆中數組的大小
5. 其它
此篇博文主要講解的是最大堆 和 最大索引堆,與之相對應的還有最小堆、 最小索引堆,可自行查看源碼實現。這其實也對應着最大、最小優先隊列,這樣的優先隊列可輕易找到隊列中最大或最小元素,那麼是否可以設計一個“最大最小類”,能同時找到最大和最小數據?
這裏僅提供思路,其實可以在這個類中同時放一個最大堆和最小堆,兩者維護同一組數據。
最後此篇文章重點講解二叉堆和索引堆,但其實有關於堆還衍生出了二項堆和斐波那契堆,有興趣可自行研究。
所有以上解決算法詳細代碼請查看liuyubo老師的github:
https://github.com/liuyubobobo/Play-with-Algorithms
以上就是有關於堆的所有內容,堆其實就是一棵“數”,包括之前學習的有關O(n*logn)排序算法,雖然是在數組上進行排序,但是通過其算法使用的遞歸可畫出“樹”結構,可見“樹”的重要性,下一篇將開始講解
若有錯誤,虛心指教~