淺談堆-Heap(一)

發表於2019-05-28

應用場景和前置知識複習

  • 堆排序

排序我們都很熟悉,如氣泡排序、選擇排序、希爾排序、歸併排序、快速排序等,其實堆也可以用來排序,嚴格來說這裡所說的堆是一種資料結構,排序知識它的應用場景之一

  • Top N的求解

  • 優先佇列

堆得另一個重要的應用場景就是優先佇列

我們知道普通佇列是:先進先出

而 優先佇列:出隊順序和入隊順序無關;和優先順序相關

實際生活中有很多優先佇列的場景,如醫院看病,急診病人是最優先的,雖然這一類病人可能比普通病人到的晚,但是他們可能隨時有生命危險,需要及時進行治療. 再比如 作業系統要"同時"執行多個任務,實際上現代作業系統都會將CPU的執行週期劃分成非常小的時間片段,每個時間片段只能執行一個任務,究竟要執行哪個任務,是有每個任務的優先順序決定的.每個任務都有一個優先順序.作業系統動態的每一次選擇一個優先順序最高的任務執行.要讓作業系統動態的選擇優先順序最高的任務去執行,就需要維護一個優先佇列,也就是說所有任務都會進入這個優先佇列.

 

基本實現

首先堆是一顆二叉樹,這個二叉樹必須滿足兩個兩條件

  1. 這個二叉樹必須是一顆完全二叉樹,所謂完全二叉樹就是除了最後一層外,其他層的節點的個數必須是最大值,且最後一層的節點都必須集中在左側.即最後一層從左往右數節點必須是緊挨著的,不能是中間空出一個,右邊還有兄弟節點.

  2. 這個二叉樹必須滿足 左右子樹的節點值必須小於或等於自身的值(大頂堆) 或者 左右子樹的節點值必須大於或等於自身的值(小頂堆)

下圖分別是一個大頂堆和小頂堆的示例

 

看到這兩顆二叉樹,我們首先就能定義出樹節點的結構:

 1 Class Node {
 2     //節點本身的值
 3     private Object value;
 4     
 5     private Node left;
 6     
 7     private Node right;
 8     
 9     ....getter and setter
10     
11 }

但是這裡我們利用完全二叉樹的性質用陣列來構建這棵樹.先從上到下,自左至右的來給樹的每一個節點編上號.

以大頂堆為例

標上編號後,我們發現每個節點的左子節點(如果存在)的序號都是其自身的2倍,右子節點(如果存在)的序號是其自身的2倍加1. 相反,如果已知某個節點的序號,父節點的序號是其自身的二分之一(計算機中整型相除,捨棄餘數)

下面來用程式碼構建一個堆得骨骼

 1 public class MaxHeap {
 2 
 3     /*
 4      * 堆中有多少元素
 5      */
 6     private int count;
 7 
 8     /*
 9      * 存放堆資料的陣列
10      */
11     private Object[] data;
12 
13 
14     public MaxHeap(int capacity) {
15         /*
16          * 因為序號是從1 開始的,我們不用下標是0的這個位置的數
17          */
18         this.data = new Object[capacity + 1];
19     }
20 
21     /**
22      * 返回堆中有多少資料
23      * @return
24      */
25     public int size()  {
26         return count;
27     }
28 
29     /**
30      * 堆是否還有元素
31      * @return
32      */
33     public boolean isEmpty() {
34         return count == 0;
35     }
36     
37 }
View Code

骨骼是構建好了,乍一看堆中存放的資料是一個object型別的資料, 父子節點按節點值 無法比較,這裡再調整一下

 1 public class MaxHeap<T extends Comparable<T>> {
 2 
 3     /*
 4      * 堆中有多少元素
 5      */
 6     private int count;
 7 
 8     /*
 9      * 存放堆資料的陣列
10      */
11     private T[] data;
12 
13     /**
14      * @param clazz 堆裡放的元素的型別
15      * @param capacity  堆的容量
16      */
17     public MaxHeap(Class<T> clazz, int capacity) {
18         /*
19          * 因為序號是從1 開始的,我們不用下標是0的這個位置的數
20          */
21         this.data = (T[]) Array.newInstance(clazz, capacity + 1);
22     }
23 
24     /**
25      * 返回堆中有多少資料
26      *
27      * @return
28      */
29     public int size() {
30         return count;
31     }
32 
33     /**
34      * 堆是否還有元素
35      *
36      * @return
37      */
38     public boolean isEmpty() {
39         return count == 0;
40     }
41 
42     public T[] getData() {
43         return data;
44     }
45 }

這樣骨架算是相對完好了,下面實現向堆中新增資料的過程,首先我們先把上面的二叉樹的形式按標號對映成陣列的形式如圖對比(已經說了0號下標暫時不用)

現在這個大頂堆被對映成陣列,所以向堆中插入元素,相當於給陣列新增元素,這裡我們規定每新插入一個元素就插在當前陣列最後面,也即陣列最大標 + 1的位置處.對於一顆完全二叉樹來說就是插在最後一層的靠左處,如果當前二叉樹是一顆滿二叉樹,則新開闢一層,插在最後一層最左側.但是這樣插入有可能破壞堆的性質. 如插入節點45

 

插入新節點後已經破壞了大頂堆的性質,因為45比父節點17大, 這裡我們只要把新插入的節點45和父節點17 交換,類似依次比較與父節點的大小做交換即可

第一次交換:

第二次交換:

這裡我們發現經過兩次交換,已經滿足了堆的性質,這樣我們就完成了一次插入,這個過程,我們發現待插入的元素至底向頂依次向樹根上升,我們給這個過程起個名叫shiftUp,用程式碼實現便是:

 1     /**
 2      * 插入元素t到堆中
 3      * @param t
 4      */
 5     public void insert(T t) {
 6         //把這個元素插入到陣列的尾部,這時堆的性質可能被破壞
 7         data[count + 1] = t;
 8         //插入一個元素,元素的個數增加1
 9         count++;
10         //移動資料,進行shiftUp操作,修正堆
11         shiftUp(count);
12 
13     }
14 
15     private void shiftUp(int index) {
16         while (index > 1 && ((data[index].compareTo(data[index >> 1]) > 0))) {
17             swap(index, index >>> 1);
18             index >>>= 1;
19         }
20     }
21 
22     /**
23      * 這裡使用引用交換,防止基本型別值傳遞
24      * @param index1
25      * @param index2
26      */
27     private void swap(int index1, int index2) {
28         T tmp = data[index1];
29         data[index1] = data[index2];
30         data[index2] = tmp;
31     }

這裡有一個隱藏的問題,初始化我們指定了存放資料陣列的大小,隨著資料不斷的新增,總會有陣列越界的這一天.具體體現在以上程式碼 data[count + 1] = t 這一行

 1    /**
 2      * 插入元素t到堆中
 3      * @param t
 4      */
 5     public void insert(T t) {
 6         //把這個元素插入到陣列的尾部,這時堆的性質可能被破壞
 7         data[count + 1] = t;   //這一行會引發陣列越界異常
 8         //插入一個元素,元素的個數增加1
 9         count++;
10         //移動資料,進行shiftUp操作,修正堆
11         shiftUp(count);
12 
13     }

我們可以考慮在插入之前判斷一下容量,所以宣告一個成員變數,在例項初始化時,初始化這個capacity

 1     private int capacity;
 2     //構造方法變成:
 3     /**
 4      * @param clazz 堆裡放的元素的型別
 5      * @param capacity  堆的容量
 6      */
 7     public MaxHeap(Class<T> clazz, int capacity) {
 8         /*
 9          * 因為序號是從1 開始的,我們不用下標是0的這個位置的數
10          */
11         this.data = (T[]) Array.newInstance(clazz, capacity + 1);
12         this.capacity = capacity;
13     }
14     
15     /**
16      * 插入元素t到堆中
17      * @param t
18      */
19     public void insert(T t) {
20         //插入的方法加入容量限制判斷
21         if(count + 1 > capacity)
22             throw new IndexOutOfBoundsException("can't insert a new element...");
23         //把這個元素插入到陣列的尾部,這時堆的性質可能被破壞
24         data[count + 1] = t;   //這一行會引發陣列越界異常
25         //插入一個元素,元素的個數增加1
26         count++;
27         //移動資料,進行shiftUp操作,修正堆
28         shiftUp(count);
29 
30     }

至此,整個大頂堆的插入已經還算完美了,來一波兒資料測試一下,應該不是問題

可能上面插入時我們看到有shiftUp這個操作,可能會想到從堆中刪除元素是不是shiftDown這個操作. 沒錯就是shiftDown,只不過是刪除堆中元素只能刪除根節點元素,對於大頂堆也就是剔除最大的元素.下面我們用圖說明一下.

 

刪除掉根節點,那根節點的元素由誰來補呢. 簡單,直接剁掉原來陣列中最後一個元素,也就是大頂堆中最後一層最後一個元素,摘了補給根節點即可,相應的堆中元素的個數要減一

 

最終我們刪除了大頂堆中最大的元素,也就是根節點,堆中序號最大的元素變成了根節點.

 

此時整個堆不滿足大頂堆的性質,因為根節點17比其子節點小,這時,shiftDown就管用了,只需要把自身與子節點交換即可,可是子節點有兩個,與哪個交換呢,如果和右子節點30交換,30變成父節點,比左子節點45小,還是不滿足大頂堆的性質.所以應該依次與左子節點最大的那個交換,直至父節點比子節點大才可.所以剔除後新被替換的根節點依次下沉,所以這個過程被稱為shiftDown,最終變成

所以移除z最大元素的方法實現:

 1     /**
 2      * 彈出最大的元素並返回
 3      *
 4      * @return
 5      */
 6     public T popMax() {
 7         if (count <= 0)
 8             throw new IndexOutOfBoundsException("empty heap");
 9         T max = data[1];
10         //把最後一個元素補給根節點
11         swap(1, count);
12         //補完後元素個數減一
13         count--;
14         //下沉操作
15         shiftDown(1);
16         return max;
17     }
18 
19     /**
20      * 下沉
21      *
22      * @param index
23      */
24     private void shiftDown(int index) {
25         //只要這個index對應的節點有左子節點(完全二叉樹中不存在 一個節點只有 右子節點沒有左子節點)
26         while (count >= (index << 1)) {
27             //比較左右節點誰大,當前節點跟誰換位置
28             //左子節點的inedx
29             int left = index << 1;
30             //右子節點則是
31             int right = left + 1;
32             //如果右子節點存在,且右子節點比左子節點大,則當前節點與右子節點交換
33             if (right <= count) {
34                 //有右子節點
35                 if ((data[left].compareTo(data[right]) < 0)) {
36                     //左子節點比右子節點小,且節點值比右子節點小
37                     if (data[index].compareTo(data[right]) < 0) {
38                         swap(index, right);
39                         index = right;
40                     } else
41                         break;
42 
43                 } else {
44                     //左子節點比右子節點大
45                     if (data[index].compareTo(data[left]) < 0) {
46                         swap(index, left);
47                         index = left;
48                     } else
49                         break;
50                 }
51             } else {
52                 //右子節點不存在,只有左子節點
53                 if (data[index].compareTo(data[left]) < 0) {
54                     swap(index, left);
55                     index = left;
56                 } else
57                     //index 的值大於左子節點,終止迴圈
58                     break;
59             }
60         }
61     }

至此,大頂堆的插入和刪除最大元素就都實現完了.來寫個測試

 1 public static void main(String[] args) {
 2         MaxHeap<Integer> mh = new MaxHeap<Integer>(Integer.class, 12);
 3         mh.insert(66);
 4         mh.insert(44);
 5         mh.insert(30);
 6         mh.insert(27);
 7         mh.insert(17);
 8         mh.insert(25);
 9         mh.insert(13);
10         mh.insert(19);
11         mh.insert(11);
12         mh.insert(8);
13         mh.insert(45);
14         Integer[] data = mh.getData();
15         for (int i = 1 ; i <= mh.count ; i++ ) {
16             System.err.print(data[i] + " ");
17         }
18         mh.popMax();
19         for (int i = 1 ; i <= mh.count ; i++ ) {
20             System.err.print(data[i] + " ");
21         }
22     }
View Code

嗯,還不錯,結果跟上面圖上對應的陣列一樣.結果倒是期望的一樣,但總感覺上面的shiftDown的程式碼比shiftUp的程式碼要多幾倍,而且看著很多類似一樣的重複的程式碼, 看著難受.於是乎想個辦法優化一下. 對我這種強迫症來說,不幹這件事,晚上老是睡不著覺.

思路: 上面我們不斷的迴圈條件是這個index對應的節點有子節點.如果節點堆的性質破壞,最終是要用這個值與其左子節點或者右子節點的值交換,所以我們計算出了左子節點和右子節點的序號.其實不然,我們定義一個抽象的最終要和父節點交換的變數,這個變數可能是左子節點,也可能是右子節點,初始化成左子節點的序號,只有在其左子節點的值小於右子節點,且父節點的值也左子節點,父節點才可能與右子節點,這時讓其這個交換的變數加1變成右子節點的序號即可,其他情況則要麼和左子節點交換,要麼不作交換,跳出迴圈,所以shiftDown簡化成:

 1    /**
 2      * 下沉
 3      *
 4      * @param index
 5      */
 6     private void shiftDown(int index) {
 7         //只要這個index對應的節點有左子節點(完全二叉樹中不存在 一個節點只有 右子節點沒有左子節點)
 8         while (count >= (index << 1)) {
 9             //比較左右節點誰大,當前節點跟誰換位置
10             //左子節點的inedx
11             int left = index << 1;
12             //data[index]預交換的index的序號
13             int t = left;
14             //如果右子節點存在,且右子節點比左子節點大,則當前節點可能與右子節點交換
15             if (((t + 1) <= count) && (data[t].compareTo(data[t + 1]) < 0))
16                 t += 1;
17             //如果index序號節點比t序號的節點小,才交換,否則什麼也不作, 退出迴圈
18             if(data[index].compareTo(data[t]) >= 0)
19                 break;
20             swap(index, t);
21             index = t;
22         }
23     }

嗯,還不錯,這下完美了.簡單多了.其他還有待優化的地方留在下篇討論

總結

  • 首先複習了堆的應用場景,具體的應用場景程式碼實現留在下一篇.

  • 引入堆的概念,性質和大頂堆,小頂堆的概念,實現了大頂堆的元素新增和彈出

  • 根據堆的性質和彈出時下沉的規律,優化下沉方法程式碼.

  • 下一篇優化堆的構建,用程式碼實現其應用場景,如排序, topN問題,優先佇列等並引入其他的堆分析及其與普通堆的效能差異

 

相關文章