堆和堆的應用:堆排序和優先佇列

發表於2018-01-28

1.堆

堆(Heap))是一種重要的資料結構,是實現優先佇列(Priority Queues)首選的資料結構。由於堆有很多種變體,包括二項式堆、斐波那契堆等,但是這裡只考慮最常見的就是二叉堆(以下簡稱堆)。

堆是一棵滿足一定性質的二叉樹,具體的講堆具有如下性質:父節點的鍵值總是不大於它的孩子節點的鍵值(小頂堆), 堆可以分為小頂堆大頂堆,這裡以小頂堆為例,其主要包含的操作有:

  • insert()
  • extractMin
  • peek(findMin)
  • delete(i)

由於堆是一棵形態規則的二叉樹,因此堆的父節點和孩子節點存在如下關係:

設父節點的編號為 i, 則其左孩子節點的編號為2*i+1, 右孩子節點的編號為2*i+2
設孩子節點的編號為i, 則其父節點的編號為(i-1)/2

由於二叉樹良好的形態已經包含了父節點和孩子節點的關係資訊,因此就可以不使用連結串列而簡單的使用陣列來儲存堆。

要實現堆的基本操作,涉及到的兩個關鍵的函式

  • siftUp(i, x) : 將位置i的元素x向上調整,以滿足堆得性質,常常是用於insert後,用於調整堆;
  • siftDown(i, x):同理,常常是用於delete(i)後,用於調整堆;

具體的操作如下:

可以看到siftUpsiftDown不停的在父節點和子節點之間比較、交換;在不超過logn的時間複雜度就可以完成一次操作。

有了這兩個基本的函式,就可以實現上述提及的堆的基本操作。

首先是如何建堆,實現建堆操作有兩個思路:

  • 一個是不斷地insertinsert後呼叫的是siftUp
  • 另一個將原始陣列當成一個需要調整的堆,然後自底向上地
    在每個位置i呼叫siftDown(i),完成後我們就可以得到一個滿足堆性質的堆。這裡考慮後一種思路:

通常堆的insert操作是將元素插入到堆尾,由於新元素的插入可能違反堆的性質,因此需要呼叫siftUp操作自底向上調整堆;堆移除堆頂元素操作是將堆頂元素刪除,然後將堆最後一個元素放置在堆頂,接著執行siftDown操作,同理替換堆頂元素也是相同的操作。

建堆

那麼建堆操作的時間複雜度是多少呢?答案是O(n)。雖然siftDown的操作時間是logn,但是由於高度在遞減的同時,每一層的節點數量也在成倍減少,最後通過數列錯位相減可以得到時間複雜度是O(n)

extractMin
由於堆的固有性質,堆的根便是最小的元素,因此peek操作就是返回根nums[0]元素即可;
若要將nums[0]刪除,可以將末尾的元素nums[n-1]覆蓋nums[0],然後將堆得size = size-1,呼叫siftDown(0)調整堆。時間複雜度為logn

peek
同上

delete(i)

刪除堆中位置為i的節點,涉及到兩個函式siftUpsiftDown,時間複雜度為logn,具體步驟是,

  • 將元素last覆蓋元素i,然後siftDown
  • 檢查是否需要siftUp

注意到堆的刪除操作,如果是刪除堆的根節點,則不用考慮執行siftUp的操作;若刪除的是堆的非根節點,則要視情況決定是siftDown還是siftUp操作,兩個操作是互斥的。

case 1 :

刪除中間節點i21,將最後一個節點複製過來;

這裡寫圖片描述

由於沒有進行siftDown操作,節點i的值仍然為6,因此為確保堆的性質,執行siftUp操作;

這裡寫圖片描述

case 2

刪除中間節點i,將值為11的節點複製過來,執行siftDown操作;
這裡寫圖片描述

由於執行siftDown操作後,節點i的值不再是11,因此就不用再執行siftUp操作了,因為堆的性質在siftDown操作生效後已經得到了保持。

這裡寫圖片描述


可以看出,堆的基本操作都依賴於兩個核心的函式siftUpsiftDown;較為完整的Heap程式碼如下:

2.堆的應用:堆排序

運用堆的性質,我們可以得到一種常用的、穩定的、高效的排序演算法————堆排序。堆排序的時間複雜度為O(n*log(n)),空間複雜度為O(1),堆排序的思想是:
對於含有n個元素的無序陣列nums, 構建一個堆(這裡是小頂堆)heap,然後執行extractMin得到最小的元素,這樣執行n次得到序列就是排序好的序列。
如果是降序排列則是小頂堆;否則利用大頂堆。

Trick

由於extractMin執行完畢後,最後一個元素last已經被移動到了root,因此可以將extractMin返回的元素放置於最後,這樣可以得到sort in place的堆排序演算法。

具體操作如下:


當然,如果不使用前面定義的heap,則可以手動寫堆排序,由於堆排序設計到建堆extractMin, 兩個操作都公共依賴於siftDown函式,因此我們只需要實現siftDown即可。(trick:由於建堆操作可以採用siftUp或者siftDown,而extractMin是需要siftDown操作,因此取公共部分,則採用siftDown建堆)。

這裡便於和前面統一,採用小頂堆陣列進行降序排列。

3.堆的應用:優先佇列

