C#数据结构-Dictionary

10 篇文章 1 订阅
9 篇文章 5 订阅

我们熟悉的XX字典,首先是他的一个字根和拼音的目录,后面一部分就是字的解读内容,我们会发现字典的排列并不是无序的,拼音相同的字展示在一个分类里面

在C#里面字典是一个哈希的集合,字典存储结构是键值(key,value)存储,最大的优点就是它查找元素的时间复杂度接近O(1),那么它内部是如何实现的呢?

先来了解一下字典内部的成员

        private int[] buckets;//hash桶
        private Entry[] entries;//元素数组,用于维护哈希表中的数据
        private int count;//元素数量
        private int version;// 当前版本,防止迭代过程中集合被更改
        private int freeList;//空闲的列表
        private int freeCount;//空闲列表元素数量
        private IEqualityComparer<TKey> comparer;//哈希表中的比较函数
        private KeyCollection keys;//键集合
        private ValueCollection values;//值集合
        private Object _syncRoot;

        private struct Entry {
            public int hashCode;    //31位散列值,32最高位表示符号位,-1表示未使用
            public int next;        //下一项的索引值,-1表示结尾
            public TKey key;        //键
            public TValue value;    //值
        }

我们可以提取很关键的两个成员buckets和Entry,buckets是存字典的一个数组, Entry初始化里面有一个next作为下一个元素的地址,这个及其像单链表结构,但是它没有头部标签。所以我们初步的肯定字典就是一个数组+链表的结构。

Hash算法

Hash算法是一种数字摘要算法,它能将不定长度的二进制数据集给映射到一个较短的二进制长度数据集,常见的MD5算法就是一种Hash算法,通过MD5算法可对任何数据生成数字摘要。而实现了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

常见的几种方式 

  1. 直接寻址法:取keyword或keyword的某个线性函数值为散列地址。即H(key)=key或H(key) = a•key + b,当中a和b为常数(这样的散列函数叫做自身函数)
  2. 数字分析法:分析一组数据,比方一组员工的出生年月日,这时我们发现出生年月日的前几位数字大体同样,这种话,出现冲突的几率就会非常大,可是我们发现年月日的后几位表示月份和详细日期的数字区别非常大,假设用后面的数字来构成散列地址,则冲突的几率会明显减少。因此数字分析法就是找出数字的规律,尽可能利用这些数据来构造冲突几率较低的散列地址。
  3. 平方取中法:取keyword平方后的中间几位作为散列地址。
  4. 折叠法:将keyword切割成位数同样的几部分,最后一部分位数能够不同,然后取这几部分的叠加和(去除进位)作为散列地址。
  5. 随机数法:选择一随机函数,取keyword的随机值作为散列地址,通经常使用于keyword长度不同的场合。
  6. 除留余数法:取keyword被某个不大于散列表表长m的数p除后所得的余数为散列地址。即 H(key) = key MOD p, p<=m。不仅能够对keyword直接取模,也可在折叠、平方取中等运算之后取模。对p的选择非常重要,一般取素数或m,若p选的不好,容易产生碰撞. 

我们了解哈希算法后,知道被hash后会有冲突的数据,对照字典的数组+链表我们猜测,碰撞的数据应该是存到这个链表中,如果有多个冲突,把这些冲突通过头插法或者尾插法添加到链中。 

我们从c#开源代码中找到实现字典插入的一段代码来做分析,源码地址点击

字典添加元素 

