在最近釋出的 .NET 6 中,包含了一個新的資料結構,優先佇列 PriorityQueue, 實際上這個資料結構在隔壁 Java中已經存在了很多年了, 那優先佇列是怎麼實現的呢? 讓我們來一探究竟吧。
時間複雜度
因為接下來會分析時間複雜度, 這裡先貼一張幾種時間複雜度的對比圖,從低階到高階有:O(1)、O(logn)、O(n)、O(nlogn)、O(n2 )。
什麼是優先佇列
首先,佇列大家都知道, 是一個非常基礎的資料結構, 它的特點是先進先出(FIFO)。
而優先佇列卻不一定是先進先出,因為每個元素都有一個權重值, 代表著元素出隊的優先順序。
佇列可以用陣列和連結串列實現, 簡單、高效, 這樣入隊和出隊的時間複雜度都是 O(1)。
優先佇列能不能使用上面的方法呢? 也可以, 但是每次新元素入隊後, 需要和佇列內的元素進行遍歷和大小對比, 然後插入到合適的位置, 讓整個序列保持從大到小或者從小到大,這樣入隊的時間複雜度變成 O(n), 而出隊複雜度不變, 還是 O(1)。O(n) 代表入隊的時間是線性增長的, 效率較低, 有沒有更高效的方法呢?
堆 Heap
堆這種資料結構的應用場景非常多,最經典的莫過於堆排序了, 堆排序是一種原地的、時間複雜度為 O(nlog n) 的排序演算法,另外,堆也很適合用來做優先佇列。
堆和樹的結構其實是相似的, 堆有二叉堆, d-ary 堆, 2-3 堆, 斐波那契堆等等, 堆有一個特點就是每個父節點都大於等於它的兒子節點, 這種是大頂堆, 或者每個父節點都小於等於它的兒子節點, 這種是小頂堆,另外堆的兒子不分左右, 其中 java 中的 PriorityQueue 就是用二叉小頂堆實現的。
上面就是二叉堆, 而 .NET 6 中的 PriorityQueue 是由 d-ary 堆實現的, 而 d 表示父節點有幾個兒子節點, .NET 6 中指定這個值為4,並且是小頂堆,也就是 “四叉小頂堆"。
四叉堆比二叉堆更快,可以參考下面連結的論文
A Back-to-Basics Empirical Study of Priority Queues
那麼如何在程式碼中實現呢?其實可以用陣列儲存堆, 我們可以通過”廣度優先遍歷“ 的方法, 把堆的節點對映到一個陣列中,如下
另外,堆和陣列之間還有下面的關係
-
堆的頂點就是陣列的第一個元素,也是最小的元素。
-
通過子節點的下標,就可以通過公式計算出父節點的下標, 公式為
P = (C - 1) / 4
其中 P = 父節點的下標, C = 子節點的下標
現在優先佇列的資料結構確定了, 接下來看元素的入隊和出隊。
入隊 Enqueue
使用堆來實現優先佇列,入隊操作2步完成, 非常簡單!
-
新增新節點到末尾
-
通過上面的公式
P = (C - 1) / 4
, 新的子節點和父節點進行大小對比,如果子節點比較小,那麼就和父節點交換,重複這個過程,直到子節點大於或等於父節點,或者子節點變成堆頂,堆化完成, 這個交換過程是從下往上的, 入隊的時間複雜度是 O(log n)。
出隊 Dequeue
出隊,就是每次取佇列內最小的元素,基小頂堆結構,其實只需要取堆頂的元素即可,對應陣列的第1個元素 array[0]。
你會發現,當取出堆頂元素以後,小頂堆的頂已經空了, 為了保持堆的結構,我們需要重新堆化。
和上面的入隊 Enqueue 的邏輯有異曲同工之妙, 我們可以取堆的最後一個元素,把它放到堆頂, 然後父節點去和4個兒子節點比大小,如果比兒子節點大,就交換, 重複這個過程,直到父節點比4個兒子節點都大, 或者到達堆的最後一層,堆化完成,這個交換過程是從上往下的,出隊的時間複雜度同樣是 O(log n)。
另外,如果多個兒子節點都比父節點小,那父節點和最小的子節點交換。
擴容和收縮機制
優先佇列是用陣列實現的四叉小頂堆, 那麼就存在陣列的擴容和收縮的情況
擴容:最小為4,陣列滿的時候會擴大為當前容量的2倍。
收縮:陣列不會自動收縮,不過可以手動呼叫 TrimExcess() 方法, 當空餘的空間大於10% 的時候, 陣列的長度會收縮到當前佇列元素的數量。
總結
本文主要介紹了 .NET 6 新增的資料結構優先佇列,感興趣的也可以看一下 PriorityQueue 的原始碼, 其實就是基於堆這種結構實現的,也展示了入隊和出隊的堆結構的變化過程,另外需要注意的是,堆這種結構不是穩定的,因為在排序的過程,存在將堆的最後一個節點跟堆頂節點互換的操作,所以以相同優先順序入隊的元素並不能保證以相同的順序出隊。
參考
System/Collections/Generic/PriorityQueue.cs
https://github.com/dotnet/runtime/issues/14032