堆是一種特殊的樹結構,其最典型的的應用就是堆排序。堆排序是一種原地的、時間複雜度爲O(nlogn)的排序算法。
什麼是堆
- 必須是一棵完全二叉樹。
- 每個節點都必須大於等於(或小於等於)它的所有子節點,等價於每個節點都必須大於等於(或小於等於)它的左右節點。
如何實現一個最大堆
我們可以選取基於數組的順序存儲法來保存堆。根節點存儲在數組下標爲1的位置,左子節點存儲在下標2的位置,右子節點存儲在下標3的位置……以此類推,下標爲n元素的左子節點的下標爲2n,右子節點的下標爲2n+1。
- 堆的基本結構
其有三個成員變量,T[] arr用於存儲堆中元素,capacity表示這個堆的最大容量,count表示當前堆中元素個數。‘’public class MaxHeap <T extends Comparable> { private T[] arr; private int capacity; private int count; public MaxHeap(int capacity){ arr = (T[]) new Comparable[capacity+1]; this.capacity = capacity; } public MaxHeap(T[] arr) { this(arr.length); for (T t : arr) { insert(t); } } public int size() { return count; } public boolean isEmpty() { return count == 0; } public T getMax() { return arr[1]; } }
- 向堆中插入一個元素
我們可以將這個新元素直接添加到數組的最後,但這樣一來就無法保持堆的性質,所以我們需要重新調整堆的結構,以保證其維持堆的特性。我們可以將這個元素不斷向上移動到合適的位置,這個過程是一種自下而上(shift up)的堆化。
例如,我們在一個最大堆中插入一個值爲40的元素,
首先,將這個元素放在數組最後(索引n),然後與其父節點(索引n/2)比較,若大於父節點元素,則與父節點元素交換位置。
元素到達新位置後,繼續與其父節點元素比較,若仍大於父節點元素,則繼續交換。
直到該元素小於等於其父節點元素或者該元素交換到了根節點,此時整個堆就又恢復了其性質,至此堆化過程結束。
上面的思路翻譯成代碼:public void insert(T t) { arr[++count] = t; shiftUp(count); } private void shiftUp(int index) { if (index<=1) { return; } int prtindex = index/2; if (arr[index].compareTo(arr[prtindex])>0) { SortTestHelper.swap(arr, index, prtindex); shiftUp(prtindex); } }
- 取出堆中最大元素
當我們從堆中取出最大元素時,也就是從堆中移除了這元素,那麼數組中下標1的位置就空缺了,也就是二叉樹的根空缺了。我們可以將數組中最後一個元素提到根節點上,然後在不斷交換下移到正確的位置,這個過程稱爲自上而下(shift down)的堆化。
首先,將第一個元素移除,此時根結點空缺。
第二步,我們將最後一個元素移到根結點,以維持樹的基本結構。但這時34比它的左右子節點40和36都小,不滿足堆的性質。我們要想辦法將34這個節點放到正確的位置。
第三步,對比34和它的左右子節點,選擇其中較大的一個交換位置。
此時,34大於左子節點26而小於右子節點39,所以交換34與39的位置。
一直重複這個過程,不斷比較、下移,直到34這個節點大於等於其子節點,
上面的思路翻譯成代碼:public T extractMax() { T t = arr[1]; arr[1] = arr[count]; count--; shiftDown(1); return t; } private void shiftDown(int index) { if (index>count/2) { return; } int cldIndex = index*2; if (cldIndex <count && arr[cldIndex ].compareTo(arr[cldIndex +1])<0) { cldIndex ++; } if (arr[index].compareTo(arr[cldIndex ])<0) { SortTestHelper.swap(arr, index, cldIndex ); shiftDown(cldIndex ); } }
如何基於一個堆實現排序
根據上面的思路我們就可以很輕鬆的基於一個堆實現排序。
將數組元素依次插入最大堆,然後再依次取出最大值,賦值回數組。此時數組中的元素就完成了排序,無論是創建堆的過程, 還是從堆中依次取出元素的過程, 時間複雜度均爲O(nlogn),整個堆排序的整體時間複雜度爲O(nlogn)。但這個實現不是原地的。
那有沒有原地排序的實現方案呢?
首先我們可以將數組原地堆化,這個過程稱爲heapify。也就是從第一個非葉子節點(索引:(n-1)/2,注意現在根節點的索引爲0)開始,將每棵子樹heapify,得到最大堆。
此時索引0位置就是當前數組中的最大值,交換索引0和索引n-1處的元素,最大值就被放到了排序後正確的位置。而arr[0,n-2]又失去了最大堆的性質,需要重新heapify,再將新的最大值與n-2位置的元素(也就是新堆中的最後一個元素)交換。如此重複,直到堆中只剩下一個元素。此時數組就排序完成了。
下圖就是原地堆排序的過程,其中淺粉色表示不滿足堆性質的數組,淺綠色表示heapify後得到的最大堆。
上面的思路翻譯成代碼:
public class HeapSort {
private HeapSort() {}
public static void sort(Comparable[] arr) {
int r = arr.length-1;
// 原地建堆
for (int i=(r-1)/2; i>=0; i--){
shiftDown(arr, i, r);
}
// 將最大值,與新堆中的最後一個元素交換,再次原地建堆
for (int i=arr.length-1; i>0; i--) {
SortTestHelper.swap(arr,0, i);
shiftDown(arr, 0, i-1);
}
}
private static void shiftDown(Comparable[] arr, int l, int r) {
while(2*l+1<=r) {
int newIndex = 2*l+1;
if (newIndex+1<=r && arr[newIndex].compareTo(arr[newIndex+1])<0) {
newIndex++;
}
if (arr[l].compareTo(arr[newIndex])>=0) {
return;
}
SortTestHelper.swap(arr, l, newIndex);
l=newIndex;
}
}
}
總結
堆分爲大頂堆和小頂堆,上面都是以大頂堆爲例,小頂堆同理。其特點是二叉樹中的每個節點都大於等於(或小於等於)其子節點。
堆的常見的操作是插入元素和取出堆頂元素。這兩個操作都會破壞堆性質,需要重新堆化。
堆的經典應用就是堆排序。堆排序分兩個過程,堆化和排序。堆化的時間複雜度爲O(logn),排序需要遍歷n次,將n個元素依次放入正確位置。所以堆排序的時間複雜度爲O(nlogn)。