1. 入门
想象一下:你在开发一个网店系统,需要保存所有卖出去过的商品唯一编码的列表。或者更酷点,你写一个社交网络App,需要快速检查用户名是不是已经存在。再比如,你想统计一大段文本里有多少个不同的单词?
这些场景下我们都需要一个集合,每个元素只能出现一次。这时候HashSet<T>就闪亮登场了。
HashSet<T>就是一个存储无序唯一元素的集合。重点就是唯一。如果你试图加一个已经存在的元素到HashSet里,它会直接无视你的操作,不会加重复的。就像“只收独一无二的人”的俱乐部:你已经进来了,第二次就不让进了。
HashSet<T>的主要特点:
- 唯一性:保证集合里每个元素只出现一次。
- 性能:查找、添加和删除元素都超快。平均这些操作都是常数时间(O(1)),不管元素多少!这多亏了叫做哈希的机制。
- 无序:和List<T>不一样,HashSet<T>里的元素没有顺序。你不能通过索引拿到某个元素(比如“第五个元素”)。
- 基于哈希表:内部HashSet<T>用哈希表存元素,这就是它快的原因。我们现在不深挖哈希表怎么工作(那是进阶课题),你可以想象每个元素会“变成”一个特殊的数字码(哈希),以后就能超快找到它。
如果你用List<T>来存唯一元素,每次都得遍历整个列表,确认没有才加进去。大列表的话会超级慢。HashSet<T>直接秒加!
2. 程序员为啥要用HashSet<T>?
唯一集合的秘密
编程里经常有这种需求:要存不重复的元素。比如你解析App用户的email地址列表,想确保没有重复。或者收集文件夹里所有唯一的文件名。最简单的办法——用一个不能加重复元素的集合。
当然,你也可以用List<T>,手动检查元素是否存在再加:
var users = new List<string>();
if (!users.Contains("alice@example.com"))
users.Add("alice@example.com");
但这个方法在数据量大时很拉胯——Contains在List里要遍历所有元素,用户一多,程序就慢得像老掉牙的Windows XP电脑。
HashSet<T>干了啥
HashSet<T>保证每个元素只存一次。它底层用哈希表(和字典类似),所以添加、查找、删除都超快——基本都是常数时间,不用遍历所有元素。
3. HashSet<T>的基本用法
声明和创建
要用它,不用引入额外库——这个类就在System.Collections.Generic命名空间里。
using System.Collections.Generic;
var emails = new HashSet<string>();
你也可以直接在构造函数里传初始值:
var fruits = new HashSet<string> { "苹果", "香蕉", "梨", "香蕉" };
// "香蕉"出现两次,但只会存一次!
添加元素
用Add方法加元素。如果之前没有这个元素,方法返回true。如果已经有了——啥都不干,返回false。
bool added = emails.Add("alice@example.com"); // true,元素加进去了
added = emails.Add("alice@example.com"); // false,已经有了,没加
有趣:你可以用同一个值疯狂调用Add一百遍——HashSet不会生气,只会礼貌地无视重复。
检查是否存在:Contains
要查元素在不在,用Contains方法:
if (emails.Contains("alice@example.com"))
Console.WriteLine("这个email已经有了!");
删除元素
删除也很快:
emails.Remove("alice@example.com");
如果没有这个元素——没事,方法直接返回false。
4. 实战例子
我们来升级一下我们在课程里一直在写的学生CRM。
需求
假设系统要求每个用户必须有唯一的用户名(登录名)。加新用户前要检查唯一性,如果重复就要提示。
代码例子
using System;
using System.Collections.Generic;
class Program
{
static void Main()
{
// 用来存唯一用户名的集合
var userNames = new HashSet<string>();
while (true)
{
Console.Write("输入用户名(或者输入leave退出):");
string name = Console.ReadLine();
if (name == "leave")
break;
if (userNames.Add(name))
{
Console.WriteLine("用户名添加成功!");
}
else
{
Console.WriteLine("错误:这个名字已经被占用,换一个吧。");
}
}
Console.WriteLine("用户列表:");
foreach (var user in userNames)
Console.WriteLine($"- {user}");
// 注意!输出顺序可能是乱的。
}
}
就这么简单,我们就保证了唯一性。不用手动检查——HashSet全帮你搞定。
5. HashSet<T>内部怎么工作的?哈希码有啥用
类比:存储格子
想象你有一大堆用户名卡片,还有一张编号0到1000的桌子。每个用户名你都放到一个格子里,格子的编号是一个函数(GetHashCode)算出来的。如果卡片重复——它们会在同一个格子里,你就能很快知道用户名已经存在。
GetHashCode函数
HashSet<T>比较元素时,不是直接比值,而是先用GetHashCode()方法算出哈希码。大多数内置类型(int、string、double等)都已经实现得很高效了。
有趣的知识:如果你要存自定义类对象到HashSet<T>,一定要给这些类实现正确的相等比较和唯一码(Equals和GetHashCode方法),这样唯一性才靠谱。这个我们后面课会详细讲。
HashSet<T>常见新手错误
刚学用唯一集合时,大家常掉进的坑:以为HashSet<T>会按添加顺序存元素。其实不是!哈希集合不保证顺序,顺序可能是随机的。如果你在意顺序——得用别的集合,比如SortedSet<T>,但那是另一个故事了。
第二个常见错误——想用索引访问:
string name = userNames[0]; // 错误!HashSet<T>没有索引。
和数组或列表不一样,这里不能用编号取元素。只能用foreach遍历。
第三个常见混淆是序列化或保存哈希集合到文件时——因为顺序不确定,每次程序运行后元素顺序可能都不一样。
6. 集合操作:并集、交集、差集
HashSet<T>有一堆方法,让你像做数学集合那样操作它。比如:并集、交集、差集和对称差。
主要方法如下:
| 方法 | 作用 |
|---|---|
|
把other里的所有元素加到哈希集合里。 |
|
只保留同时在本集合和other里的元素。 |
|
把other里的元素从当前集合里删掉。 |
|
只保留只在本集合或other里、但不同时在两边的元素。 |
例子:交集和并集
举个例子。有两个名字集合:
var groupA = new HashSet<string> { "安雅", "鲍里斯", "薇拉" };
var groupB = new HashSet<string> { "薇拉", "格列布", "达莎" };
// 找出两个组都有谁
var common = new HashSet<string>(groupA); // 复制内容,否则groupA会变!
common.IntersectWith(groupB);
Console.WriteLine("两个组都有:");
foreach (var name in common)
Console.WriteLine(name); // 输出"薇拉"
// 合并两个组所有学生,谁都不丢:
var all = new HashSet<string>(groupA);
all.UnionWith(groupB);
Console.WriteLine("所有学生:");
foreach (var name in all)
Console.WriteLine(name); // "安雅", "鲍里斯", "薇拉", "格列布", "达莎"
7. 其他方法和属性
Count——看看集合里有多少元素:
Console.WriteLine(userNames.Count);
Clear——全删掉(就像现实里的CTRL+A, DELETE):
userNames.Clear();
SetEquals、IsSubsetOf、IsSupersetOf——检查集合是否相等、是否是子集、是否是超集等等。如果你玩(或者写)“数学家谁更牛”这种游戏很有用。
if (groupA.IsSubsetOf(groupB))
Console.WriteLine("A组所有人都在B组里");
8. 在HashSet<T>里存自定义对象
前面说过,标准类型已经会算哈希和判断相等了。
但如果你想存比如用户对象,就得按某个属性(比如登录名)来判断相等:
class User
{
public string Login { get; set; }
public override bool Equals(object obj)
{
if (obj is User other)
return Login == other.Login;
return false;
}
public override int GetHashCode()
{
return Login.GetHashCode();
}
}
// 现在可以这样:
var users = new HashSet<User>();
users.Add(new User { Login = "alice" });
users.Add(new User { Login = "bob" });
users.Add(new User { Login = "alice" }); // 不会加进去!
如果不重写Equals和GetHashCode方法,HashSet会把所有实例都当成不同的(即使登录名一样),因为默认是比内存地址。
GO TO FULL VERSION