首頁 > 軟體

.NET中的HashSet及原理解析

2022-03-14 19:01:25

在.NET中,System.Collection及System.Collection.Generic名稱空間中提供了一系列的集合類,HashSet定義在System.Collections.Generic中,是一個不重複、無序的泛型集合,本文學習下HashSet的工作原理。

雜湊表原理

HashSet是基於雜湊表的原理實現的,學習HashSet首先要了解下雜湊表。

雜湊表(hash table, 也叫雜湊表)是根據key直接存取儲存位置的資料結構,它通過一個鍵值的函數,將所需查詢的資料對映到表中一個位置來存取,加快了查詢速度。

上述函數即為雜湊函數,雜湊函數應儘量計算簡單以提高插入、檢索效率;計算得到的地址應儘量分佈均勻,以降低雜湊衝突;應具有較大的壓縮性,以節省記憶體。常見的雜湊函數構造方法有直接定址法、除留餘數法、數位分析法等。HashSet採用除留餘數法,將元素的HashCode除以某個常數(雜湊表Size)的餘數作為地址,常數通常選取一個素數。

兩個相等的物件的雜湊值相同,但兩個不等的物件的雜湊值是有可能相同的,這就是雜湊衝突。處理衝突的方法有開放定址法、連結串列法、雙雜湊法等。HashSet使用連結串列法,將衝突元素放在連結串列中。

雜湊表是一種用於高效能集合操作的資料結構,它有如下特點:

  • 無序、不重複;插入、查詢時間複雜度為O(1);
  • 不使用索引;
  • 容量不足時自動擴容,但擴容成本高;
  • 可提供很多高效能集合操作,如合併、裁剪等;

HashSet實現

HashSet內建了兩個陣列,如下。_buckets中存放由雜湊函數計算得到的索引值,_buckets中的值從1開始,因此在使用時需要-1。該值即為_entries陣列的相對索引,若未發生衝突,指向的值即為待查詢元素的相對索引。如果發生了衝突,根據衝突連結串列也可以快速定位到元素。_entries存放的是Entry物件,Entry型別如下所示。HashCode為元素的雜湊值,在查詢、插入、刪除、擴容等操作時都會用到。Value儲存資料。Next在不同時刻有不同的作用,當Entry在列表中時,形成衝突連結串列,其Next指向衝突連結串列的下一元素,連結串列最後一個元素的Next值為-1;若Entry已被列表刪除,形成空位連結串列,其Next指向空位連結串列的下一元素,空位連結串列的最後一個元素值為-2。

HashSet還有幾個關鍵成員:_count、_freeList、_freeCount。_count表示新增元素數量,注意它並不是實際儲存的元素數量,因為在刪除元素時未更新它。_freeList為空位連結串列頭,其值指向被刪除的_entries索引,_entries[_freeList].Next指向下一空位的相對位置。_freeCount表示空位數量,列表實際儲存的元素數量為_count - _freeCount。

private int[]? _buckets; // _entries索引陣列
private Entry[]? _entries; // 實體陣列

private int _count; // 實際儲存的元素數量
private int _freeList; // 空位連結串列頭索引,初始值-1
private int _freeCount; // 空位數量
private struct Entry
{
    public int HashCode;
    public int Next;
    public T Value;
}
public int Count => _count - _freeCount; // _count不會記錄被刪除的元素,因此實際元素數量為_count - _freeCount

HashSet提供了多個建構函式過載,如果不傳任何引數,不會初始化_buckets和_entries。當添元素時,會呼叫Initialize(0)。Initialize方法接受一個int引數,該參數列示需要初始化的列表容量。實際初始化的列表容量為大於等於該值的最小素數。取素數作為列表長度是因為該值作為使用除留餘數法構造的雜湊函數的除數,對素數求餘結果分佈更均勻,減少了衝突的發生。

private int Initialize(int capacity)
{
    int size = HashHelpers.GetPrime(capacity); // 獲取>=capacity的最小素數
    var buckets = new int[size];
    var entries = new Entry[size];
    // ...
    return size;
}

查詢元素時,首先呼叫元素的GetHashCode方法計算雜湊值,然後呼叫GetBucketRef方法執行雜湊函數運算,獲得索引。GetBucketRef的返回值-1為真實索引i,若i為-1,則未找到元素。若i>=0,表示列表中存在與待查詢元素雜湊值相同的元素,但相等的雜湊值並不一定表示元素相等,還要進一步判斷HashCode,若HashCode相等,再判斷元素是否相等,滿足則查詢到元素,返回_entries的索引i。

private int FindItemIndex(T item)
{
    // ...
    int hashCode = item != null ? item.GetHashCode() : 0;
    if (typeof(T).IsValueType)
    {
        int i = GetBucketRef(hashCode) - 1; // _buckets元素從1開始
        while (i >= 0) // 存在與item相同的雜湊值,不一定存在item
        {
            ref Entry entry = ref entries[i];
            if (entry.HashCode == hashCode && EqualityComparer<T>.Default.Equals(entry.Value, item))
            {
                return i; // HashCode相等且元素相等,則查詢到元素,返回_entries索引
            }
            i = entry.Next;

            collisionCount++;
            // ...
        }
    }
    // ...
    return -1;
}

private ref int GetBucketRef(int hashCode)
{
    int[] buckets = _buckets!;
    return ref buckets[(uint)hashCode % (uint)buckets.Length]; // 使用除留餘數法構造雜湊函數
}

插入元素時,首先會查詢待插入的元素是否存在,HashSet是不重複的,因此若插入元素已存在會直接返回false。若不存在元素,則會尋找存放元素的index。如果存在刪除後的空位,則會將元素放到_freeList指向的空位上;如果不存在空位,則按_entries順序插入元素。找到index後,即可將元素的HashCode及元素賦值到_entries[index]對應欄位,當沒有衝突時,Next值為-1;若存在衝突,則形成連結串列,將其新增到連結串列頭,Next指向衝突的下一位置。

