玩轉資料結構——第七章:優先佇列和堆
內容概要:
- 什麼是優先佇列?
- 堆的基礎結構
- 向堆中新增元素Sift Up
- 從堆中取出元素和Sift Down
- Heapify和Replace
- 基於堆的優先佇列
- LeetCode上優先佇列相關的問題
- java中的PriorityQueue
- 和堆相關的更多話題和廣義佇列
一、什麼是優先佇列?
不同樹的資料結構四種例子:
- 堆
- 線段樹
- 字典樹
- 並查集
- 什麼是優先佇列?
普通佇列:先進先出,後進後出
優先佇列:出隊順序和入隊順序無關;和優先順序相關
例子:比如去看病,患者需要排隊掛號,但有嚴重患者人來時,它可以提前進入看病。(這就是根據病的緊急情況來決定的)
- 為什麼要使用優先佇列呢?
動態選擇優先順序最高的任務執行
動態:不能夠一開始就確定需要處理多少任務,它需要根據新來的任務來重新進行優先順序排序,優先順序高的先執行
優先佇列的實現:
入隊 | 出隊(拿出最大元素) | |
線性結構 | ||
順序線性結構 | ||
堆 |
二、堆(Heap)的基礎結構
當時間複雜度為時,一般都是樹結構
二叉堆:是一個完全二叉樹
- 滿二叉樹 :
除了葉子節點,所有的節點都有左右孩子
- 完全二叉樹:
把元素排列成樹的形狀(一層一層的排,排完到下一層,從左到右排,它存在的葉子節點集中在右邊)
二叉堆的性質
- 堆中某個節點的值總是不大於其父親節點(根節點是最大的元素,大於它左右節點的值)
- 最大堆(相對於可以定義最小堆),每個元素的節點,小於其左右孩子的節點值
用陣列儲存二叉樹怎麼找到它任意節點的左右孩子的索引:
如果陣列索引為0空出來:
parent(i)=i/2;//父親節點
left child(i)=2*i;//該節點左孩子的索引
right child(i)=2*i+1; //該節點右孩子索引
如果索引為0不空出來
parent(i)=(i-1)/2;//父親節點 int整形除3/2為1
left child(i)=2*i+1;//該節點左孩子的索引
right child(i)=2*i+2; //該節點右孩子索引
最大堆的實現:基於動態陣列實現的最大堆MaxHeap的基礎操作
//元素可比較性
public class MaxHeap<E extends Comparable<E>> {
private Array<E> data;//動態陣列
//如果傳入容量
public MaxHeap(int capacity) {
data = new Array<>(capacity);//初始化動態陣列
}
public MaxHeap() {
data = new Array<>();
}
//返回堆中的元素個數
public int size() {
return data.getSize();
}
//返回一個布林值,表示堆中是否為空
public boolean isEmpty() {
return data.isEmpty();
}
}
輔助函式:找一個節點的父親節點、左孩子節點、右孩子節點的索引
//輔助函式
//返回完全二叉樹的陣列表示中,一個索引所表示的元素的父親節點的索引
public int parent(int index) {
if (index == 0)
throw new IllegalArgumentException("index-0 doesn't have parent");
return (index - 1) / 2;
}
//返回完全二叉樹的陣列表示中,一個索引所表示的元素的左孩子節點的索引
public int leftChild(int index) {
return (index * 2 + 1);
}
//返回完全二叉樹的陣列表示中,一個索引所表示的元素的右孩子節點的索引
public int rightChild(int index) {
return index * 2 + 2;
}
三、向堆中新增元素Sift Up//上浮
新新增的元素52,不再滿足堆的特性:節點的值大於左右孩子節點的值,那該怎麼辦?
出現問題的是52這個節點,那就可以一層一層的找它的父親節點和它的父親節點做比較,然後交換這兩個元素的位置
//在自定義Array動態陣列的類中新增
//元素交換
public void swap(int i, int j) {
if (i < 0 || i >= size || j < 0 || j >= size)
throw new IllegalArgumentException("Index is Illegal");
E t = data[i];
data[i] = data[j];
data[j] = t;
}
向堆中新增元素siftUp:
//上浮操作,傳入你要上浮元素的index
private void siftUp(int k){
//不能達到根節點 k所在元素和它的父親節點做比較,如果大於父親節點的話就要交換位置
while (k>0 && data.get(parent(k)).compareTo(data.get(k))<0){
data.swap(k,parent(k));//交換元素的值
k=parent(k);//新的位置
}
}
四、從堆中取出元素和Sift Down(下沉)
從最大堆中取出元素,即取出最大的元素,末尾的元素給頂到最大堆原來的位置
然後看末尾元素是否滿足大於左右子樹,不滿足而與其最大交換位置(稱為下沉操作)
直到最終滿足完全二叉樹和大於左右子樹值的特性。
//尋找堆的最大值
public E findMax() {
if (data.get(0) == null)//如果最大值不存在,空堆
throw new IllegalArgumentException("cnt not findMax when heap is empty!");
return data.get(0);
}
//取出堆的最大元素並刪除
public E extractMax() {
E ret = findMax();
data.swap(0, data.getSize() - 1);//最大值和最末尾的元素進行交換
//下沉操作
siftDown(0);
return ret;
}
//下沉操作,傳入需要下沉的index
public void siftDown(int k) {
while (leftChild(k) < data.getSize()) {//當索引k越界的時候迴圈結束(達到葉子節點的之下)
int j = leftChild(k);//將它的左孩子索引存起來//一定有左孩子,但不一定有右孩子
//如果它存在右孩子,並且右孩子的值大於左孩子
if (rightChild(k) < data.getSize() && data.get(rightChild(k)).compareTo(data.get(leftChild(k))) > 0) {
j = rightChild(k);//j+1;
}
//data[j]是leftChild和rightChild中的最大值
if (data.get(k).compareTo(data.get(j)) > 0)//父親節點大於它左右孩子中最大值
break;//退出迴圈
//否則交換它們的值
data.swap(j, k);
k = j;//最終k的索引變成j,進行下輪迴圈,看是否需要再次下沉
}
}
測試:實現用最大堆進行陣列從大到小的排序
/***
* 用最大堆實現元素排序(從大到小)
*/
public class Main {
public static void main(String[] args) {
int n = 10;//一個隨機數
MaxHeap<Integer> maxHeap = new MaxHeap<>();
Random random = new Random();
for (int i = 0; i < n; i++)
maxHeap.add(random.nextInt(Integer.MAX_VALUE));//從0到Integer的最大值
//建立一個數組每次從堆中取出最大元素放進去(實現從大到小的排序)
int[] arr = new int[n];
for (int i = 0; i < n; i++)
arr[i] = maxHeap.extractMax();
System.out.println(Arrays.toString(arr));//列印這個陣列
}
}
結果:
[1442712010, 1147348309, 822146177, 783463526, 594504611, 474708347, 394368000, 221767976, 196769889, 96631889]
堆的時間複雜度:
add和extractMax時間複雜度都是O(logn),和二叉樹的高度有個
一個完全二叉樹是不可能退化成連結串列
五、Heapify和Replace
Replace
取出最大元素後,放入一個新的元素。
- 實現:可以先 extraMax,再 add,兩次 O(logn)的操作
- 實現:可以直接將堆頂元素替換以後 Sift Down,一次 O(logn)的操作
程式碼演示
// 取出堆中的最大元素,並且替換成元素 e
public E replace(E e){
E ret = findMax();
data.set(0, e);
siftDown(0);
return ret;
}
Heapify
將任意陣列整理成堆的形狀。將當前陣列看成一個完全二叉樹,這個例子中,對於這個陣列並不是一個堆,不滿足堆的性質。
但是我們同樣可以把它看成一棵完全二叉樹,對於這個完全二叉樹,我們從最後一個非葉子節點開始計算,如下圖所示有五個葉子節點:
最後一個非葉子節點就是 22 這個元素所在的節點,從這個節點開始倒著從後向前不斷的 Sift Down 就可以了。
首先由一個非常重要的問題,就是我們如何定位最後一個非葉子節點所處的索引是多少?
- 從最後一個非葉子節點開始計算(如何獲得節點?答:拿到最後一個節點,然後拿到他的父親節點)
- 比如最後一個節點size-1,name它的父親節點(最後一個非葉子節點)為:parent(size-1)
找到它父親節點,接下來就進行 Sift Down 操作,22 和 62 交換,此時 22 已經是葉子節點了,下沉操作就完成了。
然後看索引為 3 的節點,13 和 41 交換,13 變成葉子節點無法繼續下沉。
然後接下來依次類推,最終結果如下,建議仔細分析一下操作流程。
這是整個流程圖:
Heapify 的演算法複雜度
不使用Heapify的過程:將 n 個元素逐個插入到一個空堆中,演算法複雜度是 O(nlogn) 使用 Heapify 的過程,演算法複雜度為 O(n)
當n>10時,O(nlogn)>O(n)
程式碼演示
//不帶參的構造,預設沒有使用Heapify
public MaxHeap(){
data = new Array<>();
}
//帶參構造,使用Heapify
public MaxHeap(E[] arr){
data = new Array<>(arr);
for(int i = parent(arr.length - 1) ; i >= 0 ; i --)//從最後一個非葉子節點開始siftDown
siftDown(i);
}
在 Array.java 中新增一個新的建構函式
//帶參構造將一個數組轉成動態陣列
public Array(E[] arr){
data = (E[])new Object[arr.length];
for(int i = 0 ; i < arr.length ; i ++)
data[i] = arr[i];
size = arr.length;
}
Main.java 中編寫一個測試函式
private static double testHeap(Integer[] testData, boolean isHeapify){
long startTime = System.nanoTime();
MaxHeap<Integer> maxHeap;
if(isHeapify)//使用Heapify插入元素
maxHeap = new MaxHeap<>(testData);
else{//不使用Heapify插入元素
maxHeap = new MaxHeap<>();
for(int num: testData)
maxHeap.add(num);
}
//取出元素的操作
int[] arr = new int[testData.length];
for(int i = 0 ; i < testData.length ; i ++)
arr[i] = maxHeap.extractMax();
for(int i = 1 ; i < testData.length ; i ++)
if(arr[i-1] < arr[i])
throw new IllegalArgumentException("Error");
System.out.println("Test MaxHeap completed.");
long endTime = System.nanoTime();
return (endTime - startTime) / 1000000000.0;
}
下面測試一下,還是用上一節的測試用例
public static void main(String[] args) {
int n = 1000000;
Random random = new Random();
Integer[] testData = new Integer[n];
for(int i = 0 ; i < n ; i ++)
testData[i] = random.nextInt(Integer.MAX_VALUE);
double time1 = testHeap(testData, false);
System.out.println("Without heapify: " + time1 + " s");
double time2 = testHeap(testData, true);
System.out.println("With heapify: " + time2 + " s");
}
最終的執行結果如下
Test MaxHeap completed.
Without heapify: 1.591017607 s
Test MaxHeap completed.
With heapify: 1.387019963 s
在我的電腦上,對於一百萬的資料量,如果不使用 Heapify 操作的話時間是 1.59秒,如果使用 Heapify 的話時間是 1.38 秒。
六、基於堆的優先佇列
如果想實現按照自己的意願進行優先順序排列的佇列的話,需要實現Comparator介面。如果不提供Comparator的話,優先佇列中元素預設按自然順序排列,也就是數字預設是小的在佇列頭,字串則按字典序排列。
/**
* 基於最大堆實現優先佇列
* @param <E>
*/
public class PriorityQueue<E extends Comparable<E>> implements Queue<E> {
private MaxHeap<E> maxHeap;
public PriorityQueue(){
maxHeap=new MaxHeap<>();
}
@Override
public int getSize(){
return maxHeap.size();
}
@Override
public boolean isEmpty(){
return maxHeap.isEmpty();
}
@Override
public E getFront(){
return maxHeap.findMax();//第一個元素是最大的
}
@Override
public void enqueue(E e){
maxHeap.add(e);
}
@Override
public E dequeue(){
return maxHeap.extractMax();//刪除最大值
}
}
七、優先佇列的經典問題:
在 100 0000個元素中選出前100名?
在N個元素總選出前M個元素(N>>M)
如果使用排序的時間複雜度O(NlogN)
使用優先佇列——>O(NlogM)
使用一個優先佇列,維護當前看到的前M個元素。
Leetcode347前K個高頻元素
給定一個非空的整數陣列,返回其中出現頻率前 k 高的元素。
例如,
給定陣列 [1,1,1,2,2,3]
, 和 k = 2,返回 [1,2]
。
注意:
- 你可以假設給定的 k 總是合理的,1 ≤ k ≤ 陣列中不相同的元素的個數。
- 你的演算法的時間複雜度必須優於 O(n log n) , n 是陣列的大小。
提前理解:Lambda(拉姆達)表示式的語法:
基本語法:(parameters) -> expression 或(parameters) ->{ statements; }
// 1. 不需要引數,返回值為 5 () -> 5 // 2. 接收一個引數(數字型別),返回其2倍的值 x -> 2 * x // 3. 接受2個引數(數字),並返回他們的差值 (x, y) -> x – y
解題思路:記錄元素出現的頻次,使用TreeMap<K,V>來記錄
首先,用TreeMap<K,V>來記錄每個元素出現的頻次
其次,遍歷TreeMap中的key鍵值,如果優先佇列沒有滿,則將這個元素新增到優先佇列裡面去,如果滿了,就判斷該元素出現的頻次是否比隊首頻次最低的相比,如果大,則移除隊首元素,要這個元素入隊(在隊尾,會根據頻次來實現上浮)。
然後,因為優先佇列是基於最小對實現的(最小值在隊首),可以利用元素出現的頻次來做優先順序,頻次越小的就放到隊首,相對它的優先順序就高。
最後,將得到的結果放到ArrayList中返回
import java.util.*;
import java.util.PriorityQueue;
/**
* leetcode Leetcode347前K個高頻元素
* java預設的PriorityQueue優先佇列是基於最小堆實現的,預設最小元素在隊首
* 自定義優先順序的話,優先順序最高的再隊首
*/
public class Solution2 {
//先把陣列中的元素存到map中,並記錄它的頻次
public List<Integer> topKFrequent(int[] nums, int k) {
//使用map來記錄元素出現的頻次
TreeMap<Integer, Integer> map = new TreeMap<>();
for (int num : nums) {
if (map.containsKey(num))//如果包含則索引加1
map.put(num, map.get(num) + 1);
else
map.put(num, 1);//如果不存在這個元素,新增到map中
}
// 自定義優先順序(利用最小堆的性質;最小值靠前,所以滿足a-b為負數時,說明a元素該上浮)
//用拉姆達表示式替換Comparator比較器來自定義優先順序
PriorityQueue<Integer> pq = new PriorityQueue<>(
//拉姆達表示式返回map.get(a) - map.get(b)的值
(a, b) -> map.get(a) - map.get(b));//通過判斷頻次的大小實現優先順序
for (int key : map.keySet()) {//對map中所有鍵的集合進行遍歷
if (pq.size() < k)//如果優先佇列中存放的數還沒達到k
pq.add(key);//將這個入隊,在新增元素的同時比較它的優先順序
else if (map.get(key) > map.get(pq.peek())) { //如果當前元素出現的頻次大於優先佇列最小出現的頻次(隊首是最小頻次)
//優先順序最高(最小堆中實現的優先佇列越小的值優先順序應該越高)的放在隊首,讓這個元素出隊
pq.remove();//移除隊首元素(末尾的元素放到上面,siftDown()直到合適的位置)
pq.add(key);//在隊尾新增元素(自動放到合適的位置實現最小堆原則)
}
}
// //輸出結果,放到一個連結串列中
LinkedList<Integer> res = new LinkedList<>();
while (!pq.isEmpty()) {//如果優先佇列不為空
res.add(pq.remove());//讓它的元素一個一個從隊首出隊進入res
}
return res;
}
測試用例:
public static void main(String[] args) {
int[] nums = {1, 1, 1, 2, 2, 3, 3, 3, 3};
int k = 3;
System.out.println((new Solution2()).topKFrequent(nums, k));
}
結果:2的元素頻次最少在隊首
[2, 1, 3]
九、和堆相關的更多話題和廣義佇列
d叉堆:有d個孩子的堆,也滿足完全二叉樹