前言
Dictionary字典型数据结构本质是关键字Key值和Value值一一映射的关系。Key可以是不同的类型,int、string等等,怎么挥事呢?答案是哈希表,是利用哈希函数将键映射到特定位置,以实现快速的键值检索。
砖家建议:先看List源码解析,再看本章。
一、Dictionary源码
变量的定义部分:
public class Dictionary<TKey,TValue>: IDictionary<TKey,TValue>, IDictionary,
IReadOnlyDictionary<TKey, TValue>, ISerializable, IDeserializationCallback
{
private struct Entry {
public int hashCode; // 低31位为Hash值,如果未使用则为-1
public int next; // 下一个实例索引,如果是最后一个则为-1
public TKey key; // 实例的键值
public TValue value; // 实例的值
}
private int[] buckets;
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;
}
Dictionary主要继承了IDictionary接口和ISerializable接口。同时也能看出Dictionary数据结构中和List一样,都是以数组为底层数据结构,所以猜测 扩容操作也是需要的。
Dictionary源码网址为:官方跳转链接
二、Add接口
接口源码如下:
public void Add(TKey key, TValue value)
{
Insert(key, value, true);
}
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;
}
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
// 如果我们触碰到阈值,则需要切换到使用随机字符串Hash的比较器上
// 在这种情况下,将是EqualityComparer<string>.Default
// 注意,默认情况下,coreclr上的随机字符串Hash是打开的,所以EqualityComparer<string>.
Default将使用随机字符串Hash
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
}
代码很多,简单的分析关键点。首先,Add是由Insert方法代理,加入之前判空后进行数据构造。
if( key == null) {
//判空
ThrowHelper.ThrowArgumentNullException(ExceptionArgument.key);
}
if (buckets == null) Initialize(0);//buchets出事为空数组,对其初始化
我们继续看Initialize方法。里面写了如何初始化的,这个和List有点差别,研究一下,发现primes数值是质数。
int size = HashHelpers.GetPrime(capacity);
HashHelpers,primes数值
public static readonly int[] primes = {
3, 7, 11, 17, 23, 29, 37, 47, 59, 71, 89, 107, 131, 163, 197, 239,
293, 353, 431, 521, 631, 761, 919,
1103, 1327, 1597, 1931, 2333, 2801, 3371, 4049, 4861, 5839, 7013,
8419, 10103, 12143, 14591,
17519, 21023, 25229, 30293, 36353, 43627, 52361, 62851, 75431, 90523,
108631, 130363, 156437,
187751, 225307, 270371, 324449, 389357, 467237, 560689, 672827,
807403, 968897, 1162687, 1395263,
1674319, 2009191, 2411033, 2893249, 3471899, 4166287, 4999559, 5999471,
7199369
};
如果超出这个界限呢,那就返回之前的2倍。如果你创建字典时指定了初始大小,会计算成应该分配的大小。
public static int GetPrime(int min)
{
if (min<0)
throw new ArgumentException(
Environment.GetResourceString("Arg_HTCapacityOverflow"));
Contract.EndContractBlock();
for (int i = 0; i<primes.Length; i++)
{
int prime = primes[i];
if (prime>= min) return prime;
}
// 如果在我们的预定义表之外,则做硬计算
for (int i = (min | 1); i<Int32.MaxValue;i+=2)
{
if (IsPrime(i) && ((i - 1) % Hashtable.HashPrime != 0))
return i;
}
return min;
}
// 返回要增长到的Hash表的大小
public static int ExpandPrime(int oldSize)
{
int newSize = 2 * oldSize;
// 在遇到容量溢出之前,允许Hash表增长到最大可能的大小(约2G个元素)
// 请注意,即使(item.Length)由于(uint)强制转换而溢出,此检查仍然有效
if ((uint)newSize>MaxPrimeArrayLength && MaxPrimeArrayLength>oldSize)
{
Contract.Assert( MaxPrimeArrayLength == GetPrime(MaxPrimeArrayLength),
"Invalid MaxPrimeArrayLength");
return MaxPrimeArrayLength;
}
return GetPrime(newSize);
}
初始化之后,对key进行hash处理,得到地址索引。
int hashCode = comparer.GetHashCode(key) & 0x7FFFFFFF;
int targetBucket = hashCode % buckets.Length;//取余,确保索引地址在数组长度范围内,否则可能溢出
然后对指定的数组单元格内的链表进行遍历,提取空位置将值填入。拉链法(下有讲解)的链表推入操作。
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
}
拉链法是一种解决哈希冲突的方法,主要应用在哈希表中。当不同的关键字经过哈希函数计算后得到相同的哈希地址时,就产生了哈希冲突。拉链法解决冲突的做法是:将所有关键字为同义词的结点链接在同一个单链表中。若选定的散列表长度为m,则可将散列表定义为一个由m个头指针组成的指针数组T[0…m-1]。凡是散列地址为i的结点,均插入到以T[i]为头指针的单链表中。T中各分量的初值均应为空指针。
如果数组空间不够,也就是代码中freeCount=0时的逻辑进行扩容,扩容后的大小为计算过的大小,通常为之前的2倍。(参考ExpandPrime方法)
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;
三、Remove接口
接口源码如下:
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;
}
移除的原理和Add一样利用了Hash值,再进行余操作,确认索引在数组范围内后,遍历地址进行查找,key值相同则进行移除操作。但是这里为了减少内存频繁操作,直接进行置空。
四、ContainsKey接口
接口源码如下:
public bool ContainsKey(TKey key)
{
return FindEntry(key)>= 0;
}
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;
}
这个方法的作用是在哈希表中查找指定键的位置,如果找到则返回该键所在的索引,如果未找到则返回 -1。和Remove的查找方式类似,查找所有冲突列表中与key相当的值。
五、TryGetValue接口
接口源码如下:
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;
}
TryGetValue接口调用FindEntry方法,其中TValue是对[]操作符的重定义。而TValue中又调用了Insert。
public TValue this[TKey key] {
get {
int i = FindEntry(key);
if (i>= 0) return entries[i].value;
ThrowHelper.ThrowKeyNotFoundException();
return default(TValue);
}
set {
Insert(key, value, false);
}
}
六、哈希函数
综上所述,可以看出哈希冲突的拉链法贯穿了其底层数据结构。所以其中哈希函数决定了字典的效率。
以下是函数创建过程源码:
private static EqualityComparer<T>CreateComparer()
{
Contract.Ensures(Contract.Result<EqualityComparer<T>>() != null);
RuntimeType t = (RuntimeType)typeof(T);
// 出于性能原因专门用字节类型
if (t == typeof(byte)) {
return (EqualityComparer<T>)(object)(new ByteEqualityComparer());
}
// 如果T implements IEquatable<T>返回一个GenericEqualityComparer<T>
if (typeof(IEquatable<T>).IsAssignableFrom(t)) {
return (EqualityComparer<T>)
RuntimeTypeHandle.CreateInstanceForAnotherGenericParameter(
(RuntimeType)typeof(GenericEqualityComparer<int>), t);
}
// 如果T是一个Nullable<U>从U implements IEquatable<U>返回的NullableEquality
Comparer<U>
if (t.IsGenericType && t.GetGenericTypeDefinition() == typeof(Nullable<>)) {
RuntimeType u = (RuntimeType)t.GetGenericArguments()[0];
if (typeof(IEquatable<>).MakeGenericType(u).IsAssignableFrom(u)) {
return (EqualityComparer<T>)
RuntimeTypeHandle.CreateInstanceForAnotherGenericParameter((
RuntimeType)typeof(NullableEqualityComparer<int>), u);
}
}
// 看这个METHOD__JIT_HELPERS__UNSAFE_ENUM_CAST和METHOD__JIT_HELPERS__UNSAFE_
ENUM_CAST_LONG在getILIntrinsicImplementation中的例子
if (t.IsEnum) {
TypeCode underlyingTypeCode = Type.GetTypeCode(
Enum.GetUnderlyingType(t));
// 根据枚举类型,我们需要对比较器进行特殊区分,以免装箱
// 注意,我们要对Short和SByte使用不同的比较器,因为对于这些类型,
// 我们需要确保在实际的基础类型上调用GetHashCode,其中,GetHashCode的实现比其他类型更复杂
switch (underlyingTypeCode) {
case TypeCode.Int16:
return (EqualityComparer<T>)
RuntimeTypeHandle.CreateInstanceForAnotherGenericParameter((
RuntimeType)typeof(ShortEnumEqualityComparer<short>), t);
case TypeCode.SByte:
return (EqualityComparer<T>)
RuntimeTypeHandle.CreateInstanceForAnotherGenericParameter((
RuntimeType)typeof(SByteEnumEqualityComparer<sbyte>), t);
case TypeCode.Int32:
case TypeCode.UInt32:
case TypeCode.Byte:
case TypeCode.UInt16:
return (EqualityComparer<T>)
RuntimeTypeHandle.CreateInstanceForAnotherGenericParameter((
RuntimeType)typeof(EnumEqualityComparer<int>), t);
case TypeCode.Int64:
case TypeCode.UInt64:
return (EqualityComparer<T>)
RuntimeTypeHandle.CreateInstanceForAnotherGenericParameter((
RuntimeType)typeof(LongEnumEqualityComparer<long>), t);
}
}
// 否则,返回一个ObjectEqualityComparer<T>
return new ObjectEqualityComparer<T>();
}
源码中可以看到有四种处理方式,分别对应byte、IEquatable、IEquatable、underlyingTypeCode四种类型。其中byte和underlyingTypeCode,一个字节、一个数字,容易比较;有IEquatable接口的则使用GenericEqualityComparer获取哈希函数;对于IEquatable来说,如果有Nullable接口就使用NullableEqualityComparer
,如果没有直接使用默认的ObjectEqualityComparer。
项目中想优化,尽量使用数值方式作为键值对。其他方式的Hash值通常使用内存地址比较,消耗较大。
在C#中,所有类指向Object类,比较时没有重写Equals函数时,都是进行内存地址进行比较,消耗较大。
七、线程安全
最后提一点,Dictionary是线程同样是不安全的,需要加锁。但是Hashtable是线程安全的。
总结
Dictionary 是一种常见的数据结构,由数组构成,使用哈希函数将键映射到数组的特定位置。它通过哈希表实现键值对的存储和检索,具有快速的查找、插入和删除操作。在解决哈希冲突方面,Dictionary 使用了拉链法,将具有相同哈希码的键值对存储在同一个链表中,从而保证了高效的冲突解决和性能表现。