歡迎光臨
每天分享高質量文章

淺析C# Dictionary實現原理

作者: InCerry

連結:https://www.cnblogs.com/InCerry/p/10325290.html

目錄

一、前言

二、理論知識

  • 1、Hash演演算法

  • 2、Hash桶演演算法

  • 3、解決衝突演演算法

三、Dictionary實現

  • 1、Entry結構體

  • 2、其它關鍵私有變數

  • 3、Dictionary – Add操作

  • 4、Dictionary – Find操作

  • 5、Dictionary – Remove操作

  • 6、Dictionary – Resize操作(擴容)

  • 7、Dictionary – 再談Add操作

  • 8、Collection版本控制

四、參考文獻及總結

筆者水平有限,如果錯誤歡迎各位批評指正!

一、前言

本篇文章配圖以及文字其實整理出來很久了,但是由於各種各樣的原因推遲到現在才發出來,還有之前立Flag的《多執行緒程式設計》的筆記也都已經寫好了,只是說還比較糙,需要找個時間整理一下才能和大家見面。

 

對於C#中的Dictionary類相信大家都不陌生,這是一個Collection(集合)型別,可以透過Key/Value(鍵值對的形式來存放資料;該類最大的優點就是它查詢元素的時間複雜度接近O(1),實際專案中常被用來做一些資料的本地快取,提升整體效率。

 

那麼是什麼樣的設計能使得Dictionary類能實現O(1)的時間複雜度呢?那就是本篇文章想和大家討論的東西;這些都是個人的一些理解和觀點,如有表述不清楚、錯誤之處,請大家批評指正,共同進步。

二、理論知識

對於Dictionary的實現原理,其中有兩個關鍵的演演算法,一個是Hash演演算法,一個是用於應對Hash碰撞衝突解決演演算法。

 

1、Hash演演算法

 

Hash演演算法是一種數字摘要演演算法,它能將不定長度的二進位制資料集給對映到一個較短的二進位制長度資料集,常見的MD5演演算法就是一種Hash演演算法,透過MD5演演算法可對任何資料生成數字摘要。而實現了Hash演演算法的函式我們叫她Hash函式。Hash函式有以下幾點特徵。

 

1、相同的資料進行Hash運算,得到的結果一定相同。HashFunc(key1) == HashFunc(key1)

2、不同的資料進行Hash運算,其結果也可能會相同,(Hash會產生碰撞)。key1 != key2 => HashFunc(key1) == HashFunc(key2).

3、Hash運算時不可逆的,不能由key獲取原始的資料。key1 => hashCode但是hashCode =\=> key1。

 

下圖就是Hash函式的一個簡單說明,任意長度的資料透過HashFunc對映到一個較短的資料集中。

 

 

關於Hash碰撞下圖很清晰的就解釋了,可從圖中得知Sandra Dee 和 John Smith透過hash運算後都落到了02的位置,產生了碰撞和衝突。

 

 

常見的構造Hash函式的演演算法有以下幾種。

 

1、直接定址法:取keyword或keyword的某個線性函式值為雜湊地址。即H(key)=key或H(key) = a•key + b,當中a和b為常數(這樣的雜湊函式叫做自身函式)

2、數字分析法:分析一組資料,比方一組員工的出生年月日,這時我們發現出生年月日的前幾位數字大體同樣,這種話,出現衝突的機率就會非常大,可是我們發現年月日的後幾位表示月份和詳細日期的數字區別非常大,假設用後面的數字來構成雜湊地址,則衝突的機率會明顯減少。因此數字分析法就是找出數字的規律,盡可能利用這些資料來構造衝突機率較低的雜湊地址。

3、平方取中法:取keyword平方後的中間幾位作為雜湊地址。

4、摺疊法:將keyword切割成位數同樣的幾部分,最後一部分位數能夠不同,然後取這幾部分的疊加和(去除進位)作為雜湊地址。

5、隨機數法:選擇一隨機函式,取keyword的隨機值作為雜湊地址,通經常使用於keyword長度不同的場合。

6、除留餘數法:取keyword被某個不大於散串列表長m的數p除後所得的餘數為雜湊地址。即 H(key) = key MOD p, p<=m。不僅能夠對keyword直接取模,也可在摺疊、平方取中等運算之後取模。對p的選擇非常重要,一般取素數或m,若p選的不好,容易產生碰撞.

 

2、Hash桶演演算法

 

說到Hash演演算法大家就會想到Hash表,一個Key透過Hash函式運算後可快速的得到hashCode,透過hashCode的對映可直接Get到Value,但是hashCode一般取值都是非常大的,經常是2^32以上,不可能對每個hashCode都指定一個對映。

 

因為這樣的一個問題,所以人們就將生成的HashCode以分段的形式來對映,把每一段稱之為一個Bucket(桶),一般常見的Hash桶就是直接對結果取餘。

 

假設將生成的hashCode可能取值有2^32個,然後將其切分成一段一段,使用8個桶來對映,那麼就可以透過bucketIndex = HashFunc(key1) % 8這樣一個演演算法來確定這個hashCode對映到具體的哪個桶中。

 

大家可以看出來,透過hash桶這種形式來進行對映,所以會加劇hash的衝突。

 

3、解決衝突演演算法

 

對於一個hash演演算法,不可避免的會產生衝突,那麼產生衝突以後如何處理,是一個很關鍵的地方,目前常見的衝突解決演演算法有拉鏈法(Dictionary實現採用的)、開放定址法、再Hash法、公共上限溢位分割槽法,本文只介紹拉鏈法與再Hash法,對於其它演演算法感興趣的同學可參考文章最後的參考文獻。

 

1、拉鏈法:這種方法的思路是將產生衝突的元素建立一個單連結串列,並將頭指標地址儲存至Hash表對應桶的位置。這樣定位到Hash表桶的位置後可透過遍歷單連結串列的形式來查詢元素。

2、再Hash法:顧名思義就是將key使用其它的Hash函式再次Hash,直到找到不衝突的位置為止。

 

對於拉鏈法有一張圖來描述,透過在衝突位置建立單連結串列,來解決衝突。

 

三、Dictionary實現

Dictionary實現我們主要對照原始碼來解析,目前對照原始碼的版本是.Net Framwork 4.7。地址可戳一戳這個連結 原始碼地址:Link

https://referencesource.microsoft.com/#mscorlib/system/collections/generic/dictionary.cs,d3599058f8d79be0

 

這一章節中主要介紹Dictionary中幾個比較關鍵的類和物件,然後跟著程式碼來走一遍插入、刪除和擴容的流程,相信大家就能理解它的設計原理。

 

1、Entry結構體

 

首先我們引入Entry這樣一個結構體,它的定義如下程式碼所示。這是Dictionary種存放資料的最小單位,呼叫Add(Key,Value)方法新增的元素都會被封裝在這樣的一個結構體中。

 

private struct Entry {
    public int hashCode;    // 除符號位以外的31位hashCode值, 如果該Entry沒有被使用,那麼為-1
    public int next;        // 下一個元素的下標索引,如果沒有下一個就為-1
    public TKey key;        // 存放元素的鍵
    public TValue value;    // 存放元素的值
}

2、其它關鍵私有變數

 

除了Entry結構體外,還有幾個關鍵的私有變數,其定義和解釋如下程式碼所示。

 

private int[] buckets;      // Hash桶
private Entry[] entries;    // Entry陣列,存放元素
private int count;          // 當前entries的index位置
private int version;        // 當前版本,防止迭代過程中集合被更改
private int freeList;       // 被刪除Entry在entries中的下標index,這個位置是空閑的
private int freeCount;      // 有多少個被刪除的Entry,有多少個空閑的位置
private IEqualityComparer comparer;   // 比較器
private KeyCollection keys;     // 存放Key的集合
private ValueCollection values;     // 存放Value的集合

