堆結構的優秀實現類----PriorityQueue優先隊列

     之前的文章中,我們有介紹過動態數組ArrayList,雙向隊列LinkedList,鍵值對集合HashMap,樹集TreeMap。他們都各自有各自的優點,ArrayList動態擴容,數組實現查詢非常快但要求連續內存空間,雙向隊列LinkedList不需要像ArrayList一樣創建連續的內存空間,它以鏈表的形式連接各個節點,但是查詢搜索效率極低。HashMap存放鍵值對,內部使用數組加鏈表實現,檢索快但是由於鍵是按照Hash值存儲的,所以無序,在某些情況下不合適。TreeMap使用優化了的排序二叉樹(紅黑樹)作爲邏輯實現,物理實現使用一個靜態內部類Entry代表一個樹節點,這是一個完全有序的結構,但是每個樹節點都需要保存一個父節點引用,左右孩子節點引用,還有一個value值,雖然效率高但開銷很大。
     今天我們將要介紹的PriorityQueue優先隊列,更多的可以理解爲是上述所有集合實現的一種折中的結構,它邏輯上使用堆結構(完全二叉樹)實現,物理上使用動態數組實現,並非像TreeMap一樣完全有序,但是如果按照指定方式出隊,結果可以是有序的。本篇就將詳細談談該結構的內部實現,以下是涉及的主要內容:

  • 堆數據結構的簡單介紹
  • 構造PriorityQueue實例
  • 有關優先隊列的基本操作(增刪改查)
  • 其他相關操作的細節
  • 一個簡單的實例的應用

一、堆結構的簡單介紹
     這裏的堆是一種數據結構而非計算機內存中的堆棧。堆結構在邏輯上是完全二叉樹,物理存儲上是數組。在介紹它之前,我們先了解完全二叉樹的相關知識。首先我們知道,滿二叉樹除了葉子節點,其餘所有節點都具有左右孩子節點,類似下圖:

這裏寫圖片描述

整棵樹看起來是滿的,除了葉子節點沒有孩子節點外,其餘所有節點都是左右孩子節點的。而我們的完全二叉樹要求沒這麼嚴格,它並不要求每個非葉子節點都具有左右孩子,但一定要按照從左到右的順序出現,不能說沒有左孩子卻有右孩子。以下是幾個完全二叉樹:

這裏寫圖片描述

但是以下幾個則不是完全二叉樹:

這裏寫圖片描述

滿足完全二叉樹的前提是,在同一層上,前面的節點沒有孩子節點,後面節點就不能有孩子節點。正如上圖第一棵樹一樣,只有2節點具有左右孩子節點之後,3節點才能具有孩子節點。上圖第二棵樹爲什麼不滿足完全二叉樹,因爲完全二叉樹中每個節點必須是先有左孩子節點然後纔能有右孩子節點。如果你學習過數據結構,上述文字可能會幫助你快速回憶起來相關概念,但是如果你沒有學習過數據結構,那麼你可能需要自行百度或者評論留言瞭解學習相關知識之後再繼續下文。

上述文字我們回顧了完全二叉樹的相關概念,但是完全二叉樹並不是堆結構,堆結構是不完全有序的完全二叉樹。我們知道完全二叉樹有個非常大的優點,你可以從任意節點根據公式推算出該節點的左右孩子節點的位置以及父節點的位置。例如:

這裏寫圖片描述

上圖中,我們爲每個節點編號,此時我們可以從任意一個節點推算出它的父節點,左右孩子節點的位置。例如:當前節點爲4號節點,那麼該節點的父節點編號爲4/2,左孩子節點編號2*4,右孩子節點編號2*4+1。想必公式大家已經能夠得出,當前節點位置爲 i ,父節點位置 i/2,左孩子節點位置2*i,右孩子節點2*i+1。利用這個特性,我們就不必維護節點與節點之間的相互引用,TreeMap中定義一個Entry類,分別一個parent引用,left引用,right引用,並使用它們維護當前節點和別的節點之間的關係。而我們利用完全二叉樹的這種特性,完全可以用數組作爲物理存儲。上述完全二叉樹可以存儲爲以下的數組:

