深入理解泛型字典






4.94/5 (80投票s)
深入了解其内部机制。
引言
泛型字典是您工具箱中的一项强大工具。
泛型部分确保了类型安全,有助于避免装箱/拆箱操作,而字典部分则允许我们管理键/值对并轻松访问它们。
它还允许我们在常数时间内添加、删除和查找项目 复杂度 - O(1) - 即,前提是您知道如何正确使用它。
让我们从向字典添加项目的简单示例开始,看看每种操作的时间复杂度:
Dictionary<int,string> myDictionary = new Dictionary<int,string>();
myDictionary.Add(1, "Item no'1"); // O(1)
myDictionary.Add(2, "Item no'2"); // O(1)
myDictionary.Add(3, "Item no'3"); // O(1)
myDictionary.Add(4, "Item no'4"); // O(N)
myDictionary.Add(5, "item no'5"); // O(1)
myDictionary.Add(6, "Item no'6"); // O(1)
myDictionary.Add(7, "item no'7"); // O(1)
myDictionary.Add(8, "item no'8"); // O(N)
myDictionary.Add(9, "item no'9"); // O(1)
我们可以看到,在前三个项目添加时,我们的时间复杂度为 O(1) - 这是我们所期望的。第四个和第八个项目的添加具有 O(N) 的时间复杂度(其中 N 代表字典中已存在的项目数量)。
要理解这里发生了什么,我们需要了解泛型字典是如何管理其项目的。
背景
为了在常见的添加/删除/查找操作中提供 O(1) 的时间复杂度,泛型字典是基于哈希表构建的。简而言之,哈希表是一种数据结构,它包含一个用于存储元素的“桶”数组。哈希表处理新项目插入的方式是提取每个对象的哈希码,并使用该哈希码来确定将项目放在哪个桶中 – 通常通过执行其他简单的计算来调整对象的哈希码以适应桶数组的大小。
Object.GetHashCode 方法
System.Object
的一小组方法之一是名为 GetHashCode 的虚拟方法,其方法签名如下:
public virtual int GetHashCode()
由于 System.Object
是 .Net 中的最终基类,因此所有类型(包括值类型,它们也继承自 System.Object)都具有内置的哈希码生成功能,如果需要,子类也可以对其进行重写。
在将对象添加、删除或访问时,所有基于哈希的集合都会使用这个重要的方法。
在大多数情况下,System.Object
提供的默认 GetHashCode 实现应该足够了,但稍后在本文中,我们将讨论重写 GetHashCode 方法并创建自己的哈希算法何时是必需的。
上图描述的是一个“简单”哈希表的原因是它不支持冲突。
.Net 中的哈希码(从对象的 GetHashCode 虚拟方法返回)是 Int32 类型 – 这意味着该方法可以返回 2^32 种可能的值。
这是否意味着我们的应用程序中不能有超过 2^32 个对象?当然不是。事实上,即使默认的 object.GetHashCode 也不保证为不同的对象返回唯一值。
此外,我们的桶数组可能比这个小得多。在上图中,桶数组设置为 30 个项目。
这里显而易见的问题是 – 是否有可能两个项目在桶数组中获得相同的索引?当然。鸽笼原理正是说明了这一点。
当两个对象在桶数组中获得相同的索引时,这意味着我们遇到了冲突 – 我们将在下一节中看到 .Net 字典如何处理冲突。
添加项目
考虑到上面的示例,我们使用了字典的默认构造函数。Dictionary<int,string> myDictionary = new Dictionary<int,string>();
使用默认构造函数实例化字典时,会分配一个包含 3 个项目的数组。
然后,我们将前三个项目添加到字典中。
myDictionary.Add(1, "Item no'1"); // O(1)
myDictionary.Add(2, "Item no'2"); // O(1)
myDictionary.Add(3, "Item no'3"); // O(1)
完成这些指令后,我们的字典内部桶数组已满。注意 - 这意味着添加到字典中的项目总数等于数组的大小。这并不意味着桶数组的所有槽都已占用 – 正如我们上面所见,可能会发生冲突,即两个项目具有相同的索引(在哈希码调整为桶数组大小后 – 在这种情况下使用 MOD 运算符)。
让我们再添加一个项目。
myDictionary.Add(4, "Item no'4"); // O(N)
在尝试添加第 4个项时,字典会检查项目数量是否等于数组大小,在这种情况下是相等的,因此它会分配一个新的更大的数组。在这种情况下,新数组的大小将是 7,在添加了几个项目后,字典将再次调整大小为 17 个项目。
三?七?十七?正是如此。
字典实现包含一个内部预先计算的素数列表,将用于数组大小。
internal static readonly int[] primes = {3, 7, 11, 17, 23, 29, 37, 47, 59, 71, 89, 107, 131, 163, 197, 239, 293, 353, 431, 521, 631, 761, 919, 1103, 1327, 1597, 1931, 2333, 2801, 3371, 4049, 4861, 5839, 7013, 8419, 10103, 12143, 14591, 17519, 21023, 25229, 30293, 36353, 43627, 52361, 62851, 75431, 90523, 108631, 130363, 156437, 187751, 225307, 270371, 324449, 389357, 467237, 560689, 672827, 807403, 968897, 1162687, 1395263, 1674319, 2009191, 2411033, 2893249, 3471899, 4166287, 4999559, 5999471, 7199369};
需要调整数组大小时,新大小将是素数列表中大于旧容量乘以 2 的结果的下一个项。
例如
在前三次插入项目时,数组大小为 3 – 下一个项目将导致字典调整为 7,计算如下:
3 * 2 = 6 –> 列表中的下一个较大素数 – 7。
添加几个项目后,当我们尝试插入第 8个项时,字典将调整为:
7 * 2 = 14 –> 列表中的下一个较大素数 – 17。
注意:调整数组大小时将大小加倍的原因是为了使内部哈希表操作具有渐近复杂度。素数用于支持双重哈希。
Dictionary<int,string> myDictionary = new Dictionary<int,string>(30); // The real size will be 37 – using the prime numbers table
myDictionary.Add(1, "Item no'1"); // O(1)
myDictionary.Add(2, "Item no'2"); // O(1)
myDictionary.Add(3, "Item no'3"); // O(1)
myDictionary.Add(4, "Item no'4"); // O(1)
myDictionary.Add(5, "item no'5"); // O(1)
myDictionary.Add(6, "Item no'6"); // O(1)
myDictionary.Add(7, "item no'7"); // O(1)
myDictionary.Add(8, "item no'8"); // O(1)
myDictionary.Add(9, "item no'9"); // O(1)
//…
myDictionary.Add(30, "item no'30"); // O(1)
在实例化字典时始终设置初始大小 – 即使您对将添加多少项目只有一个粗略的估计。这将有助于避免重新分配和复制大型数组。
处理冲突
处理冲突有几种方法。字典通过使用一种称为链式方法来处理。
字典实际上有两个长度相同的数组:
桶数组 – 存储对象在 entries 数组中存储的索引
Entries 数组 – 在特殊的 O(1) 数据结构中存储实际项目(如果是引用类型 – 将存储对实际项目的引用)
entries 数组中的每个项目都是一个具有以下字段的结构:
private struct Entry
{
public int hashCode; // Entry (Key) hash code after being adjusted to the array size using MOD operator
public int next; // Index of which the next item in the collision chain resides in the entries array - if there is no collision in that entry the value is -1
public TKey key; // Entry generic Key
public TValue value; // Entry generic Value
}
请注意,内存中结构的大小将由提供的 Key 和 Value 的类型决定。例如,对于 Dictionary<int, string>
,在 x86 操作系统上,数组中每个条目的大小将是 16 字节(每个 int
占用 4 字节,string
实际上是对实际字符串的引用,也占用 4 字节)。
上图表示了添加两个对象后哈希表的状态。
计算哈希码除以数组大小的余数时,两个对象都获得了相同的索引(4)。
Obj1 存储在 entries 数组的第一个槽中。
Obj2 存储在 entries 数组的第二个槽中。
Obj2 存储着 Obj1 的索引。
当尝试获取 Obj1(通过使用 Obj1 键)时,哈希码在桶数组中获得索引 4(在 MOD 计算后)。在该桶中,存储着最后一个具有相同索引的对象索引 – 在这种情况下,检索到的值为 1 – 即 Obj2 – 链表的根。
为了找到 Obj1(或确定它是否已存在),需要遍历链表,并在遍历每个节点时检查其相等性。
注意:有趣的是,泛型字典似乎不支持像 Hashtable 那样的自定义负载因子,并且负载率将始终保持 1:1 – 这意味着字典大小不能小于其中项目的数量(即使并非所有桶都被使用,所有 entries 数组槽都已使用)。有关 HashTable 负载因子的更多信息,请参阅 Wikipedia 页面。
自定义哈希和相等性算法
某些字典构造函数包含 IEqualityComparer<T>
,它同时用于相等性和哈希码。IEqualityComparer<T>
接口有两个方法:Equals 和 GetHashCode。
在什么情况下我们应该使用自定义 IEqualityComparer?考虑以下场景:
- 您正在使用第三方类型作为字典键,并且您想替换其 GetHashCode 实现(出于性能、数据分布等原因…)
- 您想要一种自定义的相等机制 – 例如,不区分大小写的字符串键,这样 ".txt" 和 ".TXT" 是相等的
当 IEqualityComparer 未传递给字典的构造函数时,将使用 EqualityComparer<TKey>.Default
。
字符串键的自动分发适应性
一个桶数组索引有多个字典键可能会给性能带来麻烦。
使用此键访问 字典可能会导致大量数组遍历,这使我们期望的 O(1) 时间复杂度受到质疑。
对于字符串键,有一个特殊的优化:
为了确保每个“获取”和“添加”操作在每个桶中不超过 100 个项目,会使用冲突计数器。
如果在遍历数组以查找或添加项目时冲突计数器超过 100(限制是硬编码的),并且 EqualityComparer
是 EqualityComparer<string>.Default
类型,则会生成一个新的 EqualityComparer<string>
实例以实现替代字符串哈希算法。
如果找到此类提供程序,字典将分配新数组,并使用新的哈希码和相等性提供程序将内容复制到新数组中。
此优化可能适用于您的字符串键以某种方式未均匀分布的情况,但也可能导致大量分配和浪费 CPU 时间来生成可能是字典中许多项目的新的哈希码。
注意:即使相等性比较器不是 EqualityComparer<string>.Default
,也会生成此类提供程序,方法是实现内部接口 IWellKnownStringEqualityComparer
。由于此接口是内部使用的,并未向外部公开,因此上面未提及。
将自定义值类型用作字典键
string
,它不是值类型),并且它们都重写了 GetHashCode 以实现自己的哈希算法。
哈希算法应遵循几个重要原则(实际上还有更多,您可以在 MSDN 上找到它们)
* 分布良好(在我们的情况下,分布为有效的 32 位整数)
* 如果两个对象相等,GetHashCode 应返回相同的值
* 在对象未修改的情况下,GetHashCode 应对同一对象返回相同的哈希码
例如,int
的 GetHashCode 返回数字本身。DateTime
的 GetHashCode 返回内部滴答数,依此类推…
所有原始类型都重写 GetHashCode 的原因在于,ValueType.GetHashCode 的默认实现相对较慢,并且基于遍历所有字段并将它们进行 XOR 操作,对字段之间的内存间隙进行特殊处理,以及对引用类型字段进行另一种特殊处理……所有这些都是为了获取对象的哈希码?还记得良好哈希算法的第一个原则吗?没错,它应该速度快。
这就是为什么当您实现自己的自定义值类型并打算将其用作字典键时,请务必使用良好的哈希算法重写 GetHashCode 以适应您类型的字段 – 我们在前面的示例中看到,拥有分布良好的哈希码以最大程度地减少冲突,从而在处理字典时获得更好的添加、访问和删除时间复杂度是多么重要。
由于您创建的新类型是自定义值类型,IEqualityComparer<T>.Default 将返回 ObjectEqualityComparer<T>
。当字典尝试比较键时,它最终会调用 Equals(object obj)
方法,该方法会装箱对象。有关装箱/拆箱的更多信息,请参阅 MSDN 文档。
因此,为您的自定义值类型实现 IEqualityComparer<T> 应该是必须的。(感谢Jonathan C Dickinson 强调了这个问题)
注意:在实现自定义哈希码生成算法时,请确保哈希码依赖于不可变的字段——因为在使用对象作为字典键时更改用于生成哈希码的字段之一将使其无法访问,并可能导致重复的项目和意外的结果。
同步
不用说,但值得再次强调——在并发使用字典时使用适当的同步——字典对于读写操作同时进行不是线程安全的(只支持多读)。
并发工作(同时读写)时最常见的现象是无限循环(对读者和写入者都是如此)。您可以查看以下 帖子,其中描述了症状。
如果您需要线程安全的字典,请考虑使用 ConcurrentDictionary<K,V>
(Framework 4.0 及更高版本)。
结论
.Net 中的泛型字典可能是键/值场景的最佳选择,并且在几乎所有应用程序中都广泛使用,但是正如我们上面所见,了解其内部工作原理可以帮助我们获得改进且一致的性能。