上面程式碼中,需要註意的是buckets、entries這兩個陣列,這是實現Dictionary的關鍵。

 

3、Dictionary – Add操作

 

經過上面的分析,相信大家還不是特別明白為什麼需要這麼設計,需要這麼做。那我們現在來走一遍Dictionary的Add流程,來體會一下。

 

首先我們用圖的形式來描述一個Dictionary的資料結構,其中只畫出了關鍵的地方。桶大小為4以及Entry大小也為4的一個資料結構。

 

 

然後我們假設需要執行一個Add操作,dictionary.Add(“a”,”b”),其中key = “a”,value = “b”。

 

1、根據key的值,計算出它的hashCode。我們假設”a”的hash值為6(GetHashCode(“a”) = 6)。

2、透過對hashCode取餘運算,計算出該hashCode落在哪一個buckets桶中。現在桶的長度(buckets.Length)為4,那麼就是6 % 4最後落在index為2的桶中,也就是buckets[2]。

3、避開一種其它情況不談,接下來它會將hashCode、key、value等資訊存入entries[count]中,因為count位置是空閑的;繼續count++指向下一個空閑位置。上圖中第一個位置,index=0就是空閑的,所以就存放在entries[0]的位置。

4、將Entry的下標entryIndex賦值給buckets中對應下標的bucket。步驟3中是存放在entries[0]的位置,所以buckets[2]=0。

5、最後version++,集合發生了變化,所以版本需要+1。只有增加、替換和刪除元素才會更新版本

上文中的步驟1~5只是方便大家理解,實際上有一些偏差,後文再談Add操作小節中會補充。

 

完成上面Add操作後,資料結構更新成了下圖這樣的形式。

 

 

這樣是理想情況下的操作,一個bucket中只有一個hashCode沒有碰撞的產生,但是實際上是會經常產生碰撞;那麼Dictionary類中又是如何解決碰撞的呢。

 

我們繼續執行一個Add操作,dictionary.Add(“c”,”d”),假設GetHashCode(“c”)=6,最後6 % 4 = 2。最後桶的index也是2,按照之前的步驟1~3是沒有問題的,執行完後資料結構如下圖所示。

 

如果繼續執行步驟4那麼buckets[2] = 1,然後原來的buckets[2]=>entries[0]的關係就會丟失,這是我們不願意看到的。現在Entry中的next就發揮大作用了。

 

如果對應的buckets[index]有其它元素已經存在,那麼會執行以下兩條陳述句,讓新的entry.next指向之前的元素,讓buckets[index]指向現在的新的元素,就構成了一個單連結串列。

 

entries[index].next = buckets[targetBucket];
...
buckets[targetBucket] = index;

實際上步驟4也就是做一個這樣的操作,並不會去判斷是不是有其它元素,因為buckets中桶初始值就是-1,不會造成問題。

 

經過上面的步驟以後,資料結構就更新成了下圖這個樣子。

 

 

4、Dictionary – Find操作

 

為了方便演示如何查詢,我們繼續Add一個元素dictionary.Add(“e”,”f”),GetHashCode(“e”) = 7; 7% buckets.Length=3,資料結構如下所示。

 

 

假設我們現在執行這樣一條陳述句dictionary.GetValueOrDefault(“a”),會執行以下步驟.

 

1、獲取key的hashCode,計算出所在的桶位置。我們之前提到,”a”的hashCode=6,所以最後計算出來targetBucket=2。

2、透過buckets[2]=1找到entries[1],比較key的值是否相等,相等就傳回entryIndex,不想等就繼續entries[next]查詢,直到找到key相等元素或者next == -1的時候。這裡我們找到了key == “a”的元素,傳回entryIndex=0。

3、如果entryIndex >= 0那麼傳回對應的entries[entryIndex]元素,否則傳回default(TValue)。這裡我們直接傳回entries[0].value。

 

整個查詢的過程如下圖所示.

 

將查詢的程式碼摘錄下來,如下所示。

 

