1. 程式人生 > >4.1 手寫Java PriorityQueue 核原始碼

4.1 手寫Java PriorityQueue 核原始碼

本章先講解優先順序佇列和二叉堆的結構。下一篇程式碼實現

從一個需求開始

假設有這樣一個需求:在一個子執行緒中,不停的從一個佇列中取出一個任務,執行這個任務,直到這個任務處理完畢,再取出下一個任務,再執行。

其實和 Android 的 Handler 機制中的 Looper 不停的從 MessageQueue 中取出一個訊息然後處理是一樣的。

不過這個需求還有一點。需要我們的任務是有優先之分的,優先高的先執行,優先順序低的後執行。比如現在佇列中已經有了10個任務了,現在有一個緊急的任務需要處理,怎麼辦?

解決辦法有多種:

  1. 用陣列實現,把任務放到數組裡面,每次任務入隊後,根據任務的優先順序排序,把優先順序最大的排到最前面,取任務的時候從隊頭取
  2. 用連結串列實現,每次入隊的時候把每個元素的優先順序比較一下,把優先順序最大的放連結串列尾部,取的尾部,取任務的時候每次都從尾部取就行了。

總結:上面兩種方法都可以實現,不過都有一定的缺點

  • 第一種方法,用陣列實現,入隊的時候,需要遍歷整個陣列,才能找到合適的位置

入隊的時間複雜度是O(n),出隊的時間複雜度是O(1)

  • 第二種方法,用連結串列實現,和陣列一樣,也是需要遍歷整個連結串列,才能找到合適的位置,入隊的時間複雜度是O(n),出隊的時間複雜度是O(1)

雖然出隊效率很高,但是入隊效率太低。有沒有一種方法入隊出隊效率都很高呢?
當然有,就是Java 給我們提供了一個 PriorityQueue 也就是優先順序佇列
我們來看一下PriorityQueue的用法

PriorityQueue的用法

我們有一個任務類,在裡面執行一些操作。如下:

/**
 *  我們的任務類
 */
public class Task {
    //任務的名稱
    public String name;

    //優先順序,是一個整數,我們規定,數越大優先順序越高
    public int priority;


    public Task(String name,int priority){
        this.name = name;
        this.priority = priority;
    }

    //假如這是任務需要做的事
    public void doSomthing(){
        System.out.println("do somthing");
    }

    @Override
    public String toString() {
        return "taskName=" + name + " taskPriority=" + priority;
    }
}

測試程式碼如下:

  public static void main(String[] args){

        //比較兩個任務,從大到小排序
        Comparator<Task> comparator = new Comparator<Task>() {
            @Override
            public int compare(Task o1, Task o2) {
                if(o1.priority > o2.priority){
                    return -1;
                }else if(o1.priority == o2.priority){
                    return 0;
                }else {
                    return 1;
                }
            }
        };

        //新建一個任務
        Queue<Task> priorityQueue =  new PriorityQueue<Task>(10,comparator);

        //新建了4個不同優先順序的任務入隊,數越大優先順序越大,也最先執行
        priorityQueue.add(new Task("task1",23));
        priorityQueue.add(new Task("task2",34));
        priorityQueue.add(new Task("task3",15));
        priorityQueue.add(new Task("task4",79));

        //分別取出任務,然後列印
        System.out.println(priorityQueue.poll());   // 首先應該是 task4, 先取出來,因為優先順序最大
        System.out.println(priorityQueue.poll());   // 然後才是   task2   被取出來
        System.out.println(priorityQueue.poll());   // 然後才是   task1   被取出來
        System.out.println(priorityQueue.poll());   // 最後才是   task3   被取出來,因為優先順序最小
    }

輸出如下:

taskName=task4 taskPriority=79
taskName=task2 taskPriority=34
taskName=task1 taskPriority=23
taskName=task3 taskPriority=15

由此可知,雖然入隊的順序是不一樣的,但是出隊的順序,是優先大的先出隊
如果現在有一個緊急的任務需要優先處理,那麼就可以設定這個任務的優先順序比79大,
就可以排到最前面,等到下一次從佇列中取任務的時候,這個緊急的任務就被取出來了。

這就是優先順序佇列的作用。

PriorityQueue佇列的原理

直接上結論:

  1. PriorityQueue是一個最大堆(或者用最小堆也是一樣)
  2. 堆一種完全二叉樹
  3. PriorityQueue是用一個數組存放二叉樹中的元素的。

1 什麼是完全二叉樹?

完全二叉樹:若設二叉樹的深度為h,除第 h 層外,其它各層 (1 ~ h-1) 的結點數都達到最大個數,第 h 層所有的結點都連續集中在最左邊,這就是完全二叉樹。

定義太抽象,我們上圖來描述什麼是二叉樹。

上由圖可知:二叉樹節點節點都在最左邊。這就是完全二叉樹,形態要記牢哦。

