Menu

C# Dictionary 字典:添加、TryGetValue、遍历与排序

Dictionary<TKey, TValue> 把键映射到值,查找速度很快。学习如何添加和更新条目、用 TryGetValue 安全读取、遍历 KeyValuePair 条目、统计出现次数、让键忽略大小写,以及用 SortedDictionary 让键保持有序。

本页包含可运行的编辑器。编辑、运行,立即查看输出。

Dictionary<TKey, TValue> 把值存放在唯一的键下,无论有多少条目,按键查找值的时间都大致恒定。它就是 C# 中的哈希表:从姓名到号码的电话簿、从 ID 到记录的缓存、每个单词的计数,都可以用它。

创建字典并读取值

输出:

12
2.50
2
True
False

两种初始化写法效果相同。["key"] = value 写法(C# 6)使用索引器,所以重复的键会覆盖;{ key, value } 写法调用 Add,所以重复的键会在该行运行时抛出异常。

ContainsKey 是一次哈希查找,速度很快。ContainsValue 则必须扫描每一个条目,因为值没有索引。

Add、索引器与 TryAdd

放入条目有三种方式,它们的区别只在于键已经存在时会发生什么:

输出:

26
Caught ArgumentException
True
False
31

Add 遇到重复键就抛出异常,这其实是一项功能:它能暴露本应唯一却出现重复的数据。你的意思是“插入或更新”时用索引器;希望第一个值生效时用 TryAdd(.NET Core 2.0 及更高版本)。

键不能是 null。Add(null, ...) 或 dict[null] 会抛出 ArgumentNullException。只要值的类型允许,值可以是 null。

KeyNotFoundException 与 TryGetValue

用索引器读取不存在的键会抛出 KeyNotFoundException。这是最常见的字典错误,修复方法几乎总是 TryGetValue。

输出:

Caught KeyNotFoundException
Found ana@example.com
Missing, value is null: True
no email

TryGetValue 只做一次哈希查找,并用 bool 报告是否成功。if (dict.ContainsKey(k)) { var v = dict[k]; } 这种写法也能用,但会查找两次键。键不存在时,out 变量会得到该类型的默认值(null、0、false)。

在 .NET Core 2.0 及更高版本中,还有 GetValueOrDefault(key, fallback),键不存在时返回备用值:emails.GetValueOrDefault(103, "no email")。

更新和删除条目

输出:

2
True
False
1
0

如果字典中还没有 milk,cart["milk"] += 1 会抛出 KeyNotFoundException,因为它先读后写。Remove 遇到不存在的键会返回 false 而不是抛出异常,所以不需要先检查 ContainsKey。

遍历:KeyValuePair、Keys 与 Values

对字典做 foreach 会得到一个个 KeyValuePair<TKey, TValue>,每一项都有 Key 和 Value。

输出:

Ana: 88
Ben: 72
Chloe: 95
Ana Ben Chloe 
Total 255
77

pair.Value 是只读的,所以更新值要通过索引器写入。最后一个循环遍历的是键的 List<string> 副本,这样总是安全的;直接遍历 scores.Keys 并覆盖已有的值,在 .NET Core 3.0 及更高版本中是允许的,但在 .NET Framework 上会抛出 InvalidOperationException。

在对同一个字典做 foreach 时添加新键,在所有版本上都会抛出 InvalidOperationException。在枚举过程中删除,在 .NET Framework 上会抛出异常,从 .NET Core 3.0 起是允许的。需要在所有环境下运行的代码,会先收集要删除的键,等循环结束后再删除。

在 C# 7 和 .NET Core 2.0 或更高版本中,可以在循环头中解构 KeyValuePair:

foreach (var (name, score) in scores)
{
    Console.WriteLine($"{name}: {score}");
}

用字典计数

统计出现次数是教科书级的用法。用 TryGetValue 读取当前计数(键不存在时得到 0),再写回去。

输出:

the 3
cat 1
and 2
dog 1
bird 1

同样的结构也可以用来分组:Dictionary<string, List<Order>>,用 TryGetValue 取出列表,不存在就新建一个并存进去,然后 Add 到列表中。一次性的计数和分组,用 LINQ 一个表达式就能搞定:words.GroupBy(w => w).ToDictionary(g => g.Key, g => g.Count())。参见 LINQ。

用比较器实现不区分大小写的键

字符串键默认精确比较:"Apple" 和 "apple" 是两个键。给构造函数传入一个 IEqualityComparer<string> 就能改变这一点。

输出:

False
text/html
1

对于 HTTP 头、文件扩展名和用户名这类标识符,StringComparer.OrdinalIgnoreCase 是正确的选择。存储前对每个键调用 .ToLower() 也行,但很容易在某个地方忘掉。

如果键是你自己定义的类,字典会调用键的 GetHashCode 和 Equals。没有重写这两个方法的类按引用比较,所以两个字段相同的不同对象是不同的键。如何编写这对方法,参见 HashSet。

顺序、排序与 SortedDictionary

Dictionary 不对枚举顺序做任何承诺。实际上,只添加过条目的字典会按插入顺序枚举,但在 Remove 之后,后面的 Add 可能复用腾出的位置,从而出现在更靠前的地方。代码永远不应该依赖这个顺序。

需要顺序时,在使用的地方排序,或者使用有序集合:

输出:

Cairo 210
Lima 340
Oslo 520
By value, highest first:
Oslo 520
Lima 340
Cairo 210
Berlin, Cairo, Lima, Oslo

SortedDictionary<TKey, TValue> 始终让键保持有序(它是一棵平衡树),所以查找和插入是 O(log n),而不是 O(1)。经常需要按键的顺序枚举时用它;只需要排一次序时,用 LINQ 对普通字典排序即可。SortedList<TKey, TValue> 是第三种选择,占用内存更少,但数据量大时插入很慢。

速查

任务代码
创建new Dictionary<string, int>()
插入或覆盖d[k] = v
插入,重复时抛出异常d.Add(k, v)
仅在不存在时插入d.TryAdd(k, v)
读取,不存在时抛出异常d[k]
安全读取d.TryGetValue(k, out var v)
键是否存在d.ContainsKey(k)
删除d.Remove(k)(返回 bool)
大小d.Count
键、值d.Keys、d.Values
按键排序d.OrderBy(p => p.Key) 或 SortedDictionary
忽略大小写new Dictionary<string, T>(StringComparer.OrdinalIgnoreCase)

常见错误

  • 用 d[k] 读取不存在的键。 会抛出 KeyNotFoundException;请用 TryGetValue。
  • 对可能已存在的键调用 Add。 会抛出 ArgumentException;请用索引器或 TryAdd。
  • 在遍历字典的 foreach 中添加键。 会抛出 InvalidOperationException;先收集要做的修改,循环结束后再应用。
  • 依赖枚举顺序。 请排序,或使用 SortedDictionary。
  • 把对象作为键插入后又修改它的字段。 它的哈希码会改变,这个条目就再也找不到了。
  • 先 ContainsKey 再用索引器。 查找了两次;TryGetValue 只查找一次。

常见问题

C# 中 Dictionary.Add 和索引器有什么区别?

dict.Add(key, value) 插入一个新条目,如果键已经存在就抛出 ArgumentException。dict[key] = value 在键不存在时插入条目,存在时覆盖值,遇到重复键也绝不会抛出异常。TryAdd(key, value) 只在键不存在时插入,否则返回 false。

C# 中 TryGetValue 是怎么工作的?

键存在时,dict.TryGetValue(key, out var value) 返回 true 并设置 value;键不存在时返回 false,并把 value 设为其类型的默认值。它只查找一次,而先 ContainsKey 再 dict[key] 要查找两次,而且它永远不会抛出 KeyNotFoundException。

C# 中如何遍历 Dictionary?

foreach (KeyValuePair<string, int> pair in dict) 会依次给出每个条目,通过 pair.Key 和 pair.Value 访问。只想遍历键或值时,用 dict.Keys 或 dict.Values。不要在这个循环里向字典添加键:那会抛出 InvalidOperationException。

C# 的 Dictionary 是有序的吗?

不保证任何顺序。一个只添加过条目的字典通常按插入顺序枚举,但执行 Remove 之后,新条目可能填进腾出的位置,顺序就变了。需要顺序时就排序:dict.OrderBy(p => p.Key),或者使用总是按键枚举的 SortedDictionary<TKey, TValue>。

如何让 Dictionary 的键不区分大小写?

给构造函数传入一个比较器:new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase)。这样在查找、Add 和 ContainsKey 中,"Apple" 和 "apple" 就是同一个键。比较器在创建字典时就固定了。

C# 中的 KeyValuePair 是什么?

KeyValuePair<TKey, TValue> 是枚举字典时,字典为每个条目交给你的结构体。它的 Key 和 Value 属性都是只读的,所以不能通过它修改条目;请改写 dict[pair.Key] = newValue(在循环结束后,或者遍历键的副本时)。

Coddy 编程语言插图

和 Coddy 一起学编程

开始