1. 程式人生 > >Java資料結構和演算法 - 堆

Java資料結構和演算法 - 堆

堆的介紹

Q: 什麼是堆?

A: 這裡的“堆”是指一種特殊的二叉樹,不要和Java、C/C++等程式語言裡的“堆”混淆,後者指的是程式設計師用new能得到的計算機記憶體的可用部分

A: 堆是有如下特點的二叉樹: 
1) 是一棵完全二叉樹 
2) 通常由陣列實現。前面介紹了如何用陣列表示樹 
3) 堆中的每個節點都滿足堆的條件,即每個節點的關鍵字都大於(或等於)這個節點的子節點關鍵字

下圖顯示了堆與實現它的陣列之間的關係: 

A: 堆是完全二叉樹的事實說明了表示堆的陣列中沒有“洞”,從下標0到N-1,每個元素都有資料項

A: 

本篇中假設最大的關鍵字在根節點,基於這種堆的優先順序是降序的優先順序佇列

A: 若陣列中節點的索引為i,則 
1) 它的父節點的下標為(i - 1) / 2; 
2) 它的左子節點的下標為 2 * i + 1; 
3) 它的右子節點的下標為 2 * i + 2

Q: 弱序?

A: 堆相對於二叉搜尋樹比較而言是弱序的,在二叉搜尋樹中所有的節點的左子孫的關鍵字都小於右子孫的關鍵字。二叉搜尋樹可以通過簡單的演算法就可以按序遍歷節點,但在堆中,按序遍歷節點是困難的,這是因為堆的組織規則比二叉搜尋樹的組織規則弱。

A: 對堆來說,只要求沿著從根到葉子的每一條路徑,節點都是按降序排列

A: 在堆中不能便利地查詢指定的關鍵字,因為在查詢過程中,沒有足夠的資訊來決定選擇通過節點的哪一個子節點走向下一層。同理它也不能在少至O(logN)的時間內刪除一個指定關鍵字的節點,只能以較慢的O(N)時間去執行。

A: 堆的這種組織似乎非常接近無序。不過堆支援快速移除最大節點和快速插入新節點的操作,這兩個操作恰好是優先順序佇列所需的全部操作

Q: 移除關鍵字最大節點?

A: 就是移除根節點,根在陣列的索引總是0。

maxNode = array[0]; 

A: 一旦移除了根節點,樹就不再是完全的了

A: 數組裡就有了一個空的資料元素,這個“洞”必須要填上,可以把陣列中所有資料項都向前移動一個單元,但是還有一個更好的方法

A: 這個方法的步驟是: 
1) 移除根後,把最後一個節點移動到根的位置

array[0] = array[n - 1];
n--

2) 一直向下篩選這個節點,直到放在堆的合適位置為止

A: 步驟1恢復了對的完全性的特徵(沒有洞),而步驟2恢復了堆的條件(每個節點都大於的它子節點而小於它的父節點),移除過程如下圖: 

在被篩選目標節點的每個暫時停留的位置上,向下篩選的演算法都要檢查哪一個子節點更大,然後目標節點和較大的子節點交換位置。想一想為什麼要這樣做。

A: 如果把目標節點和較小的子節點交換,那麼這個子節點就會變成大子節點的父節點,這就違背了堆的條件

Q: 插入新節點?

A: 新節點插入到陣列最後第一個空著的元素

array[n] = newNode; n++ 

如果插入的新節點大於它的父節點,就會破壞了堆的條件

A: 因此需要向上篩選這個節點,直到它放到堆中合適的位置。插入過程如下圖: 
 
向上篩選的演算法比向下篩選的演算法相對簡單,節點只有一個父節點,目標節點只要和它的父節點交換位置即可

A: 比較上面兩張圖,發現如果先移除一個節點在插入相同的一個節點,結果並不一定是恢復為原來的堆。一組給定的節點可以組成很多合法的堆,這取決於節點插入的順序

Q: 換位的時候不是真的交換?

A: 我們知道一次swap需要三次複製,因此下圖a)中3次交換就需要9次複製,當層數越大時,複製的時間將會越多。 

