計算機程式的思維邏輯 (46) - 剖析PriorityQueue

swiftma發表於2016-11-18

本系列文章經補充和完善,已修訂整理成書《Java程式設計的邏輯》(馬俊昌著),由機械工業出版社華章分社出版,於2018年1月上市熱銷,讀者好評如潮!各大網店和書店有售,歡迎購買:京東自營連結

計算機程式的思維邏輯 (46) -  剖析PriorityQueue

上節介紹了堆的基本概念和演算法,本節我們來探討堆在Java中的具體實現類 - PriorityQueue。

我們先從基本概念談起,然後介紹其用法,接著分析實現程式碼,最後總結分析其特點。

基本概念

顧名思義,PriorityQueue是優先順序佇列,它首先實現了佇列介面(Queue),與LinkedList類似,它的佇列長度也沒有限制,與一般佇列的區別是,它有優先順序的概念,每個元素都有優先順序,隊頭的元素永遠都是優先順序最高的。

PriorityQueue內部是用堆實現的,內部元素不是完全有序的,不過,逐個出隊會得到有序的輸出。

雖然名字叫優先順序佇列,但也可以將PriorityQueue看做是一種比較通用的實現了堆的性質的資料結構,可以用PriorityQueue來解決適合用堆解決的問題,下一節我們會來看一些具體的例子。

基本用法

Queue介面

PriorityQueue實現了Queue介面,我們在LinkedList一節介紹過Queue,為便於閱讀,這裡重複下其定義:

public interface Queue<E> extends Collection<E> {
    boolean add(E e);
    boolean offer(E e);
    E remove();
    E poll();
    E element();
    E peek();
}
複製程式碼

Queue擴充套件了Collection,主要操作有三個:

  • 在尾部新增元素 (add, offer)
  • 檢視頭部元素 (element, peek),返回頭部元素,但不改變佇列
  • 刪除頭部元素 (remove, poll),返回頭部元素,並且從佇列中刪除

構造方法

PriorityQueue有多個構造方法,如下所示:

public PriorityQueue()
public PriorityQueue(int initialCapacity)
public PriorityQueue(int initialCapacity, Comparator<? super E> comparator)
public PriorityQueue(Collection<? extends E> c)
public PriorityQueue(PriorityQueue<? extends E> c)
public PriorityQueue(SortedSet<? extends E> c)
複製程式碼

PriorityQueue是用堆實現的,堆物理上就是陣列,與ArrayList類似,PriorityQueue同樣使用動態陣列,根據元素個數動態擴充套件,initialCapacity表示初始的陣列大小,可以通過引數傳入。對於預設構造方法,initialCapacity使用預設值11。對於最後三個構造方法,它們接受一個已有的Collection,陣列大小等於引數容器中的元素個數。

與TreeMap/TreeSet類似,為了保持一定順序,PriorityQueue要求,要麼元素實現Comparable介面,要麼傳遞一個比較器Comparator:

  • 對於前兩個構造方法和接受Collection引數的構造方法,要求元素實現Comparable介面。
  • 第三個構造方法明確傳遞了Comparator。
  • 對於最後兩個構造方法,引數容器有comparator()方法,PriorityQueue使用和它們一樣的,如果返回的comparator為null,則也要求元素實現Comparable介面。

基本例子

我們來看個基本的例子:

Queue<Integer> pq = new PriorityQueue<>();
pq.offer(10);
pq.add(22);
pq.addAll(Arrays.asList(new Integer[]{
    11, 12, 34, 2, 7, 4, 15, 12, 8, 6, 19, 13 }));
while(pq.peek()!=null){
    System.out.print(pq.poll() + " ");
}
複製程式碼

程式碼很簡單,新增元素,然後逐個從頭部刪除,與普通佇列不同,輸出是從小到大有序的:

2 4 6 7 8 10 11 12 12 13 15 19 22 34 
複製程式碼

如果希望是從大到小呢?傳遞一個逆序的Comparator,將第一行程式碼替換為:

