淺析C# Dictionary實現原理



1、前言

本篇文章配圖以及文字其實整理出來好久了,可是因爲各類各樣的緣由推遲到如今才發出來,還有以前立Flag的《多線程編程》的筆記也都已經寫好了,只是說還比較糙,須要找個時間整理一下才能和你們見面。算法

對於C#中的Dictionary類相信你們都不陌生,這是一個Collection(集合)類型,能夠經過Key/Value(鍵值對的形式來存放數據;該類最大的優勢就是它查找元素的時間複雜度接近O(1),實際項目中常被用來作一些數據的本地緩存,提高總體效率。編程

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

2、理論知識

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

一、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映射到一個較短的數據集中。數據結構

1548491108167

關於Hash碰撞下圖很清晰的就解釋了,可從圖中得知Sandra DeeJohn Smith經過hash運算後都落到了02的位置,產生了碰撞和衝突。
1548485331574
常見的構造Hash函數的算法有如下幾種。多線程

1. 直接尋址法:取keyword或keyword的某個線性函數值爲散列地址。即H(key)=key或H(key) = a•key + b,當中a和b爲常數(這樣的散列函數叫作自身函數)dom

2. 數字分析法:分析一組數據,比方一組員工的出生年月日,這時咱們發現出生年月日的前幾位數字大致一樣,這種話,出現衝突的概率就會很是大,但是咱們發現年月日的後幾位表示月份和詳細日期的數字區別很是大,假設用後面的數字來構成散列地址,則衝突的概率會明顯減小。所以數字分析法就是找出數字的規律,儘量利用這些數據來構造衝突概率較低的散列地址。函數

3. 平方取中法:取keyword平方後的中間幾位做爲散列地址。

4. 摺疊法:將keyword切割成位數一樣的幾部分,最後一部分位數可以不一樣,而後取這幾部分的疊加和(去除進位)做爲散列地址。

5. 隨機數法:選擇一隨機函數,取keyword的隨機值做爲散列地址,通常用於keyword長度不一樣的場合。

6. 除留餘數法:取keyword被某個不大於散列表表長m的數p除後所得的餘數爲散列地址。即 H(key) = key MOD p, p<=m。不只可以對keyword直接取模,也可在摺疊、平方取中等運算以後取模。對p的選擇很是重要,通常取素數或m,若p選的很差,容易產生碰撞.

二、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的衝突。

三、解決衝突算法

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

1. 拉鍊法:這種方法的思路是將產生衝突的元素創建一個單鏈表,並將頭指針地址存儲至Hash表對應桶的位置。這樣定位到Hash表桶的位置後可經過遍歷單鏈表的形式來查找元素。

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

對於拉鍊法有一張圖來描述,經過在衝突位置創建單鏈表,來解決衝突。

1548485607652

3、Dictionary實現

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

這一章節中主要介紹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<TKey> comparer;   // 比較器
private KeyCollection keys;     // 存放Key的集合
private ValueCollection values;     // 存放Value的集合

上面代碼中,須要注意的是buckets、entries這兩個數組,這是實現Dictionary的關鍵。

3. Dictionary - Add操做

通過上面的分析,相信你們還不是特別明白爲何須要這麼設計,須要這麼作。那咱們如今來走一遍Dictionary的Add流程,來體會一下。

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

1548491185593

而後咱們假設須要執行一個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操做後,數據結構更新成了下圖這樣的形式。

1548492100757

這樣是理想狀況下的操做,一個bucket中只有一個hashCode沒有碰撞的產生,可是其實是會常常產生碰撞;那麼Dictionary類中又是如何解決碰撞的呢。

咱們繼續執行一個Add操做,dictionary.Add("c","d"),假設GetHashCode(「c」)=6,最後6 % 4 = 2。最後桶的index也是2,按照以前的步驟1~3是沒有問題的,執行完後數據結構以下圖所示。

1548493287583

