掌握 C# HashSet:如何快速存取唯一元素 – wiki基地


掌握 C# HashSet:如何快速存取唯一元素

在软件开发中,数据结构的 انتخاب (xuǎn zé – choice) 对程序的性能和效率至关重要。作为 .NET 平台的核心语言,C# 提供了丰富多样的集合类型来应对不同的数据存储和管理需求。当我们面临需要存储一组唯一元素,并且对元素的快速存取(添加、删除、检查是否存在)有很高要求时,System.Collections.Generic.HashSet<T> 便是你的不二之选。

与我们可能更熟悉的 List<T> 不同,HashSet<T> 不关心元素的顺序,它的核心价值在于保证元素的唯一性以及其基于哈希表实现的超高性能。本文将带你深入理解 HashSet<T> 的内部机制、常见用法、适用场景以及一些高级话题,帮助你彻底掌握这个强大的集合类型。

1. 初识 HashSet:为何需要它?

想象一下这样的场景:你需要记录一个网站所有独立访问者的 IP 地址,或者在一个游戏中跟踪所有已访问的房间 ID,再或者处理一个大型日志文件,提取所有不同的错误代码。在这些情况下,重复的元素是没有意义的,我们只需要关注那些独特的、不重复的值。

如果我们使用 List<T> 来存储这些元素,每次添加新元素之前,我们都需要遍历整个列表来检查它是否已经存在。随着列表元素的增加,这种检查的效率会越来越低,其时间复杂度是 O(n)(其中 n 是列表中元素的数量)。同样,查找一个元素是否存在也需要 O(n) 的时间。

HashSet<T> 应运而生,正是为了解决这个问题。它是一种无序的集合,专门用于存储唯一元素。它的核心优势在于其操作(添加、删除、查找)的平均时间复杂度接近 O(1),这意味着无论集合中元素的数量有多少,这些操作通常都能在常数时间内完成。这在处理大量数据时带来了巨大的性能提升。

简单来说,当你需要:
1. 存储一组元素,并且不希望有重复。
2. 频繁地执行“检查某个元素是否存在于集合中”的操作。
3. 对元素的顺序没有要求。

那么,HashSet<T> 几乎总是比 List<T> 或数组更合适的选择。

2. HashSet 的核心:哈希表(Hash Table)

HashSet<T> 之所以能够实现惊人的 O(1) 平均时间复杂度,其秘密武器在于它内部使用了哈希表(Hash Table)的数据结构。

哈希表是一种通过将键映射到存储位置来快速查找值的数据结构。它通过一个称为哈希函数(Hash Function)的算法,将任意大小的输入(元素)转换成一个固定大小的输出(哈希码,Hash Code)。这个哈希码随后被用来确定元素在内部存储结构(通常是数组或列表的数组)中的位置。

HashSet<T> 的上下文中:
* T 类型的元素本身充当“键”。
* 没有独立于元素的“值”;元素的存在本身就代表了“值”。
* 哈希函数由元素的 GetHashCode() 方法提供。

哈希过程概览:

  1. 当你尝试将一个元素 item 添加到 HashSet<T> 时:

    • 系统调用 item.GetHashCode() 方法,计算出该元素的哈希码。
    • 利用哈希码,通过某种算法(通常是取模运算)计算出元素在内部数组(称为“桶”或“槽”,Buckets/Slots)中的索引。
    • 定位到对应的“桶”。
    • 在这个桶中,系统会检查 item 是否已经存在。这通过调用元素的 Equals() 方法来完成。
    • 如果桶中不存在与 item 相等的元素,则将 item 添加到该桶中。如果已存在,则添加失败(因为 HashSet 只存储唯一元素),Add 方法返回 false
  2. 当你尝试检查一个元素 item 是否存在 (Contains(item)) 或移除一个元素 (Remove(item)) 时:

    • 同样,计算 item 的哈希码。
    • 根据哈希码找到对应的“桶”。
    • 遍历该桶中的元素,使用 Equals() 方法与 item 进行比较。
    • 如果找到相等的元素,则 Contains 返回 trueRemove 移除该元素并返回 true。如果遍历完桶仍未找到,则 Contains 返回 falseRemove 返回 false

