純數據結構Java實現(6/11)(二叉堆&優先隊列)

  • 2019 年 10 月 3 日
  • 筆記

堆其實也是樹結構(或者說基於樹結構),一般可以用堆實現優先隊列。

二叉堆

堆可以用於實現其他高層數據結構,比如優先隊列

而要實現一個堆,可以藉助二叉樹,其實現稱為: 二叉堆 (使用二叉樹表示的堆)。

但是二叉堆,需要滿足一些特殊性質:

其一、二叉堆一定是一棵完全二叉樹 (完全二叉樹可以用數組表示,見下面)
完全二叉樹缺失的部分一定是在右下方。(每層一定是從左到右的順序優先存放)

  • 完全二叉樹的結構,可以簡單理解成按層安放元素的。(所以數組是不錯的底層實現)

其二、父節點一定比子節點大 (針對大頂堆的情況)。

16-37-26-012314243.png

二叉堆實現

因為二叉堆是完全二叉樹,又因為完全二叉樹可以用數組的方式實現

(數組編號/索引,正好對應數組的索引/下標)

故而這裡完全二叉樹的實現就可以繞過二叉樹的定義(不用使用 left, right 這樣的定義)。

16-44-43-012636222.png

這樣表示的好處是可以根據索引來判斷父子關係(而不用 left, right關係):

16-47-12-012741501.png

具體關係,可以用數學歸納法證明。

注意一下,0 這個位置要空出來,如果從 0 開始存儲的話,規律是這樣的:

16-48-57-012946666.png

數組實現:

基本的框架很簡單,大致如下:

