目录html
本篇文章配图以及文字其实整理出来好久了,可是因为各类各样的缘由推迟到如今才发出来,还有以前立Flag的《多线程编程》的笔记也都已经写好了,只是说还比较糙,须要找个时间整理一下才能和你们见面。算法
对于C#中的Dictionary
类相信你们都不陌生,这是一个Collection(集合)
类型,能够经过Key/Value(键值对的形式来存放数据;该类最大的优势就是它查找元素的时间复杂度接近O(1)
,实际项目中常被用来作一些数据的本地缓存,提高总体效率。编程
那么是什么样的设计能使得Dictionary
类能实现O(1)
的时间复杂度呢?那就是本篇文章想和你们讨论的东西;这些都是我的的一些理解和观点,若有表述不清楚、错误之处,请你们批评指正,共同进步。c#
对于Dictionary的实现原理,其中有两个关键的算法,一个是Hash算法,一个是用于应对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映射到一个较短的数据集中。数据结构
关于Hash碰撞下图很清晰的就解释了,可从图中得知Sandra Dee
和 John Smith
经过hash运算后都落到了02
的位置,产生了碰撞和冲突。
常见的构造Hash函数的算法有如下几种。多线程
1. 直接寻址法:取keyword或keyword的某个线性函数值为散列地址。即H(key)=key或H(key) = a•key + b,当中a和b为常数(这样的散列函数叫作自身函数)dom
2. 数字分析法:分析一组数据,比方一组员工的出生年月日,这时咱们发现出生年月日的前几位数字大致一样,这种话,出现冲突的概率就会很是大,但是咱们发现年月日的后几位表示月份和详细日期的数字区别很是大,假设用后面的数字来构成散列地址,则冲突的概率会明显减小。所以数字分析法就是找出数字的规律,尽量利用这些数据来构造冲突概率较低的散列地址。函数
3. 平方取中法:取keyword平方后的中间几位做为散列地址。
4. 折叠法:将keyword切割成位数一样的几部分,最后一部分位数可以不一样,而后取这几部分的叠加和(去除进位)做为散列地址。
5. 随机数法:选择一随机函数,取keyword的随机值做为散列地址,通常用于keyword长度不一样的场合。
6. 除留余数法:取keyword被某个不大于散列表表长m的数p除后所得的余数为散列地址。即 H(key) = key MOD p, p<=m。不只可以对keyword直接取模,也可在折叠、平方取中等运算以后取模。对p的选择很是重要,通常取素数或m,若p选的很差,容易产生碰撞.
说到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的冲突。
对于一个hash算法,不可避免的会产生冲突,那么产生冲突之后如何处理,是一个很关键的地方,目前常见的冲突解决算法有拉链法(Dictionary实现采用的)、开放定址法、再Hash法、公共溢出分区法,本文只介绍拉链法与再Hash法,对于其它算法感兴趣的同窗可参考文章最后的参考文献。
1. 拉链法:这种方法的思路是将产生冲突的元素创建一个单链表,并将头指针地址存储至Hash表对应桶的位置。这样定位到Hash表桶的位置后可经过遍历单链表的形式来查找元素。
2. 再Hash法:顾名思义就是将key使用其它的Hash函数再次Hash,直到找到不冲突的位置为止。
对于拉链法有一张图来描述,经过在冲突位置创建单链表,来解决冲突。
Dictionary实现咱们主要对照源码来解析,目前对照源码的版本是.Net Framwork 4.7。地址可戳一戳这个连接 源码地址:Link
这一章节中主要介绍Dictionary中几个比较关键的类和对象,而后跟着代码来走一遍插入、删除和扩容的流程,相信你们就能理解它的设计原理。
首先咱们引入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; // 存放元素的值 }
除了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的关键。
通过上面的分析,相信你们还不是特别明白为何须要这么设计,须要这么作。那咱们如今来走一遍Dictionary的Add流程,来体会一下。
首先咱们用图的形式来描述一个Dictionary的数据结构,其中只画出了关键的地方。桶大小为4以及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的桶中,也就是buckets[2]
。避开一种其它状况不谈,接下来它会将
hashCode、key、value
等信息存入entries[count]
中,由于count
位置是空闲的;继续count++
指向下一个空闲位置。上图中第一个位置,index=0就是空闲的,因此就存放在entries[0]
的位置。将
Entry
的下标entryIndex
赋值给buckets
中对应下标的bucket
。步骤3中是存放在entries[0]
的位置,因此buckets[2]=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那么buckets[2] = 1
,而后原来的buckets[2]=>entries[0]
的关系就会丢失,这是咱们不肯意看到的。如今Entry中的next
就发挥大做用了。
若是对应的
buckets[index]
有其它元素已经存在,那么会执行如下两条语句,让新的entry.next
指向以前的元素,让buckets[index]
指向如今的新的元素,就构成了一个单链表。entries[index].next = buckets[targetBucket]; ... buckets[targetBucket] = index;实际上步骤4也就是作一个这样的操做,并不会去判断是否是有其它元素,由于
buckets
中桶初始值就是-1,不会形成问题。
通过上面的步骤之后,数据结构就更新成了下图这个样子。
为了方便演示如何查找,咱们继续Add一个元素dictionary.Add("e","f")
,GetHashCode(“e”) = 7; 7% buckets.Length=3
,数据结构以下所示。
假设咱们如今执行这样一条语句dictionary.GetValueOrDefault("a")
,会执行如下步骤.
- 获取key的hashCode,计算出所在的桶位置。咱们以前提到,"a"的
hashCode=6
,因此最后计算出来targetBucket=2
。- 经过
buckets[2]=1
找到entries[1]
,比较key的值是否相等,相等就返回entryIndex
,不想等就继续entries[next]
查找,直到找到key相等元素或者next == -1
的时候。这里咱们找到了key == "a"
的元素,返回entryIndex=0
。- 若是
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); }
前面已经向你们介绍了增长、查找,接下来向你们介绍Dictionary如何执行删除操做。咱们沿用以前的Dictionary数据结构。
删除前面步骤和查找相似,也是须要找到元素的位置,而后再进行删除的操做。
咱们如今执行这样一条语句dictionary.Remove("a")
,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
的值都被更新了。
有细心的小伙伴可能看过了Add操做之后就想问了,buckets、entries
不就是两个数组么,那万一数组放满了怎么办?接下来就是我所要介绍的Resize(扩容)这样一种操做,对咱们的buckets、entries
进行扩容。
首先咱们须要知道在什么状况下,会发生扩容操做;第一种状况天然就是数组已经满了,没有办法继续存放新的元素。以下图所示的状况。
从上文中你们都知道,Hash运算会不可避免的产生冲突,Dictionary中使用拉链法来解决冲突的问题,可是你们看下图中的这种状况。
全部的元素都恰好落在buckets[3]
上面,结果就是致使了时间复杂度O(n),查找性能会降低;因此第二种,Dictionary中发生的碰撞次数太多,会严重影响性能,也会触发扩容操做。
目前.Net Framwork 4.7中设置的碰撞次数阈值为100.
public const int HashCollisionThreshold = 100;
为了给你们演示的清楚,模拟了如下这种数据结构,大小为2的Dictionary,假设碰撞的阈值为2;如今触发Hash碰撞扩容。
开始扩容操做。
1.申请两倍于如今大小的buckets、entries
2.将现有的元素拷贝到新的entries
完成上面两步操做后,新数据结构以下所示。
三、若是是Hash碰撞扩容,使用新HashCode函数从新计算Hash值
上文提到了,这是发生了Hash碰撞扩容,因此须要使用新的Hash函数计算Hash值。新的Hash函数并必定能解决碰撞的问题,有可能会更糟,像下图中同样的仍是会落在同一个bucket
上。
四、对entries每一个元素bucket = newEntries[i].hashCode % newSize肯定新buckets位置
五、重建hash链,newEntries[i].next=buckets[bucket]; buckets[bucket]=i;
由于buckets
也扩充为两倍大小了,因此须要从新肯定hashCode
在哪一个bucket
中;最后从新创建hash单链表.
这就完成了扩容的操做,若是是达到Hash碰撞阈值触发的扩容可能扩容后结果会更差。
在JDK中,HashMap
若是碰撞的次数太多了,那么会将单链表转换为红黑树提高查找性能。目前.Net Framwork中尚未这样的优化,.Net Core中已经有了相似的优化,之后有时间在分享.Net Core的一些集合实现。
每次扩容操做都须要遍历全部元素,会影响性能。因此建立Dictionary实例时最好设置一个预估的初始大小。
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; }
在咱们以前的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代码,仍是很简单的对不对?
在上文中一直提到了version
这个变量,在每一次新增、修改和删除操做时,都会使version++
;那么这个version
存在的意义是什么呢?
首先咱们来看一段代码,这段代码中首先实例化了一个Dictionary实例,而后经过foreach
遍历该实例,在foreach
代码块中使用dic.Remove(kv.Key)
删除元素。
结果就是抛出了System.InvalidOperationException:"Collection was modified..."
这样的异常,迭代过程当中不容许集合出现变化。若是在Java中遍历直接删除元素,会出现诡异的问题,因此.Net中就使用了version
来实现版本控制。
那么如何在迭代过程当中实现版本控制的呢?咱们看一看源码就很清楚的知道。
在迭代器初始化时,就会记录dictionary.version
版本号,以后每一次迭代过程都会检查版本号是否一致,若是不一致将抛出异常。
这样就避免了在迭代过程当中修改了集合,形成不少诡异的问题。
本文在编写过程当中,主要参考了如下文献,在此感谢其做者在知识分享上做出的贡献!