1. 程式人生 > 實用技巧 >深入解讀Dictionary

深入解讀Dictionary

Dictionary<TKey,TValue>是日常.net開發中最常用的資料型別之一,基本上遇到鍵值對型別的資料時第一反應就是使用這種散列表。散列表特別適合快速查詢操作,查詢的效率是常數階O(1)。那麼為什麼這種資料型別的查詢效率能夠這麼高效?它背後的資料型別是如何支撐這種查詢效率的?它在使用過程中有沒有什麼侷限性?一起來探究下這個資料型別的奧祕吧。

本文內容針對的是.Net Framework 4.5.1的程式碼實現,在其他.Net版本中或多或少都會有些差異,但是基本的原理還是相同的。

本文的內容主要分為三個部分,第一部分是從程式碼的角度來分析並以圖文並茂的方式通俗的解釋Dictionary如何解決的雜湊衝突並實現高效的資料插入和查詢。第二部分名為“眼見為實”,由於第一部分是從程式碼層面分析Dictionary的實現,側重於理論分析,因此第二部分使用windbg直接分析記憶體結構,跟第一部分的理論分析相互印證,加深對於這種資料型別的深入理解。最後是從資料結構的時間複雜度的角度進行分析並提出了幾條實踐建議。

本文內容:

  • 第一部分 程式碼分析

    • 雜湊衝突
    • Dictionary圖文解析
    • Dictionary的初始化
    • 新增第四個元素
  • 第二部分 眼見為實
    • 新增第一個元素後的記憶體結構
    • 新增第四個元素後的記憶體結構
  • 第三部分
    • 時間複雜度分析
    • 實踐建議

雜湊衝突

提到散列表,就不能不提雜湊衝突。由於雜湊演演算法被計算的資料是無限的,而計算後的結果範圍有限,因此總會存在不同的資料經過計算後得到的值相同,這就是雜湊衝突。(兩個不同的資料計算後的結果一樣)。雜湊衝突的解決方案有好幾個,比如開放定址法、鏈式定址法。

Dictionary使用的是鏈式定址法,也叫做拉鍊法。拉鍊法的基本思想是將雜湊值相同的資料存在同一個連結串列中,如果有雜湊值相同的元素,則加到連結串列的頭部。同樣道理,在查詢元素的時候,先計算雜湊值,然後在對應雜湊值的連結串列中查詢目標元素。

用圖來表達鏈式定址法的思想:

Dictionary<TKey,TValue>的內部資料結構

Dictionary的內部儲存資料主要是依賴了兩個陣列,分別是int[] bucketsEntry[] entries。其中buckets是Dictionary所有操作的入口,類似於上文中解說拉鍊法所用的圖中的那個豎著的資料結構。Entry是一個結構體,用於封裝所有的元素並且增加了next欄位用於構建連結串列資料結構。為了便於理解,下文中截取了一段相關的原始碼並增加了註釋。

//資料在Dictionary<TKey,TValue>的儲存形式,所有的鍵值對在Dictionary<TKey,TValue>都是被包裝成一個個的Entry儲存在記憶體中
private struct Entry {
public int hashCode; // 雜湊計算結果的低31位數,預設值為-1
public int next; // 下一個Entry的索引值,連結串列的最後一個Entry的next為-1
public TKey key; // Entry物件的Key對應於傳入的TKey
public TValue value; // Entry物件的Value對應與傳入的TValue
} private int[] buckets; //hashCode的桶,是查詢所有Entry的第一級資料結構
private Entry[] entries; //儲存真正的資料

下文中以Dictionary<int,string>為例,分析Dictionary在使用過程中內部資料的組織方式。

Dictionary初始化

初始化程式碼:

Dictionary<int, string> dictionary = new Dictionary<int, string>();

Dictionary的初始化時,bucketsentries的長度都是0。

新增一個元素

dictionary.Add(1, "xyxy");

向Dictionary中新增這個新元素大致經過了7個步驟:

  1. 首先判斷陣列長度是否需要擴容(原長度為0,需要擴容);
  2. 對於陣列進行擴容,分別建立長度為3的bucket陣列和entries陣列(使用大於原陣列長度2倍的第一個素數作為新的陣列長度);
  3. 整數1的hashcode為1;
  4. 取低31位數值為1(計算公式:hashcode & 0x7FFFFFFF=1);
  5. 該key的hashcode落到bucket下標為1的位置(計算公式:hashCode % buckets.Length=1);
  6. 將hashcode、key、value包裝起來(封裝到entries陣列下標為0的結構體中);
  7. 設定bucket[1]的值為0(因為新元素被封裝到了entries陣列下標為0的位置);

