通過查閱網上相關資料和查看微軟源碼,我對Dictionary有了更深的理解。
Dictionary,翻譯為中文是字典,通過查看源碼發現,它真的內部結構真的和平時用的字典思想一樣。
我們平時用的字典主要包括兩個兩個部分,目錄和正文,目錄用來進行第一次的粗略查找,正文進行第二次精確查找。通過將數據進行分組,形成目錄,正文則是分組后的結果。
而Dictionary對應的是 int[] buckets 和 Entry[] entries,buckets用來記錄要查詢元素分組的起始位置(這么些是為了方便理解,其實是最后一個插入元素的位置沒有元素為-1,查找同組元素通過 entries 元素中的 Next 遍歷,后面會提到),entries記錄所有元素。分組依據是計算元素 Key 的哈希值與 buckets 的長度取余,余數就是分組,指向buckets 位置。通過先查找 buckets 確定元素分組的起始位置,再遍歷分組內元素查找到准確位置。與對應的目錄和正文相同,buckets的 長度大於等於 entries(我理解是為擴展做准備的),buckets 的長度使用 HashHelpers.GetPrime(capacity) 計算,是一個計算得到的最優值。capacity是字典的容量,大於等於字典中實際存儲元素個數。
Dictionary與真實的字典不同之處在於,真實字典的分組結果的物理位置是連續的,而 Dictionary 不是,他的物理位置順序就是插入的順序,而分組信息記錄在 entries 元素中的 Next 中,Next 是個 int 字段,用來記錄同組元素的下一個位置(若當前為該組第一個插入元素則記錄-1,第一個插入元素在分組遍歷的最后一個)
解析一下Dictionary的幾個關鍵方法
1.Add(Insert 新增&更新方法)
Add和使用[]更新實際就是調用的Insert,代碼如下。
首先計算key的哈希值,與buckets取余后確定目錄位置,找到entries的位置,然后通過.next方式遍歷分組內元素,確定是否存在同key元素,再進行新增或更新操作

public void Add(TKey key, TValue value) { Insert(key, value, true); }

public TValue this[TKey key] { get { int i = FindEntry(key); if (i >= 0) return entries[i].value; ThrowHelper.ThrowKeyNotFoundException(); return default(TValue); } set { Insert(key, value, false); } }

private void Insert(TKey key, TValue value, bool add) { if (key == null) { ThrowHelper.ThrowArgumentNullException(ExceptionArgument.key); } if (buckets == null) Initialize(0); int hashCode = comparer.GetHashCode(key) & 0x7FFFFFFF; int targetBucket = hashCode % buckets.Length; #if FEATURE_RANDOMIZED_STRING_HASHING int collisionCount = 0; #endif 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); } entries[i].value = value; version++; return; } #if FEATURE_RANDOMIZED_STRING_HASHING collisionCount++; #endif } int index; if (freeCount > 0) { index = freeList; freeList = entries[index].next; freeCount--; } else { if (count == entries.Length) { Resize(); targetBucket = hashCode % buckets.Length; } index = count; count++; } entries[index].hashCode = hashCode; entries[index].next = buckets[targetBucket]; entries[index].key = key; entries[index].value = value; buckets[targetBucket] = index; version++; #if FEATURE_RANDOMIZED_STRING_HASHING #if FEATURE_CORECLR // In case we hit the collision threshold we'll need to switch to the comparer which is using randomized string hashing // in this case will be EqualityComparer<string>.Default. // Note, randomized string hashing is turned on by default on coreclr so EqualityComparer<string>.Default will // be using randomized string hashing if (collisionCount > HashHelpers.HashCollisionThreshold && comparer == NonRandomizedStringEqualityComparer.Default) { comparer = (IEqualityComparer<TKey>) EqualityComparer<string>.Default; Resize(entries.Length, true); } #else if(collisionCount > HashHelpers.HashCollisionThreshold && HashHelpers.IsWellKnownEqualityComparer(comparer)) { comparer = (IEqualityComparer<TKey>) HashHelpers.GetRandomizedEqualityComparer(comparer); Resize(entries.Length, true); } #endif // FEATURE_CORECLR #endif }

private int FindEntry(TKey key) { if( key == null) { ThrowHelper.ThrowArgumentNullException(ExceptionArgument.key); } if (buckets != null) { int hashCode = comparer.GetHashCode(key) & 0x7FFFFFFF; 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; }
2.Resize(重新調整大小)
雖然這是個私有方法,但我認為關鍵。它會在元素個數即將超過容量時調用,代碼如下,簡單說明一下。
該方法會聲明一個 newBuckets 和 newEntrues 用來替換之前的 buckets 和 entrues,聲明后會重構這兩個數組,將 entrues 的值復制到 entrues,重新計算 newBuckets 的值,如果頻繁觸發該方法消耗是較大的,所以創建 Dictionary 時建議指定合理的 capacity(容量)

private void Resize(int newSize, bool forceNewHashCodes) { Contract.Assert(newSize >= entries.Length); int[] newBuckets = new int[newSize]; for (int i = 0; i < newBuckets.Length; i++) newBuckets[i] = -1; Entry[] newEntries = new Entry[newSize]; Array.Copy(entries, 0, newEntries, 0, count); if (forceNewHashCodes) { for (int i = 0; i < count; i++) { if (newEntries[i].hashCode != -1) { newEntries[i].hashCode = (comparer.GetHashCode(newEntries[i].key) & 0x7FFFFFFF); } } } 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; }
3.Remove(移除)
依據key查到元素,將元素賦值為初始值,freeList記錄該位置,下次新增會填充該位置

public bool Remove(TKey key) { if (key == null) { ThrowHelper.ThrowArgumentNullException(ExceptionArgument.key); } if (buckets != null) { int hashCode = comparer.GetHashCode(key) & 0x7FFFFFFF; int bucket = hashCode % buckets.Length; int last = -1; for (int i = buckets[bucket]; i >= 0; last = i, i = entries[i].next) { if (entries[i].hashCode == hashCode && comparer.Equals(entries[i].key, key)) { if (last < 0) { buckets[bucket] = entries[i].next; } else { entries[last].next = entries[i].next; } entries[i].hashCode = -1; entries[i].next = freeList; entries[i].key = default(TKey); entries[i].value = default(TValue); freeList = i; freeCount++; version++; return true; } } } return false; }
展示一個 Dictionary 實際存儲效果圖
效率對比
新增效率:
時間ms | |
Dictionary | 0.5643 |
Array | 0.0238 |
List | 0.0853 |
這是新增10000個元素操作耗費時間,Dictionary要比Array和List差不多高一個數量級。Array耗時最小是因為最開始就把所有空間申請好了。
查詢效率:
10 | 100 | 1000 | 10000 | |
Dictionary | 0.0056 | 0.0062 | 0.0056 | 0.0079 |
Array遍歷 | 0.0022 | 0.0142 | 0.1228 | 1.2396 |
Array迭代器 | 0.0574 | 0.4278 | 6.1383 | 82.0934 |
List遍歷 | 0.0028 | 0.0238 | 0.2588 | 2.3558 |
List.Find | 0.0654 | 0.091 | 0.3384 | 2.8768 |
List迭代器 | 0.0079 | 0.029 | 0.2958 | 3.6625 |
可以看出元素在10個時除了Array迭代器和List.Find外,其他的沒有較大差異。Array迭代器耗時的原因是涉及拆箱操作,List.Find可能是Lambda表達式的原因。當元素個數達到100時,Dictionary查詢速度就相對快很多。隨着元素數量增加,Dictionary查詢速度並無太大差異,而其他查詢呈倍數增長。
內存對比(元素個數10000)
字節差異 | |
Dictionary | 350456 |
Array | 40000左右 |
List | 65572 |
通過VS查看內存情況(太菜了不知道Array對應的內存怎么看,估計40000左右)
附測試代碼

using System; using System.Collections.Generic; using Microsoft.VisualStudio.TestTools.UnitTesting; using System.Diagnostics; namespace ToolBoxTest { /// <summary> /// CommonTest 的摘要說明 /// </summary> [TestClass] public class CommonTest { [TestMethod] public void Test1() { Stopwatch sw = new Stopwatch(); sw.Start(); List<int> list = new List<int>(); Dictionary<int, int> dic = new Dictionary<int, int>(); int count = 10000; int key = count / 2; int[] arr = new int[count]; sw.Stop(); sw.Restart(); for (int i = 0; i < count; i++) { arr[i] = i; } sw.Stop(); TimeSpan tt1 = sw.Elapsed; sw.Restart(); for (int i = 0; i < count; i++) { list.Add(i); } sw.Stop(); TimeSpan tt2 = sw.Elapsed; sw.Restart(); for (int i = 0; i < count; i++) { dic.Add(i, i); } sw.Stop(); TimeSpan tt3 = sw.Elapsed; //字典 sw.Restart(); for(int j=0;j<100;j++) { int index2 = dic[key]; } sw.Stop(); TimeSpan ts0 = sw.Elapsed; //數組 遍歷 sw.Restart(); for (int j = 0; j < 100; j++) { for (int i = 0; i < count; i++) if (arr[i] == key) break; } sw.Stop(); TimeSpan ts11 = sw.Elapsed; //數組 迭代器 sw.Restart(); for (int j = 0; j < 100; j++) { var p = arr.GetEnumerator(); while (p.MoveNext()) { if ((int)p.Current == key) break; } } sw.Stop(); TimeSpan ts12 = sw.Elapsed; //列表 遍歷 sw.Restart(); for (int j = 0; j < 100; j++) { for (int i = 0; i < count; i++) if (list[i] == key) break; } sw.Stop(); TimeSpan ts21 = sw.Elapsed; //列表 Find sw.Restart(); for (int j = 0; j < 100; j++) { list.Find(x => x == key); } sw.Stop(); TimeSpan ts22 = sw.Elapsed; //列表 迭代器 sw.Restart(); for (int j = 0; j < 100; j++) { var q = list.GetEnumerator(); while (q.MoveNext()) { if (q.Current == key) break; } } sw.Stop(); TimeSpan ts23 = sw.Elapsed; } } }
總結一下
Dictionary 和我們日常用到的字典原理是一樣的,通過目錄→正文兩次查找的方式查找元素,是一種空間換時間的方式,查詢效率很高,大多數情況經過2次查詢即可查到(需計算hashCode),但是相應的,開辟了多幾倍的內存空間。另外,新增效率Dictionary明顯較差,所以使用時要分情況而定,查詢>新增(編輯)時優先考慮字典,它的查詢效率真的很高。
使用注意點:
1.使用前盡量指定合適的容量,字典內元素個數應盡量避免超過容量
2.查詢時應避免使用 Contains + [] 的方式取值,建議使用 TryGetValue,因為前者實際上是進行了兩次查詢,而后者是一次
參考:
https://www.cnblogs.com/zhili/p/DictionaryInDepth.html
https://referencesource.microsoft.com/#mscorlib/system/collections/generic/dictionary.cs