優先佇列是一種抽象的資料型別,它和堆的關係類似於,List和陣列、連結串列的關係一樣;我們常常使用堆來實現優先佇列,因此很多時候堆和優先佇列都很相似,它們只是概念上的區分。
優先佇列的應用場景十分的廣泛:
常見的應用有:

  • Dijkstra’s algorithm(單源最短路問題中需要在鄰接表中找到某一點的最短鄰接邊,這可以將複雜度降低。)
  • Huffman coding(貪心演算法的一個典型例子,採用優先佇列構建最優的字首編碼樹(prefixEncodeTree))
  • Prim’s algorithm for minimum spanning tree
  • Best-first search algorithms

這裡簡單介紹上述應用之一:Huffman coding

Huffman編碼是一種變長的編碼方案,對於每一個字元,所對應的二進位制位串的長度是不一致的,但是遵守如下原則:

  • 出現頻率高的字元的二進位制位串的長度小
  • 不存在一個字元c的二進位制位串s是除c外任意字元的二進位制位串的字首

遵守這樣原則的Huffman編碼屬於變長編碼,可以無損的壓縮資料,壓縮後通常可以節省20%-90%的空間,具體壓縮率依賴於資料的固有結構。

Huffman編碼的實現就是要找到滿足這兩種原則的 字元-二進位制位串 對照關係,即找到最優字首碼的編碼方案(字首碼:沒有任何字元編碼後的二進位制位串是其他字元編碼後位串的字首)。
這裡我們需要用到二叉樹來表達最優字首碼,該樹稱為最優字首碼樹
一棵最優字首碼樹看起來像這樣:

這裡寫圖片描述

演算法思想:用一個屬性為freqeunce關鍵字的最小優先佇列Q,將當前最小的兩個元素x,y合併得到一個新元素z(z.frequence = x.freqeunce + y.frequence),
然後插入到優先佇列中Q中,這樣執行n-1次合併後,得到一棵最優字首碼樹(這裡不討論演算法的證明)。

一個常見的構建流程如下:

這裡寫圖片描述

樹中指向某個節點左孩子的邊上表示位0,指向右孩子的邊上的表示位1,這樣遍歷一棵最優字首碼樹就可以得到對照表。

輸出如下:

4 堆的應用:海量實數中(一億級別以上)找到TopK(一萬級別以下)的數集合。

  • A:通常遇到找一個集合中的TopK問題,想到的便是排序,因為常見的排序演算法例如快排算是比較快了,然後再取出K個TopK數,時間複雜度為O(nlogn),當n很大的時候這個時間複雜度還是很大的;
  • B:另一種思路就是打擂臺的方式,每個元素與K個待選元素比較一次,時間複雜度很高:O(k*n),此方案明顯遜色於前者。

對於一億資料來說,A方案大約是26.575424*n

  • C:由於我們只需要TopK,因此不需要對所有資料進行排序,可以利用堆得思想,維護一個大小為K的小頂堆,然後依次遍歷每個元素e, 若元素e大於堆頂元素root,則刪除root,將e放在堆頂,然後調整,時間複雜度為logK;若小於或等於,則考察下一個元素。這樣遍歷一遍後,最小堆裡面保留的數就是我們要找的topK,整體時間複雜度為O(k+n*logk)約等於O(n*logk),大約是13.287712*n(由於k與n數量級差太多),這樣時間複雜度下降了約一半。

A、B、C三個方案中,C通常是優於B的,因為logK通常是小於k的,當Kn的數量級相差越大,這種方式越有效。

以下為具體操作:


ps:大致測試了一下,10億個數中找到top5需要140秒左右,應該是很快了。

5 總結

  • 堆是基於樹的滿足一定約束的重要資料結構,存在許多變體例如二叉堆、二項式堆、斐波那契堆(很高效)等。
  • 堆的幾個基本操作都依賴於兩個重要的函式siftUpsiftDown,堆的insert通常是在堆尾插入新元素並siftUp調整堆,而extractMin是在
    刪除堆頂元素,然後將最後一個元素放置堆頂並呼叫siftDown調整堆。
  • 二叉堆是常用的一種堆,其是一棵二叉樹;由於二叉樹良好的性質,因此常常採用陣列來儲存堆。
    堆得基本操作的時間複雜度如下表所示:
heapify insert peek extractMin delete(i)
O(n) O(logn) O(1) O(logn) O(logn)
  • 二叉堆通常被用來實現堆排序演算法,堆排序可以sort in place,堆排序的時間複雜度的上界是O(nlogn),是一種很優秀的排序演算法。由於存在相同鍵值的兩個元素處於兩棵子樹中,而兩個元素的順序可能會在後續的堆調整中發生改變,因此堆排序不是穩定的。降序排序需要建立小頂堆,升序排序需要建立大頂堆。
  • 堆是實現抽象資料型別優先佇列的一種方式,優先佇列有很廣泛的應用,例如Huffman編碼中使用優先佇列利用貪心演算法構建最優字首編碼樹。
  • 堆的另一個應用就是在海量資料中找到TopK個數,思想是維護一個大小為K的二叉堆,然後不斷地比較堆頂元素,判斷是否需要執行替換對頂元素的操作,採用
    此方法的時間複雜度為n*logk,當kn的數量級差距很大的時候,這種方式是很有效的方法。

6 references

[1] https://en.wikipedia.org/wiki/Heap_(data_structure))

[2] https://en.wikipedia.org/wiki/Heapsort

[3] https://en.wikipedia.org/wiki/Priority_queue

[4] https://www.cnblogs.com/swiftma/p/6006395.html

[5] Thomas H.Cormen, Charles E.Leiserson, Ronald L.Rivest, Clifford Stein.演算法導論[M].北京:機械工業出版社,2015:245-249

[6] Jon Bentley.程式設計珠璣[M].北京:人民郵電出版社,2015:161-174

相關文章