當向Dictionary中新增一個元素後,內部資料結構如下圖(為了便於理解,圖上將bucket和entries中各個連結串列頭結點用線標出了關聯關係):

新增第二個元素

dictionary.Add(7, "xyxy");

向Dictionary中新增這個元素大致經過了6個步驟:

  1. 計算7的hashcode是7;
  2. 取低31位數值為7(計算公式:hashcode & 0x7FFFFFFF=1);
  3. 該key的hashcode落到bucket下標為1的位置(計算公式:hashCode % buckets.Length=1);
  4. 將hashcode、key、value包裝起來(封裝到entries陣列下標為1的結構體中,跟步驟3計算得到的1沒有關係,只是因為entries陣列下標為1的元素是空著的所以放在這裡);
  5. 原bucket[1]為0,所以設定當前結構體的Entry.next為0;
  6. 設定bucket[1]為1(因為連結串列的頭部節點位於entries陣列下標為1的位置)

當向Dictionary中新增第二個元素後,內部資料結構是這樣的:

新增第三個元素

dictionary.Add(2, "xyxy");

向Dictionary新增這個元素經過瞭如下5個步驟:

  1. 整數2計算的hashcode是2;
  2. hashcode取低31位數值為2(計算公式:hashcode & 0x7FFFFFFF=2);
  3. 該key的hashcode落到bucket下標為2的位置(計算公式:hashCode % buckets.Length=2);
  4. 將hashcode、key、value包裝起來(封裝到entries陣列下標為2的結構體中,到此entries的陣列就存滿了);
  5. 原bucket[2]上為-1,所以bucket[2]節點下並沒有對應連結串列,設定當前結構體的Entry.next為-1;
  6. 設定bucket[2]為2(因為連結串列的頭部節點位於entries陣列下標為2的位置)

當向Dictionary中新增第三個元素後,內部資料結構:

新增第四個元素

dictionary.Add(4, "xyxy");

通過前面幾個操作可以看出,當前資料結構中entries陣列中的元素已滿,如果再新增元素的話,會發生怎樣的變化呢?

假如再對於dictionary新增一個元素,原來申請的記憶體空間肯定是不夠用的,必須對於當前資料結構進行擴容,然後在擴容的基礎上再執行新增元素的操作。那麼在解釋這個Add方法原理的時候,分為兩個場景分別進行:陣列擴容和元素新增。

陣列擴容

在發現陣列容量不夠的時候,Dictionary首先執行擴容操作,擴容的規則與該資料型別首次初始化的規則相同,即使用大於原陣列長度2倍的第一個素數7作為新陣列的長度(3*2=6,大於6的第一個素數是7)。

