堆是一種特殊型別的二叉樹,具有以下兩個性質:
- 每個節點的值大於等於(或小於等於)其每個子節點的值
- 堆屬於完全二叉樹
就像這樣:
上圖是大頂堆,如果每個節點小於等於其每個子節點的值,那它就是小頂堆。
有趣的是,堆可以通過陣列來實現。例如,陣列 data = [50 43 49 15 28 40 30 5 10 23 15 20] 可以表示上面的堆。陣列中元素的排放順序表示節點按照從頂到底、每一層從左到右的順序放置。堆之所以可以放到陣列中,是因為它是一顆完全二叉樹。可以根據任意節點的下標,通過公式計算出子節點的下標。假設節點P的下標為i,那麼節點P的左子節點下標為2i + 1,右子節點下標為2i + 2,相關證明過程可以看這裡資料結構與演算法-二叉樹性質。我們這裡就以陣列來實現堆結構,探討相關的建立、插入、刪除、運用的邏輯。
- 建立
堆的建立有兩種方式。第一種是自上向下從一個空陣列中建立堆,第二種是自下向上將已有的陣列調整為堆。首先探討自上向下的邏輯。
1、自上向下
從一個空陣列開始,每一個新節點都放到當前堆的末尾,如果發現插入節點之後破壞了堆的特性,那麼將新節點與其父節點進行交換,直至重新調整為堆。假如現在有一段資料流:
49 50 43 15 28 40 5 25 10 23 30 20 55
自上向下的過程:
核心邏輯在於每次插入新節點之後,如果破壞了堆的結構,只要和父節點進行交換,直至調整為堆即可。該演算法的時間複雜度有O(nlgn),不是很理想,資料量比較大時不適合這種方案。
2、自下向上
一顆完整的二叉樹可以分解成根節點,左子樹,右子樹。對於左右子樹又可以分解成更多的子樹。如果我們將樹中的每個節點都看做一顆樹,只要每棵樹符合堆的特性,那麼整棵樹也是符合堆的特性的。基於以上思想,前人提出了一種自下向上的建立堆地方式。樹中最小的子樹其實是葉子節點,由於葉子節點沒有子樹,可以認為是符合堆的特性的。所以,我們就從最後一個非葉子節點開始,按照從下向上,從右到左的順序,調整每個子樹符合堆的特性,直到根節點,那麼整棵樹就成了堆。
假設一共有n的元素,最後一個非葉子節點的下標是多少呢?答案是n/2 -1。證明如下:
假設堆中度為0、度為1、度為2的節點分別有n0、n1、n2個,那麼n = n0 + n1 + n2,又知道n0 = n2 +1,證明方式在資料結構與演算法-二叉樹性質。那麼n = 2*n2 + n1 +1。
- n1如果為0
那麼最後一個非葉子節點的下標為n2 - 1,即(n - n1 - 1)/2 - 1= n/2 - 3/2,由於n = 2*n2 + 1是奇數,所以n/2 - 1向下取整等於n/2 - 3/2。
- n1如果為1
那麼最後一個非葉子節點的下標為n2,即(n - n1 -1)/2 = n/2 - 1。
對於完全二叉樹來講,n1要麼為0要麼為1。因此,綜上所述,最後一個非葉子節點的下標為n/2 - 1。
既然找到了最後一個非葉子節點P,那就從P節點開始自下向上,自右向左的調整每一個遇到的子樹為堆,最終整棵樹成為堆。
假設現在存在陣列data = [39 50 43 15 28 40 5 25 10 23 30 20 55],展開之後是這樣的:
按照自下向上的演算法調整是這樣的:
該演算法的時間複雜度是O(n),通常情況下優於自上向下建立堆的方式。
- 新增
在堆中插入一個新的元素,通常會放入到陣列的末尾,如果插入到頭部或者中間某個位置,有兩個缺陷。第一,會移動陣列裡大量資料,第二,嚴重破壞堆結構,只能依靠自下向上的方式調整所有子樹,才能保持整棵樹的堆結構,得不償失。
如果將新元素插入到陣列末尾,僅僅有可能破壞少量子樹的堆結構,也能在短時間內調整完畢。假如有以下堆結構:
可以發現,無論是自上向下建立堆還是自下向上調整堆,又或者在堆中插入新的元素,核心邏輯總是調整堆的過程。關鍵在於,二叉樹中每個節點總是有兩個身份,既是某顆子樹(P子樹)的根節點,也是其父節點所在樹(Q子樹)的子節點。調整堆的過程其實就是既要保證P子樹是堆,也要保證Q子樹是堆。
- 刪除
在堆中刪除某個元素,通常我們會刪除堆的根節點,因為它是有價值的,要麼是最大值要麼是最小值。為了避免刪除頭結點之後陣列元素大量移動,前人想出一個巧妙的方式。那就是將最後一個葉子節點和根結點進行交換。最後一個葉子節點成了根節點,根節點成了最後一個葉子節點。這時,我們刪除元素不必移動陣列資料,但是破壞了堆結構,怎麼辦呢?老樣子,調整堆,調整方式和上面介紹的方法一致,就不多說了。
我們當然可以刪除陣列中任一元素,方法和刪除根節點是一致的。但是,我們往往不會這麼做,因為在堆中除了根節點,其他元素沒有特別的地方,價值不大。
- 運用
我們已經瞭解堆的建立、新增、刪除,那它有什麼用處呢?
答案是優先佇列。對於堆來說,新元素總是插入到陣列末尾,被刪除元素總是根節點,並且要麼是最大的要麼是最小的,這不就是優先佇列嗎?在資料結構與演算法-棧與佇列中,我們推薦使用連結串列來實現佇列,這對於普通的佇列是可以的,因為普通佇列保持的是資料插入時的次序。但是對於優先佇列就不合適了,資料插入到優先佇列之後,需要依靠優先順序排序,從佇列彈出的資料總是優先順序最大(最小)的元素。如果使用連結串列來實現優先佇列,操作複雜度是O(n),但是如果使用陣列,將資料按照堆結構排列,那麼操作複雜度僅僅有O(lgn),這是非常吸引人的。並且,標準庫中的優先佇列就是以向量實現的。
堆的另外一個運用是對資料進行排序,稱之為堆排序。
演算法邏輯很簡單,就是不停的刪除根節點、調整堆的過程。那麼,資料就會以從大到小或者從小到大的順序從堆中刪除。堆排序的操作複雜度有O(nlgn),算不上很優秀,但至少比冒泡、插入這些演算法要好用。
堆的內容到這裡已經探討完畢,更多內容就需要在實踐中摸索了。