平时做技术实践时,很多问题不是概念不会,而是细节没串起来。拿“C# Dictionary字典增删改查、哈希原理与高频踩坑指南”来说,它看着像小点,放到项目里常会牵出环境、配置、兼容性和维护成本。下面按实际采用顺序,把思路、关键写法和容易踩坑的地方讲清楚,便于大家直接对照操作。
导语:Dictionary(字典)就像一本真实的字典书,每个"词目"(键 Key)对应一段"释义"(值 Value)。你不用从头翻到尾,只要报出词目,就能直接定位到释义,这就是它最大的本事——按键更快查找。日常开发中只要遇到"借助一个东西找另一个东西"的场景(学号找学生、单词计数、缓存数据),基本都该用它。本文从新建、增删改查一直讲到哈希表原理和高频踩坑,适合初学者入门和复习。
一、Dictionary 是什么
Dictionary 是一个泛型键值对集合,位于 System.Collections.Generic 命名空间,两个泛型参数分别是键的类型和值的类型:
using System.Collections.Generic;
// 键是 string 类型,值是 int 类型
DictionaryageDict = new Dictionary ();
它有三个核心特点:
- 键唯一:同一个键只能存一个值,重复添加会报错。
- 按键查找快:平均查找时间接近 O(1),不会因为数据变多就明显变慢。
- 类型安全:因为是泛型,键和值的类型在编译期就定死了,不会出现装箱拆箱和类型转换问题。
【插图 1:Dictionary 成员知识地图】

二、新建与初始化
2.1 最常用的新建方式
// 方式一:new 出来再逐个添加
Dictionarydict1 = new Dictionary ();
// 方式二:集合初始化器,创建时就把数据写好(最常用)
Dictionarydict2 = new Dictionary
{
{ "张三", 18 },
{ "李四", 20 }
};
// 方式三:索引器初始化(C# 6 及以上),写法更短
Dictionarydict3 = new Dictionary
{
["张三"] = 18,
["李四"] = 20
};
2.2 指定初始容量
事先知道大概要存多少条数据,能够在新建时指定容量,避免后面频繁扩容:
// 预计要放 100 条左右,提前把空间留好
Dictionarydict = new Dictionary (100);
2.3 忽略大小写的字符串键
键是字符串时默认区分大小写,"Apple" 和 "apple" 会被当成两个键。需忽略大小写时,传入系统提供的比较器:
Dictionarydict = new Dictionary (StringComparer.OrdinalIgnoreCase);
dict["Apple"] = 5;
Console.WriteLine(dict["apple"]); // 5:被当成同一个键
三、增删改查(核心)
3.1 增加:Add 和索引器
Dictionarydict = new Dictionary ();
// Add:键不存在就添加,键已存在会抛 ArgumentException
dict.Add("张三", 18);
// 索引器:键不存在时自动添加,键已存在时覆盖旧值
dict["李四"] = 20;
dict["李四"] = 21; // 不报错,值被改成 21
两种方式的区别:
| 方式 | 键已存在时 | 适用场景 |
|---|---|---|
Add(key, value) | 抛异常 | 确定键不应该重复,重复说明逻辑有问题 |
dict[key] = value | 覆盖旧值 | 有则更新、无则新增 |
3.2 读取:索引器和 TryGetValue
Dictionarydict = new Dictionary
{
["张三"] = 18
};
// 索引器读取:键存在正常返回,键不存在抛 KeyNotFoundException
Console.WriteLine(dict["张三"]); // 18
// TryGetValue:安全的读取方式,键不存在返回 false,不抛异常
if (dict.TryGetValue("王五", out int age))
{
Console.WriteLine(age);
}
else
{
Console.WriteLine("没找到这个人");
}
【插图 2:索引器与 TryGetValue 两种读取方式对比】

读取一个"不一定存在"的键,优先用 TryGetValue实际处理时,,一次调用同时完成判断和取值,比先 ContainsKey 再读索引器少查一次。
3.3 判断键和值是否存在
Dictionarydict = new Dictionary
{
["张三"] = 18
};
Console.WriteLine(dict.ContainsKey("张三")); // True
Console.WriteLine(dict.ContainsValue(18)); // True
注意:ContainsKey 很快(哈希查找),而 ContainsValue 是从头到尾逐个比对,数据量大时比较慢,不要滥用。
3.4 删除和清空
dict.Remove("张三"); // 键存在就删除并返回 true,键不存在返回 false,不抛异常
dict.Clear(); // 清空所有键值对
3.5 常用成员速查表
| 成员 | 作用 |
|---|---|
Add(key, value) | 添加,键重复抛异常 |
dict[key] | 读取/赋值,读不存在的键抛异常 |
TryGetValue(key, out value) | 安全读取,得到是否找到 |
ContainsKey(key) | 判断键是否存在 |
ContainsValue(value) | 判断值是否存在(线性查找,较慢) |
Remove(key) | 删除指定键 |
Clear() | 清空 |
Count | 当前键值对数量 |
Keys / Values | 所有键 / 所有值的集合 |
四、遍历字典
4.1 foreach 遍历键值对
Dictionarydict = new Dictionary
{
["张三"] = 18,
["李四"] = 20
};
// 每一项都是 KeyValuePair
foreach (KeyValuePairpair in dict)
{
Console.WriteLine($"{pair.Key}:{pair.Value}");
}
4.2 只遍历键或只遍历值
foreach (string name in dict.Keys)
{
Console.WriteLine(name);
}
foreach (int age in dict.Values)
{
Console.WriteLine(age);
}
需特别注意:字典不保证遍历顺序实际处理时,,不要写依赖"先添加的先被遍历到"的逻辑;需有序就用后面讲到的 SortedDictionary。
五、底层原理:为什么按键查找这么快
5.1 哈希表的基本思路
Dictionary 内部用的是哈希表(Hash Table)。查找过程能够理解为三步:
- 对键调用
GetHashCode(),得到一个整数(哈希码)。 - 落到代码里,用哈希码做一次取模运算,算出这个键应该放在哪个"桶"(bucket,能够理解为编号的格子)。
- 直接去那个桶里找,不用翻遍整个集合。
【插图 3:哈希表查找原理】
5.2 哈希冲突
两个不同的键可能算出同一个桶,这叫哈希冲突从实现思路看,。Dictionary 的处理办法是让同一个桶里的元素串成一条链,先定位到桶,再在链上逐个比较键。所以:
- 平均查找复杂度是 O(1),一次定位。
- 实际处理时,极端情况下(大量冲突)会退化到 O(n),这也是为什么键的哈希算法要尽量分散。
5.3 扩容
落到代码里,元素不断增加、桶变得拥挤时,字典会自动扩容(容量取更大的质数)同时重新计算所有元素的位置,这一步有一定开销。所以能预估数据量时,新建时就带上容量参数。
六、相关集合对比
| 集合 | 特点 | 适用场景 |
|---|---|---|
Dictionary | 哈希表,按键 O(1) 查找,无序 | 绝大多数键值查找场景 |
Hashtable | 非泛型老版本,键值都是 object | 老代码维护,新项目不用 |
SortedDictionary | 按键自动排序,底层是二叉树 | 需按键有序输出 |
List | 保持插入顺序,查找靠遍历 | 顺序重要、数据量小 |
ConcurrentDictionary | 线程安全 | 多线程同时读写 |
Dictionary 本身不是线程安全的结合项目来看,,多线程环境下同时读写可能出问题,这种场景应采用 ConcurrentDictionary。
七、实战:统计字符出现次数
落到代码里,字典最经典的应用之一就是"计数"。下面统计一个字符串里每个字符出现了多少次:
string text = "hello dictionary";
DictionarycountDict = new Dictionary ();
foreach (char c in text)
{
// 键已存在就加 1,不存在就先初始化为 1
if (countDict.ContainsKey(c))
{
countDict[c]++;
}
else
{
countDict[c] = 1;
}
}
foreach (KeyValuePairpair in countDict)
{
Console.WriteLine($"字符 {pair.Key} 出现 {pair.Value} 次");
}
【插图 4:字符计数程序运行结果】(发布时在此插入图片 fig4-console-result.png)
八、常用坑
- 实际处理时,用索引器直接读不确定存在的键,抛 KeyNotFoundException。 落到代码里,读取外部数据、用户输入对应的键时,统一用
TryGetValue,或者先ContainsKey判断,不要赌键一定存在。 - 用 Add 添加重复键,抛 ArgumentException。 实际处理时,想实现"有则更新、无则新增",直接用索引器赋值
dict[key] = value;坚持用 Add 时先判断 ContainsKey。 - 从实现思路看,自定义类当键,却没有重写 GetHashCode 和 Equals,导致"内容相同"的键查不到。 默认按引用比较,两个
new出来的对象即使字段完全一样也是不同的键。解决办法是在键类里重写这两个方法,或者直接用record类型(自带基于内容的相等比较),也能够在构造字典时传入自定义的IEqualityComparer。 - 理解这一步时,foreach 遍历过程中添加或删除元素,抛 InvalidOperationException。 在这个场景下,遍历中不能修改集合。需删除时,先把要删的键收集到一个 List 里,遍历结束后再统一 Remove。
- 依赖字典的遍历顺序。 从实现思路看,字典的契约里没有"有序"这一条,不同 .NET 版本的内部实现也可能不同,依赖顺序属于埋雷。需按插入顺序就用 List,需按键排序就用 SortedDictionary。另外引用类型的键不能为 null,传入 null 键会抛 ArgumentNullException。
九、小结
- Dictionary 是泛型键值对集合结合项目来看,,键唯一、按键平均 O(1) 查找,是"借助一个东西找另一个东西"的首选。
- 记住增删改查的正确姿势:新增用 Add 或索引器,不确定的读取用 TryGetValue,删除用 Remove,遍历用 KeyValuePair。
- 底层是哈希表:键的哈希码决定桶位置,哈希冲突在桶内挂链处理;能预估数据量就指定初始容量。
- 理解这一步时,自定义类型当键要重写 GetHashCode 和 Equals(或用 record);多线程场景用 ConcurrentDictionary;需有序用 SortedDictionary。
实际处理时,总的来说,C# Dictionary增删改查、哈希原理与踩坑适合结合实际项目边做边理解。先抓住核心思路,再逐步补上细节和边界处理,最后效果会更稳定,也更容易复用。
