C#常用集合的实现原理(Dictionary)

目录

Entry结构体

关键私有变量

初始化

Add方法

查找方法

Remove方法

Resize方法

Hash算法

Hash桶算法

Hash碰撞冲突解决算法

再谈Add方法

Collection版本控制

查找时的一些注意点


Entry结构体

Dictionary中存放数据的最小单位

 private struct Entry {
            public int hashCode;    // Lower 31 bits of hash code, -1 if unused
            public int next;        // Index of next entry, -1 if last
            public TKey key;           // Key of entry
            public TValue value;         // Value of entry
        }

关键私有变量

        private int[] buckets;//Has桶
        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的集合

初始化

private void Initialize(int capacity) {
            int size = HashHelpers.GetPrime(capacity);
            buckets = new int[size];
            for (int i = 0; i < buckets.Length; i++) buckets[i] = -1;
            entries = new Entry[size];
            freeList = -1;
        }

在初始化时做了以下几件事:

1、初始化一个  buckets = new int[size];

2、初始化一个 entries = new Entry<TKey,TValue>[prime]

3、buckets 和entries的容量都为大于字典容量的一个最小的质数

其中buckets主要用来进行Hash碰撞,entries用来存储字典的内容,并且标识下一个元素的位置。

Add方法

private void Insert(TKey key, TValue value, bool add) {
        
            if( key == null ) {
                ThrowHelper.ThrowArgumentNullException(ExceptionArgument.key);
            }
 
            if (buckets == null) Initialize(0);
            int hashCode = comparer.GetHashCode(key) & 0x7FFFFFFF;
            int targetBucket = hashCode % buckets.Length;
 
#if FEATURE_RANDOMIZED_STRING_HASHING
            int collisionCount = 0;
#endif
 
            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);
                    }
                    entries[i].value = value;
                    version++;
                    return;
                } 
 
#if FEATURE_RANDOMIZED_STRING_HASHING
                collisionCount++;
#endif
            }
            int index;
            if (freeCount > 0) {
                index = freeList;
                freeList = entries[index].next;
                freeCount--;
            }
            else {
                if (count == entries.Length)
                {
                    Resize();
                    targetBucket = hashCode % buckets.Length;
                }
                index = count;
                count++;
            }
 
            entries[index].hashCode = hashCode;
            entries[index].next = buckets[targetBucket];
            entries[index].key = key;
            entries[index].value = value;
            buckets[targetBucket] = index;
            version++;
 
#if FEATURE_RANDOMIZED_STRING_HASHING
 
#if FEATURE_CORECLR
            // In case we hit the collision threshold we'll need to switch to the comparer which is using randomized string hashing
            // in this case will be EqualityComparer<string>.Default.
            // Note, randomized string hashing is turned on by default on coreclr so EqualityComparer<string>.Default will 
            // be using randomized string hashing
 
            if (collisionCount > HashHelpers.HashCollisionThreshold && comparer == NonRandomizedStringEqualityComparer.Default) 
            {
                comparer = (IEqualityComparer<TKey>) EqualityComparer<string>.Default;
                Resize(entries.Length, true);
            }
#else
            if(collisionCount > HashHelpers.HashCollisionThreshold && HashHelpers.IsWellKnownEqualityComparer(comparer)) 
            {
                comparer = (IEqualityComparer<TKey>) HashHelpers.GetRandomizedEqualityComparer(comparer);
                Resize(entries.Length, true);
            }
#endif // FEATURE_CORECLR
 
#endif
 
        }

以Dictionary<int, string>为例,展示以下这个过程:

首先定义一个容量为6的字典:

Dictionary<int, string> test = new Dictionary<int, string>(6);

在调用如下方法:

test.Add(4,"4")

根据Hash算法:4.GetHashCode()%7=4,因此碰撞到buckets中下表为4的槽上,此时由于Count为0,因此元素放在Entries中第0个元素上,添加后,Count变为1

继续调用:

test.Add(11,"11");

根据Hash算法,11.GetHashCode()%7=4,因此再次碰撞到buckets中下标为4的槽上,由于此槽上的值已经不是-1,此时Count=1,因此把这个新加的元素放到entries中下标为1的数组中,并且让buckets槽指向下标为1的entries中,下标为1的entry的下一个元素为下标为0的entry。

查找方法

