1. 程式人生 > >純資料結構Java實現(6/11)(二叉堆&優先佇列)

純資料結構Java實現(6/11)(二叉堆&優先佇列)

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

二叉堆

堆可以用於實現其他高層資料結構,比如優先佇列

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

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

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

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

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

二叉堆實現

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

(陣列編號/索引,正好對應陣列的索引/下標)

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

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

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

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

陣列實現:

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

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 (上浮)
}

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

直接說結論: 先新增,後調整。

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

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

但是放上去還沒有完。

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

怎麼調整?上浮。

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

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

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

  • 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 調整)

舉個例子:

替換/覆蓋後:

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

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

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

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

簡單實現:

    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 調整

這裡有一個非常重要的預設思想,那就是,一個元素一個元素的放入陣列,慢慢構建大頂堆。

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

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

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

  • 陣列最大索引處可以定位第一個非葉子節點(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)。

堆可以保證,在最差的情況都是 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,所以邏輯自然簡潔了(但是不容易想到)

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

擴充套件

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

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

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

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

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


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

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