本系列文章經補充和完善,已修訂整理成書《Java程式設計的邏輯》(馬俊昌著),由機械工業出版社華章分社出版,於2018年1月上市熱銷,讀者好評如潮!各大網店和書店有售,歡迎購買:京東自營連結
前面幾節介紹了Java中的基本容器類,每個容器類背後都有一種資料結構,ArrayList是動態陣列,LinkedList是連結串列,HashMap/HashSet是雜湊表,TreeMap/TreeSet是紅黑樹,本節介紹另一種資料結構 - 堆。
引入堆
之前我們提到過堆,那裡,堆指的是記憶體中的區域,儲存動態分配的物件,與棧相對應。這裡的堆是一種資料結構,與記憶體區域和分配無關。
堆是什麼結構呢?這個我們待會再細看。我們先來說明,堆有什麼用?為什麼要介紹它?
堆可以非常高效方便的解決很多問題,比如說:
- 優先順序佇列,我們之前介紹的佇列實現類LinkedList是按新增順序排隊的,但現實中,經常需要按優先順序來,每次都應該處理當前佇列中優先順序最高的,高優先順序的,即使來得晚,也應該被優先處理。
- 求前K個最大的元素,元素個數不確定,資料量可能很大,甚至源源不斷到來,但需要知道到目前為止的最大的前K個元素。這個問題的變體有:求前K個最小的元素,求第K個最大的,求第K個最小的。
- 求中值元素,中值不是平均值,而是排序後中間那個元素的值,同樣,資料量可能很大,甚至源源不斷到來。
堆還可以實現排序,稱之為堆排序,不過有比它更好的排序演算法,所以,我們就不介紹其在排序中的應用了。
Java容器中有一個類PriorityQueue,就表示優先順序佇列,它實現了堆,下節我們會詳細介紹。關於後面兩個問題,它們是如何使用堆高效解決的,我們會在接下來的幾節中用程式碼實現並詳細解釋。
說了這麼多好處,堆到底是什麼呢?
堆的概念
完全二叉樹
堆首先是一顆二叉樹,但它是完全二叉樹。什麼是完全二叉樹呢?我們先來看另一個相似的概念,滿二叉樹。
滿二叉樹是指,除了最後一層外,每個節點都有兩個孩子,而最後一層都是葉子節點,都沒有孩子。比如,下圖兩個二叉樹都是滿二叉樹。
![計算機程式的思維邏輯 (45) - 神奇的堆](https://i.iter01.com/images/86aa3ee1565cd944b0fed0af30a784b7c49634107acc76303d7b45f99075d021.jpg)
滿二叉樹一定是完全二叉樹,但完全二叉樹不要求最後一層是滿的,但如果不滿,則要求所有節點必須集中在最左邊,從左到右是連續的,中間不能有空的。比如說,下面幾個二叉樹都是完全二叉樹:
![計算機程式的思維邏輯 (45) - 神奇的堆](https://i.iter01.com/images/f6f7e5c2851256d1c8574db46f96c88b2c37272e6838de5b19c9e3f9d8b96865.jpg)
而下面的這幾個則都不是完全二叉樹:
![計算機程式的思維邏輯 (45) - 神奇的堆](https://i.iter01.com/images/95aa8dd3bcd2f05d29df82a217248d86cc720c0ba35c4ca9d61b72ba3461413b.jpg)
編號與陣列儲存
在完全二叉樹中,可以給每個節點一個編號,編號從1開始連續遞增,從上到下,從左到右,如下圖所示:
![計算機程式的思維邏輯 (45) - 神奇的堆](https://i.iter01.com/images/f3e5c91363df7ca791fff6ea6f528cdff803ab1dbacb33f38a61408a5f92fa43.jpg)
完全二叉樹有一個重要的特點,給定任意一個節點,可以根據其編號直接快速計算出其父節點和孩子節點編號,如果編號為i,則父節點編號即為i/2,左孩子編號即為2*i
,右孩子編號即為2*i+1
。比如,對於5號節點,父節點為5/2即2,左孩子為2*5
即10,右孩子為2*5+1
即11。
這個特點為什麼重要呢?它使得邏輯概念上的二叉樹可以方便的儲存到陣列中,陣列中的元素索引就對應節點的編號,樹中的父子關係通過其索引關係隱含維持,不需要單獨保持。比如說,上圖中的邏輯二叉樹,儲存到陣列中,其結構為:
![計算機程式的思維邏輯 (45) - 神奇的堆](https://i.iter01.com/images/2298e9f0491a4483ac2c87a77a0e874d14371affdd6bb626c96b63cba78cc628.jpg)
這種儲存二叉樹的方法與之前介紹的TreeMap是不一樣的,在TreeMap中,有一個單獨的內部類Entry,Entry有三個引用,分別指向父節點、左孩子、右孩子。
使用陣列儲存,優點是很明顯的,節省空間,訪問效率高。
最大堆/最小堆
堆邏輯概念上是一顆完全二叉樹,而物理儲存上使用陣列,除了這兩點,堆還有一定的順序要求。
之前介紹過排序二叉樹,排序二叉樹是完全有序的,每個節點都有確定的前驅和後繼,而且不能有重複元素。
與排序二叉樹不同,在堆中,可以有重複元素,元素間不是完全有序的,但對於父子節點之間,有一定的順序要求,根據順序分為兩種堆,一種是最大堆,另一種是最小堆。
最大堆是指,每個節點都不大於其父節點。這樣,對每個父節點,一定不小於其所有孩子節點,而根節點就是所有節點中最大的,對每個子樹,子樹的根也是子樹所有節點中最大的。
最小堆與最大堆正好相反,每個節點都不小於其父節點。這樣,對每個父節點,一定不大於其所有孩子節點,而根節點就是所有節點中最小的,對每個子樹,子樹的根也是子樹所有節點中最小的。
我們看下圖示:
![計算機程式的思維邏輯 (45) - 神奇的堆](https://i.iter01.com/images/6f288fdeaa6ec500b5febac06e8c797caf911683f1532745ef948003bf2ad328.jpg)
堆概念總結
總結來說,邏輯概念上,堆是完全二叉樹,父子節點間有特定順序,分為最大堆和最小堆,最大堆根是最大的,最小堆根是最小的,堆使用陣列進行物理儲存。
這個資料結構為什麼就可以高效的解決之前我們說的問題呢?在回答之前,我們需要先看下,如何在堆上進行資料的基本操作,在操作過程中,如何保持堆的屬性不變。
堆的演算法
下面,我們來看下,如何在堆上進行資料的基本操作。最大堆和最小堆的演算法是類似的,我們以最小堆來說明。先來看如何新增元素。
新增元素
如果堆為空,則直接新增一個根就行了。我們假定已經有一個堆了,要在其中新增元素。基本步驟為:
- 新增元素到最後位置。
- 與父節點比較,如果大於等於父節點,則滿足堆的性質,結束,否則與父節點進行交換,然後再與父節點比較和交換,直到父節點為空或者大於等於父節點。
我們來看個例子。下面是初始結構:
![計算機程式的思維邏輯 (45) - 神奇的堆](https://i.iter01.com/images/bc3c2b9a65ac345e0d4ed1bc93f76b316dad10f56c93d6e099d6cbfe9b431b34.jpg)
新增元素3,第一步後,結構變為:
![計算機程式的思維邏輯 (45) - 神奇的堆](https://i.iter01.com/images/8b6bb4f5760d9c0bf58865f44372fc062452b8344c54a30f212a8f9fa5e8d939.jpg)
3小於父節點8,不滿足最小堆的性質,所以與父節點交換,會變為:
![計算機程式的思維邏輯 (45) - 神奇的堆](https://i.iter01.com/images/844ec89c97ca15b968c89f5d50f6a49b3ae8eaeeedc3a9c8de6c3e50236efc4e.jpg)
交換後,3還是小於父節點6,所以繼續交換,會變為:
![計算機程式的思維邏輯 (45) - 神奇的堆](https://i.iter01.com/images/7053df0d58500e91bfc54c71d445ba74f2e64dfeb6115a0415025629937bff56.jpg)
交換後,3還是小於父節點,也是根節點4,繼續交換,變為:
![計算機程式的思維邏輯 (45) - 神奇的堆](https://i.iter01.com/images/8c9130af284555cd718809cb205d787852372275a57b80792736624b52b6b996.jpg)
這時,調整就結束了,樹保持了堆的性質。
從以上過程可以看出,新增一個元素,需要比較和交換的次數最多為樹的高度,即log2(N),N為節點數。
這種自低向上比較、交換,使得樹重新滿足堆的性質的過程,我們稱之為siftup。
從頭部刪除元素
在佇列中,一般是從頭部刪除元素,Java中用堆實現優先順序佇列,我們來看下如何在堆中刪除頭部,其基本步驟為:
- 用最後一個元素替換頭部元素,並刪掉最後一個元素。
- 將新的頭部與兩個孩子節點中較小的比較,如果不大於該孩子節點,則滿足堆的性質,結束,否則與較小的孩子進行交換,交換後,再與較小的孩子比較和交換,一直到沒有孩子,或者不大於兩個孩子節點。這個過程我們般稱為siftdown。
我們來看個例子。下面是初始結構:
![計算機程式的思維邏輯 (45) - 神奇的堆](https://i.iter01.com/images/0beef1ee4892cd7d9ce97e6f794ee3a9c9c5ef0d74091d9a8c0f2cada2b574be.jpg)
執行第一步,用最後元素替換頭部,會變為:
![計算機程式的思維邏輯 (45) - 神奇的堆](https://i.iter01.com/images/63d0b25f12524f34ac84311ca944a5386038687de44cb6cced83a7a58f4003fc.jpg)
現在根節點16大於孩子節點,與更小的孩子節點6進行替換,結構會變為:
![計算機程式的思維邏輯 (45) - 神奇的堆](https://i.iter01.com/images/5edd50468541627cec17be09ae042524fb6b4fd531903fb43d01c1dfb554dd49.jpg)
16還是大於孩子節點,與更小的孩子8進行交換,結構會變為:
![計算機程式的思維邏輯 (45) - 神奇的堆](https://i.iter01.com/images/034a9af35e0c5d5f2920c64fb8834ea589bb8f8521c19ad132a1230bf91308e6.jpg)
此時,就滿足堆的性質了。
從中間刪除元素
那如果需要從中間刪除某個節點呢?與從頭部刪除一樣,都是先用最後一個元素替換待刪元素。不過替換後,有兩種情況,如果該元素大於某孩子節點,則需向下調整(siftdown),否則,如果小於父節點,則需向上調整(siftup)。
我們來看個例子,刪除值為21的節點,第一步如下圖所示:
![計算機程式的思維邏輯 (45) - 神奇的堆](https://i.iter01.com/images/f04b2c78dbd4ffa2ca8bba5465401c070d0fb519d29d674e8b19bd66b69c7805.jpg)
替換後,6沒有子節點,小於父節點12,執行向上調整siftup過程,最後結果為:
![計算機程式的思維邏輯 (45) - 神奇的堆](https://i.iter01.com/images/fd1421ad969111b4817d5104250b2dcdc147f0d965593f83696c5fa22e57f452.jpg)
我們再來看個例子,刪除值為9的節點,第一步如下圖所示:
![計算機程式的思維邏輯 (45) - 神奇的堆](https://i.iter01.com/images/e57c3fef5d8720edb32eba3f37f8cac525e2f5cbd83d65a49e56a5c5d24d8a39.jpg)
交換後,11大於右孩子10,所以執行siftdown過程,執行結束後為:
![計算機程式的思維邏輯 (45) - 神奇的堆](https://i.iter01.com/images/51c1737957daea9ab97319949847c6cd372c68f1d414006b4ff04c0997cc8db7.jpg)
構建初始堆
給定一個無序陣列,如何使之成為一個最小堆呢?將普通無序陣列變為堆的過程我們稱之為heapify。
基本思路是,從最後一個非葉子節點開始,一直往前直到根,對每個節點,執行向下調整siftdown。換句話說,是自底向上,先使每個最小子樹為堆,然後每對左右子樹和其父節點合併,調整為更大的堆,因為每個子樹已經為堆,所以調整就是對父節點執行siftdown,就這樣一直合併調整直到根。這個演算法的虛擬碼是:
void heapify() {
for (int i=size/2; i >= 1; i--)
siftdown(i);
}
複製程式碼
size表示節點個數, 節點編號從1開始,size/2表示第一個非葉節點的編號。
這個構建的時間效率為O(N),N為節點個數,具體就不證明了。
查詢和遍歷
在堆中進行查詢沒有特殊的演算法,就是從陣列的頭找到尾,效率為O(N)。
在堆中進行遍歷也是類似的,堆就是陣列,堆的遍歷就是陣列的遍歷,第一個元素是最大值或最小值,但後面的元素沒有特定的順序。
需要說明的是,如果是逐個從頭部刪除元素,堆可以確保輸出是有序的。
演算法小結
以上就是堆操作的主要演算法:
- 在新增和刪除元素時,有兩個關鍵的過程以保持堆的性質,一個是向上調整(siftup),另一個是向下調整(siftdown),它們的效率都為O(log2(N))。由無序陣列構建堆的過程heapify是一個自底向上迴圈的過程,效率為O(N)。
- 查詢和遍歷就是對陣列的查詢和遍歷,效率為O(N)。
小結
本節介紹了堆這一資料結構的基本概念和演算法。
堆是一種比較神奇的資料結構,概念上是樹,儲存為陣列,父子有特殊順序,根是最大值/最小值,構建/新增/刪除效率都很高,可以高效解決很多問題。
但在Java中,堆到底是如何實現的呢?本文開頭提到的那些問題,用堆到底如何解決呢?讓我們在接下來的幾節中繼續探索。
未完待續,檢視最新文章,敬請關注微信公眾號“老馬說程式設計”(掃描下方二維碼),深入淺出,老馬和你一起探索Java程式設計及計算機技術的本質。用心原創,保留所有版權。
![計算機程式的思維邏輯 (45) - 神奇的堆](https://i.iter01.com/images/3234cba0a8e16b6235062a7e9b4e02c28d78c519454e46e0ff0dbafb07bf55f6.jpg)