A: 可以使用複製的方案來取代交換方案,可以減少所需的複製總數。如b)所示,複製次數只有5次,首先暫時儲存節點A,然後B覆蓋A,C覆蓋B,D覆蓋C,最後,在從臨時儲存中取出A覆蓋到D,這樣就把複製的次數從9次減少到5次

堆的Java程式碼

Q: insert?

A: 首先要檢查一下陣列是否已滿; 
然後用引數傳遞的關鍵字值建立一個新的節點,把這個節點插入到陣列的末端; 
最後呼叫trickleUp()把這個節點向上移動到適當的位置。

Q: remove?

A: 首先儲存根節點,把最後一個節點(下標為mSize - 1)放到根的位置上,然後呼叫trickleDown()把這個根節點放到適當的位置。

Q: change?

A: 有了trickleDown()和trickleUp()方法之後,很容易實現改變節點的優先順序演算法,先更改節點關鍵字的值,然後再把節點向上或者向下移動到適當的位置。

Q: 堆操作的效率?

A: 對於有足夠多資料項的堆來說,向上篩選和向下篩選演算法是最費時的部分,這兩個演算法的時間都花費在一個迴圈中,沿著一條路徑重複地向上或者向下移動節點,所需要的複製次數和堆的高度有關。

A: trickleUp()方法在它的迴圈裡只有一個主要的操作:比較新插入節點的關鍵字和當前位置節點的關鍵字。

A: trickleDown()方法需要兩次比較:一次找到最大的子節點,一次比較這個最大的子節點和臨時節點。

A: 它們必須都要從頂層到底層或者從底層到頂層複製節點來完成操作。堆是一種特殊的二叉樹,二叉樹的層數L等於log2(N+1),其中N為節點數。trickleUp()和trickDown()中的迴圈執行了L-1次,所以trickleUp()執行的時間和Log2N成正比,trickleDown()執行時間略長一點,因為它需要執行額外的比較。

A: 總之,堆操作的時間複雜度是O(logN)

基於樹的堆

Q: 實現原理?

A: 前面的Java程式碼實現堆是基於陣列的,不過也可以基於真正的樹來實現。

A: 這棵樹可以是二叉樹,但不會是二叉搜尋樹。不過因為滿足堆的條件,必須是一棵滿二叉樹,沒有空缺的結點,因此也可以稱這樣的樹為樹堆(tree heap)

A: 可以用二進位制碼來表示從根到葉子的路徑,用二進位制數字指示從每個父節點到它子節點的路徑:0表示左子節點,1表示右子節點

A: 假設樹中有29個節點,根的編號為1,現在想要查詢最後一個節點,十進位制29轉化為二進位制是11101。移除開始的1,保留1101。下圖就是從根到編號為29的結點的路徑:向右,向右,向左,向右 

A: 為了執行這個運算,可以重複使用%操作符求出節點n被2整除後的餘數,並再用/操作符執行真正的整除。當n小於1時,操作完成,所得的餘數序列,可以儲存在一個數組或者字串中,這就是二進位制碼字。也可以使用遞迴的方法來實現。

while(n >= 1) { array[i++] = n % 2; n = n / 2; } 

堆排序?

Q: 基本思想?

A: 堆排序(英語:Heapsort)是指利用堆這種資料結構所設計的一種排序演算法

A: 首先使用普通的insert()在堆中插入全部無序的資料項,然後重複用remove(),就可以按序移除所有資料項. 
示例: HeapTestCase.testHeapSort2()

A: 因為insert()和remove()方法操作的時間複雜度都是O(logN),並且每個方法都必須執行N次,所以整個排序操作需要O(N*logN),這和快排一樣。但是它不如快排快,部分原因是trickDown()裡while迴圈的操作比快排裡迴圈的操作要多。

