不久前,我在我母亲的坟墓上发誓要为这个问题带来详细的答案,我花了很长时间,因为我的一些细节和概念有点生疏,但是,没有任何进一步的废话不多说:
.NET 字典在长度上的工作原理,或...
首先,让我们从概念开始,就像许多其他答案已经指出的那样,Dictionary<TKey, TValue> 是hash table 的通用(在 C# 语言功能的意义上)实现。
哈希表只是一个关联数组,也就是说,当您传递一对(键,值)时,该键用于计算哈希码,该哈希码本身将有助于计算内存插槽的位置(称为存储桶)在底层存储阵列(称为...存储桶)中,您刚刚传递的对和一些其他附加信息将被保存在其中。这通常通过对数组/桶的大小计算哈希码的模% 来实现:hashCode % buckets.Length。
这种关联数组的搜索、插入和删除的平均复杂度为 O(1)(即恒定时间)……除非在某些情况下,我们稍后会深入研究。所以一般来说,在字典中查找内容比在列表或数组中查找要快得多,因为您不必~通常~遍历所有值。
如果您到现在为止注意我所说的话,您会注意到可能已经存在问题。如果基于我们的密钥计算的哈希码与另一个完全相同怎么办?或者更糟糕的是一堆其他的钥匙?这基本上意味着我们可以最终在同一个位置?我们如何管理这些冲突?很显然,非常聪明的人早在几十年前就已经考虑过这个特殊问题,并提出了两种解决碰撞的主要方法:
任一冲突解决规则的实施有时可能会有很大差异。在 .NET 字典的情况下,数据结构依赖于冲突解决的独立链接类型,就像我们将在几分钟后看到的那样。
好的,现在让我们看看如何在 .NET Dictionary<TKey, TValue> 中插入东西,归结为通过以下方法的代码:
private void Insert(TKey key, TValue value, bool add)
注意:阅读下面的插入步骤后,您可以通过检查在我的答案底部作为链接给出的代码来找出删除和查找操作背后的基本原理。
第 1 步:给我哈希码
TKey 键的哈希码有两种计算方式:
-
如果您不将任何作为Dictionary<TKey, TValue> 的参数传递,则依赖默认的IEqualityComparer<TKey> 实现比较器,这基本上是由EqualityComparer<TKey>.Default 生成的(实现可用here),以防TKey 是一种类型与自定义类型等所有常见的东西(如原语和字符串)不同,IEqualityComparer<in TKey> 将利用以下实现(包括overrides):
bool Equals(object obj)
int GetHashCode()
-
另一个依赖于 IEqualityComparer<in TKey> 的实现,您可以将其传递给 Dictionary<TKey, TValue> 构造函数。
界面IEqualityComparer<in T>是这样的:
// The generic IEqualityComparer interface implements methods to if check two objects are equal
// and generate Hashcode for an object.
// It is use in Dictionary class.
public interface IEqualityComparer<in T>
{
bool Equals(T x, T y);
int GetHashCode(T obj);
}
无论哪种方式,字典最终都会使用比较器获得第一个哈希码:comparer.GetHashCode()
第 2 步:获取目标存储桶
我们从TKey 键通过IEqualityComparer<in T> 获得的哈希码有时可能是负数,如果我们想获得数组的正数索引,这并没有真正的帮助...
为了消除负值,comparer.GetHashCode() 得到的Int32 哈希码与Int32.MaxValue(即2147483647 或0x7FFFFFFF)“与”(在某种意义上布尔逻辑:位):
var hashCode = comparer.GetHashCode(key) & 0x7FFFFFFF;
目标bucket(索引)获取方式如下:
var targetBucket = hashCode % buckets.Length
稍后还将看到如何调整 buckets 数组的大小。
buckets (int[]) 是Dictionary<TKey, TValue> 的private 字段,包含entries 字段中第一个相关槽的索引,即Entry[],其中Entry 被定义如下:
private struct Entry
{
public int hashCode;
public int next;
public TKey key;
public TValue value;
}
key、value 和hashcode 是不言自明的字段,关于next 字段,它基本上表示该链中是否有另一个项目(即具有相同哈希码的多个键) ),如果该条目是链的最后一项,则 next 字段设置为 -1。
注意:Entrystruct中的hashCode字段为负值调整后的字段。
第 3 步:检查是否已有条目
在那个阶段,重要的是要注意,行为会有所不同,具体取决于您是更新 (add = false) 还是严格插入 (add = true) 新值。
我们现在将检查与targetBucket 相关的条目,从第一个条目开始,该条目可由以下方式给出:
var entryIndex = buckets[targetBucket];
var firstEntry = entries[entryIndex];
带有 cmets 的实际(简化)源代码:
// Iterating through all the entries related to the targetBucket
for (var i = buckets[targetBucket]; i >= 0; i = entries[i].next)
{
// Checked if all
if (entries[i].hashCode == hashCode &&
comparer.Equals(entries[i].key, key))
{
// If update is not allowed
if (add)
{
// Argument Exception:
// "Item with Same Key has already been added" thrown =]
ThrowHelper.ThrowArgumentException(ExceptionResource.Argument_AddingDuplicate);
}
// We update the entry value
entries[i].value = value;
// Modification while iterating check field
version++;
return;
}
}
注意:version 字段也用于其他常见的 .NET 数据结构(例如 List<T>),有助于在迭代时检测(在 MoveNext() 上)(并引发相关异常)。
第 4 步:检查数组是否需要调整大小
// The entries location in which the data will be inserted
var index = 0;
// The freeCount field indicates the number of holes / empty slotes available for insertions.
// Those available slots are the results of prior removal operations
if (freeCount > 0)
{
// The freeList field points to the first hole (ie. available slot) in the entries
index = freeList;
freeList = entries[index].next;
// The hole is no longer available
freeCount--;
}
else
{
// The entries array is full
// Need to resize it to make it bigger
if (count == entries.Length)
{
Resize();
targetBucket = hashCode % buckets.Length;
}
index = count;
count++;
}
注意:关于Resize()的电话:
其实在Resize()方法的早期,新的大小是这样计算的:
public static int ExpandPrime(int oldSize)
{
var min = 2 * oldSize;
if ((uint) min > 2146435069U && 2146435069 > oldSize)
{
return 2146435069;
}
return HashHelpers.GetPrime(min);
}
第 5 步:添加条目
由于字典检查完孔和大小,它可以最后使用计算的hashCode、key、value和正确的index添加条目并调整目标桶相应地:
entries[index].hashCode = hashCode;
// If the bucket already contained an item, it will be the next in the collision resolution chain.
entries[index].next = buckets[targetBucket];
entries[index].key = key;
entries[index].value = value;
// The bucket will point to this entry from now on.
buckets[targetBucket] = index;
// Again, modification while iterating check field
version++;
奖励:字符串特殊处理
引自下面列出的 CodeProject 源代码:
为了确保每个“获取”和“添加”操作不会超过每个存储桶的 100 项以上,正在使用碰撞计数器。
如果在遍历数组以查找或添加项目时,碰撞计数器超过 100(限制是硬编码的)并且 IEqualityComparer 的类型为 EqualityComparer<string>.Default,则正在生成一个新的 IEqualityComparer<string> 实例以供替代字符串哈希算法。
如果找到这样的提供者,字典将分配新数组并使用新的哈希码和相等提供者将内容复制到新数组中。
这种优化可能对您的字符串键未均匀分布的情况很有用,但也可能导致大量分配和浪费 CPU 时间来生成字典中可能有很多项目的新哈希码.
用法
每当您使用自定义类型作为键时,请不要忘记实现IEqualityComparer 接口或重写两个 Object 方法(hashcode + equal),以免在插入时误伤自己。
您不仅可以避免一些糟糕和令人讨厌的意外,还可以控制您插入的项目的分布。通过均匀分布的哈希码,您可以避免链接过多的项目,从而避免浪费时间迭代相关条目。
受访者/ers的旁注
我想强调一个事实,知道面试的那些实现细节通常不是什么大问题(实际实现与 .NET 的某些版本(“常规”或核心......)不同,而且可能仍然是可能会在稍后的时间点发生变化))。
如果有人问我这个问题,我会说:
- 您正在寻找的答案在 StackOverflow 上 :)
- 您正在寻找的答案也在
- 您正在寻找的答案不需要实现细节,官方文档here 足以满足大多数用例。
除非,除非...您应该在日常工作中自己实现哈希表斜线字典,在这种情况下,这种知识(即实现细节)可能会派上用场或甚至是强制性的。
来源: