淺談演算法和資料結構: 五 優先順序佇列與堆排序
在很多應用中,我們通常需要按照優先順序情況對待處理物件進行處理,比如首先處理優先順序最高的物件,然後處理次高的物件。最簡單的一個例子就是,在手機上玩遊戲的時候,如果有來電,那麼系統應該優先處理打進來的電話。
在這種情況下,我們的資料結構應該提供兩個最基本的操作,一個是返回最高優先順序物件,一個是新增新的物件。這種資料結構就是優先順序佇列(Priority Queue) 。
本文首先介紹優先順序佇列的定義,有序和無序陣列以及堆資料結構實現優先順序佇列,最後介紹了基於優先順序佇列的堆排序(Heap Sort)
一 定義
優先順序佇列和通常的棧和佇列一樣,只不過裡面的每一個元素都有一個”優先順序”,在處理的時候,首先處理優先順序最高的。如果兩個元素具有相同的優先順序,則按照他們插入到佇列中的先後順序處理。
優先順序佇列可以通過連結串列,陣列,堆或者其他資料結構實現。
二 實現
陣列
最簡單的優先順序佇列可以通過有序或者無序陣列來實現,當要獲取最大值的時候,對陣列進行查詢返回即可。程式碼實現起來也比較簡單,這裡就不列出來了。
如上圖:
· 如果使用無序陣列,那麼每一次插入的時候,直接在陣列末尾插入即可,時間複雜度為O(1),但是如果要獲取最大值,或者最小值返回的話,則需要進行查詢,這時時間複雜度為O(n)。
· 如果使用有序陣列,那麼每一次插入的時候,通過插入排序將元素放到正確的位置,時間複雜度為O(n),但是如果要獲取最大值的話,由於元阿蘇已經有序,直接返回陣列末尾的 元素即可,所以時間複雜度為O(1).
所以採用普通的陣列或者連結串列實現,無法使得插入和排序都達到比較好的時間複雜度。所以我們需要採用新的資料結構來實現。下面就開始介紹如何採用二叉堆(binary heap)來實現優先順序佇列
二叉堆
二叉堆是一個近似完全二叉樹的結構,並同時滿足堆積的性質:即子結點的鍵值或索引總是小於(或者大於)它的父節點。 有了這一性質,那麼二叉堆上最大值就是根節點了。
二叉堆的表現形式:我們可以使用陣列的索引來表示元素在二叉堆中的位置。
從二叉堆中,我們可以得出:
· 元素k的父節點所在的位置為[k/2]
· 元素k的子節點所在的位置為2k和2k+1
跟據以上規則,我們可以使用二維陣列的索引來表示二叉堆。通過二叉堆,我們可以實現插入和刪除最大值都達到O(nlogn)的時間複雜度。
對於堆來說,最大元素已經位於根節點,那麼刪除操作就是移除並返回根節點元素,這時候二叉堆就需要重新排列;當插入新的元素的時候,也需要重新排列二叉堆以滿足二叉堆的定義。現在就來看這兩種操作。
從下至上的重新建堆操作: 如果一個節點的值大於其父節點的值,那麼該節點就需要上移,一直到滿足該節點大於其兩個子節點,而小於其根節點為止,從而達到使整個堆實現二叉堆的要求。
由上圖可以看到,我們只需要將該元素k和其父元素k/2進行比較,如果比父元素大,則交換,然後迭代,一直到比父元素小為止。
private static void Swim(int k) { //如果元素比其父元素大,則交換 while (k > 1 && pq[k].CompareTo(pq[k / 2]) > 0) { Swap(pq, k, k / 2); k = k / 2; } }
這樣,往堆中插入新元素的操作變成了,將該元素從下往上重新建堆操作:
程式碼實現如下:
public static void Insert(T s) { //將元素新增到陣列末尾 pq[++N] = s; //然後讓該元素從下至上重建堆 Swim(N); }
動畫如下:
由上至下的重新建堆操作:當某一節點比其子節點要小的時候,就違反了二叉堆的定義,需要和其子節點進行交換以重新建堆,直到該節點都大於其子節點為止:
程式碼實現如下:
private static void Sink(int k) { while (2 * k < N) { int j = 2 * k; //去左右子節點中,稍大的那個元素做比較 if (pq[j].CompareTo(pq[j + 1]) < 0) j++; //如果父節點比這個較大的元素還大,表示滿足要求,退出 if (pq[k].CompareTo(pq[j]) > 0) break; //否則,與子節點進行交換 Swap(pq, k, j); k = j; } }
這樣,移除並返回最大元素操作DelMax可以變為:
1. 移除二叉堆根節點元素,並返回
2. 將陣列中最後一個元素放到根節點位置
3. 然後對新的根節點元素進行Sink操作,直到滿足二叉堆要求。
移除最大值並返回的操作如下圖所示:
以上操作的實現如下:
public static T DelMax() { //根元素從1開始,0不存放值 T max = pq[1]; //將最後一個元素和根節點元素進行交換 Swap(pq, 1, N--); //對根節點從上至下重新建堆 Sink(1); //將最後一個元素置為空 pq[N + 1] = default(T); return max; }
動畫如下:
三 堆排序
概念
運用二叉堆的性質,可以利用它來進行一種就地排序,該排序的步驟為:
1. 使用序列的所有元素,建立一個最大堆。
2. 然後重複刪除最大元素。
如下圖,以對S O R T E X A M P L E 排序為例,首先本地構造一個最大堆,即對節點進行Sink操作,使其符合二叉堆的性質。
然後再重複刪除根節點,也就是最大的元素,操作方法與之前的二叉堆的刪除元素類似。
建立最大二叉堆:
使用至下而上的方法建立二叉堆的方法為,分別對葉子結點的上一級節點以重上之下的方式重建堆。
程式碼如下:
for (int k = N / 2; k >= 1; k--) { Sink(pq, k, N); }
排序
利用二叉堆排序其實就是迴圈移除頂部元素到陣列末尾,然後利用Sink重建堆的操作。如下圖,實現程式碼如下:
while (N > 1) { Swap(pq, 1, N--); Sink(pq, 1, N); }
堆排序的動畫如下:
分析
1. 在構建最大堆的時候,最多需要2N次比較和交換
2. 堆排序最多需要2NlgN次比較和交換操作
優點:堆排序最顯著的優點是,他是就地排序,並且其最壞情況下時間複雜度為NlogN。經典的合併排序不是就地排序,它需要線性長度的額外空間,而快速排序其最壞時間複雜度為N2
缺點:堆排序對時間和空間都進行了優化,但是:
1. 其內部迴圈要比快速排序要長。
2. 並且其操作在N和N/2之間進行比較和交換,當陣列長度比較大的時候,對CPU快取利用效率比較低。
3. 非穩定性排序。
四 排序演算法的小結
本文及前面文章介紹了選擇排序,插入排序,希爾排序,合併排序,快速排序以及本文介紹的堆排序。各排序的穩定性,平均,最壞,最好的時間複雜度如下表:
可以看到,不同的排序方法有不同的特徵,有的速度快,但是不穩定,有的穩定,但是不是就地排序,有的是就地排序,但是最壞情況下時間複雜度不好。那麼有沒有一種排序能夠集合以上所有的需求呢?
五 結語
本文介紹了二叉堆,以及基於二叉堆的堆排序,他是一種就地的非穩定排序,其最好和平均時間複雜度和快速排序相當,但是最壞情況下的時間複雜度要優於快速排序。但是由於他對元素的操作通常在N和N/2之間進行,所以對於大的序列來說,兩個運算元之間間隔比較遠,對CPU快取利用不太好,故速度沒有快速排序快。
轉載自:https://www.cnblogs.com/yangecnu/p/Introduce-Priority-Queue-And-Heap-Sort.html