1. 程式人生 > >玩轉資料結構——第七章:優先佇列和堆

玩轉資料結構——第七章:優先佇列和堆

內容概要:

  1. 什麼是優先佇列?
  2. 堆的基礎結構
  3. 向堆中新增元素Sift Up
  4. 從堆中取出元素和Sift Down
  5. Heapify和Replace
  6. 基於堆的優先佇列
  7. LeetCode上優先佇列相關的問題
  8. java中的PriorityQueue
  9. 和堆相關的更多話題和廣義佇列

一、什麼是優先佇列?

不同樹的資料結構四種例子:

  1. 線段樹
  2. 字典樹
  3. 並查集
  • 什麼是優先佇列?

普通佇列:先進先出,後進後出

優先佇列:出隊順序和入隊順序無關;和優先順序相關

例子:比如去看病,患者需要排隊掛號,但有嚴重患者人來時,它可以提前進入看病。(這就是根據病的緊急情況來決定的)

  • 為什麼要使用優先佇列呢?

動態選擇優先順序最高的任務執行

動態:不能夠一開始就確定需要處理多少任務,它需要根據新來的任務來重新進行優先順序排序,優先順序高的先執行

優先佇列的實現:

入隊 出隊(拿出最大元素)
線性結構 O(1) O(n)
順序線性結構 O(n) O(1)
O(logn) O(logn)

二、堆(Heap)的基礎結構 

當時間複雜度為O(logn)時,一般都是樹結構

二叉堆:是一個完全二叉樹

  • 滿二叉樹 :

除了葉子節點,所有的節點都有左右孩子

  • 完全二叉樹:

把元素排列成樹的形狀(一層一層的排,排完到下一層,從左到右排,它存在的葉子節點集中在右邊)

 二叉堆的性質

  • 堆中某個節點的值總是不大於其父親節點(根節點是最大的元素,大於它左右節點的值)
  • 最大堆(相對於可以定義最小堆),每個元素的節點,小於其左右孩子的節點值

用陣列儲存二叉樹怎麼找到它任意節點的左右孩子的索引:

如果陣列索引為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個孩子的堆,也滿足完全二叉樹