若是繼續執行步驟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,不會形成問題。

通過上面的步驟之後,數據結構就更新成了下圖這個樣子。

1548494357566

4. Dictionary - Find操做

爲了方便演示如何查找,咱們繼續Add一個元素dictionary.Add("e","f")GetHashCode(「e」) = 7; 7% buckets.Length=3,數據結構以下所示。

1548494583006

假設咱們如今執行這樣一條語句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

整個查找的過程以下圖所示.

1548495296415

將查找的代碼摘錄下來,以下所示。

// 尋找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數據結構。

1548494583006

刪除前面步驟和查找相似,也是須要找到元素的位置,而後再進行刪除的操做。

咱們如今執行這樣一條語句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< 0,表明當前是bucket中最後一個元素,那麼直接讓bucket內下標賦值爲 entries[i].next便可
                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的值都被更新了。

1548496815179

6. Dictionary - Resize操做(擴容)

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

6.1 擴容操做的觸發條件

首先咱們須要知道在什麼狀況下,會發生擴容操做;第一種狀況天然就是數組已經滿了,沒有辦法繼續存放新的元素。以下圖所示的狀況。

1548498710430

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

1548498901496

全部的元素都恰好落在buckets[3]上面,結果就是致使了時間複雜度O(n),查找性能會降低;因此第二種,Dictionary中發生的碰撞次數太多,會嚴重影響性能,也會觸發擴容操做。

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

public const int HashCollisionThreshold = 100;

6.2 擴容操做如何進行

爲了給你們演示的清楚,模擬瞭如下這種數據結構,大小爲2的Dictionary,假設碰撞的閾值爲2;如今觸發Hash碰撞擴容。

1548499708530

開始擴容操做。

1.申請兩倍於如今大小的buckets、entries
2.將現有的元素拷貝到新的entries

完成上面兩步操做後,新數據結構以下所示。

1548499785441

三、若是是Hash碰撞擴容,使用新HashCode函數從新計算Hash值

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

1548500174305

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

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

由於buckets也擴充爲兩倍大小了,因此須要從新肯定hashCode在哪一個bucket中;最後從新創建hash單鏈表.

1548500290419

這就完成了擴容的操做,若是是達到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 < newBuckets.Length; i++) newBuckets[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 < count; i++) {
            if(newEntries[i].hashCode != -1) {
                newEntries[i].hashCode = (comparer.GetHashCode(newEntries[i].key) & 0x7FFFFFFF);
            }
        }
    }
    // 4. 肯定新的bucket位置
    // 5. 重建Hahs單鏈表
    for (int i = 0; i < count; 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 value, bool 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<TKey>) HashHelpers.GetRandomizedEqualityComparer(comparer);
        Resize(entries.Length, true);
    }
}

上面就是完整的Add代碼,仍是很簡單的對不對?

8. Collection版本控制

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

首先咱們來看一段代碼,這段代碼中首先實例化了一個Dictionary實例,而後經過foreach遍歷該實例,在foreach代碼塊中使用dic.Remove(kv.Key)刪除元素。

1548504444217

結果就是拋出了System.InvalidOperationException:"Collection was modified..."這樣的異常,迭代過程當中不容許集合出現變化。若是在Java中遍歷直接刪除元素,會出現詭異的問題,因此.Net中就使用了version來實現版本控制。

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

1548504844162

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

這樣就避免了在迭代過程當中修改了集合,形成不少詭異的問題。

4、參考文獻及總結

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

  1. http://www.cnblogs.com/mengfanrong/p/4034950.html
  2. https://en.wikipedia.org/wiki/Hash_table
  3. http://www.javashuo.com/article/p-rrwsyfge-hk.html
  4. http://www.javashuo.com/article/p-ulnynbbb-by.html
  5. https://referencesource.microsoft.com/#mscorlib/system/collections/generic/dictionary.cs,fd1acf96113fbda9

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

相關文章
相關標籤/搜索