对于C#中的Dictionary类相信大家都不陌生,这是一个Collection(集合)类型,可以通过Key/Value(键值对的形式来存放数据;该类最大的优点就是它查找元素的时间复杂度接近O(1)。那么什么样的设计能使得Dictionary类实现O(1)的时间复杂度呢
目录
- 一、理论知识
- 1.1、Hash算法
- 1.2、Hash桶算法
- 1.3、解决冲突算法
- 二、Dictionary实现
- 2.1、Entry结构体
- 2.2、其它关键私有变量
- 2.3、Dictionary - Add操作
- 2.4、Dictionary - Find操作
- 2.5、Dictionary - Remove操作
- 2.6、Dictionary - Resize操作(扩容)
- 2.6.1、扩容操作的触发条件
- 2.6.2、扩容操作如何进行
- 2.7、Dictionary - 再谈Add操作
- 2.8、Collection版本控制
一、理论知识
对于Dictionary的实现原理,其中有两个关键的算法,一个是Hash算法,一个是用于应对Hash碰撞冲突解决算法。
1.1、Hash算法
Hash算法是一种数字摘要算法,它能将不定长度的二进制数据集给映射到一个较短的二进制长度数据集,常见的MD5算法就是一种Hash算法,通过MD5算法可对任何数据生成数字摘要。而实现了Hash算法的函数我们叫她Hash函数。Hash函数有以下几点特征。
- 相同的数据进行Hash运算,得到的结果一定相同。
HashFunc(key1) == HashFunc(key1)
- 不同的数据进行Hash运算,其结果也可能会相同,(Hash会产生碰撞)。
key1 != key2 => HashFunc(key1) == HashFunc(key2).
- Hash运算时不可逆的,不能由key获取原始的数据。
key1 => hashCode但是hashCode =\=> key1。
下图就是Hash函数的一个简单说明,任意长度的数据通过HashFunc映射到一个较短的数据集中。
![](L3VwZmlsZXMvaW1nZmlsZS8yMzAyLzFDRFYyWjJDMjAtTTJHLmpwZw==)
![](https://oss.womengda.netL3VwZmlsZXMvaW1nZmlsZS8yMzAyLzFDRFYyWkoxWi1ROTU3LmpwZw==)
1.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的冲突。
1.3、解决冲突算法
对于一个hash算法,不可避免的会产生冲突,那么产生冲突以后如何处理,是一个很关键的地方,目前常见的冲突解决算法有拉链法(Dictionary实现采用的)、开放定址法、再Hash法、公共溢出分区法,本文只介绍拉链法与再Hash法,对于其它算法感兴趣的同学可参考文章最后的参考文献。
1. 拉链法:这种方法的思路是将产生冲突的元素建立一个单链表,并将头指针地址存储至Hash表对应桶的位置。这样定位到Hash表桶的位置后可通过遍历单链表的形式来查找元素。
2. 再Hash法:顾名思义就是将key使用其它的Hash函数再次Hash,直到找到不冲突的位置为止。
对于拉链法有一张图来描述,通过在冲突位置建立单链表,来解决冲突。
![](https://oss.womengda.netL3VwZmlsZXMvaW1nZmlsZS8yMzAyLzFDRFYyOTEwMDA2MC05NjNRLmpwZw==)
二、Dictionary实现
Dictionary实现我们主要对照源码来解析,目前对照源码的版本是.Net Framwork 4.7。地址可戳一戳这个链接 源码地址:Link
这一章节中主要介绍Dictionary中几个比较关键的类和对象,然后跟着代码来走一遍插入、删除和扩容的流程,相信大家就能理解它的设计原理。
2.1、Entry结构体
首先我们引入Entry这样一个结构体,它的定义如下代码所示。这是Dictionary种存放数据的最小单位,调用Add(Key,Value)
方法添加的元素都会被封装在这样的一个结构体中。
private struct Entry {
public int hashCode; // 除符号位以外的31位hashCode值, 如果该Entry没有被使用,那么为-1
public int next; // 下一个元素的下标索引,如果没有下一个就为-1
public TKey key; // 存放元素的键
public TValue value; // 存放元素的值
}
2.2、其它关键私有变量
除了Entry结构体外,还有几个关键的私有变量,其定义和解释如下代码所示。
private int[] buckets; // Hash桶
private Entry[] entries; // Entry数组,存放元素
private int count; // 当前entries的index位置
private int version; // 当前版本,防止迭代过程中集合被更改
private int freeList; // 被删除Entry在entries中的下标index,这个位置是空闲的
private int freeCount; // 有多少个被删除的Entry,有多少个空闲的位置
private IEqualityComparer<TKey> comparer; // 比较器
private KeyCollection keys; // 存放Key的集合
private ValueCollection values; // 存放Value的集合
上面代码中,需要注意的是buckets、entries
这两个数组,这是实现Dictionary的关键。
2.3、Dictionary - Add操作
经过上面的分析,相信大家还不是特别明白为什么需要这么设计,需要这么做。那我们现在来走一遍Dictionary的Add流程,来体会一下。
首先我们用图的形式来描述一个Dictionary的数据结构,其中只画出了关键的地方。桶大小为4以及Entry大小也为4的一个数据结构。
,其中key = "a",value = "b"
。
1.根据key的值,计算出它的hashCode。我们假设"a"的hash值为6(GetHashCode("a") = 6
)。
2.通过对hashCode取余运算,计算出该hashCode落在哪一个buckets桶中。现在桶的长度(buckets.Length
)为4,那么就是6 % 4
最后落在index
为2的桶中,也就是buckets[2]
。
3.避开一种其它情况不谈,接下来它会将hashCode、key、value
等信息存入entries[count]
中,因为count
位置是空闲的;继续count++
指向下一个空闲位置。上图中第一个位置,index=0就是空闲的,所以就存放在entries[0]
的位置。
4.将Entry
的下标entryIndex
赋值给buckets
中对应下标的bucket
。步骤3中是存放在entries[0]
的位置,所以buckets[2]=0
。
5.最后version++
,集合发生了变化,所以版本需要+1。只有增加、替换和删除元素才会更新版本
上文中的步骤1~5只是方便大家理解,实际上有一些偏差,后文再谈Add操作小节中会补充。
完成上面Add操作后,数据结构更新成了下图这样的形式。
,假设GetHashCode(“c”)=6
,最后6 % 4 = 2
。最后桶的index
也是2,按照之前的步骤1~3是没有问题的,执行完后数据结构如下图所示。
entries[index].next = buckets[targetBucket];
...
buckets[targetBucket] = index;
实际上步骤4也就是做一个这样的操作,并不会去判断是不是有其它元素,因为buckets
中桶初始值就是-1,不会造成问题。
经过上面的步骤以后,数据结构就更新成了下图这个样子。
![](https://oss.womengda.netL3VwZmlsZXMvaW1nZmlsZS8yMzAyLzFDRFYyOTIxNVUwLTEzNjE5My5qcGc=)
2.4、Dictionary - Find操作
为了方便演示如何查找,我们继续Add一个元素dictionary.Add("e","f")
,GetHashCode(“e”) = 7; 7% buckets.Length=3
,数据结构如下所示。
,会执行以下步骤.
1.获取key的hashCode,计算出所在的桶位置。我们之前提到,"a"的hashCode=6
,所以最后计算出来targetBucket=2
。
2.通过buckets[2]=1
找到entries[1]
,比较key的值是否相等,相等就返回entryIndex
,不想等就继续entries[next]
查找,直到找到key相等元素或者next == -1
的时候。这里我们找到了key == "a"
的元素,返回entryIndex=0
。
3.如果entryIndex >= 0
那么返回对应的entries[entryIndex]
元素,否则返回default(TValue)
。这里我们直接返回entries[0].value
。
整个查找的过程如下图所示.
// 寻找Entry元素的位置
private int FindEntry(TKey key) {
if( key == null) {
ThrowHelper.ThrowArgumentNullException(ExceptionArgument.key);
}
if (buckets != null) {
int hashCode = comparer.GetHashCode(key) & 0x7FFFFFFF; // 获取HashCode,忽略符号位
// int i = buckets[hashCode % buckets.Length] 找到对应桶,然后获取entry在entries中位置
// i >= 0; i = entries[i].next 遍历单链表
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;
}
...
internal TValue GetValueOrDefault(TKey key) {
int i = FindEntry(key);
// 大于等于0代表找到了元素位置,直接返回value
// 否则返回该类型的默认值
if (i >= 0) {
return entries[i].value;
}
return default(TValue);
}
2.5、Dictionary - Remove操作
前面已经向大家介绍了增加、查找,接下来向大家介绍Dictionary如何执行删除操作。我们沿用之前的Dictionary数据结构。
,hashFunc运算结果和上文中一致。步骤大部分与查找类似,我们直接看摘录的代码,如下所示。
public bool Remove(TKey key) {
if(key == null) {
ThrowHelper.ThrowArgumentNullException(ExceptionArgument.key);
}
if (buckets != null) {
// 1. 通过key获取hashCode
int hashCode = comparer.GetHashCode(key) & 0x7FFFFFFF;
// 2. 取余获取bucket位置
int bucket = hashCode % buckets.Length;
// last用于确定是否当前bucket的单链表中最后一个元素
int last = -1;
// 3. 遍历bucket对应的单链表
for (int i = buckets[bucket]; i >= 0; last = i, i = entries[i].next) {
if (entries[i].hashCode == hashCode && comparer.Equals(entries[i].key, key)) {
// 4. 找到元素后,如果last< 0,代表当前是bucket中最后一个元素,那么直接让bucket内下标赋值为 entries[i].next即可
if (last < 0) {
buckets[bucket] = entries[i].next;
}
else {
// 4.1 last不小于0,代表当前元素处于bucket单链表中间位置,需要将该元素的头结点和尾节点相连起来,防止链表中断
entries[last].next = entries[i].next;
}
// 5. 将Entry结构体内数据初始化
entries[i].hashCode = -1;
// 5.1 建立freeList单链表
entries[i].next = freeList;
entries[i].key = default(TKey);
entries[i].value = default(TValue);
// *6. 关键的代码,freeList等于当前的entry位置,下一次Add元素会优先Add到该位置
freeList = i;
freeCount++;
// 7. 版本号+1
version++;
return true;
}
}
}
return false;
}
执行完上面代码后,数据结构就更新成了下图所示。需要注意varsion、freeList、freeCount
的值都被更新了。
![](https://oss.womengda.netL3VwZmlsZXMvaW1nZmlsZS8yMzAyLzFDRFYyOTJLQTAtMTYzMDY0LmpwZw==)
2.6、Dictionary - Resize操作(扩容)
有细心的小伙伴可能看过了Add操作以后就想问了,buckets、entries
不就是两个数组么,那万一数组放满了怎么办?接下来就是我所要介绍的Resize(扩容)这样一种操作,对我们的buckets、entries
进行扩容。
2.6.1、扩容操作的触发条件
首先我们需要知道在什么情况下,会发生扩容操作;第一种情况自然就是数组已经满了,没有办法继续存放新的元素。如下图所示的情况。
![](L3VwZmlsZXMvaW1nZmlsZS8yMzAyLzFDRFYyOTMwMzFaLTFNOTI5LmpwZw==)
public const int HashCollisionThreshold = 100;
2.6.2、扩容操作如何进行
为了给大家演示的清楚,模拟了以下这种数据结构,大小为2的Dictionary,假设碰撞的阈值为2;现在触发Hash碰撞扩容。
![](L3VwZmlsZXMvaW1nZmlsZS8yMzAyLzFDRFYyOTNCMzMwLTE5NDVOLmpwZw==)
![](L3VwZmlsZXMvaW1nZmlsZS8yMzAyLzFDRFYyOTNUNDQwLTIwMjBILmpwZw==)
![](L3VwZmlsZXMvaW1nZmlsZS8yMzAyLzFDRFYyOTQwNTI0MC0yMTNSNC5qcGc=)
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;
}
2.7、Dictionary - 再谈Add操作
在我们之前的Add操作步骤中,提到了这样一段话,这里提到会有一种其它的情况,那就是有元素被删除的情况。
避开一种其它情况不谈,接下来它会将hashCode、key、value
等信息存入entries[count]
中,因为count
位置是空闲的;继续count++
指向下一个空闲位置。上图中第一个位置,index=0就是空闲的,所以就存放在entries[0]
的位置。
因为count
是通过自增的方式来指向entries[]
下一个空闲的entry
,如果有元素被删除了,那么在count
之前的位置就会出现一个空闲的entry
;如果不处理,会有很多空间被浪费。
这就是为什么Remove操作会记录freeList、freeCount
,就是为了将删除的空间利用起来。实际上Add操作会优先使用freeList
的空闲entry
位置,摘录代码如下。
private void Insert(TKey key, TValue value, bool add){
if( key == null ) {
ThrowHelper.ThrowArgumentNullException(ExceptionArgument.key);
}
if (buckets == null) Initialize(0);
// 通过key获取hashCode
int hashCode = comparer.GetHashCode(key) & 0x7FFFFFFF;
// 计算出目标bucket下标
int targetBucket = hashCode % buckets.Length;
// 碰撞次数
int collisionCount = 0;
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);
}
// 如果不是增加操作,那可能是索引赋值操作 dictionary["foo"] = "foo"
// 那么赋值后版本++,退出
entries[i].value = value;
version++;
return;
}
// 每遍历一个元素,都是一次碰撞
collisionCount++;
}
int index;
// 如果有被删除的元素,那么将元素放到被删除元素的空闲位置
if (freeCount > 0) {
index = freeList;
freeList = entries[index].next;
freeCount--;
}
else {
// 如果当前entries已满,那么触发扩容
if (count == entries.Length)
{
Resize();
targetBucket = hashCode % buckets.Length;
}
index = count;
count++;
}
// 给entry赋值
entries[index].hashCode = hashCode;
entries[index].next = buckets[targetBucket];
entries[index].key = key;
entries[index].value = value;
buckets[targetBucket] = index;
// 版本号++
version++;
// 如果碰撞次数大于设置的最大碰撞次数,那么触发Hash碰撞扩容
if(collisionCount > HashHelpers.HashCollisionThreshold && HashHelpers.IsWellKnownEqualityComparer(comparer))
{
comparer = (IEqualityComparer<TKey>) HashHelpers.GetRandomizedEqualityComparer(comparer);
Resize(entries.Length, true);
}
}
上面就是完整的Add代码,还是很简单的对不对?
2.8、Collection版本控制
在上文中一直提到了version
这个变量,在每一次新增、修改和删除操作时,都会使version++
;那么这个version
存在的意义是什么呢?
首先我们来看一段代码,这段代码中首先实例化了一个Dictionary实例,然后通过foreach
遍历该实例,在foreach
代码块中使用dic.Remove(kv.Key)
删除元素。
这样的异常,迭代过程中不允许集合出现变化。如果在Java中遍历直接删除元素,会出现诡异的问题,所以.Net中就使用了version
来实现版本控制。
那么如何在迭代过程中实现版本控制的呢?我们看一看源码就很清楚的知道。
![](https://oss.womengda.netL3VwZmlsZXMvaW1nZmlsZS8yMzAyLzFDRFYyOTQ2NDU1MC0yNDQ1MTIuanBn)
在迭代器初始化时,就会记录dictionary.version
版本号,之后每一次迭代过程都会检查版本号是否一致,如果不一致将抛出异常。
这样就避免了在迭代过程中修改了集合,造成很多诡异的问题。
以上就是分析C# Dictionary的实现原理的详细内容,更多关于C# Dictionary的资料请关注得得之家其它相关文章!