Q: 向下篩選到適當的位置((Trickling Down in Place)?

A: 有一個更妙的技巧,可以使堆排序更有效,其一是節省時間,其二是節省記憶體。

A: 由兩個正確的子堆形成一個正確的堆

 

如上圖,假設A節點作為兩個堆的根,此時A不滿足堆的條件,這個時候對A進行trickleDown()一次, 又變成一個堆了。

A: 這就提出了一個把無序的陣列變成堆的方法,從陣列末端的節點開始,然後上行直到根的各個節點都呼叫trickleDown,在每一步呼叫方法時,該節點下面的子堆都是正確的堆(因為已經對它們呼叫了trickleDown()方法),然後在對根呼叫trickleDown()之後,無序的陣列就轉化為堆了。

A: 不過,注意在最後一行的節點,由於沒有子節點,它們本身已經是正確的堆了(因為它們是單節點的樹,沒有違背堆的條件,因此不用對這些節點呼叫trickleDown()方法)。可以從節點N/2 - 1開始,即最右邊一個有子節點的節點,這樣篩選操作只需執行N/2次insert()方法就夠了。 

如上圖顯示了使用向下篩選的演算法的次序:堆中一共有15個結點,從節點6開始篩選。

Q: 使用同一個陣列?

A: 原始程式碼片段顯示了陣列中的無序資料,然後把資料插入到堆中,最後從堆中移除它並把它有序地寫回陣列,這個過程需要兩個大小為N的陣列:初始陣列和用於堆的陣列。

A: 事實上,堆和初始陣列可以使用同一個陣列,這樣推排序所需要的儲存空間減少了一半。

A: 每從堆頂移除一個數據項,堆陣列的末端單元就變成空的;堆減少一個節點,可以把最近一次移除的節點放到這個新空出的單元中。因此,有序陣列和堆陣列就可以共同使用一塊儲存空間。如下圖 

A: 示例:HeapSort,注意這次增加的方法沒有依照面向物件程式設計的思想(Heap類介面應該對類使用者遮蔽掉堆內部的實現),這裡允許違背OOP的原則是因為陣列和堆結構的聯絡太緊密了。

Q: 堆排序的效率?

A: 前面已經講過,堆排序執行的時間複雜度為O(NlogN)。儘管它比快排略慢,但是它比快速排序優越的一點是它對初始資料的分佈不敏感。比如,快排的時間複雜度可以降到O(N2)級,然而堆排序對任意排列的資料,都是O(NlogN)。

小結

  • 在一個升序優先順序佇列中,最大關鍵字的資料項被稱為有最高的優先順序,反之在降序優先順序佇列中優先順序最高的是最小的資料項
  • 優先順序佇列是提供了資料插入和移除最大(或者最小)資料項方法的抽象資料型別(ADT)
  • 堆是優先順序佇列ADT的有效實現方式
  • 堆提供移除最大資料項和插入的方法,時間複雜度為O(logN)
  • 最大資料項總是在根的位置上
  • 堆不能有序地遍歷所有的資料,不能找到特定關鍵字資料項的位置,也不能移除特定關鍵字的資料項
  • 堆通常用陣列來實現,表現為一棵完全二叉樹,根節點的下標為0,最後一個節點的下標為N-1
  • 每個節點的關鍵字都小於它的父節點,大於它的子節點
  • 要插入的資料項總是先被存放到陣列第一個空的單元中,然後再向上篩選它至適當的位置
  • 當從根移除一個數據項時,用陣列中最後一個數據項取代它的位置,然後再向下篩選這個結點到適當的位置
  • 向上篩選和向下篩選演算法可以被看作是一系列的交換,但更有效的做法是進行一系列的複製
  • 可以更改任一個資料項的優先順序。首先,更改它的關鍵字。如果關鍵字增加了,資料項就向上篩選;而如果關鍵字減少了,資料項就向下篩選。
  • 堆的實現可以基於二叉樹(不是搜尋樹),它對映堆的結構,稱為樹堆。
  • 堆排序是一種高效的排序過程,它的時間複雜度為O(N*logN)
  • 在概念上堆排序的過程包括先在堆中插入N次,然後再做N次移除
  • 通過堆無序陣列中的N/2個數據項施用向下篩選演算法,而不作N次插入,可以使堆排序的執行速度更快
  • 可以使用同一個陣列來存放初始無序的資料、堆以及最後有序的資料,因此堆排序不需要額外的儲存空間

參考

1.《Java資料結構和演算法》Robert Lafore 著,第12章 - 堆