這裏寫圖片描述

雖然數組中並沒有顯示出任何節點之間的關係,但是他們之間的關係是隱含的。例如:5號節點的父節點編號5/2,是2號,左右孩子節點分別爲5*2,5*2+1節點。

以上我們便完成了對堆結構的大致描述,完全二叉樹加數組。下面我們簡單介紹堆結構中添加元素,刪除元素是怎麼做到保持堆結構不變的。在詳細介紹之前,我們需要知道,堆分大根堆和小根堆。大根堆的要求是父節點比子節點的值大,小根堆要求父節點的值比子節點的值小,至於左右孩子節點的值的大小沒有要求,所以我們說堆是不完全有序結構。下文我們將主要以小根堆爲例,介紹堆結構中添加刪除元素是怎麼做到保持這種結構不發生改變的。

這裏寫圖片描述

這是一個小根堆,假設我們現在要添加一個元素到該堆結構中。假定新元素的值爲9,主要操作有以下兩個步驟:

  • 將新元素添加到堆結構的末尾(不論該值的大小)
  • 不斷調整直至滿足堆結構

第一步,添加新元素到堆結構末尾:
這裏寫圖片描述

第二步,調整結構:
這裏寫圖片描述

添加元素還是比較簡單的,就兩個步驟。無論將要添加的新元素的值是多大,第一步始終是將該新元素添加到最後位置,第二步可能不止一次的調整結構,但最終會調整完成,保持該堆結構。下面我們看刪除節點的不同情況。
1、刪除頭節點
這裏寫圖片描述

假定現在我們需要刪除頭部元素3,我們主要還是兩個步驟:

  • 用最後一個元素替換頭部元素
  • 用頭元素和兩個孩子中值較小的節點相比較,如果小於該節點的值則滿足堆結構,不做任何調整,否則交換之後做同樣的判斷

第一步,用尾部元素替換頭元素:
這裏寫圖片描述

第二步,和較小值的子節點比較並完成交換:
這裏寫圖片描述

這裏寫圖片描述

最後刪除後的結果如上圖所示,刪除頭節點的情況還是比較簡單的,下面我們看從中間刪除元素。

2、刪除中間元素
這裏寫圖片描述

現在假如我們需要刪除5號節點,主要是三個步驟:

  • 用最後一個元素替換將要被刪除的元素並刪除最後元素
  • 判斷該節點的值與其子節點中最小的值比較,如果小於最小值則維持堆結構,否則向下調整
  • 判斷該節點的值是否小於父節點的值,如果小於則向上調整,否則維持堆結構

第一步,用最後元素替換將要被刪除的元素:
這裏寫圖片描述

第二步,與子節點比較判斷:
這裏寫圖片描述

第三步,與父節點比較,滿足條件,維持堆結構。
概括整個刪除的過程,無論是從頭部刪除還是從中間刪除元素,都是先用最後的元素替換被刪元素,然後向下調整來維持堆結構,接着向上調整維持堆結構。

至此,我們簡單介紹了堆這種數據結構,包括向其中添加刪除元素的時候,它維持這種的結構的解決辦法。我們花了大量文筆介紹這種結構,是因爲PriorityQueue就是對這種堆結構的實現,只有理解了這種數據結構才能更好的理解PriorityQueue。下面我們開始看PriorityQueue的原理及具體實現的代碼。

二、構造PriorityQueue實例
     在實際介紹PriorityQueue原理之前,再次囉嗦PriorityQueue的內部結構。PriorityQueue中的元素在邏輯上構成了一棵完全二叉樹,但是在實際存儲時轉換爲了數組保存在內存中,而我們的PriorityQueue繼承了接口Queue,表名這是一個隊列,只是它不像普通隊列(例如:LinkedList)在遍歷輸出的時候簡單的按順序從頭到尾輸出,PriorityQueue總是先輸出根節點的值,然後調整樹使之繼續成爲一棵完全二叉樹 樣每次輸出的根節點總是整棵樹優先級最高的,要麼數值最小要麼數值最大。下面我們看如何構造一個PriorityQueue實例。