private bool AddIfNotPresent(T value, out int location)
{
    bucket = ref GetBucketRef(hashCode); // bucket為ref int型別,若不存在衝突,bucket應為0,因為int預設值為0
    // ...

    int index;
    if (_freeCount > 0) // 存在刪除後的空位,將元素放在空位上
    {
        index = _freeList;
        _freeCount--; // 更新刪除後空位數量
        _freeList = StartOfFreeList - entries[_freeList].Next; // 更新空位索引
    }
    else // 按_entries順序儲存元素
    {
        int count = _count;
        if (count == entries.Length) // 容量不足,擴容
        {
            Resize();
            bucket = ref GetBucketRef(hashCode);
        }
        index = count;
        _count = count + 1;
        entries = _entries;
    }

    {   // 賦值
        ref Entry entry = ref entries![index];
        entry.HashCode = hashCode;
        entry.Next = bucket - 1; // 若不存在衝突則為-1,否則形成連結串列,指向衝突的下一元素索引
        entry.Value = value;
        bucket = index + 1; // 此處對bucket賦值,即改變_buckets對應元素,即將以插入元素雜湊值為索引的_buckets值指向存放插入元素的_entries的索引+1
        _version++;
        location = index;
    }

    // ...
    return true;
}

插入時若列表容量不足,會呼叫Resize方法進行擴容。擴容後的大小為大於等於原大小2倍的最小素數。獲取待擴容的大小後,以新大小重新分配entries記憶體,並呼叫Array.Copy方法將原內容拷貝到新位置。由於列表長度變了,因此雜湊值會變,因此需要更新_buckets的內容(_entries索引),同理entry.Next的值也要更新。

private void Resize() => Resize(HashHelpers.ExpandPrime(_count), forceNewHashCodes: false);

public static int ExpandPrime(int oldSize)
{
    int num = 2 * oldSize;
    if ((uint)num > 2146435069u && 2146435069 > oldSize)
    {
        return 2146435069;
    }

    return GetPrime(num); // 返回原大小2倍的最小素數
}

private void Resize(int newSize, bool forceNewHashCodes)
{
    var entries = new Entry[newSize];
    Array.Copy(_entries, entries, count);
    // ...

    _buckets = new int[newSize];
    for (int i = 0; i < count; i++)
    {
        ref Entry entry = ref entries[i];
        if (entry.Next >= -1) // 更新_buckets內容
        {
            ref int bucket = ref GetBucketRef(entry.HashCode); // 獲取以新大小作為除數的雜湊函數運算得到的雜湊值
            entry.Next = bucket - 1; // 更新Next
            bucket = i + 1; // 更新_buckets的元素,指向重新計算的_entry索引+1
        }
    }

    _entries = entries;
}

當刪除元素時,首先查詢待刪除元素是否存在。若雜湊值存在衝突,會記錄衝突連結串列的上一索引。查詢到元素後,需要更新衝突連結串列的指標。刪除元素後,會更新_freeCount空位數量,並將刪除元素索引賦值給_freeList,記錄刪除空位,新增到空位連結串列頭,其Next指向下一空位的相對位置。插入元素時,會將元素插入到_freeList記錄的空位索引處,並根據該空位的Next更新_freeList的值。

public bool Remove(T item)
{
    int last = -1;
    int hashCode = item != null ? (_comparer?.GetHashCode(item) ?? item.GetHashCode()) : 0;
    ref int bucket = ref GetBucketRef(hashCode);
    int i = bucket - 1;

    while (i >= 0)
    {
        ref Entry entry = ref entries[i];
        if (entry.HashCode == hashCode && (_comparer?.Equals(entry.Value, item) ?? EqualityComparer<T>.Default.Equals(entry.Value, item)))
        {
            // 找到待刪除元素
            if (last < 0) // 待刪除元素位於連結串列頭部,更新_buckets元素值指向連結串列下一位置
            {
                bucket = entry.Next + 1;
            }
            else // 待刪除元素非連結串列頭,需更新連結串列上一元素Next值
                entries[last].Next = entry.Next;
            entry.Next = StartOfFreeList - _freeList; // 空位連結串列,記錄下一空位索引相對位置,插入時根據該值更新_freeList
            if (RuntimeHelpers.IsReferenceOrContainsReferences<T>())
                entry.Value = default!;
            _freeList = i; // 記錄刪除元素位置,下次插入元素時,會插入在此
            _freeCount++;  // 更新刪除後空位數量
            return true;
        }
        last = i; // 存在衝突,記錄連結串列上一位置
        i = entry.Next;
    }
    return false;
}

總結

通過上文分析可以看出HashSet是個設計巧妙,使用靈活的資料結構。基於雜湊表的思想,HashSet的插入、查詢速度很快,只需要簡單的計算。基於此,HashSet也具備了高效能集合運算的條件,可以高效執行合併、裁剪等運算。但這也導致了HashSet無法儲存重複元素。刪除時空位連結串列的設計非常巧妙,但也導致了HashSet無序,也就無法使用索引。因此,當選擇資料結構時,如果需要包含重複元素,或者需要有序,則應考慮使用其它資料結構,如List。

另外,Dictionary與HashSet原理相同,只是HashSet只有Key,沒有Value。

參考文章

到此這篇關於.NET中的HashSet的文章就介紹到這了,更多相關.NET中的HashSet內容請搜尋it145.com以前的文章或繼續瀏覽下面的相關文章希望大家以後多多支援it145.com!


IT145.com E-mail:sddin#qq.com