string str = test[4];
 private int FindEntry(TKey key) {
            if( key == null) {
                ThrowHelper.ThrowArgumentNullException(ExceptionArgument.key);
            }
 
            if (buckets != null) {
                int hashCode = comparer.GetHashCode(key) & 0x7FFFFFFF;
                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;
        }

获取key的hashCode,计算出所在的桶位置。我们之前提到,4的hashCode=4,所以最后计算出来targetBucket=4。
通过buckets[4]=1找到entries[1],比较key的值是否相等,相等就返回entryIndex,不想等就继续entries[next]查找,直到找到key相等元素或者next == -1的时候。这里我们找到了key == 4的元素,返回entryIndex=0。
如果entryIndex >= 0那么返回对应的entries[entryIndex]元素,否则返回default(TValue)。这里我们直接返回entries[0].value。

Remove方法

test.Remove(4);
public bool Remove(TKey key) {
            if(key == null) {
                ThrowHelper.ThrowArgumentNullException(ExceptionArgument.key);
            }
 
            if (buckets != null) {
                int hashCode = comparer.GetHashCode(key) & 0x7FFFFFFF;
                int bucket = hashCode % buckets.Length;
                int last = -1;
                for (int i = buckets[bucket]; i >= 0; last = i, i = entries[i].next) {
                    if (entries[i].hashCode == hashCode && comparer.Equals(entries[i].key, key)) {
                        if (last < 0) {
                            buckets[bucket] = entries[i].next;
                        }
                        else {
                            entries[last].next = entries[i].next;
                        }
                        entries[i].hashCode = -1;
                        entries[i].next = freeList;
                        entries[i].key = default(TKey);
                        entries[i].value = default(TValue);
                        freeList = i;
                        freeCount++;
                        version++;
                        return true;
                    }
                }
            }
            return false;
        }
 

删除元素时,通过一次碰撞,并且沿着链表寻找3次,找到key为4的元素所在的位置,删除当前元素,并且把FreeList的位置指向当前删除元素的位置,FreeCount置为1。

删除的数据会形成一个FreeList的链表,添加数据的时候,优先向FreeList链表中添加数据,FreeList为空则按照count一次排序。

Resize方法

 private void Resize(int newSize, bool forceNewHashCodes) {
            Contract.Assert(newSize >= entries.Length);
            int[] newBuckets = new int[newSize];
            for (int i = 0; i < newBuckets.Length; i++) newBuckets[i] = -1;
            Entry[] newEntries = new Entry[newSize];
            Array.Copy(entries, 0, newEntries, 0, count);
            if(forceNewHashCodes) {
                for (int i = 0; i < count; i++) {
                    if(newEntries[i].hashCode != -1) {
                        newEntries[i].hashCode = (comparer.GetHashCode(newEntries[i].key) & 0x7FFFFFFF);
                    }
                }
            }
            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;
        }

扩容的触发条件

第一种情况自然就是数组已经满了,没有办法继续存放新的元素,如下图所示。

第二种,Dictionary中发生的碰撞次数太多,会严重影响性能,也会触发扩容操作。

Hash运算会不可避免的产生冲突,Dictionary中使用拉链法来解决冲突的问题,但是,大家看下图中的这种情况,所有的元素都刚好落在buckets[3]上面,结果就导致了时间复杂度O(n),查找性能会下降。

目前.Net Framwork 4.7中设置的碰撞次数阈值为100。

扩容如何进行

1、申请两倍于现在大小的buckets、entries

2、将现有的元素拷贝到新的entries

3、如果此时Hash碰撞扩容,使用新HashCode函数重新计算Hash值

4、对entries每个元素bucket=newEntries[i].hashCode%newSize确定新buckets位置

5、重建hash链,newEntries[i].next=buckets[bucket];buckets[bucket]=i;

每次扩容操作都需要遍历所有元素,会影响性能。所以创建Dictionary实例时最好设置一个预估的初始大小。

关注点

对于Dictionary的实现原理,其中有两个关键的算法,1、Hash算法。2、用于对应Hash碰撞冲突解决算法。

Hash算法

Hash算法是一种术语摘要算法,它将能不定长度的二进制数据集给映射到一个较短的二进制长度数据集。

实现了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函数的算法有以下几种。

1、直接寻址法:取keyword或者keyword的某个线性函数值为散列地址,即H(key)=key或者H(key)=a·key+b,当中a和b为常数(这样的散列函数叫做自身函数)。这个的应用就是,比如我们世界地图的掩码,直接用坐标x*1000+坐标y,得到key。

2、数字分析法:找出数字的规律,尽可能利用这些数据来构造冲突几率较低的散列地址。分析一组数据,比方一组员工的出生年月日,这时,我们发现出生年月日的前几位数字大体相同,这种话,出现冲突的几率就会非常大,可是我们发现年月日的后几位表示月份和详细日期的数字区别非常大,假设用后面的数字来构造散列地址,则冲突几率就会明显减少。

3、平方取中法:取keyword平方后的中间几位作为散列地址。

4、折叠法:将keyword切割成位数同样的几部分,最后一部分分数能够不同,然后取这及部分的叠加和(去除进位)作为散列地址。

5、随机数法:选择一随机函数,取keyword的随机值作为散列地址,通常适用于keyword长度不同的场合。

6、除留余数法:取keyword被某个不大于散列表表长m的数p除后所得的余数为散列地址。即H(key)=key MOP p ,  p<=m。不仅能够对keyword直接取模,也可在折叠、平方取中等运算后取模,对p的选择非常重要,一般取素数或m,若p选的不好,容易产生碰撞。

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映射到具体哪个桶中。

Dictionary就是这用的哈希桶算法。

int hashCode =comparer.GetHashCode(key)&0x7FFFFFFF;
int targetBucket = hashCode %buckets.Length;

Hash碰撞冲突解决算法

对于一个hash算法,不可避免地会产生冲突,那么产生冲突以后如何处理,是一个很关键的地方,目前常见的冲突解决算法有拉链法(Dictionary实现采用的)、开放定址法、再Hash法、公共溢出分区法。

1、拉链法(开散列):将产生冲突的元素建立一个单链表,并将头指针地址存储在Hash表对应桶的位置,这样定位到Hash表桶的位置后通过遍历单链表的形式来查找元素。

2、开放定址法(闭散列):当发生哈希冲突时,如果哈希表未被装满,说明再哈希表中必然还有空位置,那么可以把key存放到冲突位置中的“下一个”空位置中去。

3、再Hash法:顾名思义就是将key使用其他的Hash函数再次Hash,直到找到不冲突的位置为止。

拉链法:

开放地址法:

假设现在有一个关键码集合(1、4、5、6、7、9),哈希结构的容量为10,哈希函数为Hash(key)=key%10。将所有关键码插入到该哈希结构中,如图:

假如现在有一个关键码24要插入该结构中,使用哈希函数求得哈希地址为24,但是该地址已经存放了元素,此时发生哈希冲突。

线性探测:从发生哈希冲突的位置开始,一次向后探测,直到找到下一个空位置为止,例如上面的地址,插入关键码24时,进行线性探测,插入后如下图:

限制:

1、用该方法需要关键码必须为整形才能被模,所以我们需要实现将非整形转化为整形。

2、模的数值最好为素数,需要我们创建一个素数表。

3、增容问题。

再谈Add方法

在有元素被删除时,因为count是通过自增的方式来指向entries[]下一个空闲的entry,如果有元素被删除了,那么在count之前的位置就会出现一个空闲的entry;如果不处理,会有很多空间被浪费。

这就是为什么Remove方法会记录freeList、freeCount,就是为了将删除的空间利用起来。实际上Add方法会优先使用freeList的空闲entry位置,所以我们能看到前文中的源码中有这样一个判断:

// 如果有被删除的元素,那么将元素放到被删除元素的空闲位置
    if (freeCount > 0) {
        index = freeList;
        freeList = entries[index].next;
        freeCount--;
    }

Collection版本控制

在上文中的源码中很多处都用到了version这个变量,在每一次新增、修改和删除操作时,都会使version++;那么这个version存在的意义是什么呢?

首先我们来看一段代码,这段代码中首先实例化了一个Dictionary实例,然后通过foreach遍历该实例,在foreach代码块中使用dic.Remove(kv.Key)删除元素。

1548504444217

结果就是抛出了System.InvalidOperationException:"Collection was modified..."这样的异常,迭代过程中不允许集合出现变化。会出现诡异的问题,所以.Net中就使用了version来实现版本控制。

那么如何在迭代过程中实现版本控制的呢?我们看一看源码就很清楚的知道。

1548504844162

在迭代器初始化时,就会记录dictionary.version版本号,之后每一次迭代过程都会检查版本号是否一致,如果不一致将抛出异常。

查找时的一些注意点

在进行字典查找时,经常会需要先判断字典中有没有这个key值,如果有才去取。

使用

public bool TryGetValue(TKey key, out TValue value) {
            int i = FindEntry(key);
            if (i >= 0) {
                value = entries[i].value;
                return true;
            }
            value = default(TValue);
            return false;
        }

而不是通过

if(test.ContainsKey(4))
{
     return test[4];
}

  • 0
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值