HashSet<T> 是一种每个元素最多只出现一次的集合。它没有索引,也没有保证的顺序,作为交换,它能在大约常数时间内判断元素是否存在:在一百万个标签中找一个,和在十个中找一个花的时间差不多。
添加元素:Add 返回 bool
输出:
True
False
3
True
True
False
2
添加重复项不算错误;Add 只是返回 false,集合保持不变。这个返回值是该方法最有用的地方。它把“我见过这个吗?”和“记住它”合并成了一次调用:
输出:
Duplicate: ana@x.com
Duplicate: ben@x.com
3 unique
为什么 Contains 很快
List<T>.Contains 会依次把值和每个元素比较,所以它的开销随列表增长。HashSet<T> 则计算元素的哈希码,直接跳到这个哈希码对应的桶,只比较存放在那里的少数几个元素。对于循环内部的存在性检查,这会把一个 O(n) 的步骤变成 O(1),也会把两个列表的嵌套循环变成一次遍历:
// Slow on large inputs: Contains scans bannedList for every order.
var flagged = orders.Where(o => bannedList.Contains(o.CustomerId));
// Fast: build the set once, then each lookup is constant time.
var banned = new HashSet<int>(bannedList);
var flagged2 = orders.Where(o => banned.Contains(o.CustomerId));
构建集合需要遍历一次列表,所以只有当你查找的次数不止寥寥几次时才划算。
对列表去重
常见的方法有三种,区别在于顺序会怎样:
输出:
Lima, Oslo, Pune, Kyiv
4
Lima, Oslo, Pune, Kyiv
当你想得到一个列表时,Distinct 是合适的默认选择。原地去重的版本之所以可行,是因为 RemoveAll 会按顺序对每个元素调用一次谓词:对于第二个及之后的副本,seen.Add 返回 false,所以被删除的正好是这些副本。
集合运算:并集、交集、差集
HashSet<T> 提供了集合论中的各种运算。...With 系列方法会修改调用它们的那个集合,并且不返回任何值。
输出:
Union: Ana, Ben, Chloe, Dev
Intersect: Ben, Chloe
Except: Ana
Symmetric: Ana, Dev
True
True
False
True
参数可以是任何 IEnumerable<T>:数组、列表或另一个集合。SetEquals 会忽略参数中的顺序和重复项。辅助方法 Show 在打印前先排序,因为集合的遍历顺序是不能依赖的。
LINQ 有对应的方法,它们返回一个新序列,不改动输入:monday.Union(tuesday)、monday.Intersect(tuesday)、monday.Except(tuesday)。当你不想修改集合,或者输入是列表时,就用这些方法。
为自己的类自定义相等比较
集合通过 GetHashCode 和 Equals 判断“是否是同一个元素”。对于没有重写这两个方法的类,它们都基于对象的标识,所以两个字段相同的对象是两个不同的元素:
输出:
2
1
True
规则是:Equals 相等的对象必须返回相同的 GetHashCode。如果只重写 Equals,集合会去错误的桶里查找,仍然会出现重复项。在 .NET Core 2.1 及以上版本中,HashCode.Combine(X, Y) 无需手写运算就能生成一个好的哈希码。
当你无法修改这个类,或者某个集合需要不同的“相同”标准时,给构造函数传入一个 IEqualityComparer<T>。字符串自带现成的比较器:
输出:
True
False
2
在 C# 9 及以上版本中,record 会为你生成基于值的 Equals 和 GetHashCode,所以 record Point(int X, int Y); 不需要额外代码就能用在集合中。
对象在集合中时,永远不要修改参与计算 GetHashCode 的字段。对象仍然留在按旧哈希码对应的桶里,于是 Contains 和 Remove 就再也找不到它了。
顺序与 SortedSet
HashSet<T> 的遍历顺序应当视为任意的。如果需要元素有序,就在打印时排序(set.OrderBy(x => x)),或者使用 SortedSet<T>,它始终保持元素有序,并提供 Min、Max 和范围查询,每次操作的开销是 O(log n):
var ranks = new SortedSet<int> { 30, 10, 20 };
Console.WriteLine(string.Join(", ", ranks)); // 10, 20, 30
Console.WriteLine(ranks.Min); // 10
HashSet、List 与 Dictionary 的对比
| 需求 | 使用 |
|---|---|
| 元素不重复,快速判断“在不在” | HashSet<T> |
| 元素不重复,并始终有序 | SortedSet<T> |
| 需要顺序、重复项或按索引访问 | List<T> |
| 每个键下存一个值 | Dictionary<TKey, TValue> |
集合就是只有键、没有值的字典。如果你发现自己写 Dictionary<string, bool> 只是为了记录某个元素是否存在,那么 HashSet<string> 能更清楚地表达同样的意思。
常见错误
- 指望有顺序。 集合没有可以依赖的顺序;请排序,或者使用
SortedSet<T>。 - 自定义类没有
Equals和GetHashCode。 看起来相等的对象会成为不同的元素。 - 只重写
Equals。 一定要同时重写GetHashCode。 - 添加元素后再修改它。 集合就再也找不到它了。
- 对集合使用索引。
set[0]无法编译;集合没有索引。如果需要位置,用ToList()转换。
常见问题
C# 中的 HashSet 是什么?
HashSet<T> 是一个由不重复元素组成、没有固定顺序的集合。添加一个已经存在的元素不会产生任何效果,而且无论集合有多大,Contains 都能在大约常数时间内给出结果,因为元素像 Dictionary 的键一样按哈希码存储。
HashSet.Add 返回什么?
元素被添加时 Add 返回 true,元素已经在集合中时返回 false。因此 if (!seen.Add(x)) 就是一行代码的重复检查:同一次调用中,它既添加新元素,又告诉你哪些是重复的。
C# 中如何对 List 去重?
list.Distinct().ToList() 返回一个没有重复项的新列表,并按原始顺序保留每个元素第一次出现的位置。new HashSet<T>(list) 也能去重,但集合没有保证的顺序。要原地去重,使用 var seen = new HashSet<T>(); list.RemoveAll(x => !seen.Add(x));。
什么时候应该用 HashSet 而不是 List?
当你主要关心“这个元素在不在集合里?”或者需要元素不重复时,用 HashSet<T>。List<T>.Contains 会扫描每个元素,所以列表越大越慢,而 HashSet<T>.Contains 不会。当顺序、重复项或按索引访问很重要时,用 List<T>。
为什么我的 HashSet 里有重复的对象?
你的类没有重写 Equals 和 GetHashCode,所以集合比较的是引用,两个字段值相同的对象会被当成不同的元素。重写这两个方法(总是一起重写),或者给集合的构造函数传入一个 IEqualityComparer<T>。