在PriorityQueue的內部,主要有以下結構屬性構成:

//默認用於存儲節點信息的數組的大小
private static final int DEFAULT_INITIAL_CAPACITY = 11;
//用於存儲節點信息的數組
transient Object[] queue;
//數組中實際存放元素的個數
private int size = 0;
//Comparator比較器
private final Comparator<? super E> comparator;
//用於記錄修改次數的變量
transient int modCount = 0;

我們知道,堆這種數據結構主要分類有兩種,大根堆和小根堆。而我們每次的調整結構都是不斷按照某種規則比較兩個元素的值大小,然後調整結構,這裏就需要用到我們的比較器。所以構建一個PriorityQueue實例主要還是初始化數組容量和comparator比較器,而在PriorityQueue主要有以下幾種構造器:

public PriorityQueue() {
    this(DEFAULT_INITIAL_CAPACITY, null);
}
public PriorityQueue(int initialCapacity) {
    this(initialCapacity, null);
}
public PriorityQueue(Comparator<? super E> comparator) {
    this(DEFAULT_INITIAL_CAPACITY, comparator);
}

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

主要構造器有上述四種,前三種在內部會調用最後一個構造器。兩個參數,一個指定要初始化數組的容量,一個則用於初始化一個比較器。如果沒有顯式指定他們的值,則對於容量則默認爲DEFAULT_INITIAL_CAPACITY(11),comparator則爲null。下面我們看獲取到PriorityQueue實例之後,如何向其中添加和刪除節點卻一樣保持原堆結構不變。

三、有關優先隊列的基本操作(增刪改查)
     首先我們看添加一個元素到堆結構中,我們使用add或者offer方法完成新添一個元素到堆結構中。

public boolean add(E e) {
   return offer(e);
}

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;
}

實際上add方法的內部調用的還是offer方法,所以我們主要看看offer是如何實現添加一個元素到堆結構中並維持這種結構不被破壞的。首先該方法定義了一 變量獲取queue中實際存放的元素個數,緊接着一個if判斷,如果該數組已經被完全使用了(沒有可用空間了),會調用grow方法進行擴容,grow方法會根據具體情況判斷,如果原數組較小則會擴大兩倍,否則增加50%容量,由於具體代碼比較清晰,此處不再贅述。接着判斷該完全二叉樹是否爲空,如果沒有任何節點,那麼直接將新增節點作爲根節即可,否則會調用siftUp添加新元素並調整結構,所以該方法是重點。

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

此處會根據調用者是否傳入比較器而分爲兩種情況,代碼類似,我們只看一種即可。

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;
}

該方法首先獲取最後一個位置的父節點的索引,然後定義變量接受父節點的值,如果新增的節點值和父節點的值比較之後滿足堆結構,則直接break返回,否則循環向上交換,最終完成堆結構調整。具體我們通過一個實例演示整個過程:

這裏寫圖片描述

首先初始化一個小根堆,假如現在我們要添加一個新元素值爲5,根據siftUpUsingComparator方法的代碼,此時參數k的值應爲6,那麼最後一個節點的父節點的索引爲2(即三號節點11),然後e的值就爲11,通過比較器比較判斷5是否小於e,如果小於則說明需要調整結構,那麼會將最後一個節點的值用父節點e的值取代,也就是會變成這個樣子:

這裏寫圖片描述

再次進入循環,parent 的值爲(2-1)/2=0,比較器比較索引爲0的節點和我們需要新插入的節點(值爲5),發現3小於5,則break出循環,最後將queue[k] = x;,最終結果如下:

這裏寫圖片描述

以上就完成了新添一個元素到堆結構中並保持堆結構不被破壞,可能上述文字在有些地方描述不清,但是相信大致意思應該是表達出來了,大家可以自行查看源碼感受下。下面我們簡單看看刪除一個節點的代碼部分:

private E removeAt(int i) {
    modCount++;
    int s = --size;
    if (s == i) 
        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;
}