Queue<Integer> pq = new PriorityQueue<>(11, Collections.reverseOrder());
複製程式碼

輸出就會變為:

34 22 19 15 13 12 12 11 10 8 7 6 4 2 
複製程式碼

任務佇列

我們再來看個例子,模擬一個任務佇列,定義一個內部類Task表示任務,如下所示:

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

    public int getPriority() {
        return priority;
    }
    
    public String getName() {
        return name;
    }
}
複製程式碼

Task有兩個例項變數,priority表示優先順序,值越大優先順序越高,name表示任務名稱。

Task沒有實現Comparable,我們定義一個單獨的靜態成員taskComparator表示比較器,如下所示:

private static Comparator<Task> taskComparator = new Comparator<Task>() {

    @Override
    public int compare(Task o1, Task o2) {
        if(o1.getPriority()>o2.getPriority()){
            return -1;
        }else if(o1.getPriority()<o2.getPriority()){
            return 1;
        }
        return 0;
    }
};
複製程式碼

下面來看任務佇列的示例程式碼:

Queue<Task> tasks = new PriorityQueue<Task>(11, taskComparator);
tasks.offer(new Task(20, "寫日記"));
tasks.offer(new Task(10, "看電視"));
tasks.offer(new Task(100, "寫程式碼"));

Task task = tasks.poll();
while(task!=null){
    System.out.print("處理任務: "+task.getName()
            +",優先順序:"+task.getPriority()+"\n");
    task = tasks.poll();
}
複製程式碼

程式碼很簡單,就不解釋了,輸出任務按優先順序排列:

處理任務: 寫程式碼,優先順序:100
處理任務: 寫日記,優先順序:20
處理任務: 看電視,優先順序:10
複製程式碼

實現原理

理解了PriorityQueue的用法和特點,我們來看其具體實現程式碼,從內部組成開始。

內部組成

內部有如下成員:

private transient Object[] queue;
private int size = 0;
private final Comparator<? super E> comparator;
private transient int modCount = 0;
複製程式碼

queue就是實際儲存元素的陣列。size表示當前元素個數。comparator為比較器,可以為null。modCount記錄修改次數,在介紹第一個容器類ArrayList時已介紹過。

如何實現各種操作,且保持堆的性質呢?我們來看程式碼,從基本構造方法開始。

基本構造方法

幾個基本構造方法的程式碼是:

public PriorityQueue() {
    this(DEFAULT_INITIAL_CAPACITY, null);
}

public PriorityQueue(int initialCapacity) {
    this(initialCapacity, null);
}

public PriorityQueue(int initialCapacity,
                     Comparator<? super E> comparator) {
    if (initialCapacity < 1)
        throw new IllegalArgumentException();
    this.queue = new Object[initialCapacity];
    this.comparator = comparator;
} 
複製程式碼

程式碼很簡單,就是初始化了queue和comparator。

下面介紹一些操作的程式碼,大部分的演算法和圖示,我們在上節已經介紹過了。

新增元素 (入隊)

程式碼為:

public boolean offer(E e) {
    if (e == null)
        throw new NullPointerException();
    modCount++;
    int i = size;
    if (i >= queue.length)
        grow(i + 1);
    size = i + 1;
    if (i == 0)
        queue[0] = e;
    else
        siftUp(i, e);
    return true;
}
複製程式碼

offer方法的基本步驟為:

  1. 首先確保陣列長度是夠的,如果不夠,呼叫grow方法動態擴充套件。
  2. 增加長度 (size=i+1)
  3. 如果是第一次新增,直接新增到第一個位置即可 (queue[0]=e)。
  4. 否則將其放入最後一個位置,但同時向上調整,直至滿足堆的性質 (siftUp)

有兩步複雜一些,一步是grow,另一步是siftUp,我們來細看下。

grow方法的程式碼為:

private void grow(int minCapacity) {
    int oldCapacity = queue.length;
    // Double size if small; else grow by 50%
    int newCapacity = oldCapacity + ((oldCapacity < 64) ?
                                     (oldCapacity + 2) :
                                     (oldCapacity >> 1));
    // overflow-conscious code
    if (newCapacity - MAX_ARRAY_SIZE > 0)
        newCapacity = hugeCapacity(minCapacity);
    queue = Arrays.copyOf(queue, newCapacity);
}
複製程式碼

如果原長度比較小,大概就是擴充套件為兩倍,否則就是增加50%,使用Arrays.copyOf方法拷貝陣列。

siftUp的基本思路我們在上節介紹過了,其實際程式碼為:

private void siftUp(int k, E x) {
    if (comparator != null)
        siftUpUsingComparator(k, x);
    else
        siftUpComparable(k, x);
}
複製程式碼

根據是否有comparator分為了兩種情況,程式碼類似,我們只看一種:

private void siftUpUsingComparator(int k, E x) {
    while (k > 0) {
        int parent = (k - 1) >>> 1;
        Object e = queue[parent];
        if (comparator.compare(x, (E) e) >= 0)
            break;
        queue[k] = e;
        k = parent;
    }
    queue[k] = x;
}
複製程式碼

引數k表示插入位置,x表示新元素。k初始等於陣列大小,即在最後一個位置插入。程式碼的主要部分是:往上尋找x真正應該插入的位置,這個位置用k表示。

怎麼找呢?新元素(x)不斷與父節點(e)比較,如果新元素(x)大於等於父節點(e),則已滿足堆的性質,退出迴圈,k就是新元素最終的位置,否則,將父節點往下移(queue[k]=e),繼續向上尋找。這與上節介紹的演算法和圖示是對應的。

檢視頭部元素

程式碼為:

public E peek() {
    if (size == 0)
        return null;
    return (E) queue[0];
}
複製程式碼

就是返回第一個元素。

刪除頭部元素 (出隊)

程式碼為:

public E poll() {
    if (size == 0)
        return null;
    int s = --size;
    modCount++;
    E result = (E) queue[0];
    E x = (E) queue[s];
    queue[s] = null;
    if (s != 0)
        siftDown(0, x);
    return result;
}
複製程式碼

返回結果result為第一個元素,x指向最後一個元素,將最後位置設定為null (queue[s] = null),最後呼叫siftDown將原來的最後元素x插入頭部並調整堆,siftDown的程式碼為:

private void siftDown(int k, E x) {
    if (comparator != null)
        siftDownUsingComparator(k, x);
    else
        siftDownComparable(k, x);
}
複製程式碼

同樣分為兩種情況,程式碼類似,我們只看一種:

private void siftDownComparable(int k, E x) {
    Comparable<? super E> key = (Comparable<? super E>)x;
    int half = size >>> 1;        // loop while a non-leaf
    while (k < half) {
        int child = (k << 1) + 1; // assume left child is least
        Object c = queue[child];
        int right = child + 1;
        if (right < size &&
            ((Comparable<? super E>) c).compareTo((E) queue[right]) > 0)
            c = queue[child = right];
        if (key.compareTo((E) c) <= 0)
            break;
        queue[k] = c;
        k = child;
    }
    queue[k] = key;
}
複製程式碼

k表示最終的插入位置,初始為0,x表示原來的最後元素。程式碼的主要部分是:向下尋找x真正應該插入的位置,這個位置用k表示。

怎麼找呢?新元素key不斷與較小的孩子比較,如果小於等於較小的孩子,則已滿足堆的性質,退出迴圈,k就是最終位置,否則將較小的孩子往上移,繼續向下尋找。這與上節介紹的演算法和圖示也是對應的。

解釋下其中的一些程式碼:

  • k<half,表示的是,編號為k的節點有孩子節點,沒有孩子,就不需要繼續找了。
  • child表示較小的孩子編號,初始為左孩子,如果有右孩子(編號right)且小於左孩子則child會變為right。
  • c表示較小的孩子節點。

查詢元素

程式碼為:

public boolean contains(Object o) {
    return indexOf(o) != -1;
}
複製程式碼

indexOf的程式碼為:

private int indexOf(Object o) {
    if (o != null) {
        for (int i = 0; i < size; i++)
            if (o.equals(queue[i]))
                return i;
    }
    return -1;
}
複製程式碼

程式碼很簡單,就是陣列的查詢。

根據值刪除元素

也可以根據值刪除元素,程式碼為:

public boolean remove(Object o) {
    int i = indexOf(o);
    if (i == -1)
        return false;
    else {
        removeAt(i);
        return true;
    }
}
複製程式碼

先查詢元素的位置i,然後呼叫removeAt進行刪除,removeAt的程式碼為:

private E removeAt(int i) {
    assert i >= 0 && i < size;
    modCount++;
    int s = --size;
    if (s == i) // removed last element
        queue[i] = null;
    else {
        E moved = (E) queue[s];
        queue[s] = null;
        siftDown(i, moved);
        if (queue[i] == moved) {
            siftUp(i, moved);
            if (queue[i] != moved)
                return moved;
        }
    }
    return null;
}
複製程式碼

如果是刪除最後一個位置,直接刪即可,否則移動最後一個元素到位置i並進行堆調整,調整有兩種情況,如果大於孩子節點,則向下調整,否則如果小於父節點則向上調整。

程式碼先向下調整(siftDown(i, moved)),如果沒有調整過(queue[i] == moved),可能需向上調整,呼叫siftUp(i, moved)。

如果向上調整過,返回值為moved,其他情況返回null,這個主要用於正確實現PriorityQueue迭代器的刪除方法,迭代器的細節我們就不介紹了。

構建初始堆

如果從一個既不是PriorityQueue也不是SortedSet的容器構造堆,程式碼為:

private void initFromCollection(Collection<? extends E> c) {
    initElementsFromCollection(c);
    heapify();
}
複製程式碼

initElementsFromCollection的主要程式碼為:

private void initElementsFromCollection(Collection<? extends E> c) {
    Object[] a = c.toArray();
    if (a.getClass() != Object[].class)
        a = Arrays.copyOf(a, a.length, Object[].class);
    this.queue = a;
    this.size = a.length;
}
複製程式碼

主要是初始化queue和size。

heapify的程式碼為:

private void heapify() {
    for (int i = (size >>> 1) - 1; i >= 0; i--)
        siftDown(i, (E) queue[i]);
}
複製程式碼

與之前演算法一樣,heapify也在上節介紹過了,就是從最後一個非葉節點開始,自底向上合併構建堆。

如果構造方法中的引數是PriorityQueue或SortedSet,則它們的toArray方法返回的陣列就是有序的,就滿足堆的性質,就不需要執行heapify了。

PriorityQueue特點分析

PriorityQueue實現了Queue介面,有優先順序,內部是用堆實現的,這決定了它有如下特點:

  • 實現了優先順序佇列,最先出隊的總是優先順序最高的,即排序中的第一個。
  • 優先順序可以有相同的,內部元素不是完全有序的,如果遍歷輸出,除了第一個,其他沒有特定順序。
  • 檢視頭部元素的效率很高,為O(1),入隊、出隊效率比較高,為O(log2(N)),構建堆heapify的效率為O(N)。
  • 根據值查詢和刪除元素的效率比較低,為O(N)。

小結

本節介紹了Java中堆的實現類PriorityQueue,它實現了佇列介面Queue,但按優先順序出隊,我們介紹了其用法和實現程式碼。

除了用作基本的優先順序佇列,PriorityQueue還可以作為一種比較通用的資料結構,用於解決一些其他問題,讓我們在下一節繼續探討。


未完待續,檢視最新文章,敬請關注微信公眾號“老馬說程式設計”(掃描下方二維碼),深入淺出,老馬和你一起探索Java程式設計及計算機技術的本質。用心原創,保留所有版權。

計算機程式的思維邏輯 (46) -  剖析PriorityQueue

相關文章