C# 中的 HashSet<T>
:深入介绍与基本用法
在 C# 的集合类库中,HashSet<T>
是一个功能强大且高度优化的数据结构,专门用于存储唯一元素的无序集合。它在需要快速检查元素是否存在、执行集合运算(如并集、交集)或 simply 确保集合中没有重复项的场景下表现卓越。本文将详细介绍 HashSet<T>
,从其基本概念、内部工作原理,到创建、基本操作、高级用法以及与其他集合类型的比较,带你全面掌握这一重要的集合类型。
1. 引言:什么是集合?为什么需要 HashSet<T>
?
在软件开发中,我们经常需要处理一组数据。C# 提供了丰富的集合类来组织和操作这些数据,例如 List<T>
(列表)、Dictionary<TKey, TValue>
(字典)、Queue<T>
(队列)、Stack<T>
(栈)等等。每种集合类型都有其特定的用途和性能特点。
有时,我们需要存储一组元素,但对元素的顺序没有要求,并且最重要的是,这组元素中不允许出现重复项。例如:
- 你想存储一个用户访问过的独特网页 URL 列表,而不需要关心访问顺序,也不想记录重复的访问。
- 你需要统计一篇文章中出现的不同单词的数量。
- 你正在开发一个标签系统,需要判断一个物品是否属于某个特定的标签集合。
- 你需要快速判断某个元素是否存在于一个大型数据集中。
在这些场景下,传统的 List<T>
虽然可以存储元素,但要检查重复项或查找元素通常需要遍历列表,这在大数据量时效率较低(平均 O(n) 时间复杂度)。手动去重也增加了代码的复杂性。
这就是 HashSet<T>
的用武之地。它专门设计用来解决“无序、唯一元素集合”的问题,并提供了极其高效的元素查找和集合操作能力。
从数学上讲,一个集合(Set)就是一些元素的总体,这些元素之间没有顺序关系,并且集合中的每个元素都必须是唯一的。HashSet<T>
正是 C# 中对数学集合概念的一种高效实现。
2. HashSet<T>
的核心概念与工作原理
要理解 HashSet<T>
的优势,就必须了解它的两个核心特性以及背后的实现原理:
2.1. 核心特性:无序与唯一性
- 无序 (Unordered):
HashSet<T>
不保证元素的存储或迭代顺序。元素的顺序取决于其内部哈希桶的分布,这个分布是动态的,并且不反映元素的插入顺序。如果你需要一个有序的唯一元素集合,应该考虑使用SortedSet<T>
。 - 唯一性 (Unique Elements):
HashSet<T>
中不允许存在重复的元素。当你尝试添加一个已经存在的元素时,添加操作会被忽略,并且Add
方法会返回false
。
2.2. 工作原理:哈希表 (Hash Table)
HashSet<T>
内部使用了哈希表(Hash Table)数据结构来实现其高效的性能。简单来说,哈希表通过一个哈希函数(Hash Function)将元素的键(在这里就是元素本身)映射到表中的一个位置(称为哈希码或索引)。
当你在 HashSet<T>
中执行操作(如添加、查找、删除)时,过程大致如下:
- 计算哈希码: 对于要操作的元素,
HashSet<T>
会调用该元素的GetHashCode()
方法来获取一个整数哈希码。 - 定位桶 (Bucket):
HashSet<T>
使用哈希码来确定该元素应该存储或查找在哈希表中的哪个“桶”里。这个过程通常是取哈希码的模运算。 - 处理哈希冲突 (Collision): 不同的元素可能计算出相同的哈希码(称为哈希冲突),或者不同的哈希码映射到同一个桶。
HashSet<T>
使用一种技术(通常是链表法或开放寻址法)来处理这些冲突,确保在同一个桶内可以存储多个元素。 - 比较元素 (Equality Check): 在定位到可能的桶后,
HashSet<T>
会在该桶内遍历元素,并使用元素的Equals()
方法来与目标元素进行比较,以确定是否是同一个逻辑上的元素。
为什么这种方式高效?
- 平均 O(1) 性能: 如果哈希函数能够将元素均匀地分散到不同的桶中,那么在平均情况下,查找、添加和删除一个元素只需要计算一次哈希码,然后直接访问对应的桶,并在少数几个元素中进行
Equals
比较。这个过程与集合中元素的总数无关,所以是常数时间复杂度 O(1)。 - 为什么需要
Equals
检查? 仅仅哈希码相同不足以证明两个对象是相等的,因为哈希冲突是存在的。只有当哈希码相同 并且Equals()
方法返回true
时,HashSet<T>
才认为这两个元素是同一个元素。 - 对
GetHashCode()
和Equals()
的要求: 为了HashSet<T>
正确工作并发挥其性能优势,存储在其中的类型T
必须正确地实现GetHashCode()
和Equals()
方法。对于 .NET 的内置值类型(如int
,string
),这已经做好了。对于自定义的引用类型,如果你希望基于对象的内容而不是引用相等性来判断唯一性,你必须重写这两个方法。否则,默认情况下,HashSet<T>
将使用Object.GetHashCode()
(通常基于对象内存地址)和Object.Equals()
(引用相等性),这意味着只有同一个对象的不同引用才会被视为重复。
3. HashSet<T>
的创建与初始化
创建 HashSet<T>
非常简单,它位于 System.Collections.Generic
命名空间下。
首先,确保你的代码文件顶部有 using System.Collections.Generic;
。
3.1. 创建空的 HashSet<T>
你可以使用默认构造函数创建一个空的 HashSet<T>
:
“`csharp
// 创建一个存储字符串的空 HashSet
HashSet
// 创建一个存储整数的空 HashSet
HashSet
“`
3.2. 从其他集合初始化 HashSet<T>
HashSet<T>
的一个常用构造函数接受一个实现了 IEnumerable<T>
接口的集合。这非常方便,可以用来快速地从一个列表、数组或其他集合中创建一个包含唯一元素的 HashSet
。如果源集合包含重复项,HashSet
会自动去重。
“`csharp
// 从一个包含重复项的 List 创建 HashSet
List
HashSet
// uniqueNumbersFromList 现在包含 { 1, 2, 3, 4, 5 } (顺序不确定)
Console.WriteLine(“Unique numbers from list:”);
foreach (int num in uniqueNumbersFromList)
{
Console.Write(num + ” “);
}
Console.WriteLine(); // Output might be: Unique numbers from list: 1 2 3 4 5 (or any other order)
// 从一个数组创建 HashSet
string[] colorsArray = { “Red”, “Green”, “Blue”, “Red”, “Yellow” };
HashSet
// uniqueColorsFromArray 现在包含 { “Red”, “Green”, “Blue”, “Yellow” } (顺序不确定)
Console.WriteLine(“Unique colors from array:”);
foreach (string color in uniqueColorsFromArray)
{
Console.Write(color + ” “);
}
Console.WriteLine(); // Output might be: Unique colors from array: Red Green Blue Yellow (or any other order)
“`
3.3. 使用集合初始化器 (Collection Initializer)
C# 提供了集合初始化器语法,使得创建并填充 HashSet<T>
更加简洁:
“`csharp
// 使用集合初始化器创建并初始化 HashSet
HashSet
{
“Apple”,
“Banana”,
“Orange”,
“Apple” // Duplicate, will be ignored
};
// fruits 现在包含 { “Apple”, “Banana”, “Orange” } (顺序不确定)
Console.WriteLine(“Fruits set:”);
foreach (string fruit in fruits)
{
Console.Write(fruit + ” “);
}
Console.WriteLine(); // Output might be: Fruits set: Apple Banana Orange (or any other order)
“`
4. HashSet<T>
的基本用法与操作
一旦创建了 HashSet<T>
,就可以执行各种基本操作。
4.1. 添加元素 (Add
)
使用 Add(T item)
方法向 HashSet<T>
中添加元素。如果元素成功添加(即之前不存在),该方法返回 true
;如果元素已经存在于集合中(是重复项),则添加操作无效并返回 false
。
“`csharp
HashSet
bool addedJohn = attendees.Add(“John”); // Returns true, set: {“John”}
Console.WriteLine($”Added John: {addedJohn}”);
bool addedJane = attendees.Add(“Jane”); // Returns true, set: {“John”, “Jane”}
Console.WriteLine($”Added Jane: {addedJane}”);
bool addedJohnAgain = attendees.Add(“John”); // Returns false, set remains: {“John”, “Jane”}
Console.WriteLine($”Added John again: {addedJohnAgain}”);
Console.WriteLine(“Current attendees:”);
foreach (string attendee in attendees)
{
Console.Write(attendee + ” “);
}
Console.WriteLine(); // Output might be: Current attendees: John Jane (or Jane John)
“`
4.2. 移除元素 (Remove
)
使用 Remove(T item)
方法从 HashSet<T>
中移除指定的元素。如果元素在集合中并被成功移除,该方法返回 true
;如果元素不在集合中,则移除操作无效并返回 false
。
“`csharp
HashSet
bool removedBob = guests.Remove(“Bob”); // Returns true, set: {“Alice”, “Charlie”, “David”}
Console.WriteLine($”Removed Bob: {removedBob}”);
bool removedEve = guests.Remove(“Eve”); // Returns false, set remains: {“Alice”, “Charlie”, “David”}
Console.WriteLine($”Removed Eve: {removedEve}”);
Console.WriteLine(“Remaining guests:”);
foreach (string guest in guests)
{
Console.Write(guest + ” “);
}
Console.WriteLine(); // Output order varies
“`
4.3. 检查元素是否存在 (Contains
)
使用 Contains(T item)
方法检查 HashSet<T>
中是否包含指定的元素。由于 HashSet
的内部实现,这个操作非常高效(平均 O(1))。
“`csharp
HashSet
bool hasRed = validColors.Contains(“Red”); // Returns true
Console.WriteLine($”Contains Red: {hasRed}”);
bool hasYellow = validColors.Contains(“Yellow”); // Returns false
Console.WriteLine($”Contains Yellow: {hasYellow}”);
“`
4.4. 获取元素数量 (Count
)
使用 Count
属性获取 HashSet<T>
中当前包含的元素数量。
“`csharp
HashSet
Console.WriteLine($”Number of elements: {mySet.Count}”); // Output: Number of elements: 3
mySet.Add(20); // Duplicate, not added
Console.WriteLine($”Number of elements after adding duplicate: {mySet.Count}”); // Output: Number of elements after adding duplicate: 3
mySet.Remove(10); // Remove existing element
Console.WriteLine($”Number of elements after removing 10: {mySet.Count}”); // Output: Number of elements after removing 10: 2
“`
4.5. 清空集合 (Clear
)
使用 Clear()
方法移除 HashSet<T>
中的所有元素。
csharp
HashSet<int> tempSet = new HashSet<int> { 1, 2, 3, 4, 5 };
Console.WriteLine($"Count before clear: {tempSet.Count}"); // Output: Count before clear: 5
tempSet.Clear();
Console.WriteLine($"Count after clear: {tempSet.Count}"); // Output: Count after clear: 0
4.6. 遍历集合
你可以使用 foreach
循环遍历 HashSet<T>
中的所有元素。记住,遍历的顺序是不可预测的,不保证与插入顺序或任何特定排序有关。
“`csharp
HashSet
Console.WriteLine(“Iterating through the set:”);
foreach (char letter in letters)
{
Console.Write(letter + ” “);
}
Console.WriteLine(); // Output order varies, e.g., d b a c
“`
重要提示: 在使用 foreach
循环遍历 HashSet
时,绝对不要修改(添加或移除)集合中的元素。这会导致迭代器失效,引发 InvalidOperationException
异常。如果你需要在遍历时修改集合,常见的做法是将要修改的项存储在另一个临时集合中,然后在遍历完成后再执行修改操作,或者在遍历前将 HashSet
转换为 List
或数组再进行遍历和修改。
5. HashSet<T>
的高级用法:集合运算
HashSet<T>
最强大的特性之一是其内置的集合运算方法,这些方法允许你高效地执行数学集合操作,如并集、交集、差集等。这些操作直接在 HashSet
对象上修改自身或用于比较,通常比手动使用循环和查找来完成这些任务要高效得多。
以下是 HashSet<T>
提供的主要集合运算方法:
假设有两个 HashSet<T>
集合 A 和 B。
5.1. 并集 (UnionWith
)
setA.UnionWith(collectionB)
:修改集合 A,使其包含集合 A 和集合 B 中的所有独特元素。
“`csharp
HashSet
HashSet
Console.WriteLine(“Set 1 before UnionWith:”);
foreach (int i in set1) Console.Write(i + ” “);
Console.WriteLine(); // e.g., 1 2 3
set1.UnionWith(set2);
Console.WriteLine(“Set 1 after UnionWith Set 2:”);
foreach (int i in set1) Console.Write(i + ” “);
Console.WriteLine(); // e.g., 1 2 3 4 5 (order varies)
“`
5.2. 交集 (IntersectWith
)
setA.IntersectWith(collectionB)
:修改集合 A,使其只包含同时存在于集合 A 和集合 B 中的元素。
“`csharp
HashSet
HashSet
Console.WriteLine(“Set A before IntersectWith:”);
foreach (int i in setA) Console.Write(i + ” “);
Console.WriteLine(); // e.g., 1 2 3 4
setA.IntersectWith(setB);
Console.WriteLine(“Set A after IntersectWith Set B:”);
foreach (int i in setA) Console.Write(i + ” “);
Console.WriteLine(); // e.g., 3 4 (order varies)
“`
5.3. 差集 (ExceptWith
)
setA.ExceptWith(collectionB)
:修改集合 A,使其移除所有存在于集合 B 中的元素(A 减去 B)。
“`csharp
HashSet
HashSet
Console.WriteLine(“Set X before ExceptWith:”);
foreach (int i in setX) Console.Write(i + ” “);
Console.WriteLine(); // e.g., 10 20 30 40
setX.ExceptWith(setY);
Console.WriteLine(“Set X after ExceptWith Set Y:”);
foreach (int i in setX) Console.Write(i + ” “);
Console.WriteLine(); // e.g., 10 20 (order varies)
“`
5.4. 对称差集 (SymmetricExceptWith
)
setA.SymmetricExceptWith(collectionB)
:修改集合 A,使其只包含只存在于集合 A 或只存在于集合 B 中的元素(不包括同时存在于两者的元素)。这相当于 (A 联合 B) 减去 (A 交集 B)。
“`csharp
HashSet
HashSet
Console.WriteLine(“Set P before SymmetricExceptWith:”);
foreach (int i in setP) Console.Write(i + ” “);
Console.WriteLine(); // e.g., 1 2 3 4
setP.SymmetricExceptWith(setQ);
Console.WriteLine(“Set P after SymmetricExceptWith Set Q:”);
foreach (int i in setP) Console.Write(i + ” “);
Console.WriteLine(); // e.g., 1 2 5 6 (order varies)
“`
5.5. 子集、超集和重叠判断 (IsSubsetOf
, IsSupersetOf
, Overlaps
, SetEquals
)
HashSet<T>
还提供了一系列用于比较两个集合之间关系的方法,它们返回布尔值:
IsSubsetOf(IEnumerable<T> other)
: 判断当前集合是否是另一个集合(other)的子集。如果当前集合中的所有元素都存在于 other 集合中,则返回true
。IsSupersetOf(IEnumerable<T> other)
: 判断当前集合是否是另一个集合(other)的超集。如果 other 集合中的所有元素都存在于当前集合中,则返回true
。Overlaps(IEnumerable<T> other)
: 判断当前集合是否与另一个集合(other)有任何共同元素。如果存在至少一个相同的元素,则返回true
。SetEquals(IEnumerable<T> other)
: 判断当前集合是否包含与另一个集合(other)完全相同的元素。元素的数量和种类必须一致,但顺序无关。
“`csharp
HashSet
HashSet
HashSet
HashSet
Console.WriteLine($”Set A is subset of Set B: {setA.IsSubsetOf(setB)}”); // True
Console.WriteLine($”Set B is superset of Set A: {setB.IsSupersetOf(setA)}”); // True
Console.WriteLine($”Set A is subset of Set C: {setA.IsSubsetOf(setC)}”); // True (equal sets are subsets/supersets of each other)
Console.WriteLine($”Set A is superset of Set C: {setA.IsSupersetOf(setC)}”); // True
Console.WriteLine($”Set A overlaps with Set B: {setA.Overlaps(setB)}”); // True (elements 1, 2, 3)
Console.WriteLine($”Set A overlaps with Set D: {setA.Overlaps(setD)}”); // False
Console.WriteLine($”Set A equals Set B: {setA.SetEquals(setB)}”); // False
Console.WriteLine($”Set A equals Set C: {setA.SetEquals(setC)}”); // True
“`
这些集合运算方法接收的参数类型是 IEnumerable<T>
,这意味着你可以与任何实现了 IEnumerable<T>
的集合(如 List<T>
, Array
, 甚至另一个 HashSet<T>
)进行运算或比较。
6. HashSet<T>
的性能特点总结
基于其哈希表的内部实现,HashSet<T>
在性能上有显著优势,尤其是在以下操作中:
- 添加 (
Add
): 平均 O(1),最差 O(n)(哈希冲突严重)。 - 移除 (
Remove
): 平均 O(1),最差 O(n)。 - 检查存在 (
Contains
): 平均 O(1),最差 O(n)。 - 清空 (
Clear
): O(n),需要遍历内部结构并重置。 - 迭代 (
foreach
): O(n),需要遍历所有元素所在的桶。 - 集合运算 (
UnionWith
,IntersectWith
,ExceptWith
,SymmetricExceptWith
): 通常是 O(n) 或 O(m) 或 O(n + m) 的时间复杂度,其中 n 和 m 是参与运算的两个集合的大小。这远优于在列表上进行这些操作所需的 O(n * m) 或 O(n * n) 复杂度。例如,判断两个列表是否有共同元素可能需要 O(n*m),但将其中一个或两个转换为HashSet
后,可以使用Overlaps
或IntersectWith
,其效率要高得多。 - 子集/超集/相等判断 (
IsSubsetOf
,IsSupersetOf
,SetEquals
): 这些操作通常也具有较高的效率,通常是 O(n) 或 O(m) 或 O(n + m)。IsSubsetOf(other)
检查需要遍历当前集合并在other
中查找,如果other
是HashSet
,则查找是 O(1),总计 O(n)。如果other
是列表,查找是 O(m),总计 O(n*m)。所以,与HashSet
进行集合运算或比较时,参数集合的类型也会影响整体性能。
性能注意事项:
HashSet<T>
的最差性能 O(n) 主要发生在哈希函数质量差导致大量哈希冲突时,所有元素都挤在少数几个桶里,此时查找元素退化为遍历链表,接近于列表的性能。选择合适的元素类型或提供高质量的哈希函数是关键。- 对于自定义对象,正确实现
GetHashCode()
和Equals()
至关重要,否则HashSet
的行为可能不符合预期,并且性能会受到影响。
7. 与其他集合类型的比较
理解 HashSet<T>
的最佳方式之一是将其与 C# 中其他常用的集合类型进行比较。
-
List<T>
:- 有序性:
List<T>
保持元素的插入顺序,可以通过索引访问。HashSet<T>
无序,不支持索引访问。 - 唯一性:
List<T>
允许重复元素。HashSet<T>
只存储唯一元素。 - 查找 (
Contains
):List<T>
平均 O(n),最差 O(n)。HashSet<T>
平均 O(1),最差 O(n)。 - 添加/移除:
List<T>
在末尾添加是 O(1),中间添加/移除是 O(n)。HashSet<T>
平均 O(1)。 - 适用场景:
List<T>
适用于需要有序、可能包含重复项、需要按索引访问的场景。HashSet<T>
适用于需要唯一元素、快速查找和集合运算的场景。
- 有序性:
-
Dictionary<TKey, TValue>
:- 结构:
Dictionary<TKey, TValue>
存储键值对,键是唯一的。HashSet<T>
只存储唯一的元素(相当于只有键)。 - 内部实现: 两者都使用哈希表。
- 用途:
Dictionary
用于需要根据唯一的键快速查找对应值的场景。HashSet
用于只需要判断元素是否存在或执行集合运算,不需要关联额外值的场景。可以说HashSet<T>
在某种程度上是Dictionary<T, bool>
的简化版本,但专门针对集合操作进行了优化。
- 结构:
-
SortedSet<T>
:- 有序性:
SortedSet<T>
存储唯一元素,并保持元素的排序(默认使用元素的默认比较器或指定的IComparer<T>
)。HashSet<T>
无序。 - 内部实现:
SortedSet<T>
使用红黑树(一种自平衡二叉搜索树)。HashSet<T>
使用哈希表。 - 性能:
SortedSet<T>
的添加、移除、查找操作都是 O(log n)。HashSet<T>
这些操作平均 O(1)。 - 适用场景:
SortedSet<T>
适用于需要唯一的、并且始终保持排序的集合。HashSet<T>
适用于不需要排序,但需要最快查找和集合运算的场景(在哈希冲突不严重的情况下)。如果集合非常大,O(log n) 可能仍然非常快,但 O(1) 在理论上更快。选择哪个取决于具体需求:需要排序吗?是否能接受平均 O(1) 而非 guaranteed O(log n)?
- 有序性:
何时选择 HashSet<T>
?
- 你需要一个集合,其中的元素必须是唯一的。
- 你对元素的顺序不关心。
- 你需要频繁地检查某个元素是否存在于集合中(
Contains
操作)。 - 你需要高效地执行集合运算(并集、交集、差集等)。
8. 使用自定义类型和 IEqualityComparer<T>
如前所述,HashSet<T>
依赖于元素的 GetHashCode()
和 Equals()
方法来判断唯一性。对于值类型和 string
类型,这些方法已经正确实现。但对于你自己的类(引用类型),默认的实现是基于引用的。
考虑以下 Person
类:
“`csharp
public class Person
{
public int Id { get; set; }
public string Name { get; set; }
// Default implementation uses reference equality
// public override bool Equals(object obj) { return base.Equals(obj); }
// public override int GetHashCode() { return base.GetHashCode(); }
}
“`
如果你将 Person
对象添加到 HashSet<Person>
中,即使两个 Person
对象的 Id
和 Name
都相同,但它们是不同的对象实例,HashSet
也会将它们视为不同的元素:
“`csharp
HashSet
Person p1 = new Person { Id = 1, Name = “Alice” };
Person p2 = new Person { Id = 2, Name = “Bob” };
Person p3 = new Person { Id = 1, Name = “Alice” }; // Same content as p1, but different object
peopleSet.Add(p1); // Added
peopleSet.Add(p2); // Added
peopleSet.Add(p3); // Added (because p3 is a different object reference than p1)
Console.WriteLine($”Count: {peopleSet.Count}”); // Output: Count: 3
“`
要让 HashSet<Person>
根据 Id
和 Name
来判断唯一性,你需要重写 Equals()
和 GetHashCode()
方法:
“`csharp
public class PersonWithEquality
{
public int Id { get; set; }
public string Name { get; set; }
// Override Equals to compare by Id and Name
public override bool Equals(object obj)
{
if (obj == null || GetType() != obj.GetType())
{
return false;
}
PersonWithEquality other = (PersonWithEquality)obj;
return Id == other.Id && Name == other.Name;
}
// Override GetHashCode to generate a hash based on Id and Name
public override int GetHashCode()
{
// A common way to combine hash codes, requires System.HashCode (modern .NET)
return System.HashCode.Combine(Id, Name);
// For older .NET versions or manual combination:
// int hash = 17; // Start with a prime number
// hash = hash * 23 + Id.GetHashCode(); // Combine with Id's hash
// hash = hash * 23 + (Name != null ? Name.GetHashCode() : 0); // Combine with Name's hash
// return hash;
}
public override string ToString()
{
return $"Id: {Id}, Name: {Name}";
}
}
“`
现在,使用 PersonWithEquality
类:
“`csharp
HashSet
PersonWithEquality pEq1 = new PersonWithEquality { Id = 1, Name = “Alice” };
PersonWithEquality pEq2 = new PersonWithEquality { Id = 2, Name = “Bob” };
PersonWithEquality pEq3 = new PersonWithEquality { Id = 1, Name = “Alice” }; // Same content as pEq1
peopleSetWithEquality.Add(pEq1); // Added
peopleSetWithEquality.Add(pEq2); // Added
peopleSetWithEquality.Add(pEq3); // Not added (because it’s considered equal to pEq1)
Console.WriteLine($”Count with custom equality: {peopleSetWithEquality.Count}”); // Output: Count with custom equality: 2
Console.WriteLine(“Elements in set:”);
foreach (var person in peopleSetWithEquality)
{
Console.WriteLine(person); // Output includes pEq1 and pEq2 (or pEq3 if pEq1 wasn’t added first)
}
“`
使用 IEqualityComparer<T>
有时,你可能不想修改类的定义来重写 Equals
和 GetHashCode
,或者你需要在不同的 HashSet
中使用不同的相等性逻辑。这时,你可以创建一个实现 IEqualityComparer<T>
接口的单独类,并在创建 HashSet
时将其传递给构造函数。
“`csharp
public class PersonIdComparer : IEqualityComparer
{
public bool Equals(Person x, Person y)
{
if (ReferenceEquals(x, y)) return true;
if (ReferenceEquals(x, null) || ReferenceEquals(y, null)) return false;
// Compare only by Id
return x.Id == y.Id;
}
public int GetHashCode(Person obj)
{
if (obj == null) return 0;
// Hash based on Id
return obj.Id.GetHashCode();
}
}
“`
现在,你可以使用这个比较器创建 HashSet
:
“`csharp
// Create HashSet using the custom comparer
HashSet
Person pA = new Person { Id = 1, Name = “Alice” };
Person pB = new Person { Id = 2, Name = “Bob” };
Person pC = new Person { Id = 1, Name = “Charlie” }; // Same Id as pA, different name
peopleSetWithComparer.Add(pA); // Added
peopleSetWithComparer.Add(pB); // Added
peopleSetWithComparer.Add(pC); // Not added (because Id 1 is already present)
Console.WriteLine($”Count with Id comparer: {peopleSetWithComparer.Count}”); // Output: Count with Id comparer: 2
Console.WriteLine(“Elements in set with Id comparer:”);
foreach (var person in peopleSetWithComparer)
{
Console.WriteLine($”Id: {person.Id}, Name: {person.Name}”); // Output includes pA and pB
}
“`
使用 IEqualityComparer<T>
提供了更大的灵活性,尤其适用于处理第三方库中的类,或者需要多种方式判断对象相等性的场景。
9. 常见的使用场景与示例
HashSet<T>
在实际开发中有许多应用:
- 去重: 从一个可能包含重复项的集合中快速获取所有唯一元素。
csharp
List<string> emailsWithDuplicates = new List<string> { "[email protected]", "[email protected]", "[email protected]", "[email protected]" };
HashSet<string> uniqueEmails = new HashSet<string>(emailsWithDuplicates);
// uniqueEmails now contains {"[email protected]", "[email protected]", "[email protected]"} - 快速查找: 判断某个元素是否存在于一个大型数据集中。
csharp
HashSet<string> bannedUsernames = GetBannedUsernames(); // Assume this returns a large set
string usernameToCheck = "hacker123";
if (bannedUsernames.Contains(usernameToCheck))
{
Console.WriteLine($"{usernameToCheck} is banned.");
} -
比较列表/集合: 找出两个列表中的共同元素、不同元素等。
“`csharp
Listlist1 = new List { 1, 2, 3, 4, 5 };
Listlist2 = new List { 4, 5, 6, 7, 8 }; HashSet
set1 = new HashSet (list1);
HashSetset2 = new HashSet (list2); // Find common elements (intersection)
set1.IntersectWith(set2);
Console.WriteLine(“Common elements:”);
foreach (int i in set1) Console.Write(i + ” “); // Output: 4 5 (order varies)
Console.WriteLine();// Find elements only in list1 (difference)
set1 = new HashSet(list1); // Re-initialize set1
set1.ExceptWith(set2);
Console.WriteLine(“Elements only in list1:”);
foreach (int i in set1) Console.Write(i + ” “); // Output: 1 2 3 (order varies)
Console.WriteLine();
``
HashSet` 是一个非常高效的工具。
* **标签系统:** 检查一个物品是否具有某个标签集合中的所有标签,或者是否与某个标签集合有交集。
* **算法实现:** 在一些算法中,例如图的遍历(判断节点是否已访问过)或查找不重复元素,
10. HashSet<T>
的容量 (Capacity)
与 List<T>
类似,HashSet<T>
在内部也有一个容量的概念,表示哈希表可以容纳多少桶。当你添加的元素数量超过当前容量所能有效处理时,HashSet
会自动增加容量并重新组织内部结构(称为重新哈希或 Rehash)。这个过程需要遍历现有元素并重新计算它们的哈希码和桶位置,成本较高。
如果你提前知道集合将存储大约多少元素,可以在创建 HashSet
时通过构造函数指定初始容量,这可以减少后续不必要的重新哈希操作,从而提高性能:
csharp
// Estimate the size will be around 1000 elements
HashSet<string> largeSet = new HashSet<string>(1000);
指定一个合适的初始容量可以优化性能,但过大也不会带来显著的好处,反而会浪费内存。通常情况下,让 HashSet
自动管理容量也是可以接受的,除非你处理的是非常大的数据集且性能要求非常苛刻。
11. 潜在的陷阱与注意事项
- 对象相等性: 对于自定义引用类型,务必正确重写
Equals()
和GetHashCode()
,或者提供IEqualityComparer<T>
。这是HashSet
正确工作的基础。 - 可变对象: 将可变对象存储在
HashSet
中,并在其被添加到集合 后 修改了影响GetHashCode()
或Equals()
结果的属性,这是非常危险的。修改后的对象的哈希码可能会改变,导致HashSet
无法正确地找到或移除该对象,从而破坏集合的完整性。尽量将不可变对象放入HashSet
。如果必须存储可变对象,不要修改那些用于计算哈希码和判断相等性的属性。 - 迭代时修改: 前面已经提到,在
foreach
循环中修改HashSet
会抛出异常。 - null 值: 如果
T
是引用类型,HashSet<T>
可以包含一个null
值。Contains(null)
、Add(null)
和Remove(null)
都是有效的操作。 - 性能最差情况: 记住 O(1) 是平均情况,在哈希函数设计不佳或数据分布极端时,性能会退化到 O(n)。.NET 的默认哈希函数对于许多内置类型通常表现良好。
12. 结论
HashSet<T>
是 .NET 集合框架中一个非常有价值的成员。它提供了一种高效的方式来存储无序的、唯一的元素集合,并提供了强大的集合运算能力。理解其基于哈希表的内部工作原理是正确使用它的关键,特别是关于元素相等性的判断(Equals
和 GetHashCode
)。
当你面对以下需求时,应该优先考虑使用 HashSet<T>
:
- 需要快速判断元素是否存在。
- 需要自动处理重复项。
- 需要执行并集、交集、差集等集合运算。
- 对元素的顺序没有要求。
通过本文的详细介绍,希望你已经对 HashSet<T>
有了全面的理解,并能在你的 C# 项目中自信地运用它来编写更高效、更简洁的代码。