package maxheap;    import array.AdvanceDynamicArray;    public class MaxHeap<E extends Comparable<E>> {        //用動態數組進行實現      private AdvanceDynamicArray<E> data;        //構造函數      public MaxHeap(int capacity) {          data = new AdvanceDynamicArray<>(capacity);      }        public MaxHeap() {          data = new AdvanceDynamicArray<>();      }        //2個簡單的方法      public int getSize() {          return data.getSize();      }        public boolean isEmpty() {          return data.isEmpty();      }        //三個輔助函數,根據索引計算父,子存儲位置索引      private int parent(int index) {          if (index == 0) {              //根索引沒有父親              throw new IllegalArgumentException("index 0 沒有父親節點");          }          return (index - 1) / 2;      }        private int leftChild(int index) {          return index * 2 + 1;      }        private int rightChild(int index) {          return index * 2 + 2;      }        //存取元素      //外部的 add,對於堆來說就是 sift up (上浮)  }

但是增刪的時候,涉及到重新調整樹結構,需要分析一下。

直接說結論: 先添加,後調整。

首先、只是放上去存儲,則比較簡單,見下圖:

16-55-33-014345981.png

  • 樹的視角,本層放的下,則放在本層(從左至右的末尾);本層放不下,那麼就放在下一層。
  • 數組的角度,就是放在index末尾,圖上就是 index 為 10 的地方。

但是放上去還沒有完。

其次、一般還要進行相關的調整,否則不滿足二叉堆的第二個性質: 父節點大於子節點。

怎麼調整?上浮

即、只需要調整新節點的父節點,父節點的父節點。。。這一條線上的父節點即可。

17-01-29-014636038.png

(這裡最後一次和根節點比較,不用交換)

也就是說,總共過程就兩個:

  • 1.末尾添加
  • 2.不停的交換,直到不再大於其父節點

程式碼如下(就是一個循環替換,比較的過程):

    //外部的 add,對於堆來說就是 sift up (上浮)      public void add(E e) {          data.append(e); //先向數組末尾添加元素            //維護上浮 (隊數組進行交換)          siftUp(data.getSize()-1);      }        private void siftUp(int index) {          //給定 index 的元素不斷和父節點比較          while (index > 0 && data.get(parent(index)).compareTo(data.get(index)) < 0) {              //父節點比子節點小,交換 (上浮)              data.swap(parent(index), index);                //然後再向上找              index = parent(index);          }      }

刪除元素(取出元素):

這裡的取出元素比較特殊,為了保證堆的高效,一般定義只能取出頂部的元素

拿走堆頂的元素固然簡單,但是剩餘的兩顆子樹就要進行融合,過程就複雜了。

直接說結論: 摘頂之後,先上浮末尾元素,然後調整(下沉)合適位置。

(也就是說,末尾元素替換/覆蓋頂部元素,然後 sift down 調整)

舉個例子:

17-21-56-020511085.png

替換/覆蓋後:

17-22-52-020638218.png

然後,此時看到,並沒有歸為合適的位置。需要下沉。

如何下沉,每次和它的兩個孩子中最大的元素進行交換(因為大頂堆一定是根最大)。

17-27-51-020928159.png

什麼時候終止: 當前節點 >= 其兩個子節點(前提是其存在子節點)。

(葉子節點,沒有子節點,不需要調整了)

簡單實現:

    public E findMax() {          if (isEmpty()) {              throw new IllegalArgumentException("這是個空堆");          }          return data.get(0);      }        public E extractMax() {          E ret = findMax();          data.swap(0, getSize() - 1); //覆蓋堆頂元素          data.pop(); //刪除末尾的元素          siftDown(0); //只下沉根元素下浮調整          return ret;      }        private void siftDown(int index) {          //1.葉子節點時不用再交換了(數組越界了,說明其就是葉子節點了)          //2.當前節點還是小於 `max(其子節點)`          //有左孩子,內部再檢查有無有孩子          while (leftChild(index) < getSize()) {              //找到孩子中的大的一個 (三個元素中找最大,順便交換)                int maxIndex = leftChild(index); //默認先認為左變大              //如果右孩子存在,那就和右邊比一下              int rightIndex = rightChild(index);              if (rightIndex < getSize() && data.get(rightIndex).compareTo(data.get(maxIndex)) > 0) {                  //說明確實右邊大                  maxIndex = rightIndex;              }                //此時 maxIndex 就代表了孩子中大的一方的索引              if (data.get(index).compareTo(data.get(maxIndex)) >= 0) {                  break; //不用比了,已經是大頂堆了              }              data.swap(index, maxIndex);              index = maxIndex; //接著進行下一輪比較          }      }

測試一下放入 100 個數據,然後不斷的拿出大的來,放入數組,最後檢查這個數組是否是降序的。有點類似堆排序 (但藉助了額外的數組):

    public static void main(String[] args) {          int n = 1000000; //100萬            MaxHeap<Integer> maxHeap = new MaxHeap<>();          Random random = new Random();          //放入堆中 (需要不斷的 sift up)          for(int i = 0; i < n; i++) {              maxHeap.add(random.nextInt(Integer.MAX_VALUE));          }            //然後取出來放入 arr 中          int[] arr = new int[n];          for(int i = 0; i< n; i++) {              arr[i] = maxHeap.extractMax();          }            //檢查一下這個 arr 是否是降序的          for(int i = 1; i< n; i++) {              if(arr[i-1] < arr[i]) {                  //說明不是降序的,堆實現有問題                  throw new IllegalArgumentException("Error");              }          }          //全部檢查完畢還沒有異常,就說明 OK          System.out.println("OK");      }

複雜度分析

主要分析 add 和 extractMax, 其實還是 O(logn),因為交換的層級是高度 h,即 logn(因為是完全二叉樹)。

但是構建或者說存儲一棵大頂堆,複雜度是 O(nlogn)。

構建大頂堆的優化

上面已經說了,構建一個大頂堆,需要 O(nlogn),如何優化?

  • heapify優化 (任意數組整理成堆的存儲)。

直接說結論,用 sift down 替代 add 構建大頂堆

上面的 add 方法,慢慢構建一個大頂堆,步驟如下:

  • 添加到末尾
  • 慢慢 sift up 調整

這裡有一個非常重要的默認思想,那就是,一個元素一個元素的放入數組,慢慢構建大頂堆。

如果,現在假定存儲的數組就是一棵完全二叉樹(意思是,按照完全二叉樹那樣子,進行編號),舉個例子,見下圖:

18-53-03-025549698.png

(只是完全二叉樹,但並不能稱為堆)

然後葉子節點先不管(因為葉子節點沒有孩子,而後面要進行 sift down 下沉操作,需要孩子節點),對所有的非葉子節點進行 sift down。從第一個非葉子節點向根節點走(也就是數組末端開始),圖:

19-01-22-025857259.png

  • 數組最大索引處可以定位第一個非葉子節點(getSize()-1)的 parent
  • 最後一層的葉子節點,可以忽略 (這樣至少減少了一半的工作量) — 這是關鍵
  • siftDown方法裡面就包含了對葉子的過濾,即只對非葉子節點進行siftDown

這麼一來其實就很簡單了,大概的複雜度也就是 O(n),比一個個添加(O(n*logN))的好處在於,上來就拋棄了所有的葉子節點,這個將近減少了一半的工作量。(實際減少的數目可以根據最大索引計算)

簡單實現如下(對著數組看即可):

    public MaxHeap(E[] arr) {          data = new AdvanceDynamicArray<>(arr);          //把數組折騰成大頂堆          for (int i = parent(getSize() - 1); i >= 0; i--) {              siftDown(i);          }      }

其實在大數量級下, n 和 nlongn 近乎一致,相差不了太多。(雖然是不同的數量級)
(再次注意: 這裡的 heapify 的時間複雜度是 O(n) 級別。)


優先隊列

構建優先隊列不一定要用堆,但是底層用堆實現效率比較高

  • 普通隊列: 先進先出,後進後出
  • 出隊順序和入隊順序無關,只和優先順序有關(出隊的時候要看)

隨時根據新入隊的元素調整優先順序:

  • 優先順序的意義可以自己定義,比如每次值最大的元素先出隊
  • 優先順序,一般都是作用於出隊

介面定義

因為優先隊列也是隊列,所以介面還是 Queue,即:

interface Queue<E> {      void enqueue(E);      E dequeue(); //拿到優先順序最高的元素      E getFront(); //拿到優先順序最高的元素        int getSize();      boolean isEmpty();  }

堆實現

用線性結構,此時不論是有序線性結構還是無序線性結構,入隊和出隊總是保持在 O(1),O(n); 如果用 BST 實現的話,它最好的情況保持在 O(logn),但最壞的情況可能會退化到 O(n)。

19-55-25-010456082.png

堆可以保證,在最差的情況都是 O(logn) 水平。

也就是說,在 PrioryQueue 內部封裝一個堆即可。(堆兼容所有的隊列介面)

程式碼試下如下:

package maxheap;    import queque.Queue;    public class PriorityQueue<E extends Comparable<E>> implements Queue<E> {      //內部成員      private MaxHeap<E> maxHeap;        //構造函數      public PriorityQueue() {          maxHeap = new MaxHeap<>();      }        @Override      public boolean isEmpty() {          return maxHeap.isEmpty();      }        @Override      public int getSize() {          return maxHeap.getSize();      }        @Override      public E dequeue() {          return maxHeap.extractMax(); // 已經對空的情況做了處理      }        @Override      public E getFront() {          //獲取優先順序最大的元素          return maxHeap.findMax(); //已經對空的情況作了處理      }        @Override      public void enqueue(E o) {          maxHeap.add(o);      }  }

Java中的優先隊列

重新整理一下,Java中的優先隊列

Java 的 PriorityQueue 是最小堆(頂部始終存儲的是優先順序最低的)。

但是小頂堆是默認存儲優先順序最低的元素,但優先順序是自己定義的,所以當你反寫 compareTo 或者比較器時,那麼即便是小頂堆,那麼實際上存儲還是大頂堆的方式。(堆頂拿到的也就是優先順序最大的元素)

這裡應用就太多了,什麼N個元素中選出前M個,什麼出現頻率最多的x個等,再就是要求構建堆的時候採用 heapify 的方式(表現在要求複雜度優於O(nlogn)) 等,大多都在此範疇內。

比如 Leetcode 347 就可以用 java.util.PriorityQueue,參考程式碼:

import java.util.TreeMap;  import java.util.PriorityQueue;  import java.util.LinkedList;  //import java.util.List;    class Solution {      //放入 PriorityQueue 中的元素      private class Freq implements Comparable<Freq> {          public int e, freq;          //構造器          public Freq(int key, int freq) {              this.e = key;              this.freq = freq;          }            //由於 java.util.PriorityQueue 是小頂堆,正常些比較邏輯          @Override          public int compareTo(Freq another) {              return this.freq - another.freq;          }      }        public List<Integer> topKFrequent(int[] nums, int k) {            //首先把數組放入 map 統計頻次          TreeMap<Integer, Integer> map = new TreeMap<>();          for(int num : nums) {              if(map.containsKey(num)) {                  map.put(num, map.get(num) + 1);              } else {                  map.put(num, 1);              }          }          PriorityQueue<Freq> queue = new PriorityQueue<>(); //不使用比較器          //遍歷 map 放入 PriorityQueue 中          for(int key : map.keySet()) {              //前 k - 1 個元素直接放進去              if(queue.size() < k) {                  queue.add(new Freq(key, map.get(key)));              } else if(map.get(key) > queue.peek().freq) {                  //替換最小的                  queue.remove();                  queue.add(new Freq(key, map.get(key)));              }          }            //把 queue 中的結果整理出來,放入結果集中          LinkedList<Integer> res = new LinkedList<>();          while(!queue.isEmpty()) {              res.addFirst(queue.remove().e); //因為現出來的是頻率相對低的          }          return res;      }  }

然後,程式碼優化以下(採用傳入比較器,Lambda表達式代替匿名類捕獲外部map):

  • 可以捕獲外部 map,所以邏輯自然簡潔了(但是不容易想到)

20-25-41-153902625.png

不難看出,小頂堆用的還是蠻多的。

擴展

想讓樹的層次變少,那麼久使用 K叉堆

但是 K 叉堆(K可以取值3以上的值)需要考慮的孩子多餘兩個,此時 sift up 或者 sift down 需要的比較孩子,交換根與孩子的策略就需要重寫一下。

也就是說,調整的時候需要考慮的邏輯會多一些。

其他堆: 比如索引堆(可以操作除堆頂元素之外的元素),二項堆,斐波那契堆。

(一般相關語言實現的堆,就是最常用最常見,最有用的堆)


對於堆的認識,我也僅停留在最基本的堆。

不多言了,還是把程式碼倉庫貼一下吧 gayhub