HashSet去重核心是解决查得快、去得稳、算得准问题,非为替代List;初始化时传入集合即完成去重,自定义类型须重写GetHashCode和Equals或用record/IEqualityComparer。

直接说结论:用 HashSet<t></t> 去重,不是为了“替换 List<t></t>”,而是解决「查得快、去得稳、算得准」的问题。它不保序、不能索引、对引用类型默认去重失效——用错场景或忽略细节,性能优势立刻归零。
怎么初始化才真正完成去重
构造时传入已有集合(如 List<t></t> 或数组)是最常用方式,但要注意:这一步就已完成去重,无需后续遍历再调 Add()。
-
var set = new HashSet<int>(sourceList)</int>→ 一次性去重并建新集合,适合你只要最终结果的场景 -
var set = new HashSet<string>(StringComparer.OrdinalIgnoreCase)</string>→ 字符串大小写不敏感去重,必须显式传比较器,否则"A"和"a"被视为不同元素 - 如果
sourceList是自定义类(如Person),且没重写GetHashCode()和Equals(),那这行代码看似成功,实则去重完全没生效——两个同名同龄对象仍会共存
边遍历边去重时,Add() 返回值必须用
这是最常被忽略的性能陷阱:很多人写成 seen.Add(item); if (seen.Contains(item)) { ... },等于白跑一次哈希查找。
-
Add()本身已执行完整判断逻辑(计算哈希 → 查桶 → 比较相等 → 决定插入),返回true表示新值,false表示重复 - 正确写法:
if (seen.Add(item)) { Process(item); }—— 一行完成“尝试加入 + 确认是新值” - 错误写法:
seen.Add(item); if (seen.Contains(item)) { ... }—— 多一次O(1)查找,循环中放大为O(n)额外开销
交集/并集/差集别踩 With 后缀的坑
IntersectWith()、UnionWith()、ExceptWith() 全部是 in-place 操作,调用后原集合内容直接被覆盖。
-
setA.IntersectWith(setB)→setA变成交集,setB不变;原setA数据丢失 - 想保留原集合?两种安全做法:
• 先复制:var result = new HashSet<int>(setA); result.IntersectWith(setB);</int>
• 用 LINQ:setA.Intersect(setB).ToHashSet()(需using System.Linq;) - 名字只差一个
With,行为天壤之别:Intersect()(LINQ)返回新集合,IntersectWith()(原生)修改自身——拼错就导致后续逻辑读到意外状态
自定义类型去重失效,90% 是 GetHashCode() 和 Equals() 不匹配
HashSet 判断重复分两步:先比哈希码,哈希相同再调 Equals()。两者不一致,去重必然出错。
- 值类型(
int、DateTime、string)默认可靠,不用改 - 引用类型(
class Person)若没重写两个方法,两个字段相同的对象会被当成不同元素,Add()永远返回true - 推荐方案:
record Person(string Name, int Age)—— 编译器自动生成匹配的哈希与比较逻辑,零配置不出错 - 若必须用
class且无法改源码,传IEqualityComparer<t></t>实现:new HashSet<person>(new PersonComparer())</person>
最易被忽略的点:HashSet 不保证插入顺序,也不线程安全。需要保序就别硬用;多线程共享时,lock 会串行化,真并发请换 ConcurrentDictionary<t bool></t> 模拟。


















