做過系統編程的人都知道,幾乎任何系統都會提供一種時鐘機制,也就是SetTimer調用,你給系統提供一個回調函數,順帶一個超時時間,一旦時間過去后,系統就會回調你提供的函數。問題是,你如何實現一個系統的Timer機制。
實現Timer機制的辦法是使用堆排序。所謂的'堆'是一種特殊的二叉樹,它跟內存管理上的'堆'沒有任何聯系。數據結構'堆',實際上是把一個數組中的元素按照某種次序組合成一顆二叉樹,二叉樹根節點的特性決定了堆的特性,如果二叉樹根節點的值是數組元素中的最大值,那么這顆二叉樹表示的堆叫大堆,如果二叉樹根節點的值是元素中的最小值,那么二叉樹表示的堆叫小堆。
例如給定一個數組:
heapArray: 16, 14, 10, 8, 7 , 9 , 3, 2, 4 , 1
把他們構建成一個大堆,結果如下:
節點旁邊的數字代表它在數組中的下標,我們可以看到,給定一個節點下標i,那么它在對中對應的父節點就是i/2, 必然下標為2和3的節點,其父節點是16,而16的下標恰好是1.
給定一個節點下標為i,那么它在堆中的左孩子對應在數組中的下標為2*i,右孩子對應的下標為2*i+1,于是我們有如下三種操作:
int parent(int i) {
return i/2;
}
int left(int i ) {
return 2*i;
}
int right(int i) {
return 2*i+1;
}
堆雖然是二叉樹,但不是排序二叉樹,所以無需要求做子樹節點一定小于父節點,父節點一定小于右子樹節點。但它必須維持一個特性,那就是如果堆是大堆,那么必須保證父節點大于孩子節點;如果是小堆,那么必須保證父節點小于孩子節點。于是如果二叉樹是大堆,那么根節點肯定是所以元素中的最大那個,如果是小堆,那么根節點肯定是所有元素中值最小的那個。
由于堆是一顆二叉樹,所以堆有高度之分,一個含有n個元素的數組轉換為堆時,堆的高度就是lg(n). 接下來我們看看,如果大堆中,某個節點的值變得比它孩子的值還要小,那么我們應該如何調整才能使得前面所說的大堆性質保持不變,我們假定當前的大堆元素個數用heapSize來表示。假如下標為i的元素它的數值被改變了,我們可以通過下面代碼調整大堆,從而使得堆的性質保持不變:
private void maxHeapify(int i) {
//這里+1的原因是,數組下標是從0開始,而算法對數組下標是從1開始
i++;
int l = left(i);
int r = right(i);
//減1的原因是,數組元素的下標是由0起始的,而算法對數組下標的起始是由1開始
i--;
l--;
r--;
int largest = -1;
if (l < heapSize && heapArray[l] > heapArray[i]) {
largest = l;
} else {
largest = i;
}
if (r < heapSize && heapArray[r] > heapArray[largest]) {
largest = r;
}
if (largest != i) {
int temp = heapArray[i];
heapArray[i] = heapArray[largest];
heapArray[largest] = temp;
maxHeapify(largest);
}
}
它的思路很簡單,如果某個節點值變小了,那么它就找到他左右孩子節點中值最大的那個,然后兩種互換,這個動作一直進行,直到走到堆的底部為止。注意,上面代碼的邏輯假設節點i的左子樹和右子樹都滿足大堆的性質。假設一個滿足條件的二叉樹如下:
節點4比它的左右孩子要小,但以節點14和7為根的二叉樹是滿足大堆性質的,由此我們可以根據上面代碼邏輯進行調整。先從節點4的左右孩子中找到最大的一個,顯然左孩子是14是最大的,于是把4和14交換:
節點4下降一層,然后再和他的左右節點中值最大的那個互換,從上圖看,節點4應該和節點8互換,交換后結果如下:
我們看到,這一次調換后,整個二叉樹滿足大堆的性質。在調整過程中,每調整一次,原來元素就下降一層,由于堆的高度是lg(n),所以上面調整代碼的時間復雜度是O(lg(n))。
接下來,我們可以使用maxHeapify來將一個數組構建成一個大堆,前面我們說如果一個元素在數組中的下標是i,那么它的左孩子下標為2*i,右孩子下標為2*i+1,這樣一來,所有處于數組后半部分的元素只能處于大堆最底部,也就是只能做葉子。這里要注意的是,任何單個節點本身就是一個大堆。我們使用下面的算法來把一個數組構建成大堆:
private int[] buildMaxHeap() {
for (int i = heapSize / 2; i >= 0; i--) {
maxHeapify(i);
}
return heapArray;
}
代碼里的循環是從數組的中點開始的,前面我們提到過,中部后面的元素都是葉子節點,葉子節點自己就滿足大堆屬性。在前面我們也演示過,maxHeapify這個調用能夠保證每個以每個節點為根的二叉樹滿足大堆屬性,于是循環結束后的數組就變成了一個滿足大堆性質的二叉樹。舉個例子,假設有數組
A:1,5,3,4,2
數組長度是5,上面代碼則從下標為2處,也就是元素3開始循環,3的左孩子是下標為4的元素2,由此3和2兩個節點構成了一個大堆,接著循環往前走到元素5,5的左孩子是3,右孩子是4,此時節點5和它的兩個孩子滿足大堆性質,循環繼續往前走,來到元素1,它的左孩子是節點5,右孩子是3,此時maxHeapify調用將對元素進行調整,它先把節點1和節點5對換,然后再把節點1和節點4對換,最后數組A的情況如下:
A: 5, 4, 3, 1, 2
數組元素的排列是滿足大堆屬性的。
綜合起來,我們得到構建一個大堆的代碼如下:
public class HeapSort {
private int heapSize = 0;
private int[] heapArray = null;
public HeapSort(int[] arr) {
heapSize = arr.length;
heapArray = arr;
}
private int parent(int i) {
return i/2;
}
private int left(int i) {
return i*2;
}
private int right(int i) {
return i*2+1;
}
private void maxHeapify(int i) {
//這里+1的原因是,數組下標是從0開始,而算法對數組下標是從1開始
i++;
int l = left(i);
int r = right(i);
//減1的原因是,數組元素的下標是由0起始的,而算法對數組下標的起始是由1開始
i--;
l--;
r--;
int largest = -1;
if (l < heapSize && heapArray[l] > heapArray[i]) {
largest = l;
} else {
largest = i;
}
if (r < heapSize && heapArray[r] > heapArray[largest]) {
largest = r;
}
if (largest != i) {
int temp = heapArray[i];
heapArray[i] = heapArray[largest];
heapArray[largest] = temp;
maxHeapify(largest);
}
}
public int[] buildMaxHeap() {
for (int i = heapSize / 2; i >= 0; i--) {
maxHeapify(i);
}
return heapArray;
}
}
利用上面代碼,對給定任意數組構建一個大堆:
public class Heap {
public static void main(String[] s) {
int A[] = new int[]{1,2 ,3,4,7,8,9,10,14,16};
HeapSort hs = new HeapSort(A);
int[] heap = hs.buildMaxHeap();
for (int i = 0; i < heap.length; i++) {
System.out.print(heap[i] + " ");
}
}
}
上面代碼運行后,輸出的數組結果為:
16 14 9 10 7 8 3 1 4 2
上面數組對應的大堆二叉樹為:
maxHeapify其時間復雜度為lg(n),所以buildMaxHeap的時間復雜度為n*lg(n).
大堆的特點是,所有元素的最大值在根節點,對應的也就是最大值會放置到數組的頭部,我們可以利用這個特性來對數組進行排序,這種排序法叫堆排序。對含有n個元素的數組,我們構建一個大堆,那么最大值肯定在堆的根節點,對應于數組就是最大值會在數組下標為0處,然后我們把第0個元素和最后一個元素互換,這樣最大值就排在了數組的n-1處。接著對數組的前n-1個元素進行相同的步驟,這樣第二大的元素就會排在數組的n-2處,就這樣反復進行,整個數組就能實現從小到大排序了,對應代碼如下:
public void heapSort() {
buildMaxHeap();
for (int i = heapArray.length - 1; i > 0; i--) {
//值最大的元素在根節點對應到數組就是值最大的元素在數組的起始位置
int temp = heapArray[i];
heapArray[i] = heapArray[0];
heapArray[0] = temp;
heapSize--;
maxHeapify(0);
}
}
上面代碼上運行后,再把元素的內容打印出來:
hs.heapSort();
System.out.println("\nArray after heap sort is :");
for (int i = 0; i < heap.length; i++) {
System.out.print(A[i] + " ");
}
打印后結果如下:
1 2 3 4 7 8 9 10 14 16
由此可見,數組中的元素已經被排好序了。heapSort執行時,調用了buildMaxHeap(),其時間復雜度我們前面分析過是n*lg(n),其中的for循環次數是n,每次循環調用了maxHeapify(),其復雜度是lg(n),所以for循環所需要的時間復雜度是n*lg(n),由此一來heapSort的時間復雜度,也就是堆排序的時間復雜度是O(n*lg(n))。
使用堆排序實現優先級隊列
最大優先級隊列是這樣一種數據結構,它是一組元素的集合,同時它支持以下三種操作:
1, insert(x), 把一個元素加入集合。
2, maximun(), 返回整個集合中的最大值
3, extractMax(), 把集合中的最大值取出來并從集合中去除。
4, increaseKey(i ,k), 把集合中的第i個元素的值增加為k。
假設我們有一系列任務,任務的優先級不同,優先級越大任務越重要,這樣的話我們就可以把任務使用最大優先級隊列進行調度。與最大優先級隊列等價的是最小優先級隊列,它的原理跟最大優先級隊列一樣,只不過把返回最大值改為最小值,把元素的值增加k變為減少k,系統的Timer機制就是使用最小優先級隊列實現的。
我們看看上面的幾種操作如何基于最大堆實現,首先Maximun()實現簡單,我們只要把數組構建成大堆后,返回數組的首個元素即可。于是有
public int maximun() {
return heapArray[0];
}
Extract_Maximun()需要把最大值拿掉后,剩下的元素任然能滿足相應操作,假設我們數組中元素最大是16,第二大是14,那么調用一次Extract_Maximun()后返回16,然后調用maximun()時要返回14,它的做法是把返回堆的根元素,把數組最后一個元素跟根元素互換,接著把堆的大小由原來的n變為n-1,然后調用maxHeapify對前n-1個元素調整成大堆。代碼如下:
int extractMax() {
if (heapSize < 1) {
return -1;
}
int max = heapArray[0];
heapArray[0] = heapArray[heapSize - 1];
heapSize--;
maxHeapify(0);
return max;
}
extractMax內部調用了maxHeapify(),由于后者的時間復雜度是O(lgn),所以extractMax()的時間復雜度是O(lg(n)).
完成上面代碼后,通過以下代碼進行調試運行:
System.out.println("\nmaximun is : " + hs.maximun());
hs.extractMax();
System.out.println("the maximun after extractMax is : " + hs.maximun());
程序輸出結果為:
maximun is : 16
the maximun after extractMax is : 14
我們把最大值16從堆中抽出后,再次調用maximun()得到結果為第二大的節點14,由此可見我們代碼實現的邏輯是正確的。
我們再看看increaseKey的實現:
public void increaseKey(int i, int k) {
if (heapArray[i] > k) {
return;
}
heapArray[i] = k;
while (i > 0 && heapArray[parent(i)] < heapArray[i]) {
int temp = heapArray[parent(i)];
heapArray[parent(i)] = heapArray[i];
heapArray[i] = temp;
i = parent(i);
}
}
當某個節點值變大后,為了保證大堆性質不變,如果它的值比父節點大,那么把它和父節點互換,這個流程一直執行到根節點,這樣就可以保持整個大堆的性質不變了,該操作的時間復雜度是O(lg(n))。完成代碼后,我們在入口處添加如下代碼來檢驗效果:
hs.increaseKey(9, 17);
System.out.println("\nThe maximun after increaseKey is : " + hs.maximun());
數組第9個元素,也就是最后一個元素的值是2,我們通過increaseKey調用把它增加到17,此時它變成了整個大堆里的最大值,再次調用maximun()得到的結果應該是17,上面代碼運行后結果如下:
The maxmimun after increaseKey is : 17
由此可見,程序的實現是正確的。我們再看最后一個insert操作,給當前大堆插入一個元素并且保持堆的性質不變:
public int[] insert(int val) {
int[] mem = new int[heapArray.length + 1];
for (int i = 0; i < heapArray.length; i++) {
mem[i] = heapArray[i];
}
heapArray = mem;
heapArray[heapArray.length - 1] = Integer.MIN_VALUE;
heapSize++;
increaseKey(heapSize - 1, val);
return heapArray;
}
由于要插入一個元素,插入后堆的元素要比原來多1,因此代碼先分配一個比原來大堆對應的數組多一個元素的內存,然后把原數組內容拷貝過去,接著把最后一個元素的值設置為最小值,最后使用increaseKey調用把最后的元素值增加到要插入的元素值,這樣我們就能保證插入的元素可以添加到大堆的適當位置,適當堆的性質不會遭到破壞,該操作的時間復雜度與increaseKey一樣,都是O(lg(n))。
我們在程序入口處使用如下代碼測試上面實現的正確性:
hs.insert(20);
System.out.println("Maximun after insert is: " + hs.maximun());
我們往堆中插入一個值為20的新元素,插入后這個值將最為堆的最大值從maximun調用中返回,上面代碼運行后結果如下:
Maximun after insert is: 20
由此可見,我們代碼的實現在邏輯上是正確的。
到此,堆的數據結構及其相關算法原理和實現我們就介紹完了,要實現系統的Timer機制,我們只要實現一個小堆,然后在此基礎上實現最小優先級隊列即可。
更詳細的講解和調試演示,請參看視頻。
更多技術信息,包括操作系統,編譯器,面試算法,機器學習,人工智能,請關照我的公眾號: