淺談堆-Heap(一)


應用場景和前置知識復習

  • 堆排序

排序我們都很熟悉,如冒泡排序、選擇排序、希爾排序、歸並排序、快速排序等,其實堆也可以用來排序,嚴格來說這里所說的堆是一種數據結構,排序只是它的應用場景之一

  • 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. 相反,如果已知某個節點的序號,父節點的序號是其自身的二分之一(計算機中整型相除,舍棄余數)下面來用代碼構建一個堆的骨骼

public class MaxHeap {

    /*
     * 堆中有多少元素
     */
    private int count;

    /*
     * 存放堆數據的數組
     */
    private Object[] data;


    public MaxHeap(int capacity) {
        /*
         * 因為序號是從1 開始的,我們不用下標是0的這個位置的數
         */
        this.data = new Object[capacity + 1];
    }

    /**
     * 返回堆中有多少數據
     * @return
     */
    public int size()  {
        return count;
    }

    /**
     * 堆是否還有元素
     * @return
     */
    public boolean isEmpty() {
        return count == 0;
    }
    
}
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 Object[] data;
12     
13     /**
14      * 堆的容量
15      */
16     private int capacity;
17 
18     /**
19      * @param clazz 堆里放的元素的類型
20      * @param capacity  堆的容量
21      */
22     public MaxHeap(int capacity) {
23         /*
24          * 因為序號是從1 開始的,我們不用下標是0的這個位置的數
25          */
26         this.data = new Object[capacity + 1];
27         this.capacity = capacity;
28     }
29 
30     /**
31      * 返回堆中有多少數據
32      *
33      * @return
34      */
35     public int size() {
36         return count;
37     }
38 
39     /**
40      * 堆是否還有元素
41      *
42      * @return
43      */
44     public boolean isEmpty() {
45         return count == 0;
46     }
47 
48     public Object[] getData() {
49         return data;
50     }
51 }

這樣骨架算是相對完好了,下面實現向堆中添加數據的過程,首先我們先把上面的二叉樹的形式按標號映射成數組的形式如圖對比(已經說了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 && ((((T) data[index]).
17                 compareTo((T) data[index >> 1]) > 0))) {
18             swap(index, index >>> 1);
19             index >>>= 1;
20         }
21     }
22 
23     /**
24      * 這里使用引用交換,防止基本類型值傳遞
25      * @param index1
26      * @param index2
27      */
28     private void swap(int index1, int index2) {
29         T tmp = (T) data[index1];
30         data[index1] = data[index2];
31         data[index2] = tmp;
32     }

這里有一個隱藏的問題,初始化我們指定了存放數據數組的大小,隨着數據不斷的添加,總會有數組越界的這一天.具體體現在以上代碼 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     }

 

我們可以考慮在插入之前判斷一下容量

 1     /**
 2      * 插入元素t到堆中
 3      * @param t
 4      */
 5     public void insert(T t) {
 6         //插入的方法加入容量限制判斷
 7         if(count + 1 > capacity)
 8             throw new IndexOutOfBoundsException("can't insert a new element...");
 9         //把這個元素插入到數組的尾部,這時堆的性質可能被破壞
10         data[count + 1] = t;   //這一行會引發數組越界異常
11         //插入一個元素,元素的個數增加1
12         count++;
13         //移動數據,進行shiftUp操作,修正堆
14         shiftUp(count);
15 
16     }

 

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

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

 

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

 

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

 

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

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

 1 /**
 2      * 彈出最大的元素並返回
 3      *
 4      * @return
 5      */
 6     public T popMax() {
 7         if (count <= 0)
 8             throw new IndexOutOfBoundsException("empty heap");
 9         T max = (T) 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 ((((T)data[left]).compareTo((T)data[right]) < 0)) {
36                     //左子節點比右子節點小,且節點值比右子節點小
37                     if (((T)data[index]).compareTo((T)data[right]) < 0) {
38                         swap(index, right);
39                         index = right;
40                     } else
41                         break;
42 
43                 } else {
44                     //左子節點比右子節點大
45                     if (((T)data[index]).compareTo((T)data[left]) < 0) {
46                         swap(index, left);
47                         index = left;
48                     } else
49                         break;
50                 }
51             } else {
52                 //右子節點不存在,只有左子節點
53                 if (((T)data[index]).compareTo((T)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) && (((T) data[t]).compareTo((T) data[t + 1]) < 0))
16                 t += 1;
17             //如果index序號節點比t序號的節點小,才交換,否則什么也不作, 退出循環
18             if (((T) data[index]).compareTo((T) data[t]) >= 0)
19                 break;
20             swap(index, t);
21             index = t;
22         }
23     }

 

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

總結

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

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

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

  • 下一篇優化堆的構建,用代碼實現其應用場景,如排序, topN問題,優先隊列等

 

 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM