@TOC
一、简介
Dictionary 又称C#中的哈希表,是一个Collection(集合)类型,可以通过Key/Value(键值对)的形式来存放数据;该类最大的优点就是它查找元素的时间复杂度接近O(1),实际项目中常被用来做一些数据的本地缓存,提升整体效率。
那么是什么样的设计能使得Dictionary类能实现O(1)的时间复杂度呢?本章节就喝大家聊聊dictionary的实现及原理。如有表述不清楚、错误之处,请大家批评指正,共同进步。
二、Hash相关
在了解Dictionary前,需要了解Hash算法及Hash碰撞后冲突解决算法。这个是实现Dictionary的关键点。
1.Hash 算法
Hash算法是一种数字摘要算法,它能将不定长度的二进制数据集给映射到一个较短的二进制长度数据集,在实际中,我们通常会用一些标准哈希算法,例如 MD5、SHA-1、SHA-2 和 SHA-3 等。它们可以将任意长度的输入数据映射到恒定长度的哈希值。而实现了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映射到一个较短的数据集中。
常见的构造Hash函数的算法有以下几种。
直接寻址法:取keyword或keyword的某个线性函数值为散列地址。即H(key)=key或H(key) = a•key b,当中a和b为常数(这样的散列函数叫做自身函数) 数字分析法:分析一组数据,比方一组员工的出生年月日,这时我们发现出生年月日的前几位数字大体同样,这种话,出现冲突的几率就会非常大,可是我们发现年月日的后几位表示月份和详细日期的数字区别非常大,假设用后面的数字来构成散列地址,则冲突的几率会明显减少。因此数字分析法就是找出数字的规律,尽可能利用这些数据来构造冲突几率较低的散列地址。 平方取中法:取keyword平方后的中间几位作为散列地址。 折叠法:将keyword切割成位数同样的几部分,最后一部分位数能够不同,然后取这几部分的叠加和(去除进位)作为散列地址。 随机数法:选择一随机函数,取keyword的随机值作为散列地址,通经常使用于keyword长度不同的场合。 除留余数法:取keyword被某个不大于散列表表长m的数p除后所得的余数为散列地址。即 H(key) = key MOD p, p<=m。不仅能够对keyword直接取模,也可在折叠、平方取中等运算之后取模。对p的选择非常重要,一般取素数或m,若p选的不好,容易产生碰撞.
2.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的冲突。
3.Hash冲突
通常情况下哈希函数的输入空间远大于输出空间,因此理论上哈希冲突是不可避免的。比如,输入空间为全体整数,输出空间为数组容量大小,则必然有多个整数映射至同一桶索引。哈希冲突会导致查询结果错误,严重影响哈希表的可用性。
通过下图可以很清楚的了解Hash碰撞和Hash冲突。
若hash算法,不可避免的会产生冲突,那么产生冲突以后如何处理,是一个很关键的地方,目前常见的冲突解决算法有链式地址(Dictionary实现采用的)、开放定址法、再Hash法、公共溢出分区法。
链式地址:这种方法的思路是将产生冲突的元素建立一个单链表,并将头指针地址存储至Hash表对应桶的位置。这样定位到Hash表桶的位置后可通过遍历单链表的形式来查找元素。 再Hash法:顾名思义就是将key使用其它的Hash函数再次Hash,直到找到不冲突的位置为止。 开放定址法:不引入额外的数据结构,而是通过“多次探测”来处理哈希冲突,探测方式主要包括线性探测、平方探测和多次哈希等。
本文重点介绍和Dictionary相关的链式地址发,如可参考下图理解
三、Dictionary实现
Dictionary实现我们主要对照源码来解析,目前对照源码的版本是.net 8(和之前版本好像没啥区别)。地址可戳一戳这个链接 源码地址:Link
这章节主要介绍Dictionary中几个比较关键的类和对象,然后跟着代码来走一遍插入、删除和扩容的流程。这样大家可以了解Dictionary整个过程的实现。
1.Entry结构体
首先我们引入Entry这样一个结构体,它的定义如下代码所示。这是Dictionary种存放数据的最小单位,调用Add(Key,Value)方法添加的元素都会被封装在这样的一个结构体中。
代码语言:csharp复制private struct Entry
{
public uint hashCode; // 除符号位以外的31位hashCode值, 如果该Entry没有被使用,那么为-1
/// <summary>
/// 0-based index of next entry in chain: -1 means end of chain
/// also encodes whether this entry _itself_ is part of the free list by changing sign and subtracting 3,
/// so -2 means end of free list, -3 means index 0 but on free list, -4 means index 1 but on free list, etc.
/// </summary>
public int next; //下一个元素的下标索引,如果没有下一个就为-1
public TKey key; // 存放元素的键
public TValue value; // 存放元素的值
}
2.其他关键私有变量
除了Entry结构体外,还有几个关键的私有变量,其定义和解释如下代码所示。
代码语言:csharp复制 private int[]? _buckets; // Hash桶
private Entry[]? _entries; // Entry数组,存放元素
private int _count; // 当前entries的index位置
private int _freeList; // 被删除Entry在entries中的下标index,这个位置是空闲的
private int _freeCount; // 有多少个被删除的Entry,有多少个空闲的位置
private int _version; // 当前版本,防止迭代过程中集合被更改
private IEqualityComparer<TKey>? _comparer; // 比较器
private KeyCollection? _keys; // 存放Key的集合
private ValueCollection? _values; // 存放Value的集合
上面代码中,需要注意的是buckets、entries这两个数组,这是实现Dictionary的关键。
3.Dictionary-Add
现在我们通过走一遍Add流程,来理解为什么这样设计。
首先我们用图的形式来描述一个Dictionary的数据结构,其中只画出了关键地方。桶的大小和Entry都为4。
然后我们假设需要执行一个Add操作,dictionary.Add("a","b"),其中key = "a",value = "b"。
根据key的值,计算出它的hashCode。我们假设"a"的hash值为6(GetHashCode("a") = 6)。 通过对hashCode取余运算,计算出该hashCode落在哪一个buckets桶中。现在桶的长度(buckets.Length)为4,那么就是6 % 4最后落在index为2的桶中,也就是buckets2。 避开一种其它情况不谈,接下来它会将hashCode、key、value等信息存入entriescount中,因为count位置是空闲的;继续count 指向下一个空闲位置。上图中第一个位置,index=0就是空闲的,所以就存放在entries0的位置。 将Entry的下标entryIndex赋值给buckets中对应下标的bucket。步骤3中是存放在entries0的位置,所以buckets2=0。 最后version ,集合发生了变化,所以版本需要 1。只有增加、替换和删除元素才会更新版本 上文中的步骤1~5只是方便大家理解,实际上有一些偏差,后文再谈Add操作小节中会补充。
完成上面Add操作后,数据结构更新成了下图这样的形式。
这样是理想情况下的操作,一个bucket中只有一个hashCode没有碰撞的产生,但是实际上是会经常产生碰撞;那么Dictionary类中又是如何解决碰撞的呢。
我们继续执行一个Add操作,dictionary.Add("c","d"),假设GetHashCode(“c”)=6,最后6 % 4 = 2。最后桶的index也是2,按照之前的步骤1~3是没有问题的,执行完后数据结构如下图所示。
如果继续执行步骤4那么buckets2 = 1,然后原来的buckets2=>entries0的关系就会丢失,这是我们不愿意看到的。现在Entry中的next就发挥大作用了。
如果对应的bucketsindex有其它元素已经存在,那么会执行以下两条语句,让新的entry.next指向之前的元素,让bucketsindex指向现在的新的元素,就构成了一个单链表。
代码语言:csharp复制ref Entry reference = ref entries[num4]; //num4为新元素在entries的索引
reference.hashCode = num;
reference.next = bucket - 1; //新的entry.next指向之前的元素(因bucket从1开始,所以减去1)
reference.key = key;
reference.value = value;
bucket = num4 1; //更新bucket(指向entries元素的索引(索引从1开始))
经过上面的步骤以后,数据结构就更新成了下图这个样子。
4.Dictionary - Find
为了方便演示如何查找,我们继续Add一个元素dictionary.Add("e","f"),GetHashCode(“e”) = 7; 7% buckets.Length=3,数据结构如下所示。
假设我们现在执行这样一条语句dictionary.GetValueOrDefault("a"),会执行以下步骤.
获取key的hashCode,计算出所在的桶位置。我们之前提到,"a"的hashCode=6,所以最后计算出来targetBucket=2。 通过buckets2=1找到entries1,比较key的值是否相等,相等就返回entryIndex,不想等就继续entriesnext查找,直到找到key相等元素或者next == -1的时候。这里我们找到了key == "a"的元素,返回entryIndex=0。 如果entryIndex >= 0那么返回对应的entriesentryIndex元素,否则返回default(TValue)。这里我们直接返回entries0.value。整个查找的过程如下图所示.
将查找的代码摘录下来,如下所示。
代码语言:csharp复制 uint hashCode = (uint)comparer.GetHashCode(key);
int i = GetBucket(hashCode);
Entry[]? entries = _entries;
uint collisionCount = 0;
i--; // Value in _buckets is 1-based; subtract 1 from i. We do it here so it fuses with the following conditional.
do
{
// Test in if to drop range check for following array access
if ((uint)i >= (uint)entries.Length)
{
goto ReturnNotFound;
}
entry = ref entries[i];
if (entry.hashCode == hashCode && comparer.Equals(entry.key, key))
{
goto ReturnFound;
}
i = entry.next;
collisionCount ;
} while (collisionCount <= (uint)entries.Length);
// The chain of entries forms a loop; which means a concurrent update has happened.
// Break out of the loop and throw, rather than looping forever.
goto ConcurrentOperation;
ConcurrentOperation:
ThrowHelper.ThrowInvalidOperationException_ConcurrentOperationsNotSupported();
ReturnFound:
ref TValue value = ref entry.value;
Return:
return ref value;
ReturnNotFound:
value = ref Unsafe.NullRef<TValue>();
goto Return;
5.Dictionary - Remove
前面已经向大家介绍了增加、查找,接下来向大家介绍Dictionary如何执行删除操作。我们沿用之前的Dictionary数据结构。
删除前面步骤和查找类似,也是需要找到元素的位置,然后再进行删除的操作。
我们现在执行这样一条语句dictionary.Remove("a"),hashFunc运算结果和上文中一致。步骤大部分与查找类似,我们直接看摘录的代码,如下所示。
代码语言:csharp复制 public bool Remove(TKey key)
{
// The overload Remove(TKey key, out TValue value) is a copy of this method with one additional
// statement to copy the value for entry being removed into the output parameter.
// Code has been intentionally duplicated for performance reasons.
if (key == null)
{
ThrowHelper.ThrowArgumentNullException(ExceptionArgument.key);
}
if (_buckets != null)
{
Debug.Assert(_entries != null, "entries should be non-null");
uint collisionCount = 0;
IEqualityComparer<TKey>? comparer = _comparer;
Debug.Assert(typeof(TKey).IsValueType || comparer is not null);
//1. 通过key获取hashCode
uint hashCode = (uint)(typeof(TKey).IsValueType && comparer == null ? key.GetHashCode() : comparer!.GetHashCode(key));
//2.取余获取bucket位置的值
ref int bucket = ref GetBucket(hashCode);
Entry[]? entries = _entries;
int last = -1;
int i = bucket - 1; // Value in buckets is 1-based
//3. 遍历bucket对应的单链表
while (i >= 0)
{
ref Entry entry = ref entries[i];
if (entry.hashCode == hashCode &&
(typeof(TKey).IsValueType && comparer == null ? EqualityComparer<TKey>.Default.Equals(entry.key, key) : comparer!.Equals(entry.key, key)))
{
// 4. 找到元素后,如果last< 0,代表当前是bucket中最后一个元素,那么直接让bucket内下标赋值为 entrie.next 1即可
if (last < 0)
{
bucket = entry.next 1; // Value in buckets is 1-based
}
else
{
// 4.1 last不小于0,代表当前元素处于bucket单链表中间位置,需要将该元素的头结点和尾节点相连起来,防止链表中断
entries[last].next = entry.next;
}
//5.1 建立freeList单链表(StartOfFreeList 默认为-3,记录被删除的空闲位置)
entry.next = StartOfFreeList - _freeList;
if (RuntimeHelpers.IsReferenceOrContainsReferences<TKey>())
{
entry.key = default!;
}
if (RuntimeHelpers.IsReferenceOrContainsReferences<TValue>())
{
entry.value = default!;
}
//6.关键的代码,freeList等于当前的entry位置,下一次Add元素会优先Add到该位置
_freeList = i;
_freeCount ;//记录总的删除元素数
return true;
}
last = i;
i = entry.next;
collisionCount ;
if (collisionCount > (uint)entries.Length)
{
// The chain of entries forms a loop; which means a concurrent update has happened.
// Break out of the loop and throw, rather than looping forever.
ThrowHelper.ThrowInvalidOperationException_ConcurrentOperationsNotSupported();
}
}
}
return false;
}
执行完上面代码后,数据结构就更新成了下图所示。需要注意varsion、freeList、freeCount的值都被更新了。
6.Dictionary - Resize
有细心的小伙伴可能看过了Add操作以后就想问了,buckets、entries不就是两个数组么,那万一数组放满了怎么办?接下来就是我所要介绍的Resize(扩容)这样一种操作,对我们的buckets、entries进行扩容。
6.1 扩容操作的触发条件
首先我们需要知道在什么情况下,会发生扩容操作;第一种情况自然就是数组已经满了,没有办法继续存放新的元素。如下图所示的情况。
从上文中大家都知道,Hash运算会不可避免的产生冲突,Dictionary中使用拉链法来解决冲突的问题,但是大家看下图中的这种情况。
所有的元素都刚好落在buckets3上面,结果就是导致了时间复杂度O(n),查找性能会下降;所以第二种,Dictionary中发生的碰撞次数太多,会严重影响性能,也会触发扩容操作。
目前.net8中设置的碰撞次数阈值为100.(源码如下)
代码语言:csharp复制if (!typeof(TKey).IsValueType && num2 > 100 && comparer is NonRandomizedStringEqualityComparer)
{
Resize(entries.Length, forceNewHashCodes: true);
}
6.2 扩容操作如何进行
为了给大家演示的清楚,模拟了以下这种数据结构,大小为2的Dictionary,假设碰撞的阈值为2;现在触发Hash碰撞扩容。
开始扩容操作。
申请两倍于现在大小的buckets、entries 将现有的元素拷贝到新的entries
完成上面两步操作后,新数据结构如下所示。
3.如果是Hash碰撞扩容,使用新HashCode函数重新计算Hash值
上文提到了,这是发生了Hash碰撞扩容,所以需要使用新的Hash函数计算Hash值。新的Hash函数并一定能解决碰撞的问题,有可能会更糟,像下图中一样的还是会落在同一个bucket上。
对entries每个元素bucket = newEntriesi.hashCode % newSize确定新buckets位置 重建hash链,newEntriesi.next=bucketsbucket; bucketsbucket=i; **
因为buckets也扩充为两倍大小了,所以需要重新确定hashCode在哪个bucket中;最后重新建立hash单链表.
这就完成了扩容的操作,如果是达到Hash碰撞阈值触发的扩容可能扩容后结果会更差。
在java JDK中,HashMap如果碰撞的次数太多了,那么会将单链表转换为红黑树提升查找性能。目前.Net 中还没有这样的优化. SortDictionary等采用红黑树,具体可查看源码实现。
每次扩容操作都需要遍历所有元素,会影响性能。所以创建Dictionary实例时最好设置一个预估的初始大小。
代码语言:csharp复制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操作步骤中,提到了这样一段话,这里提到会有一种其它的情况,那就是有元素被删除的情况。
因为count是通过自增的方式来指向entries[]下一个空闲的entry,如果有元素被删除了,那么在count之前的位置就会出现一个空闲的entry;如果不处理,会有很多空间被浪费。
这就是为什么Remove操作会记录freeList、freeCount,就是为了将删除的空间利用起来。实际上Add操作会优先使用freeList的空闲entry位置,摘录代码如下。
代码语言:csharp复制private bool TryInsert(TKey key, TValue value, InsertionBehavior behavior)
{
if (key == null)
{
ThrowHelper.ThrowArgumentNullException(ExceptionArgument.key);
}
if (_buckets == null)
{
Initialize(0);
}
Entry[] entries = _entries;
IEqualityComparer<TKey> comparer = _comparer;
// 通过key获取hashCode
uint num = (uint)((typeof(TKey).IsValueType && comparer == null) ? key.GetHashCode() : comparer.GetHashCode(key));
uint num2 = 0u;
// 计算出目标bucket下标的值
ref int bucket = ref GetBucket(num);
int num3 = bucket - 1;
//获取碰撞次数,碰撞次数多则扩容
if (typeof(TKey).IsValueType && comparer == null)
{
while ((uint)num3 < (uint)entries.Length)
{
if (entries[num3].hashCode == num && EqualityComparer<TKey>.Default.Equals(entries[num3].key, key))
{
switch (behavior)
{
// 如果不是增加操作,那可能是索引赋值操作 dictionary["foo"] = "foo"
// 那么赋值后版本 ,退出
case InsertionBehavior.OverwriteExisting:
entries[num3].value = value;
return true;
//如果是增加操作,遍历到了相同的元素,那么抛出异常
case InsertionBehavior.ThrowOnExisting:
ThrowHelper.ThrowAddingDuplicateWithKeyArgumentException(key);
break;
}
return false;
}
num3 = entries[num3].next;
// 每遍历一个元素,都是一次碰撞
num2 ;
if (num2 > (uint)entries.Length)
{
ThrowHelper.ThrowInvalidOperationException_ConcurrentOperationsNotSupported();
}
}
}
else
{
while ((uint)num3 < (uint)entries.Length)
{
if (entries[num3].hashCode == num && comparer.Equals(entries[num3].key, key))
{
switch (behavior)
{
// 如果不是增加操作,那可能是索引赋值操作 dictionary["foo"] = "foo"
// 那么赋值后版本 ,退出
case InsertionBehavior.OverwriteExisting:
entries[num3].value = value;
return true;
//如果是增加操作,遍历到了相同的元素,那么抛出异常
case InsertionBehavior.ThrowOnExisting:
ThrowHelper.ThrowAddingDuplicateWithKeyArgumentException(key);
break;
}
return false;
}
num3 = entries[num3].next;
// 每遍历一个元素,都是一次碰撞
num2 ;
if (num2 > (uint)entries.Length)
{
ThrowHelper.ThrowInvalidOperationException_ConcurrentOperationsNotSupported();
}
}
}
int num4;
// 如果有被删除的元素,那么将元素放到被删除元素的空闲位置
if (_freeCount > 0)
{
num4 = _freeList;
_freeList = -3 - entries[_freeList].next;
_freeCount--;
}
else
{
int count = _count;
// 如果当前entries已满,那么触发扩容
if (count == entries.Length)
{
Resize();
bucket = ref GetBucket(num);
}
num4 = count;
_count = count 1;
entries = _entries;
}
// 给entry赋值
ref Entry reference = ref entries[num4];
reference.hashCode = num;
reference.next = bucket - 1;
reference.key = key;
reference.value = value;
bucket = num4 1;
// 版本号
_version ;
// 如果碰撞次数大于设置的最大碰撞次数,那么触发Hash碰撞扩容
if (!typeof(TKey).IsValueType && num2 > 100 && comparer is NonRandomizedStringEqualityComparer)
{
Resize(entries.Length, forceNewHashCodes: true);
}
return true;
}
8. Collection版本控制
在上文中一直提到了version这个变量,在每一次新增、修改和删除操作时,都会使version ;那么这个version存在的意义是什么呢?
首先我们来看一段代码,这段代码中首先实例化了一个Dictionary实例,然后通过foreach遍历该实例,在foreach代码块中使用dic.Remove(kv.Key)删除元素。
结果就是抛出了System.InvalidOperationException:"Collection was modified..."这样的异常,迭代过程中不允许集合出现变化。如果在遍历直接删除元素,会出现诡异的问题,所以.Net中就使用了version来实现版本控制。
那么如何在迭代过程中实现版本控制的呢?我们看一看源码就很清楚的知道。
在迭代器初始化时,就会记录dictionary.version版本号,之后每一次迭代过程都会检查版本号是否一致,如果不一致将抛出异常。
这样就避免了在迭代过程中修改了集合,造成很多诡异的问题。