源碼解析C#中PriorityQueue(優先級隊列)的實現

前言

前段時間看到有大佬對.net 6.0新出的PriorityQueue(優先級隊列)數據結構做了解析,但是沒有源碼分析,所以本着探究源碼的心態,看了看並分享出來。它不像普通隊列先進先出(FIFO),而是根據優先級出隊。
ps:讀者多注意代碼的注釋。

D叉樹的認識(d-ary heap)

首先我們在表示一個堆(大頂堆或小頂堆)的時候,實際上是通過一個一維數組來維護一個二叉樹(d=2,d表示每個父節點最多有幾個子節點),首先看下圖的二叉樹,數字代表索引:

  • 任意一個節點的父節點的索引為:(index – 1) / d
  • 任意一個節點的左子節點的索引為:(index * d) + 1
  • 任意一個節點的右子節點的索引為:(index * d) + 2
  • 它的時間複雜度為O(logndn)
    通過以上公式,我們就可以輕鬆通過一個數組來表達一個堆,只需保證能拿到正確的索引即可進行快速的插入和刪除。

源碼解析

構造初始化

關於這部分主要介紹關鍵的字段和方法,比較器的初始化以及堆的初始化,請看如下代碼:

public class PriorityQueue<TElement, TPriority>
{
    /// <summary>
    /// 保存所有節點的一維數組且每一項是個元組
    /// </summary>
    private (TElement Element, TPriority Priority)[] _nodes;

    /// <summary>
    /// 優先級比較器,這裡用的泛型,比較器可以自己實現
    /// </summary>
    private readonly IComparer<TPriority>? _comparer;

    /// <summary>
    /// 當前堆的大小
    /// </summary>
    private int _size;

    /// <summary>
    /// 版本號
    /// </summary>
    private int _version;

    /// <summary>
    /// 代表父節點最多有4個子節點,也就是d=4(d=4時好像效率最高)
    /// </summary>
    private const int Arity = 4;

    /// <summary>
    /// 使用位運算符,表示左移2或右移2(效率更高),即相當於除以4,
    /// </summary>
    private const int Log2Arity = 2;

    /// <summary>
    ///  構造函數初始化堆和比較器
    /// </summary>
    public PriorityQueue()
    {
        _nodes = Array.Empty<(TElement, TPriority)>();
        _comparer = InitializeComparer(null);
    }

    /// <summary>
    ///  重載構造函數,來定義比較器否則使用默認的比較器
    /// </param>
    public PriorityQueue(IComparer<TPriority>? comparer)
    {
        _nodes = Array.Empty<(TElement, TPriority)>();
        _comparer = InitializeComparer(comparer);
    }
    private static IComparer<TPriority>? InitializeComparer(IComparer<TPriority>? comparer)
    {
        //如果是值類型,如果是默認比較器則返回null
        if (typeof(TPriority).IsValueType)
        {
            if (comparer == Comparer<TPriority>.Default)
            {
                return null;
            }

            return comparer;
        }
        //否則就使用自定義的比較器
        else
        {
            return comparer ?? Comparer<TPriority>.Default;
        }
    }

    /// <summary>
    /// 獲取索引的父節點
    /// </summary>
    private int GetParentIndex(int index) => (index - 1) >> Log2Arity;

    /// <summary>
    /// 獲取索引的左子節點
    /// </summary>
    private int GetFirstChildIndex(int index) => (index << Log2Arity) + 1;
}

單元總結:

  1. 實際所有元素使用一維數組來維護這個堆。
  2. 調用方可以自定義比較器,但是類型得一致。 如果沒有比較器,則使用默認的比較器。
  3. 默認一個父節點最多有4個子節點,D=4時效率好像是最好的。
  4. 獲取父節點索引位置和子節點索引位置使用位運算符,效率更高。

入隊操作

入隊操作操作相對簡單,主要是做擴容和插入處理,請看如下代碼:

public void Enqueue(TElement element, TPriority priority)
{
    //拿到最大位置的索引,然後再將數組長度+1
    int currentSize = _size++;
    _version++;
    //如果長度相等,說明已經到達最大位置,數組需要擴容了才能容下更多的元素
    if (_nodes.Length == currentSize)
    {
        //擴容,參數是代表數組最小容量
        Grow(currentSize + 1);
    }

    if (_comparer == null)
    {
        
        MoveUpDefaultComparer((element, priority), currentSize);
    }
    else
    {
        MoveUpCustomComparer((element, priority), currentSize);
    }
}
private void Grow(int minCapacity)
{
    //增長倍數
    const int GrowFactor = 2;
    //每次擴容的最小值
    const int MinimumGrow = 4;
    //每次擴容都2倍擴容
    int newcapacity = GrowFactor * _nodes.Length;

    //數組不能大於最大長度
    if ((uint)newcapacity > Array.MaxLength) newcapacity = Array.MaxLength;

    //使用他們兩個的最大值
    newcapacity = Math.Max(newcapacity, _nodes.Length + MinimumGrow);

    //如果比參數小,則使用參數的最小值
    if (newcapacity < minCapacity) newcapacity = minCapacity;
    //重新分配內存,設置大小,因為數組的保存在內存中是連續的
    Array.Resize(ref _nodes, newcapacity);
}
private void MoveUpDefaultComparer((TElement Element, TPriority Priority) node, int nodeIndex)
{
    //nodes保存副本
    (TElement Element, TPriority Priority)[] nodes = _nodes;
    //這裡入隊操作是從空閑索引第一個位置開始插入
    while (nodeIndex > 0)
    {
        //找父節點索引位置
        int parentIndex = GetParentIndex(nodeIndex);
        (TElement Element, TPriority Priority) parent = nodes[parentIndex];
        //插入節點和父節點比較,如果小於0(默認比較器情況下構建的小頂堆),則交換位置
        if (Comparer<TPriority>.Default.Compare(node.Priority, parent.Priority) < 0)
        {
            nodes[nodeIndex] = parent;
            nodeIndex = parentIndex;
        }
        //算是性能優化吧,不必檢查所有節點,當發現大於時,就直接退出就可以了
        else
        {
            break;
        }
    }
    //將插入節點放到它應該的位置
    nodes[nodeIndex] = node;
}

單元總結:

  1. 首先記錄當前元素最大的索引位置,根據適當的情況來擴容。
  2. 擴容正常情況下是以2倍的增長速度擴容。
  3. 插入數據時,從最後一個節點的父節點向上還是找,比較元素的Priority,交換位置,默認情況下是構建小頂堆。

出隊操作

出隊操作簡單來說就是將根元素返回並移除(也就是數組的第一個元素),然後根據比較器將最小或最大的元素放到堆頂,請看如下代碼:

public TElement Dequeue()
{
    if (_size == 0)
    {
        throw new InvalidOperationException(SR.InvalidOperation_EmptyQueue);
    }
    //將堆頂元素返回
    TElement element = _nodes[0].Element;
    //然後調整堆
    RemoveRootNode();
    return element;
}
private void RemoveRootNode()
{
    //記錄最後一個元素的索引位置,並且將堆的大小-1
    int lastNodeIndex = --_size;
    _version++;

    if (lastNodeIndex > 0)
    {
        //堆的大小已經被減1,所以將最後一個元素作為副本,開始從堆頂重新整理堆
        (TElement Element, TPriority Priority) lastNode = _nodes[lastNodeIndex];
        if (_comparer == null)
        {
            MoveDownDefaultComparer(lastNode, 0);
        }
        else
        {
            MoveDownCustomComparer(lastNode, 0);
        }
    }

    if (RuntimeHelpers.IsReferenceOrContainsReferences<(TElement, TPriority)>())
    {
        //將最後一個位置的元素設置為默認值
        _nodes[lastNodeIndex] = default;
    }
}
private void MoveDownDefaultComparer((TElement Element, TPriority Priority) node, int nodeIndex)
{
    (TElement Element, TPriority Priority)[] nodes = _nodes;
    //堆的實際大小
    int size = _size;

    int i;
    //當左子節點的索引小於堆的實際大小時
    while ((i = GetFirstChildIndex(nodeIndex)) < size)
    {
        //左子節點元素
        (TElement Element, TPriority Priority) minChild = nodes[i];
        //當前左子節點的索引
        int minChildIndex = i;
        //這裡即找到所有同一個父節點的相鄰子節點,但是要判斷是否超出了總的大小
        int childIndexUpperBound = Math.Min(i + Arity, size);
        //按照索引區間順序查找,並根據比較器找到最小的子元素位置
        while (++i < childIndexUpperBound)
        {
            (TElement Element, TPriority Priority) nextChild = nodes[i];
            if (Comparer<TPriority>.Default.Compare(nextChild.Priority, minChild.Priority) < 0)
            {
                minChild = nextChild;
                minChildIndex = i;
            }
        }
        //如果最後一個節點的元素,比這個最小的元素還小,那麼直接放到父節點即可
        if (Comparer<TPriority>.Default.Compare(node.Priority, minChild.Priority) <= 0)
        {
            break;
        }
        //將最小的子元素賦值給父節點
        nodes[nodeIndex] = minChild;
        nodeIndex = minChildIndex;
    }
    //將最後一個節點放到空閑出來的索引位置
    nodes[nodeIndex] = node;
}

單元總結:

  1. 返回根節點元素,然後移除根節點元素,調整堆。
  2. 從根節點開始,依次查找對應父節點的所有子節點,放到堆頂,也就是數組索引0的位置,然後如果樹還有層數,繼續循環查找。
  3. 將最後一個元素放到堆適當的位置,然後再將最後一個位置的元素置為默認值。

總結

通過源碼的解讀,除了了解類的設計之外,更對對優先級隊列數據結構的實現有了更加深刻和清晰的認識。
這裡也只是粘貼出主要的代碼,需要看詳細代碼的請點擊這裡,筆者可能有一些解讀錯誤的地方,歡迎評論指正。

Tags: