C# HashSet 介绍与基本用法 – wiki基地


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> 中执行操作(如添加、查找、删除)时,过程大致如下:

  1. 计算哈希码: 对于要操作的元素,HashSet<T> 会调用该元素的 GetHashCode() 方法来获取一个整数哈希码。
  2. 定位桶 (Bucket): HashSet<T> 使用哈希码来确定该元素应该存储或查找在哈希表中的哪个“桶”里。这个过程通常是取哈希码的模运算。
  3. 处理哈希冲突 (Collision): 不同的元素可能计算出相同的哈希码(称为哈希冲突),或者不同的哈希码映射到同一个桶。HashSet<T> 使用一种技术(通常是链表法或开放寻址法)来处理这些冲突,确保在同一个桶内可以存储多个元素。
  4. 比较元素 (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 uniqueWords = new HashSet();

// 创建一个存储整数的空 HashSet
HashSet uniqueNumbers = new HashSet();
“`

3.2. 从其他集合初始化 HashSet<T>

HashSet<T> 的一个常用构造函数接受一个实现了 IEnumerable<T> 接口的集合。这非常方便,可以用来快速地从一个列表、数组或其他集合中创建一个包含唯一元素的 HashSet。如果源集合包含重复项,HashSet 会自动去重。

“`csharp
// 从一个包含重复项的 List 创建 HashSet
List numbersList = new List { 1, 2, 3, 2, 4, 1, 5 };
HashSet uniqueNumbersFromList = new HashSet(numbersList);

// 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 = new HashSet(colorsArray);

// 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 fruits = new 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 attendees = new 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 guests = new HashSet { “Alice”, “Bob”, “Charlie”, “David” };

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 validColors = new HashSet { “Red”, “Green”, “Blue” };

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 mySet = new HashSet { 10, 20, 30 };
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 letters = new HashSet { ‘a’, ‘b’, ‘c’, ‘d’ };

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 set1 = new HashSet { 1, 2, 3 };
HashSet set2 = new HashSet { 3, 4, 5 };

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 setA = new HashSet { 1, 2, 3, 4 };
HashSet setB = new HashSet { 3, 4, 5, 6 };

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 setX = new HashSet { 10, 20, 30, 40 };
HashSet setY = new HashSet { 30, 40, 50, 60 };

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 setP = new HashSet { 1, 2, 3, 4 };
HashSet setQ = new HashSet { 3, 4, 5, 6 };

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 setA = new HashSet { 1, 2, 3 };
HashSet setB = new HashSet { 1, 2, 3, 4, 5 };
HashSet setC = new HashSet { 1, 2, 3 };
HashSet setD = new HashSet { 4, 5, 6 };

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 后,可以使用 OverlapsIntersectWith,其效率要高得多。
  • 子集/超集/相等判断 (IsSubsetOf, IsSupersetOf, SetEquals): 这些操作通常也具有较高的效率,通常是 O(n) 或 O(m) 或 O(n + m)。IsSubsetOf(other) 检查需要遍历当前集合并在 other 中查找,如果 otherHashSet,则查找是 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 对象的 IdName 都相同,但它们是不同的对象实例,HashSet 也会将它们视为不同的元素:

“`csharp
HashSet peopleSet = new 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> 根据 IdName 来判断唯一性,你需要重写 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 peopleSetWithEquality = new 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>

有时,你可能不想修改类的定义来重写 EqualsGetHashCode,或者你需要在不同的 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 peopleSetWithComparer = new HashSet(new PersonIdComparer());

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
    List list1 = new List { 1, 2, 3, 4, 5 };
    List list2 = new List { 4, 5, 6, 7, 8 };

    HashSet set1 = new HashSet(list1);
    HashSet set2 = 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 集合框架中一个非常有价值的成员。它提供了一种高效的方式来存储无序的、唯一的元素集合,并提供了强大的集合运算能力。理解其基于哈希表的内部工作原理是正确使用它的关键,特别是关于元素相等性的判断(EqualsGetHashCode)。

当你面对以下需求时,应该优先考虑使用 HashSet<T>

  • 需要快速判断元素是否存在。
  • 需要自动处理重复项。
  • 需要执行并集、交集、差集等集合运算。
  • 对元素的顺序没有要求。

通过本文的详细介绍,希望你已经对 HashSet<T> 有了全面的理解,并能在你的 C# 项目中自信地运用它来编写更高效、更简洁的代码。


发表评论

您的邮箱地址不会被公开。 必填项已用 * 标注

滚动至顶部