在C#中,Dictionary<TKey, TValue>
是一个非常常用的数据结构,它允许我们通过键(Key)快速查找对应的值(Value)。它就像一个现实生活中的词典:你输入一个单词(键),就能迅速找到它的意思(值)。那么,Dictionary
是如何做到这么高效的呢?下面我们将一步步拆解它的内部实现原理。
什么是Dictionary?
Dictionary是一个键值对集合,每个键都是唯一的,通过键可以快速访问对应的值。
cs
// 创建一个Dictionary
Dictionary<string, int> studentGrades = new Dictionary<string, int>();
studentGrades["张三"] = 85;
studentGrades["李四"] = 92;
studentGrades["王五"] = 78;
// 快速查找
int grade = studentGrades["张三"]; // 结果:85
为什么需要Dictionary?
想象一下,如果我们用数组存储学生成绩:
cs
string[] names = {"张三", "李四", "王五"};
int[] grades = {85, 92, 78};
// 要找张三的成绩,需要遍历整个数组
int findGrade = -1;
for(int i = 0; i < names.Length; i++)
{
if(names[i] == "张三")
{
findGrade = grades[i];
break;
}
}
这种方式效率很低,时间复杂度是O(n)。而Dictionary可以在O(1)时间内完成查找!
哈希表
Dictionary
的核心是一个叫做**哈希表(Hash Table)**的数据结构。想象一下,你有一个很大的图书馆,里面有很多书。如果每次找书都要从头一本一本翻,那会非常慢。哈希表的想法就像给每本书分配一个编号(哈希值),然后根据这个编号把书放到特定的书架上。找书时,只需要知道编号,直接去对应的书架取就行了。
在 Dictionary
中:
-
键(Key) 就像书的编号。
-
值(Value) 就像书的内容。
-
哈希表负责根据键快速找到值的位置。
哈希表的第一步是用一个哈希函数(Hash Function) 把键变成一个数字(哈希值)。在C#中,这个过程由键的 GetHashCode()
方法完成。
举个例子:
-
假设键是字符串
"apple"
,GetHashCode()
可能会生成一个数字,比如12345
。 -
如果键是
"banana"
,可能会生成67890
。
这个哈希值就像一个地址,告诉 Dictionary
把键值对存储在哪里。不过,哈希值可能很大,而 Dictionary
的存储空间是有限的,所以它会通过一个数学运算(通常是取模运算),把哈希值压缩到一个较小的范围,比如 0 到数组长度-1。
简单的哈希函数示例
cs
// 一个简单的字符串哈希函数示例
public int SimpleHash(string key, int arraySize)
{
int hash = 0;
foreach(char c in key)
{
hash += (int)c; // 将字符的ASCII值相加
}
return hash % arraySize; // 取模确保索引在数组范围内
}
// 示例
string key = "张三";
int index = SimpleHash(key, 10); // 假设数组大小为10
// 现在可以直接通过index访问数组中的数据
桶数组
Dictionary
内部维护了一个数组,这个数组就像一排编号的抽屉(也叫"桶",Buckets)。每个抽屉可以存放键值对。数组的大小通常是动态调整的(后面会讲到)。
当你添加一个键值对(比如 "apple", 5"
)时:
-
调用
"apple"
的GetHashCode()
,得到哈希值(比如12345
)。 -
用哈希值对数组长度取模(假设数组有10个位置,
12345 % 10 = 5
)。 -
把
"apple", 5"
存到数组的第5个位置。
这样,下次查找 "apple"
时,Dictionary
会重复这个过程,直接去第5个位置取值,速度非常快。
Dictionary的内部结构
Dictionary内部主要包含以下几个重要组件:
cs
// Dictionary的简化内部结构
public class SimpleDictionary<TKey, TValue>
{
private struct Entry
{
public int hashCode; // 存储哈希值
public int next; // 指向下一个Entry的索引(处理冲突用)
public TKey key; // 键
public TValue value; // 值
}
private int[] buckets; // 桶数组,存储Entry的索引
private Entry[] entries; // 实际存储数据的数组
private int count; // 当前元素数量
private int freeList; // 空闲位置链表
private int freeCount; // 空闲位置数量
}
Dictionary的工作流程
添加元素(Put操作)
cs
public void Add(TKey key, TValue value)
{
// 步骤1: 计算哈希值
int hashCode = key.GetHashCode();
// 步骤2: 计算桶索引
int bucketIndex = hashCode % buckets.Length;
// 步骤3: 检查是否已存在该键
for (int i = buckets[bucketIndex]; i >= 0; i = entries[i].next)
{
if (entries[i].hashCode == hashCode &&
EqualityComparer<TKey>.Default.Equals(entries[i].key, key))
{
throw new ArgumentException("键已存在");
}
}
// 步骤4: 添加新元素
int index = count;
entries[index] = new Entry
{
hashCode = hashCode,
next = buckets[bucketIndex], // 链接到现有链表头部
key = key,
value = value
};
buckets[bucketIndex] = index; // 更新桶指向新元素
count++;
}
查找元素(Get操作)
cs
public TValue Get(TKey key)
{
// 步骤1: 计算哈希值和桶索引
int hashCode = key.GetHashCode();
int bucketIndex = hashCode % buckets.Length;
// 步骤2: 在对应的桶中查找
for (int i = buckets[bucketIndex]; i >= 0; i = entries[i].next)
{
if (entries[i].hashCode == hashCode &&
EqualityComparer<TKey>.Default.Equals(entries[i].key, key))
{
return entries[i].value; // 找到了!
}
}
throw new KeyNotFoundException("键不存在");
}
哈希冲突处理
什么是哈希冲突?
当两个不同的键计算出相同的哈希值时,就发生了哈希冲突。
cs
// 示例:两个不同的字符串可能产生相同的哈希值
string key1 = "abc";
string key2 = "bca";
// 如果使用简单的字符ASCII值相加作为哈希函数
// 这两个字符串会产生相同的哈希值
Dictionary的冲突解决:链地址法
Dictionary使用链地址法来处理冲突:
cs
桶索引2的冲突处理示例:
buckets[2] → Entry索引5 → Entry索引3 → Entry索引1 → null
Entry[5]: {key: "张三", value: 85, next: 3}
Entry[3]: {key: "赵六", value: 90, next: 1} // 哈希冲突
Entry[1]: {key: "孙七", value: 88, next: -1} // 哈希冲突
冲突处理的详细流程
cs
// 查找时遍历冲突链
public bool TryGetValue(TKey key, out TValue value)
{
int hashCode = key.GetHashCode();
int bucketIndex = hashCode % buckets.Length;
// 遍历冲突链中的所有元素
for (int i = buckets[bucketIndex]; i >= 0; i = entries[i].next)
{
if (entries[i].hashCode == hashCode &&
EqualityComparer<TKey>.Default.Equals(entries[i].key, key))
{
value = entries[i].value;
return true;
}
}
value = default(TValue);
return false;
}
动态扩容
Dictionary
的数组大小不是固定的。当你不断添加键值对,数组可能会装满(或者达到一定填充比例,比如75%)。这时,Dictionary
会进行扩容:
-
创建一个更大的新数组(通常是当前大小的两倍)。
-
把原来的键值对重新计算哈希值,放入新数组。
为什么重新计算呢?因为数组长度变了,取模的结果会变。比如原来长度是10,取模是 % 10
,现在变成20,取模就变成 % 20
,位置会重新分配。
扩容虽然能解决问题,但它是有代价的------需要时间把所有数据搬到新数组。所以,Dictionary
会尽量避免频繁扩容,通常一开始会预留一些空间。
扩容触发条件
cs
// 当负载因子超过阈值时触发扩容
// 负载因子 = 元素数量 / 桶数量
if (count >= buckets.Length * 0.75) // 负载因子达到75%
{
Resize(); // 触发扩容
}
扩容过程
cs
private void Resize()
{
// 步骤1: 创建新的更大的桶数组(通常是原来的2倍)
int newSize = buckets.Length * 2;
int[] newBuckets = new int[newSize];
for (int i = 0; i < newBuckets.Length; i++)
{
newBuckets[i] = -1;
}
// 步骤2: 重新计算所有元素的桶位置(rehashing)
for (int i = 0; i < count; i++)
{
if (entries[i].hashCode >= 0)
{
int bucketIndex = entries[i].hashCode % newSize;
entries[i].next = newBuckets[bucketIndex];
newBuckets[bucketIndex] = i;
}
}
// 步骤3: 替换旧的桶数组
buckets = newBuckets;
}
性能分析
时间复杂度
-
平均情况:O(1) - 理想的哈希分布
-
最坏情况:O(n) - 所有元素都冲突到同一个桶
空间复杂度
- 空间复杂度:O(n) - 需要存储n个键值对
Dictionary
的查找、添加和删除操作平均时间复杂度是 O(1),也就是"常数时间"。这是因为:
-
哈希函数直接算出存储位置,不需要遍历整个数据。
-
如果冲突少,每个桶的链表很短,检查时间几乎可以忽略。
但如果冲突太多(比如哈希函数不好,键都挤到少数几个桶里),性能会下降到 O(n)。
性能优化要点
- 选择合适的初始容量
- 实现高质量的GetHashCode方法
- 比如,使用多个字段计算哈希值,减少冲突
cs
// 1. 选择合适的初始容量
Dictionary<string, int> dict = new Dictionary<string, int>(1000);
// 2. 实现高质量的GetHashCode方法
public class Student
{
public string Name { get; set; }
public int Age { get; set; }
public override int GetHashCode()
{
// 使用多个字段计算哈希值,减少冲突
return HashCode.Combine(Name, Age);
}
public override bool Equals(object obj)
{
if (obj is Student other)
{
return Name == other.Name && Age == other.Age;
}
return false;
}
}