純資料結構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。