擴容步驟:

  1. 新申請一個容量為7的陣列,並將原陣列的元素拷貝至新陣列(程式碼:Array.Copy(entries, 0, newEntries, 0, count);
  2. 重新計算原Dictionary中的元素的hashCode在bucket中的位置(注意新的bucket陣列中數值的變化);
  3. 重新計算連結串列(注意entries陣列中結構體的next值的變化);

擴容完成後Dictionary的內容資料結構:

新增元素

當前已經完成了entriesbucket陣列的擴容,有了充裕的空間來儲存新的元素,所以可以在新的資料結構的基礎上繼續新增元素。

當向Dictionary中新增第四個元素後,內部資料結構是這樣的:

新增這個新的元素的步驟:

  1. 整數4計算的hashcode是4;
  2. hashcode取低31位數值為4(計算公式:hashcode & 0x7FFFFFFF=4);
  3. 該key的hashcode落到bucket下標為4的位置(計算公式:hashCode % buckets.Length=4);
  4. 將hashcode、key、value包裝起來;(封裝到entries陣列下標為3的結構體中);
  5. 原bucket[4]上為-1,所以當前節點下並沒有連結串列,設定當前結構體的Entry.next為-1;
  6. 設定bucket[4]為3(因為連結串列的頭部節點位於entries陣列下標為3的位置)

眼見為實

畢竟本文的主題是圖文並茂分析Dictionary<Tkey,Tvalue>的原理,雖然已經從程式碼層面和理論層面分析了Dictionary<Tkey,Tvalue>的實現,但是如果能夠分析這個資料型別的實際記憶體資料結果,可以獲得更直觀的感受並且對於這個資料型別能夠有更加深入的認識。由於篇幅的限制,無法將Dictionary<Tkey,Tvalue>的所有操作場景結果都進行記憶體分析,那麼本文中精選有代表性的兩個場景進行分析:一是該資料型別初始化後新增第一個元素的記憶體結構,二是該資料型別進行第一次擴容後的資料結構。

Dictionary新增第一個元素後的記憶體結構

執行程式碼:

Dictionary<int, string> dic = new Dictionary<int, string>();
dic.Add(1, "xyxy");
Console.Read();

開啟windbg附加到該程式(由於使用的是控制檯應用程式,當前執行緒是0號執行緒,因此如果附加程式後預設的不是0號執行緒時執行~0s切換到0號執行緒),執行!clrstack -l檢視當前執行緒及執行緒上使用的所有變數:

0:000> !clrstack -l
OS Thread Id: 0x48b8 (0)
Child SP IP Call Site
0000006de697e998 00007ffab577c134 [InlinedCallFrame: 0000006de697e998] Microsoft.Win32.Win32Native.ReadFile(Microsoft.Win32.SafeHandles.SafeFileHandle, Byte*, Int32, Int32 ByRef, IntPtr)
0000006de697e998 00007ffa96abc9c8 [InlinedCallFrame: 0000006de697e998] Microsoft.Win32.Win32Native.ReadFile(Microsoft.Win32.SafeHandles.SafeFileHandle, Byte*, Int32, Int32 ByRef, IntPtr)
0000006de697e960 00007ffa96abc9c8 *** ERROR: Module load completed but symbols could not be loaded for C:\WINDOWS\assembly\NativeImages_v4.0.30319_64\mscorlib\5c1b7b73113a6f079ae59ad2eb210951\mscorlib.ni.dll
DomainNeutralILStubClass.IL_STUB_PInvoke(Microsoft.Win32.SafeHandles.SafeFileHandle, Byte*, Int32, Int32 ByRef, IntPtr) 0000006de697ea40 00007ffa972d39ec System.IO.__ConsoleStream.ReadFileNative(Microsoft.Win32.SafeHandles.SafeFileHandle, Byte[], Int32, Int32, Boolean, Boolean, Int32 ByRef)
LOCALS:
<no data>
<no data>
<no data>
<no data>
<no data>
<no data> 0000006de697ead0 00007ffa972d38f5 System.IO.__ConsoleStream.Read(Byte[], Int32, Int32)
LOCALS:
<no data>
<no data> 0000006de697eb30 00007ffa96a882d4 System.IO.StreamReader.ReadBuffer()
LOCALS:
<no data>
<no data> 0000006de697eb80 00007ffa97275f23 System.IO.StreamReader.Read()
LOCALS:
<no data> 0000006de697ebb0 00007ffa9747a2fd System.IO.TextReader+SyncTextReader.Read() 0000006de697ec10 00007ffa97272698 System.Console.Read() 0000006de697ec40 00007ffa38670909 ConsoleTest.DictionaryDebug.Main(System.String[])
LOCALS:
0x0000006de697ec70 = 0x00000215680d2dd8 0000006de697ee88 00007ffa97ba6913 [GCFrame: 0000006de697ee88]

通過對於執行緒堆疊的分析很容易看出當前執行緒上使用了一個區域性變數,地址為:0x000001d86c972dd8,使用!do命令檢視該變數的內容:

0:000> !do 0x00000215680d2dd8
Name: System.Collections.Generic.Dictionary`2[[System.Int32, mscorlib],[System.String, mscorlib]]
MethodTable: 00007ffa96513328
EEClass: 00007ffa9662f610
Size: 80(0x50) bytes
File: C:\WINDOWS\Microsoft.Net\assembly\GAC_64\mscorlib\v4.0_4.0.0.0__b77a5c561934e089\mscorlib.dll
Fields:
MT Field Offset Type VT Attr Value Name
00007ffa964a8538 4001887 8 System.Int32[] 0 instance 00000215680d2ee8 buckets
00007ffa976c4dc0 4001888 10 ...non, mscorlib]][] 0 instance 00000215680d2f10 entries
00007ffa964a85a0 4001889 38 System.Int32 1 instance 1 count
00007ffa964a85a0 400188a 3c System.Int32 1 instance 1 version
00007ffa964a85a0 400188b 40 System.Int32 1 instance -1 freeList
00007ffa964a85a0 400188c 44 System.Int32 1 instance 0 freeCount
00007ffa96519630 400188d 18 ...Int32, mscorlib]] 0 instance 00000215680d2ed0 comparer
00007ffa964c6ad0 400188e 20 ...Canon, mscorlib]] 0 instance 0000000000000000 keys
00007ffa977214e0 400188f 28 ...Canon, mscorlib]] 0 instance 0000000000000000 values
00007ffa964a5dd8 4001890 30 System.Object 0 instance 0000000000000000 _syncRoot

從記憶體結構來看,該變數中就是我們查詢的Dic存在buckets、entries、count、version等欄位,其中buckets和entries在上文中已經有多次提及,也是本文的分析重點。既然要眼見為實,那麼buckets和entries這兩個陣列的內容到底是什麼樣的呢?這兩個都是陣列,一個是int陣列,另一個是結構體陣列,對於這兩個內容分別使用!da命令檢視其內容:

首先是buckets的內容:

0:000> !da -start 0 -details 00000215680d2ee8
Name: System.Int32[]
MethodTable: 00007ffa964a8538
EEClass: 00007ffa966160e8
Size: 36(0x24) bytes
Array: Rank 1, Number of elements 3, Type Int32
Element Methodtable: 00007ffa964a85a0
[0] 00000215680d2ef8
Name: System.Int32
MethodTable: 00007ffa964a85a0
EEClass: 00007ffa96616078
Size: 24(0x18) bytes
File: C:\WINDOWS\Microsoft.Net\assembly\GAC_64\mscorlib\v4.0_4.0.0.0__b77a5c561934e089\mscorlib.dll
Fields:
MT Field Offset Type VT Attr Value Name
00007ffa964a85a0 40005a2 0 System.Int32 1 instance -1 m_value
[1] 00000215680d2efc
Name: System.Int32
MethodTable: 00007ffa964a85a0
EEClass: 00007ffa96616078
Size: 24(0x18) bytes
File: C:\WINDOWS\Microsoft.Net\assembly\GAC_64\mscorlib\v4.0_4.0.0.0__b77a5c561934e089\mscorlib.dll
Fields:
MT Field Offset Type VT Attr Value Name
00007ffa964a85a0 40005a2 0 System.Int32 1 instance 0 m_value
[2] 00000215680d2f00
Name: System.Int32
MethodTable: 00007ffa964a85a0
EEClass: 00007ffa96616078
Size: 24(0x18) bytes
File: C:\WINDOWS\Microsoft.Net\assembly\GAC_64\mscorlib\v4.0_4.0.0.0__b77a5c561934e089\mscorlib.dll
Fields:
MT Field Offset Type VT Attr Value Name
00007ffa964a85a0 40005a2 0 System.Int32 1 instance -1 m_value

當前buckets中有三個值,分別是:-1、0和-1,其中-1是陣列初始化後的預設值,而下表為1的位置的值0則是上文中新增dic.Add(1, "xyxy");這個指令的結果,代表其對應的連結串列首節點在entries陣列中下標為0的位置,那麼entries陣列中的數值是什麼樣子的呢?

0:000> !da -start 0 -details 00000215680d2f10
Name: System.Collections.Generic.Dictionary`2+Entry[[System.Int32, mscorlib],[System.String, mscorlib]][]
MethodTable: 00007ffa965135b8
EEClass: 00007ffa9662d1f0
Size: 96(0x60) bytes
Array: Rank 1, Number of elements 3, Type VALUETYPE
Element Methodtable: 00007ffa96513558
[0] 00000215680d2f20
Name: System.Collections.Generic.Dictionary`2+Entry[[System.Int32, mscorlib],[System.String, mscorlib]]
MethodTable: 00007ffa96513558
EEClass: 00007ffa966304e8
Size: 40(0x28) bytes
File: C:\WINDOWS\Microsoft.Net\assembly\GAC_64\mscorlib\v4.0_4.0.0.0__b77a5c561934e089\mscorlib.dll
Fields:
MT Field Offset Type VT Attr Value Name
00007ffa964a85a0 4003502 8 System.Int32 1 instance 1 hashCode
00007ffa964a85a0 4003503 c System.Int32 1 instance -1 next
00007ffa964a85a0 4003504 10 System.Int32 1 instance 1 key
00007ffa964aa238 4003505 0 System.__Canon 0 instance 00000215680d2db0 value
[1] 00000215680d2f38
Name: System.Collections.Generic.Dictionary`2+Entry[[System.Int32, mscorlib],[System.String, mscorlib]]
MethodTable: 00007ffa96513558
EEClass: 00007ffa966304e8
Size: 40(0x28) bytes
File: C:\WINDOWS\Microsoft.Net\assembly\GAC_64\mscorlib\v4.0_4.0.0.0__b77a5c561934e089\mscorlib.dll
Fields:
MT Field Offset Type VT Attr Value Name
00007ffa964a85a0 4003502 8 System.Int32 1 instance 0 hashCode
00007ffa964a85a0 4003503 c System.Int32 1 instance 0 next
00007ffa964a85a0 4003504 10 System.Int32 1 instance 0 key
00007ffa964aa238 4003505 0 System.__Canon 0 instance 0000000000000000 value
[2] 00000215680d2f50
Name: System.Collections.Generic.Dictionary`2+Entry[[System.Int32, mscorlib],[System.String, mscorlib]]
MethodTable: 00007ffa96513558
EEClass: 00007ffa966304e8
Size: 40(0x28) bytes
File: C:\WINDOWS\Microsoft.Net\assembly\GAC_64\mscorlib\v4.0_4.0.0.0__b77a5c561934e089\mscorlib.dll
Fields:
MT Field Offset Type VT Attr Value Name
00007ffa964a85a0 4003502 8 System.Int32 1 instance 0 hashCode
00007ffa964a85a0 4003503 c System.Int32 1 instance 0 next
00007ffa964a85a0 4003504 10 System.Int32 1 instance 0 key
00007ffa964aa238 4003505 0 System.__Canon 0 instance 0000000000000000 value

通過對於entries陣列的分析可以看出,這個陣列也有三個值,其中下標為0的位置已經填入相關內容,比如hashCode為1,key為1,其中value的內容是一個記憶體地址:000001d86c972db0,這個地址指向的就是字串物件,它的內容是xyxy,使用!do指令來看下具體內容:

0:000> !do  00000215680d2db0
Name: System.String
MethodTable: 00007ffcc6b359c0
EEClass: 00007ffcc6b12ec0
Size: 34(0x22) bytes
File: C:\WINDOWS\Microsoft.Net\assembly\GAC_64\mscorlib\v4.0_4.0.0.0__b77a5c561934e089\mscorlib.dll
String: xyxy
Fields:
MT Field Offset Type VT Attr Value Name
00007ffcc6b385a0 4000283 8 System.Int32 1 instance 4 m_stringLength
00007ffcc6b36838 4000284 c System.Char 1 instance 78 m_firstChar
00007ffcc6b359c0 4000288 e0 System.String 0 shared static Empty

簡要分析擴容後的記憶體結構

此次執行的程式碼為:

Dictionary<int, string> dic = new Dictionary<int, string>();
dic.Add(1, "xyxy");
dic.Add(7, "xyxy");
dic.Add(2, "xyxy");
dic.Add(4, "xyxy");
Console.Read();

同樣採取附加程式的方式分析這段程式碼執行後的記憶體結構,本章節中忽略掉如何查詢Dictionary變數地址的部分,直接分析buckets陣列和entries陣列的內容。

首先是buckets陣列的記憶體結構:

0:000> !da -start 0 -details 0000019a471a54f8
Name: System.Int32[]
MethodTable: 00007ffcc6b38538
EEClass: 00007ffcc6ca60e8
Size: 52(0x34) bytes
Array: Rank 1, Number of elements 7, Type Int32
Element Methodtable: 00007ffcc6b385a0
[0] 0000019a471a5508
Name: System.Int32
MethodTable: 00007ffcc6b385a0
EEClass: 00007ffcc6ca6078
Size: 24(0x18) bytes
File: C:\WINDOWS\Microsoft.Net\assembly\GAC_64\mscorlib\v4.0_4.0.0.0__b77a5c561934e089\mscorlib.dll
Fields:
MT Field Offset Type VT Attr Value Name
00007ffcc6b385a0 40005a2 0 System.Int32 1 instance 1 m_value
[1] 0000019a471a550c
Name: System.Int32
MethodTable: 00007ffcc6b385a0
EEClass: 00007ffcc6ca6078
Size: 24(0x18) bytes
File: C:\WINDOWS\Microsoft.Net\assembly\GAC_64\mscorlib\v4.0_4.0.0.0__b77a5c561934e089\mscorlib.dll
Fields:
MT Field Offset Type VT Attr Value Name
00007ffcc6b385a0 40005a2 0 System.Int32 1 instance 0 m_value
[2] 0000019a471a5510
Name: System.Int32
MethodTable: 00007ffcc6b385a0
EEClass: 00007ffcc6ca6078
Size: 24(0x18) bytes
File: C:\WINDOWS\Microsoft.Net\assembly\GAC_64\mscorlib\v4.0_4.0.0.0__b77a5c561934e089\mscorlib.dll
Fields:
MT Field Offset Type VT Attr Value Name
00007ffcc6b385a0 40005a2 0 System.Int32 1 instance 2 m_value
[3] 0000019a471a5514
Name: System.Int32
MethodTable: 00007ffcc6b385a0
EEClass: 00007ffcc6ca6078
Size: 24(0x18) bytes
File: C:\WINDOWS\Microsoft.Net\assembly\GAC_64\mscorlib\v4.0_4.0.0.0__b77a5c561934e089\mscorlib.dll
Fields:
MT Field Offset Type VT Attr Value Name
00007ffcc6b385a0 40005a2 0 System.Int32 1 instance -1 m_value
[4] 0000019a471a5518
Name: System.Int32
MethodTable: 00007ffcc6b385a0
EEClass: 00007ffcc6ca6078
Size: 24(0x18) bytes
File: C:\WINDOWS\Microsoft.Net\assembly\GAC_64\mscorlib\v4.0_4.0.0.0__b77a5c561934e089\mscorlib.dll
Fields:
MT Field Offset Type VT Attr Value Name
00007ffcc6b385a0 40005a2 0 System.Int32 1 instance 3 m_value
[5] 0000019a471a551c
Name: System.Int32
MethodTable: 00007ffcc6b385a0
EEClass: 00007ffcc6ca6078
Size: 24(0x18) bytes
File: C:\WINDOWS\Microsoft.Net\assembly\GAC_64\mscorlib\v4.0_4.0.0.0__b77a5c561934e089\mscorlib.dll
Fields:
MT Field Offset Type VT Attr Value Name
00007ffcc6b385a0 40005a2 0 System.Int32 1 instance -1 m_value
[6] 0000019a471a5520
Name: System.Int32
MethodTable: 00007ffcc6b385a0
EEClass: 00007ffcc6ca6078
Size: 24(0x18) bytes
File: C:\WINDOWS\Microsoft.Net\assembly\GAC_64\mscorlib\v4.0_4.0.0.0__b77a5c561934e089\mscorlib.dll
Fields:
MT Field Offset Type VT Attr Value Name
00007ffcc6b385a0 40005a2 0 System.Int32 1 instance -1 m_value

然後是entries的記憶體結構:

0:000> !da -start 0 -details 00000237effb2fa8
Name: System.Collections.Generic.Dictionary`2+Entry[[System.Int32, mscorlib],[System.String, mscorlib]][]
MethodTable: 00007ffa965135b8
EEClass: 00007ffa9662d1f0
Size: 192(0xc0) bytes
Array: Rank 1, Number of elements 7, Type VALUETYPE
Element Methodtable: 00007ffa96513558
[0] 00000237effb2fb8
Name: System.Collections.Generic.Dictionary`2+Entry[[System.Int32, mscorlib],[System.String, mscorlib]]
MethodTable: 00007ffa96513558
EEClass: 00007ffa966304e8
Size: 40(0x28) bytes
File: C:\WINDOWS\Microsoft.Net\assembly\GAC_64\mscorlib\v4.0_4.0.0.0__b77a5c561934e089\mscorlib.dll
Fields:
MT Field Offset Type VT Attr Value Name
00007ffa964a85a0 4003502 8 System.Int32 1 instance 1 hashCode
00007ffa964a85a0 4003503 c System.Int32 1 instance -1 next
00007ffa964a85a0 4003504 10 System.Int32 1 instance 1 key
00007ffa964aa238 4003505 0 System.__Canon 0 instance 00000237effb2db0 value
[1] 00000237effb2fd0
Name: System.Collections.Generic.Dictionary`2+Entry[[System.Int32, mscorlib],[System.String, mscorlib]]
MethodTable: 00007ffa96513558
EEClass: 00007ffa966304e8
Size: 40(0x28) bytes
File: C:\WINDOWS\Microsoft.Net\assembly\GAC_64\mscorlib\v4.0_4.0.0.0__b77a5c561934e089\mscorlib.dll
Fields:
MT Field Offset Type VT Attr Value Name
00007ffa964a85a0 4003502 8 System.Int32 1 instance 7 hashCode
00007ffa964a85a0 4003503 c System.Int32 1 instance -1 next
00007ffa964a85a0 4003504 10 System.Int32 1 instance 7 key
00007ffa964aa238 4003505 0 System.__Canon 0 instance 00000237effb2db0 value
[2] 00000237effb2fe8
Name: System.Collections.Generic.Dictionary`2+Entry[[System.Int32, mscorlib],[System.String, mscorlib]]
MethodTable: 00007ffa96513558
EEClass: 00007ffa966304e8
Size: 40(0x28) bytes
File: C:\WINDOWS\Microsoft.Net\assembly\GAC_64\mscorlib\v4.0_4.0.0.0__b77a5c561934e089\mscorlib.dll
Fields:
MT Field Offset Type VT Attr Value Name
00007ffa964a85a0 4003502 8 System.Int32 1 instance 2 hashCode
00007ffa964a85a0 4003503 c System.Int32 1 instance -1 next
00007ffa964a85a0 4003504 10 System.Int32 1 instance 2 key
00007ffa964aa238 4003505 0 System.__Canon 0 instance 00000237effb2db0 value
[3] 00000237effb3000
Name: System.Collections.Generic.Dictionary`2+Entry[[System.Int32, mscorlib],[System.String, mscorlib]]
MethodTable: 00007ffa96513558
EEClass: 00007ffa966304e8
Size: 40(0x28) bytes
File: C:\WINDOWS\Microsoft.Net\assembly\GAC_64\mscorlib\v4.0_4.0.0.0__b77a5c561934e089\mscorlib.dll
Fields:
MT Field Offset Type VT Attr Value Name
00007ffa964a85a0 4003502 8 System.Int32 1 instance 4 hashCode
00007ffa964a85a0 4003503 c System.Int32 1 instance -1 next
00007ffa964a85a0 4003504 10 System.Int32 1 instance 4 key
00007ffa964aa238 4003505 0 System.__Canon 0 instance 00000237effb2db0 value
[4] 00000237effb3018
Name: System.Collections.Generic.Dictionary`2+Entry[[System.Int32, mscorlib],[System.String, mscorlib]]
MethodTable: 00007ffa96513558
EEClass: 00007ffa966304e8
Size: 40(0x28) bytes
File: C:\WINDOWS\Microsoft.Net\assembly\GAC_64\mscorlib\v4.0_4.0.0.0__b77a5c561934e089\mscorlib.dll
Fields:
MT Field Offset Type VT Attr Value Name
00007ffa964a85a0 4003502 8 System.Int32 1 instance 0 hashCode
00007ffa964a85a0 4003503 c System.Int32 1 instance 0 next
00007ffa964a85a0 4003504 10 System.Int32 1 instance 0 key
00007ffa964aa238 4003505 0 System.__Canon 0 instance 0000000000000000 value
[5] 00000237effb3030
Name: System.Collections.Generic.Dictionary`2+Entry[[System.Int32, mscorlib],[System.String, mscorlib]]
MethodTable: 00007ffa96513558
EEClass: 00007ffa966304e8
Size: 40(0x28) bytes
File: C:\WINDOWS\Microsoft.Net\assembly\GAC_64\mscorlib\v4.0_4.0.0.0__b77a5c561934e089\mscorlib.dll
Fields:
MT Field Offset Type VT Attr Value Name
00007ffa964a85a0 4003502 8 System.Int32 1 instance 0 hashCode
00007ffa964a85a0 4003503 c System.Int32 1 instance 0 next
00007ffa964a85a0 4003504 10 System.Int32 1 instance 0 key
00007ffa964aa238 4003505 0 System.__Canon 0 instance 0000000000000000 value
[6] 00000237effb3048
Name: System.Collections.Generic.Dictionary`2+Entry[[System.Int32, mscorlib],[System.String, mscorlib]]
MethodTable: 00007ffa96513558
EEClass: 00007ffa966304e8
Size: 40(0x28) bytes
File: C:\WINDOWS\Microsoft.Net\assembly\GAC_64\mscorlib\v4.0_4.0.0.0__b77a5c561934e089\mscorlib.dll
Fields:
MT Field Offset Type VT Attr Value Name
00007ffa964a85a0 4003502 8 System.Int32 1 instance 0 hashCode
00007ffa964a85a0 4003503 c System.Int32 1 instance 0 next
00007ffa964a85a0 4003504 10 System.Int32 1 instance 0 key
00007ffa964aa238 4003505 0 System.__Canon 0 instance 0000000000000000 value

從記憶體的結構來看,擴容後bucket陣列中使用了下標為0、1、2和4這四個位置,entries中使用了0~3儲存了示例中新增的資料,符合前文中理論分析的結果,兩者相互之間具有良好的印證關係。

時間複雜度分析

時間複雜度表達的是資料結構操作資料的時候所消耗的時間隨著資料集規模的增長的變化趨勢。常用的指標有最好情況時間複雜度、最壞情況時間複雜度和均攤時間複雜度。那麼對於Dictionary<Tkey,TValue>來說,插入和查詢過程中這些時間複雜度分別是什麼樣的呢?

最好情況時間複雜度:對於查詢來說最好的是元素處於連結串列的頭部,查詢效率不會隨著資料規模的增加而增加,因此該複雜度為常量階複雜度,即O(1);插入操作最理想的情況是陣列中有空餘的空間,不需要進行擴容操作,此時時間複雜度也是常量階的,即O(1);

最壞情況時間複雜度:對於插入來說,比較耗時的操作場景是需要順著連結串列查詢符合條件的元素,連結串列越長,查詢時間越長(下文稱為場景一);而對於插入來說最壞的情況是陣列長度不足,需要動態擴容並重新組織連結串列結構(下文稱為場景二);

場景一中時間複雜度隨著連結串列長度的增加而增加,但是Dictionary中規定連結串列的最大長度為100,如果有長度超過100的連結串列就需要擴容並調整連結串列結構,所以順著連結串列查詢資料不會隨著資料規模的增長而增長,最大時間複雜度是固定的,因此時間複雜度還是常量階複雜度,即O(1);

場景二中時間複雜度隨著陣列中元素的數量增加而增加,如果原來的陣列元素為n個,那麼擴容時需要將這n個元素拷貝到新的陣列中並計算其在新連結串列中的位置,因此該操作的時間複雜度是隨著陣列的長度n的增加而增加的,屬於線性階時間複雜度,即O(n)。

綜合場景一和場景二的分析結果得出最壞情況時間複雜度出現在資料擴容過程中,時間複雜度為O(n)。

最好情況時間複雜度和最壞情況時間複雜度都過於極端,只能描述最好的情況和最壞的情況,那麼在使用過程中如何評價資料結構在大部分情況下的時間複雜度?通常對於複雜的資料結構可以使用均攤時間複雜度來評價這個指標。均攤時間複雜度適用於對於一個資料進行連續操作,大部分情況下時間複雜度都很低,只有個別情況下時間複雜度較高的場景。這些操作存在前後連貫性,這種情況下將較高的複雜度攤派到之前的操作中,一般均攤時間複雜度就相當於最好情況時間複雜度。

通過前面的分析可以看出Dictionary恰好就符合使用均攤時間複雜度分析的場景。以插入操作為例,假設預申請的entries陣列長度為n,在第n+1次插入資料時必然會遇到一次陣列擴容導致的時間消耗較高的場景。將這次較為複雜的操作的時間均攤到之前的n次操作後,每次操作時間的複雜度也是常量階的,因此Dictionary插入資料時均攤時間複雜度也是O(1)。

實踐建議

首先,Dictionary這種型別適合用於對於資料檢索有明顯目的性的場景,比如讀寫比例比較高的場景。其次,如果有大資料量的場景,最好能夠提前宣告容量,避免多次分配記憶體帶來的額外的時間和空間上的消耗。因為不進行擴容的場景,插入和查詢效率都是常量階O(1),在引起擴容的情況下,時間複雜度是線性階O(n)。如果僅僅是為了儲存資料,使用Dictionary並不合適,因為它相對於List<T>具有更加複雜的資料結構,這樣會帶來額外的空間上面的消耗。雖然Dictionary<TKey,TValue>的TKey是個任意型別的,但是除非是對於判斷物件是否相等有特殊的要求,否則不建議直接使用自定義類作為Tkey。

總結

C#中的Dictionary<TKey,TValue>是藉助於雜湊函式構建的高效能資料結構,Dictionary解決雜湊衝突的方式是使用連結串列來儲存雜湊值存在衝突的節點,俗稱拉鍊法。本文中通過圖文並茂的方式幫助理解Dictionary新增元素和擴容過程這兩個典型的應用場景,在理論分析之後使用windbg分析了記憶體中的實際結構,以此加深對於這種資料型別的深入理解。隨後在此基礎上分析了Dictionary的時間複雜度。Dictionary的最好情況時間複雜度是O(1),最壞情況複雜度是O(n),均攤時間複雜度是O(1),Dictionary在大多數情況下都是常量階時間複雜度,在內部陣列自動擴容過程中會產生明顯的效能下降,因此在實際實踐過程中最好在宣告新物件的時候能夠預估容量,盡力避免陣列自動擴容導致的效能下降。

參考資料