我们熟悉的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 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算法是一种数字摘要算法,它能将不定长度的二进制数据集给映射到一个较短的二进制长度数据集,常见的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后会有冲突的数据,对照字典的数组+链表我们猜测,碰撞的数据应该是存到这个链表中,如果有多个冲突,把这些冲突通过头插法或者尾插法添加到链中。
我们从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)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