哈希碰撞(Hash Collision):

不同的元素可能会产生相同的哈希码,这种情况称为哈希碰撞。哈希表需要有一种机制来处理碰撞。 HashSet<T> 通常采用链式法(Chaining)来解决碰撞:在哈希表数组的每个位置上,不是直接存储元素,而是存储一个链表(或者更常见的是一个内部列表或数组),所有哈希到同一个位置的元素都存储在这个链表中。

为什么是 O(1) 平均时间复杂度?

理想情况下,哈希函数能将元素均匀地分散到不同的桶中,使得每个桶中的元素数量非常少。在这种情况下,查找、添加或删除一个元素只需要计算哈希码,找到桶,并在一个很小的链表中进行查找/比较。这些步骤所需的时间是常数级的,不随集合总元素数量 n 的增加而显著增加,因此平均时间复杂度是 O(1)。

为什么是 平均 O(1),而不是 总是 O(1)?

如果哈希函数设计得不好,或者输入数据分布非常特殊,导致大量元素哈希到同一个或少数几个桶中,那么某些桶对应的链表就会变得很长。在这种极端情况下,查找、添加或删除一个元素可能需要遍历这个长链表,其时间复杂度就退化到了 O(k),其中 k 是该桶中元素的数量。在最坏的情况下(所有元素都哈希到同一个桶),这会退化到 O(n),与 List<T> 的线性查找效率相同。

因此,一个好的哈希函数对于 HashSet<T> 的性能至关重要。幸运的是,对于大多数内置类型(如 int, string, double 等),.NET Framework 提供了高质量的默认 GetHashCode() 实现。对于自定义类型,你需要确保正确实现 Equals()GetHashCode() 方法。

3. HashSet 的主要特性

  • 唯一性 (Uniqueness): 这是 HashSet<T> 的核心特性。不允许存储重复元素。当你尝试添加一个已经存在的元素时,Add 方法会返回 false,并且集合内容不变。
  • 无序性 (No Order): HashSet<T> 不保证元素的存储顺序或迭代顺序。迭代时元素的顺序可能与添加顺序不同,并且在集合内容改变(如添加或删除元素)后,迭代顺序也可能发生变化。如果你需要保持元素的顺序,List<T>SortedSet<T> 可能是更好的选择。
  • 高性能 (High Performance): 添加、删除、检查元素存在性的操作平均时间复杂度接近 O(1)。
  • 元素要求 (Element Requirements): 存储在 HashSet<T> 中的类型 T 必须正确实现 Equals()GetHashCode() 方法,以便 HashSet 能正确判断元素的相等性和计算哈希码。对于引用类型,如果没有重写这两个方法,默认会使用引用相等性(即判断是否是同一个对象实例)和基于对象地址的哈希码,这通常不是我们期望的基于内容的值相等。
  • 不支持索引访问 (No Indexer): 由于其无序性,HashSet<T> 不支持通过索引(如 myHashSet[0])直接访问元素。

4. HashSet 的常用操作与代码示例

让我们通过代码示例来学习 HashSet<T> 的基本用法。

首先,需要引入命名空间:

csharp
using System.Collections.Generic;

4.1 创建 HashSet

可以创建空的 HashSet,也可以在创建时传入一个已有的集合来初始化。

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

// 从一个列表创建 HashSet,重复元素会自动被去除
List wordsList = new List { “apple”, “banana”, “apple”, “orange”, “banana” };
HashSet uniqueWords = new HashSet(wordsList);

Console.WriteLine($”Unique words: {string.Join(“, “, uniqueWords)}”);
// Output might be: Unique words: apple, banana, orange (顺序不确定)
“`

4.2 添加元素 (Add)

Add(T item) 方法尝试将元素添加到集合中。如果元素成功添加(即之前不存在),则返回 true;如果元素已经存在,则返回 false

“`csharp
HashSet numbers = new HashSet();

bool added1 = numbers.Add(10); // 添加成功,返回 true
bool added2 = numbers.Add(20); // 添加成功,返回 true
bool added3 = numbers.Add(10); // 元素 10 已存在,添加失败,返回 false
bool added4 = numbers.Add(30); // 添加成功,返回 true

Console.WriteLine($”Added 10 first time: {added1}”); // True
Console.WriteLine($”Added 20: {added2}”); // True
Console.WriteLine($”Added 10 second time: {added3}”); // False
Console.WriteLine($”Added 30: {added4}”); // True

Console.WriteLine($”Current numbers: {string.Join(“, “, numbers)}”);
// Output might be: Current numbers: 10, 20, 30 (顺序不确定)
Console.WriteLine($”Number of elements: {numbers.Count}”); // 3
“`

4.3 检查元素是否存在 (Contains)

Contains(T item) 方法检查集合中是否存在指定的元素。这是 HashSet<T> 的一个非常高效的操作。

“`csharp
HashSet fruits = new HashSet { “apple”, “banana”, “orange” };

bool hasApple = fruits.Contains(“apple”); // true
bool hasGrape = fruits.Contains(“grape”); // false

Console.WriteLine($”Does the set contain apple? {hasApple}”); // True
Console.WriteLine($”Does the set contain grape? {hasGrape}”); // False
``
List.Contains()相比,HashSet.Contains()` 的性能优势在集合规模较大时尤为明显(O(1) vs O(n))。

4.4 移除元素 (Remove)

Remove(T item) 方法尝试从集合中移除指定的元素。如果成功移除(即元素存在),则返回 true;如果元素不存在,则返回 false

“`csharp
HashSet data = new HashSet { 1, 2, 3, 4, 5 };

bool removed3 = data.Remove(3); // 元素 3 存在,移除成功,返回 true
bool removed6 = data.Remove(6); // 元素 6 不存在,移除失败,返回 false

Console.WriteLine($”Removed 3: {removed3}”); // True
Console.WriteLine($”Removed 6: {removed6}”); // False

Console.WriteLine($”Current data: {string.Join(“, “, data)}”);
// Output might be: Current data: 1, 2, 4, 5 (顺序不确定)
Console.WriteLine($”Number of elements: {data.Count}”); // 4
“`

4.5 获取元素数量 (Count)

Count 属性获取集合中元素的数量。

csharp
HashSet<string> colors = new HashSet<string> { "red", "green", "blue" };
Console.WriteLine($"Number of colors: {colors.Count}"); // 3
colors.Add("red"); // Duplicate, Count remains 3
Console.WriteLine($"Number of colors after adding red again: {colors.Count}"); // 3

4.6 清空集合 (Clear)

Clear() 方法移除集合中的所有元素。

csharp
HashSet<int> tempSet = new HashSet<int> { 10, 20, 30 };
Console.WriteLine($"Before clear, count: {tempSet.Count}"); // 3
tempSet.Clear();
Console.WriteLine($"After clear, count: {tempSet.Count}"); // 0

4.7 遍历集合

虽然 HashSet<T> 不保证顺序,但可以通过 foreach 循环遍历其中的所有元素。

“`csharp
HashSet characters = new HashSet { ‘a’, ‘b’, ‘c’, ‘d’ };

Console.WriteLine(“Iterating through characters:”);
foreach (char c in characters)
{
Console.Write(c + ” “); // Output: characters in some order, e.g., a b c d or d c b a etc.
}
Console.WriteLine();
“`

5. 使用自定义类型作为 HashSet 的元素

前面提到,HashSet<T> 依赖于元素的 Equals()GetHashCode() 方法来判断唯一性和计算哈希码。对于内置值类型(如 int, float, bool 等)以及 string 类型,.NET 已经提供了正确的实现。但对于你自定义的类(引用类型),你需要自己确保这两个方法的正确性。

为什么需要重写 Equals()GetHashCode()

默认情况下,对于引用类型,Equals() 方法(继承自 object)判断的是两个引用是否指向同一个内存地址(即是否是同一个对象实例)。GetHashCode() 也通常是基于对象地址计算的。这意味着,即使两个不同的对象实例拥有完全相同的属性值,默认情况下它们也被视为不相等的,并且可能有不同的哈希码。

考虑以下 Person 类:

“`csharp
public class Person
{
public int Id { get; set; }
public string Name { get; set; }
public int Age { get; set; }

public Person(int id, string name, int age)
{
    Id = id;
    Name = name;
    Age = age;
}

// 没有重写 Equals 和 GetHashCode

}
“`

现在,将 Person 对象添加到 HashSet<Person> 中:

“`csharp
HashSet people = new HashSet();