該方法內部也調用了多個其他方法,此處爲了節約篇幅,大致說明下整個刪除過程,具體代碼大家可以自行體會。首先該方法會獲取到最後一個節點的索引並判斷刪除元素是否爲最後一個節點,如果是則直接刪除即可。

如果刪除索引不是最後一個位置,那麼首先會獲取到最後一個節點的值並將其刪除,緊接着將最後一個節點的值覆蓋掉待刪位置的節點值並調整結構,調整完成之後,會判斷queue[i] == moved,如果爲true表示新增元素之後並沒有調整結構(滿足堆結構),那麼就會向上調整結構。(如果向下調整過結構自然是不需要再向上調整了),如果queue[i] != moved值爲true表示向上調整過結構,那麼將會返回moved。(至於爲什麼要在向上調整結構之後返回moved,主要是用於迭代器使用,此處暫時不會介紹)。

這裏就是刪除一個節點的大致過程,該方法還是比較底層的,其實PriorityQueue中是有一些其他刪除節點的方法的,但是他們內部調用的幾乎都是removeAt這個方法。例如:

//根據值刪除節點
public boolean remove(Object o) {
    int i = indexOf(o);
    if (i == -1)
        return false;
    else {
        removeAt(i);
        return true;
    }
}

當然如果一個隊列中有多個具有重複值的節點,那麼該方法調用了indexOf方法會獲取第一個符合條件的節點並刪除。當然還有其他一些刪除方法,此處不再介紹,大家可以自行體會。

四、有序出隊
     我們說過,PriorityQueue這種結構使用的是堆結構,所以他是一種不完全有序的結構,但是我們也提過,可以逐個出隊來實現有序輸出。下面我們看看它是如何實現的:

public E peek() {
    return (size == 0) ? null : (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;
}

上述我們列出了兩個方法的源碼,peek方法表示獲取隊列隊頭元素,代碼還是容易的,我們主要看poll方法,該方法用於出隊頭節點並維持堆結構。

有了之前的基礎,poll方法的代碼還是簡單的,首先判斷該隊中是否有元素,如果沒有則直接返回null,否則分別獲取頭節點的值和末節點的值,刪除尾節點並將尾節點的值替換頭節點的值,接着向下調整結構,最後返回被刪除的頭節點的值。下面我們看一個實例:

public static void main(String[] args){
    PriorityQueue pq = new PriorityQueue();
    pq.offer(1);
    pq.offer(21);
    pq.offer(345);
    pq.offer(23);
    pq.offer(22);
    pq.offer(44);
    pq.offer(0);
    pq.offer(34);
    pq.offer(2);
    while(pq.peek()!=null){
        System.out.print(pq.poll() + " ");
    }
}

我們亂序添加一些元素到隊列中,當然每次添加都會維持堆結構,然後我們循環輸出。程序運行結果如下:

這裏寫圖片描述

當然這裏我們沒有顯式的傳入比較器,此處會默認使用Integer的comparator,如果我們需要自己控制比較方式,可以傳入一個comparator用於比較。例如:

public static void main(String[] args){
   PriorityQueue pq = new PriorityQueue(
           new Comparator() {
               @Override
               public int compare(Object o1, Object o2) {
                   if((Integer)o1<=(Integer)o2){
                       return 1;
                   }
                   else
                       return -1;
               }
           }
   );

   pq.offer(1);
   pq.offer(22);
   pq.offer(4);
   pq.offer(45);
   pq.offer(12);
   pq.offer(5);
   pq.offer(76);
   pq.offer(34);
   pq.offer(23);
   pq.offer(22);
   while(pq.peek()!=null){
       System.out.print(pq.poll() + " ");
   }
}

以上代碼在構建PriorityQueue實例對象的時候顯式傳入一個comparator比較器,按照從大到小的順序構建一個堆結構。輸出結果如下:
這裏寫圖片描述

至此我們完成了對PriorityQueue這種堆結構的容器的簡單介紹,至於在何種情況下選擇該結構還需結合實際需求,總結不到之處,望大家補充!

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章