// 尋找Entry元素的位置
private int FindEntry(TKey key{
    if( key == null) {
        ThrowHelper.ThrowArgumentNullException(ExceptionArgument.key);
    }

    if (buckets != null) {
        int hashCode = comparer.GetHashCode(key) & 0x7FFFFFFF// 獲取HashCode,忽略符號位
        // int i = buckets[hashCode % buckets.Length] 找到對應桶,然後獲取entry在entries中位置
        // i >= 0; i = entries[i].next 遍歷單連結串列
        for (int i = buckets[hashCode % buckets.Length]; i >= 0; i = entries[i].next) {
            // 找到就傳回了
            if (entries[i].hashCode == hashCode && comparer.Equals(entries[i].key, key)) return i;
        }
    }
    return -1;
}
...
internal TValue GetValueOrDefault(TKey key{
    int i = FindEntry(key);
    // 大於等於0代表找到了元素位置,直接傳回value
    // 否則傳回該型別的預設值
    if (i >= 0) {
        return entries[i].value;
    }
    return default(TValue);
}

5、Dictionary – Remove操作

 

前面已經向大家介紹了增加、查詢,接下來向大家介紹Dictionary如何執行刪除操作。我們沿用之前的Dictionary資料結構。

 

 

刪除前面步驟和查詢類似,也是需要找到元素的位置,然後再進行刪除的操作。

 

我們現在執行這樣一條陳述句dictionary.Remove(“a”),hashFunc運算結果和上文中一致。步驟大部分與查詢類似,我們直接看摘錄的程式碼,如下所示。

 

public bool Remove(TKey key{
    if(key == null) {
        ThrowHelper.ThrowArgumentNullException(ExceptionArgument.key);
    }

    if (buckets != null) {
        // 1. 透過key獲取hashCode
        int hashCode = comparer.GetHashCode(key) & 0x7FFFFFFF;
        // 2. 取餘獲取bucket位置
        int bucket = hashCode % buckets.Length;
        // last用於確定是否當前bucket的單連結串列中最後一個元素
        int last = -1;
        // 3. 遍歷bucket對應的單連結串列
        for (int i = buckets[bucket]; i >= 0; last = i, i = entries[i].next) {
            if (entries[i].hashCode == hashCode && comparer.Equals(entries[i].key, key)) {
                // 4. 找到元素後,如果last
                if (last 0
) {
                    buckets[bucket] = entries[i].next;
                }
                else {
                    // 4.1 last不小於0,代表當前元素處於bucket單連結串列中間位置,需要將該元素的頭結點和尾節點相連起來,防止連結串列中斷
                    entries[last].next = entries[i].next;
                }
                // 5. 將Entry結構體內資料初始化
                entries[i].hashCode = -1;
                // 5.1 建立freeList單連結串列
                entries[i].next = freeList;
                entries[i].key = default(TKey);
                entries[i].value = default(TValue);
                // *6. 關鍵的程式碼,freeList等於當前的entry位置,下一次Add元素會優先Add到該位置
                freeList = i;
                freeCount++;
                // 7. 版本號+1
                version++;
                return true;
            }
        }
    }
    return false;
}

執行完上面程式碼後,資料結構就更新成了下圖所示。需要註意varsion、freeList、freeCount的值都被更新了。

 

 

6、Dictionary – Resize操作(擴容)

 

有細心的小夥伴可能看過了Add操作以後就想問了,buckets、entries不就是兩個陣列麼,那萬一陣列放滿了怎麼辦?接下來就是我所要介紹的Resize(擴容)這樣一種操作,對我們的buckets、entries進行擴容。

 

6.1 擴容操作的觸發條件

 

首先我們需要知道在什麼情況下,會發生擴容操作;第一種情況自然就是陣列已經滿了,沒有辦法繼續存放新的元素。如下圖所示的情況。

 

 

從上文中大家都知道,Hash運算會不可避免的產生衝突,Dictionary中使用拉鏈法來解決衝突的問題,但是大家看下圖中的這種情況。

 

 

所有的元素都剛好落在buckets[3]上面,結果就是導致了時間複雜度O(n),查詢效能會下降;所以第二種,Dictionary中發生的碰撞次數太多,會嚴重影響效能,也會觸發擴容操作。

 

目前.Net Framwork 4.7中設定的碰撞次數閾值為100.

 

public const int HashCollisionThreshold = 100;

6.2 擴容操作如何進行

 

為了給大家演示的清楚,模擬了以下這種資料結構,大小為2的Dictionary,假設碰撞的閾值為2;現在觸發Hash碰撞擴容。

 

 

開始擴容操作。

 

1、申請兩倍於現在大小的buckets、entries
2、將現有的元素複製到新的entries

 

完成上面兩步操作後,新資料結構如下所示。

 

 

3、如果是Hash碰撞擴容,使用新HashCode函式重新計算Hash值

 

上文提到了,這是發生了Hash碰撞擴容,所以需要使用新的Hash函式計算Hash值。新的Hash函式並一定能解決碰撞的問題,有可能會更糟,像下圖中一樣的還是會落在同一個bucket上。

 

 

4、對entries每個元素bucket = newEntries[i].hashCode % newSize確定新buckets位置

5、重建hash鏈,newEntries[i].next=buckets[bucket]; buckets[bucket]=i;

 

因為buckets也擴充為兩倍大小了,所以需要重新確定hashCode在哪個bucket中;最後重新建立hash單連結串列.

 

這就完成了擴容的操作,如果是達到Hash碰撞閾值觸發的擴容可能擴容後結果會更差。

 

在JDK中,HashMap如果碰撞的次數太多了,那麼會將單連結串列轉換為紅黑樹提升查詢效能。目前.Net Framwork中還沒有這樣的最佳化,.Net Core中已經有了類似的最佳化,以後有時間在分享.Net Core的一些集合實現。

 

每次擴容操作都需要遍歷所有元素,會影響效能。所以建立Dictionary實體時最好設定一個預估的初始大小。

 

private void Resize(int newSize, bool forceNewHashCodes) {
    Contract.Assert(newSize >= entries.Length);
    // 1. 申請新的Buckets和entries
    int[] newBuckets = new int[newSize];
    for (int i = 0; i -1;
    Entry[] newEntries = new Entry[newSize];
    // 2. 將entries內元素複製到新的entries總
    Array.Copy(entries, 0, newEntries, 0, count);
    // 3. 如果是Hash碰撞擴容,使用新HashCode函式重新計算Hash值
    if(forceNewHashCodes) {
        for (int i = 0; i             if(newEntries[i].hashCode != -1) {
                newEntries[i].hashCode = (comparer.GetHashCode(newEntries[i].key) & 0x7FFFFFFF);
            }
        }
    }
    // 4. 確定新的bucket位置
    // 5. 重建Hahs單連結串列
    for (int i = 0; i         if (newEntries[i].hashCode >= 0) {
            int bucket = newEntries[i].hashCode % newSize;
            newEntries[i].next = newBuckets[bucket];
            newBuckets[bucket] = i;
        }
    }
    buckets = newBuckets;
    entries = newEntries;
}

7、Dictionary – 再談Add操作

 

在我們之前的Add操作步驟中,提到了這樣一段話,這裡提到會有一種其它的情況,那就是有元素被刪除的情況。

 

1、避開一種其它情況不談,接下來它會將hashCode、key、value等資訊存入entries[count]中,因為count位置是空閑的;繼續count++指向下一個空閑位置。上圖中第一個位置,index=0就是空閑的,所以就存放在entries[0]的位置。

 

因為count是透過自增的方式來指向entries[]下一個空閑的entry,如果有元素被刪除了,那麼在count之前的位置就會出現一個空閑的entry;如果不處理,會有很多空間被浪費。

 

這就是為什麼Remove操作會記錄freeList、freeCount,就是為了將刪除的空間利用起來。實際上Add操作會優先使用freeList的空閑entry位置,摘錄程式碼如下。

 

private void Insert(TKey key, TValue valuebool add){

    if( key == null ) {
        ThrowHelper.ThrowArgumentNullException(ExceptionArgument.key);
    }

    if (buckets == null) Initialize(0);
    // 透過key獲取hashCode
    int hashCode = comparer.GetHashCode(key) & 0x7FFFFFFF;
    // 計算出標的bucket下標
    int targetBucket = hashCode % buckets.Length;
    // 碰撞次數
    int collisionCount = 0;
    for (int i = buckets[targetBucket]; i >= 0; i = entries[i].next) {
        if (entries[i].hashCode == hashCode && comparer.Equals(entries[i].key, key)) {
            // 如果是增加操作,遍歷到了相同的元素,那麼丟擲異常
            if (add) {      
                ThrowHelper.ThrowArgumentException(ExceptionResource.Argument_AddingDuplicate);
            }
            // 如果不是增加操作,那可能是索引賦值操作 dictionary["foo"] = "foo"
            // 那麼賦值後版本++,退出
            entries[i].value = value;
            version++;
            return;
        }
        // 每遍歷一個元素,都是一次碰撞
        collisionCount++;
    }
    int index;
    // 如果有被刪除的元素,那麼將元素放到被刪除元素的空閑位置
    if (freeCount > 0) {
        index = freeList;
        freeList = entries[index].next;
        freeCount--;
    }
    else {
        // 如果當前entries已滿,那麼觸發擴容
        if (count == entries.Length)
        {
            Resize();
            targetBucket = hashCode % buckets.Length;
        }
        index = count;
        count++;
    }

    // 給entry賦值
    entries[index].hashCode = hashCode;
    entries[index].next = buckets[targetBucket];
    entries[index].key = key;
    entries[index].value = value;
    buckets[targetBucket] = index;
    // 版本號++
    version++;

    // 如果碰撞次數大於設定的最大碰撞次數,那麼觸發Hash碰撞擴容
    if(collisionCount > HashHelpers.HashCollisionThreshold && HashHelpers.IsWellKnownEqualityComparer(comparer)) 
    {
        comparer = (IEqualityComparer) HashHelpers.GetRandomizedEqualityComparer(comparer);
        Resize(entries.Length, true);
    }
}

上面就是完整的Add程式碼,還是很簡單的對不對?

 

8、Collection版本控制

 

在上文中一直提到了version這個變數,在每一次新增、修改和刪除操作時,都會使version++;那麼這個version存在的意義是什麼呢?

 

首先我們來看一段程式碼,這段程式碼中首先實體化了一個Dictionary實體,然後透過foreach遍歷該實體,在foreach程式碼塊中使用dic.Remove(kv.Key)刪除元素。

 

 

結果就是丟擲了System.InvalidOperationException:”Collection was modified…”這樣的異常,迭代過程中不允許集合出現變化。如果在Java中遍歷直接刪除元素,會出現詭異的問題,所以.Net中就使用了version來實現版本控制。

 

那麼如何在迭代過程中實現版本控制的呢?我們看一看原始碼就很清楚的知道。

 

 

在迭代器初始化時,就會記錄dictionary.version版本號,之後每一次迭代過程都會檢查版本號是否一致,如果不一致將丟擲異常。

 

這樣就避免了在迭代過程中修改了集合,造成很多詭異的問題。

四、參考文獻及總結

本文在編寫過程中,主要參考了以下文獻,在此感謝其作者在知識分享上作出的貢獻!

 

1、http://www.cnblogs.com/mengfanrong/p/4034950.html

2、https://en.wikipedia.org/wiki/Hash_table

3、https://www.cnblogs.com/wuchaodzxx/p/7396599.html

4、https://www.cnblogs.com/liwei2222/p/8013367.html

5、https://referencesource.microsoft.com/#mscorlib/system/collections/generic/dictionary.cs,fd1acf96113fbda9

 

筆者水平有限,如果錯誤歡迎各位批評指正!

贊(0)

分享創造快樂