Person p1 = new Person(1, “Alice”, 30);
Person p2 = new Person(2, “Bob”, 25);
Person p3 = new Person(1, “Alice”, 30); // 与 p1 属性值相同,但它是新对象

people.Add(p1);
people.Add(p2);
bool addedP3 = people.Add(p3); // 尝试添加与 p1 内容相同的 p3

Console.WriteLine($”Added p3? {addedP3}”); // True! Why?
Console.WriteLine($”Number of people: {people.Count}”); // 3! Why?

// 检查是否存在 p1, p2, p3
Console.WriteLine($”Contains p1? {people.Contains(p1)}”); // True
Console.WriteLine($”Contains p2? {people.Contains(p2)}”); // True
Console.WriteLine($”Contains p3? {people.Contains(p3)}”); // True

// 检查是否存在一个新的、内容与 p1/p3 相同的对象
Person p4 = new Person(1, “Alice”, 30);
Console.WriteLine($”Contains p4? {people.Contains(p4)}”); // False! Why?
“`

正如输出所示,尽管 p1p3Id, Name, Age 完全相同,但由于它们是不同的对象实例,默认的 Equals 认为它们不相等,HashSet 因此将 p3 添加进去了。同时,Contains(p4) 返回 false,因为 p4 是另一个新的对象实例,与集合中的任何对象都不是同一个实例。这显然不是我们期望的基于“值”的唯一性。

正确实现 Equals()GetHashCode()

为了让 HashSet<Person> 能够基于 Id, Name, Age 这些属性来判断唯一性,我们需要在 Person 类中重写 Equals(object obj)GetHashCode() 方法。

规则:
1. 如果两个对象通过 Equals() 方法被认为是相等的,那么它们的 GetHashCode() 方法必须返回相同的整数值。
2. 反之不一定成立:两个不同的对象可以有相同的哈希码(哈希碰撞),但它们必须通过 Equals() 方法区分开。
3. GetHashCode() 应该在对象的生命周期内,对于属性值不变的对象,始终返回相同的值。如果用作哈希码计算的属性值发生了变化,那么对象的哈希码也应该能够变化(尽管将可变对象放入哈希集合是不推荐的)。

以下是重写 PersonEqualsGetHashCode 的示例:

“`csharp
public class Person // 现在有了正确的 Equals 和 GetHashCode
{
public int Id { get; set; }
public string Name { get; set; }
public int Age { get; set; }

public Person(int id, string name, int age)
{
    Id = id;
    Name = name;
    Age = age;
}

// 重写 Equals 方法以基于值进行比较
public override bool Equals(object obj)
{
    // 1. 检查是否为 null
    if (obj == null)
    {
        return false;
    }

    // 2. 检查类型是否兼容
    if (obj is not Person otherPerson)
    {
        return false;
    }

    // 3. 比较关键属性
    return Id == otherPerson.Id &&
           Name == otherPerson.Name &&
           Age == otherPerson.Age;
}

// 重写 GetHashCode 方法,生成基于属性值的哈希码
public override int GetHashCode()
{
    // 结合多个属性的哈希码生成一个复合哈希码
    // System.HashCode.Combine 是 .NET Core 3.0+/.NET 5+ 推荐的方法
    // 对于旧版本,可以使用其他技术,如 XOR 或乘以素数
    return System.HashCode.Combine(Id, Name, Age);

    // 旧版本写法示例 (不太推荐,但理解原理有帮助):
    // int hashCode = Id.GetHashCode();
    // hashCode = hashCode * 31 + (Name == null ? 0 : Name.GetHashCode());
    // hashCode = hashCode * 31 + Age.GetHashCode();
    // return hashCode;
}

// 可选:实现 IEquatable<T> 接口以获得更好的性能和类型安全
// public bool Equals(Person other)
// {
//     if (other == null) return false;
//     return Id == other.Id && Name == other.Name && Age == other.Age;
// }

}
“`

现在,使用这个重写了方法的 Person 类再次运行上面的 HashSet 示例:

“`csharp
HashSet people = new HashSet();

Person p1 = new Person(1, “Alice”, 30);
Person p2 = new Person(2, “Bob”, 25);
Person p3 = new Person(1, “Alice”, 30); // 与 p1 属性值相同,是新对象

people.Add(p1);
people.Add(p2);
bool addedP3 = people.Add(p3); // 尝试添加与 p1 内容相同的 p3

Console.WriteLine($”Added p3? {addedP3}”); // False! Correct!
Console.WriteLine($”Number of people: {people.Count}”); // 2! Correct!

// 检查是否存在 p1, p2, p3
Console.WriteLine($”Contains p1? {people.Contains(p1)}”); // True
Console.WriteLine($”Contains p2? {people.Contains(p2)}”); // True
Console.WriteLine($”Contains p3? {people.Contains(p3)}”); // True! Correct!

// 检查是否存在一个新的、内容与 p1/p3 相同的对象
Person p4 = new Person(1, “Alice”, 30);
Console.WriteLine($”Contains p4? {people.Contains(p4)}”); // True! Correct!
``
这次的结果完全符合我们的期望。
HashSet` 现在能够正确地识别基于属性值相等的对象是重复的。

重要提示:

  • 如果你的类是不可变的(即对象的属性在创建后不会改变),那么实现 EqualsGetHashCode 是安全的。
  • 如果你的类是可变的,并且你将可变对象添加到 HashSet 中,然后在对象位于集合中时修改了用于计算哈希码的属性值,那么该对象可能会在哈希表中被错误地定位,导致后续的 Contains, Remove 等操作失败。应尽量避免将可变对象存储在 HashSet 中,或者确保用于相等性判断和哈希码计算的属性是不可变的。
  • C# 9 引入的 record 类型自动生成基于值相等的 Equals, GetHashCode, ToString, 以及重载 ==!= 运算符,非常适合用作 HashSet 的元素。

6. HashSet 的集合操作

HashSet<T> 不仅支持基本的增删改查,还提供了丰富的集合论操作,这在处理集合之间的关系时非常有用且高效。

假设我们有两个 HashSet<int>

csharp
HashSet<int> set1 = new HashSet<int> { 1, 2, 3, 4, 5 };
HashSet<int> set2 = new HashSet<int> { 4, 5, 6, 7, 8 };

以下是一些常用的集合操作方法:

6.1 UnionWith (并集)

将另一个集合中的所有元素添加到当前 HashSet 中(去除重复)。

csharp
HashSet<int> unionSet = new HashSet<int>(set1); // 从 set1 开始
unionSet.UnionWith(set2);
// unionSet 现在包含 {1, 2, 3, 4, 5, 6, 7, 8} (顺序不确定)
Console.WriteLine($"Union: {string.Join(", ", unionSet)}");

6.2 IntersectWith (交集)

修改当前 HashSet,使其只包含与另一个集合共有的元素。

csharp
HashSet<int> intersectSet = new HashSet<int>(set1); // 从 set1 开始
intersectSet.IntersectWith(set2);
// intersectSet 现在包含 {4, 5} (顺序不确定)
Console.WriteLine($"Intersection: {string.Join(", ", intersectSet)}");

6.3 ExceptWith (差集)

修改当前 HashSet,移除所有也存在于另一个集合中的元素(set1 – set2)。

csharp
HashSet<int> exceptSet = new HashSet<int>(set1); // 从 set1 开始
exceptSet.ExceptWith(set2);
// exceptSet 现在包含 {1, 2, 3} (顺序不确定)
Console.WriteLine($"Except: {string.Join(", ", exceptSet)}");

6.4 SymmetricExceptWith (对称差集)

修改当前 HashSet,使其只包含只存在于当前集合或只存在于另一个集合中的元素((set1 – set2) ∪ (set2 – set1))。

csharp
HashSet<int> symmetricExceptSet = new HashSet<int>(set1); // 从 set1 开始
symmetricExceptSet.SymmetricExceptWith(set2);
// symmetricExceptSet 现在包含 {1, 2, 3, 6, 7, 8} (顺序不确定)
Console.WriteLine($"Symmetric Except: {string.Join(", ", symmetricExceptSet)}");

6.5 IsSubsetOf (判断是否是子集)

检查当前 HashSet 是否是另一个集合的子集(即当前集合的所有元素都存在于另一个集合中)。

csharp
HashSet<int> subsetCandidate = new HashSet<int> { 4, 5 };
Console.WriteLine($"{string.Join(", ", subsetCandidate)} is subset of {string.Join(", ", set2)}? {subsetCandidate.IsSubsetOf(set2)}"); // True
Console.WriteLine($"{string.Join(", ", set1)} is subset of {string.Join(", ", set2)}? {set1.IsSubsetOf(set2)}"); // False

6.6 IsSupersetOf (判断是否是超集)

检查当前 HashSet 是否是另一个集合的超集(即另一个集合的所有元素都存在于当前集合中)。

csharp
HashSet<int> supersetCandidate = new HashSet<int> { 1, 2, 3, 4, 5, 6, 7, 8, 9 };
Console.WriteLine($"{string.Join(", ", supersetCandidate)} is superset of {string.Join(", ", set1)}? {supersetCandidate.IsSupersetOf(set1)}"); // True
Console.WriteLine($"{string.Join(", ", set1)} is superset of {string.Join(", ", set2)}? {set1.IsSupersetOf(set2)}"); // False

6.7 Overlaps (判断是否有交集)

检查当前 HashSet 是否与另一个集合有至少一个共同的元素。

csharp
HashSet<int> anotherSet = new HashSet<int> { 9, 10, 11 };
Console.WriteLine($"{string.Join(", ", set1)} overlaps with {string.Join(", ", set2)}? {set1.Overlaps(set2)}"); // True
Console.WriteLine($"{string.Join(", ", set1)} overlaps with {string.Join(", ", anotherSet)}? {set1.Overlaps(anotherSet)}"); // False

这些集合操作方法通常比手动使用 ContainsAdd/Remove 来实现相同的逻辑效率更高,因为它们可以利用哈希表的内部结构进行优化。

7. 何时使用 HashSet?何时选择其他集合?

使用 HashSet<T> 的典型场景:

  • 去重: 从一个包含重复元素的源(如 List<T> 或数组)中快速获取所有唯一的元素。
  • 快速查找/成员测试: 需要频繁检查某个元素是否存在于一个大型集合中。例如,验证用户输入是否存在于一个允许的单词列表中。
  • 集合操作: 执行并集、交集、差集等集合操作。
  • 缓存已处理项: 在处理流式数据或图遍历时,记录已经访问过的元素以避免重复处理。

不使用 HashSet<T> 的场景 (考虑其他集合):

  • 需要保持元素的添加顺序或特定排序: 使用 List<T> (添加顺序) 或 SortedSet<T>, SortedDictionary<K, V> (特定排序)。
  • 需要通过索引访问元素: 使用 List<T> 或数组。
  • 需要存储重复元素: 使用 List<T>Dictionary<K, List<V>> 等。
  • 需要存储键值对: 使用 Dictionary<K, V>ConcurrentDictionary<K, V>HashSet<T> 实际上可以看作是一个键值对集合,其中键和值是同一个元素。

8. 性能考量与注意事项

虽然 HashSet<T> 的平均性能非常出色,但在某些情况下需要注意:

  • 糟糕的哈希函数: 如果你存储自定义类型并且 GetHashCode() 实现得很差,导致大量元素哈希到同一个桶,那么 HashSet 的性能可能会急剧下降,甚至退化到接近 O(n)。确保你的 GetHashCode() 实现能够将不同的对象尽可能均匀地分散到不同的桶中。
  • 可变元素: 如前所述,在将元素添加到 HashSet 后修改了用于计算哈希码的属性,会导致集合的内部状态不一致,从而影响后续操作的正确性和性能。
  • 集合大小变化: HashSet 内部的哈希表需要根据元素数量进行动态调整(扩容)。当元素数量增长到一定程度时,会发生 rehashing(重建哈希表),这会涉及计算所有现有元素的哈希码并重新分配到新的更大的桶中,这是一个相对耗时的操作。然而,由于扩容是指数级进行的(例如,每次容量翻倍),这个开销在元素的平均添加时间中被分摊,仍然能保证 O(1) 的平均复杂度。
  • 内存开销: 哈希表通常比简单的数组或列表需要更多的内存,因为它需要存储桶结构以及可能用于解决碰撞的额外链接信息。

9. 高级话题:IEqualityComparer

除了在元素类型本身重写 EqualsGetHashCode,你还可以通过实现 IEqualityComparer<T> 接口来定义自定义的相等性比较和哈希码计算逻辑。这在以下情况下非常有用:

  • 你无法修改元素的源代码(例如,使用第三方库中的类型)。
  • 你需要根据不同的标准来判断元素的相等性(例如,有时只根据 ID 判断 Person 是否相等,有时根据 ID 和 Name 判断)。

HashSet<T> 的构造函数可以接受一个 IEqualityComparer<T> 实例。

示例:假设我们想创建一个 HashSet<Person>,但只根据 Id 来判断 Person 对象的相等性。

“`csharp
// 定义一个只根据 Person.Id 判断相等性的比较器
public class PersonIdComparer : IEqualityComparer
{
public bool Equals(Person x, Person y)
{
// 处理 null
if (x == null && y == null) return true;
if (x == null || y == null) return false;

    return x.Id == y.Id;
}

public int GetHashCode(Person obj)
{
    // 处理 null
    if (obj == null) return 0;

    // 只根据 Id 计算哈希码
    return obj.Id.GetHashCode();
}

}

// 使用自定义比较器创建 HashSet
HashSet peopleById = new HashSet(new PersonIdComparer());

Person pA1 = new Person(1, “Alice”, 30);
Person pA2 = new Person(1, “Alice”, 35); // 同 Id,不同 Age
Person pB1 = new Person(2, “Bob”, 25);

peopleById.Add(pA1);
bool addedPA2 = peopleById.Add(pA2); // 尝试添加同 Id 的对象

Console.WriteLine($”Added pA2 (same Id as pA1)? {addedPA2}”); // False! Correct, based on Id
Console.WriteLine($”Number of people (by Id): {peopleById.Count}”); // 2

Console.WriteLine($”Contains new Person(1, ‘Charlie’, 40)? {peopleById.Contains(new Person(1, “Charlie”, 40))}”); // True! Correct, matches pA1/pA2 by Id
“`

使用 IEqualityComparer<T> 提供了更大的灵活性来控制 HashSet 如何处理元素的唯一性。

10. 结论

HashSet<T> 是 C# 中一个极其有用的集合类型,特别适用于需要存储唯一元素并频繁进行成员资格测试的场景。其基于哈希表的实现提供了卓越的平均性能(O(1)),远超使用 List<T> 进行线性搜索的效率(O(n))。

要充分利用 HashSet<T> 的威力,理解其无序性以及依赖于元素的 Equals()GetHashCode() 方法至关重要。对于自定义类型,正确实现这两个方法是确保 HashSet 正常工作和高性能的关键。此外,HashSet<T> 提供的丰富的集合操作方法,使其成为处理集合之间关系的强大工具。

掌握 HashSet<T> 不仅能帮助你写出更高效的代码,也能让你更好地理解哈希表这一基础数据结构在实际编程中的应用。下次当你遇到需要快速存取和保证唯一性的数据时,不要犹豫,HashSet<T> 可能是你的最佳答案。

希望这篇文章能帮助你全面理解和熟练使用 C# 中的 HashSet<T>

发表评论

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

滚动至顶部