//	buckets是哈希表,用来存放Key的Hash值								
//	entries用来存放元素列表								
//	count是元素数量								
private void Insert(TKey key, TValue value, bool add)
{
    if (key == null)
    {
        throw new ArgumentNullException(key.ToString());
    }
    //	首先分配buckets和entries的空间												
    if (buckets == null) Initialize(0);
    int hashCode = comparer.GetHashCode(key) & 0x7FFFFFFF; //	计算 key值对应的哈希值(HashCode)												
    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(en tries[i].key, key))
        {
            if (add)
            {
                throw new ArgumentNullException();
            }
            entries[i].value = value;
            version++;
            return;
        }
        #if FEATURE_RANDOMIZED_STRING_HASHING
            collisionCount++;
        #endif
    }
    int index;	//index记录了元素在元素列表中的位置
    if (freeCount > 0)
    {
        index = freeList;
        freeList = entries[index].next;
        freeCount--;
    }
    else
    {
        //如果哈希表存放哈希值已满,则重新从primers数组中取出值来作为哈希 表新的大小
        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(collisionCount > HashHelpers.HashCollisionThreshold && Has hHelpers.IsWellKnownEqualityComparer(comparer))
    {
        comparer = (IEqualityComparer<TKey>)HashHelpers.GetRandomizedEqualityComparer(comparer);
        Resize(entries.Length, true);
    } 
    #endif
}

字典在插入元素时,先进行hash算法找到buckets桶的存储点,然后对值进行存储,在存储的过程中会和之前的元素产生冲突,那么这个冲突如何被解决的呢?

我们每次添加一个元素的时候字典使用对键取余法获取到该值存储的位置,如果第二次插入的键也是在第一个空间下面,这样会产生一个冲突,因为我第一次添加的元素也是在第一个空间下面,那么字典的解决办法就是通过一个单链表的方式把这些值保存起来,通过头插法进行存储,这样就解决了冲突。

 下面这段代码就是插入链表代码

    //头插法
    entries[index].hashCode = hashCode;
    entries[index].next = buckets[targetBucket];
    entries[index].key = key;
    entries[index].value = value;
    //对哈希表进行赋值
    buckets[targetBucket] = index;

下面看图解答:

插入到第一个空间:

再插入一个数据到第一个空间:

字典扩容 

 什么情况下会被扩容呢?代码中:freeCount > 0 和 count == entries.Length ,也就是说要么每个桶下面存储数据数组已满,或者是链表元素超过了这个总空间就会触发扩容。

字典的查询效率是O(1),那么一旦这个链的元素超过桶以后,我们检索的速率势必接近O(n),这个时候为了避免这样的情况,字典会触发扩容

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倍,每次扩容后重新计算Hash值,每一个元素遍历一次写入新的字典,这样会影响性能,而且在扩容的时候会引发安全问题,这个我们在设计的时候需要注意,尽量不要引起扩容。

字典查找

private int FindEntry(TKey key)
{
    if (key == null)
    {
        throw new ArgumentNullException();
    }
    if (buckets != null)
    {
        //获得Key值对应的哈希值
        int hashCode = comparer.GetHashCode(key) & 0x7FFFFFFF;
        //查找元素在元素列表中的位置,如果没有冲突的情况下,此时查找速度为O (1),存在冲突的情况下为O(N),N为存在冲突的次数
        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;
}

字典查找步骤,首先是找到hash桶的位置,然后遍历链表找到该元素 

字典中每次存储解决冲突的时候,都会用一条链来存储冲突的数据,字典存储都是把同类存储在一起,这样每次操作的时候都会减少拆箱装箱的操作,这相对哈希表查询来说是一个优化。相对于链表来说,每次查找的时候不需要遍历整个字典。字典的存储方式是以空间换时间的存储方式,所以在查找的时候是比较快速的

总结:

1.字典是一个Hash桶+单链表结构,查找速度非常快,接近O(1)

2.字典有两个非常重要的算法,Hash桶算法和拉链法,其中链表采用头插法

3.字典存储达到上限后会触发扩容,扩容原来的2倍,这个时候整个hash会重新计算,遍历所有链表,消耗大量的内存

 

 

一名正在抢救的coder

笔名:mangolove

CSDN地址:https://blog.csdn.net/mango_love

GitHub地址:https://github.com/mangoloveYu

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值