2 什麼是堆結構?

堆結構有最大堆和最小堆,這裡我們以最大堆為例。(最大堆懂了,那麼最小堆自然就懂了)。
最大堆定義: 最大堆是一個完全二叉樹,並且根節點的數都比左右兩個節點的數大。

說白了就是最大的節點作用根。
由定義可知,最大堆有 2 個性質

  1. 最大堆是一個完全二叉樹
  2. 最大堆,根節點比左右兩個子節點大

如下圖,是一個最大堆:

上圖是一個最大堆,首先是一棵完全二叉樹,並且每個根節點都大於它的左右子節點

這就是最大堆結構,當然最小堆和最大堆是反著的,根節點比左右子節點要小。
最大堆的形態要記牢哦。

最大堆和優先順序佇列

由上圖最大堆可知:

  1. 假如取元素的時候,只取整棵樹的根節點,也就是 100 那個節點。也就是優先順序最高的節點。
  2. 100 節點取走以後,我們只需要把剩下的節點中,找個最大的,放在根節點位置,
  3. 插入一個節點的時候,我們保證插入的節點放在適合的位置,滿足二叉堆的性質即可。

那麼這樣的資料結構不就是優先順序佇列嗎。
現在的問題就是

  1. 如何用陣列來存放二叉堆結構?(上面說了,二叉堆是用陣列來存放資料的)
  2. 如何向二叉堆中插入一個節點,並放到合適的位置上?
  3. 取出根節點後,怎麼找到剩下的最大的節點並放到合適的位置上?

下面我們來一一解決上面三個問題

1 如何用陣列來存放二叉堆結構?

我們以下圖一個簡單的二叉堆為例

我們從左往右,按層序遍歷,分別存放到陣列的相應索引對應的位置上。
陣列的第0個索引位置我們不用,從索引為1的位置開始存放。
最終這個最大堆存放到陣列中,如下圖

索引為0的位置不用。從1開始,為了方便後面計算。

對照圖這兩副圖可以知道,二叉堆中的元素分層存放到陣列中(從索引1開始存放)
有下面幾個性質:
1 對於一個索引為n的節點,它的左孩子的索引是 2*n ,它的右孩子索引是2*n+1

比如 索引為2的節點是19

  1. 那麼它的左孩子是 2 * 2 是陣列中的索引為4的位置 ,也就是16
  2. 它的右孩子是 2*2 + 1 是陣列中的索引為5的位置,也就是9

同樣也可以由節點的索引,知道此節點的父節點的索引。
比如 索引為2的節點是19
那麼它的父節點是 2 / 2 ,也就是索引為1的位置
比如 索引為3的節點是28
那麼它的父節點是 3 / 2 ,也是 1 ,是和圖中能對得上吧。

由此可知:用陣列存放二叉堆(從索引為1開始),有以下兩個性質:
對於索引為 n 的節點

  1. 找孩子節點:左孩子的索引是 2*n ,右孩子的索引是 2*n + 1
  2. 找父節點: 父節點的索引是 n/2

注:只有完全二叉樹按層序存放到陣列中才有這樣的性質。必須是完全二叉樹,必須是完全二叉樹,必須是完全二叉樹。重要的事情說三遍

2 如何向二叉堆中插入一個節點,並放到合適的位置上?

要向二叉堆中插入一個節點,插入節點後
只需要滿足是完全二叉樹並且任意一個根節點都比它的左右兩個孩子的大就行了。

感覺說的是廢話
如下圖,我們要向二叉堆中插入一個節點為 30 的元素。

  1. 第一步:新來的節點放到陣列的最後的位置
    也就是索引為 6 的位置(陣列大小不夠了就擴容,後面講)
  2. 第二步:不停的與自己的父節點比大小,比父節點大,就交換位置
    然後重複以上步驟

經過上面兩步,就可以將一個節點插入到合適的位置上了。如下圖

知道了如何向二叉堆中插入一個節點,,那麼如何取出根節點後,怎麼找到剩下的最大的節點並放到合適的位置上?也就是刪除根節點後,剩下的怎麼辦呢?

3 如何刪除根節點?

刪除根節點很容易,關鍵是刪除後剩下的節點怎麼擺放?如下圖

把根節點100刪除後

  1. 第一步:把最後一個節點9放到100的位置上,也就是索引為 1 的位置上。
  2. 第二步:從根節點開始,不停的與它的左右兩上節點比大小,找到左右兩個節點為中的比較大的節點,然後交換位置,以此類推,直到這個節點比它的左右兩個節點都大為止

如下圖,第一步:

第二步:找出根節點9的最大的孩子節點 ,也就是 28,然後交換位置 :如下圖

直到沒有孩子可以比較了或者說有孩子,但是比孩子的節點要大,便不再比較

由上面可以知道,插入和刪除都有了,下一章節就是程式碼實現了