はじめに:「HashSet活用」は“とにかく速く・かんたんに重複と集合を扱う”技
業務で LINQ やコレクションを使っていると、こういう処理がよく出てきます。
同じ値が含まれているかを高速に判定したい
重複を気にせず「一度だけ」扱いたい
A にあって B にないもの、共通しているものを取りたい
これを、List<T> だけで頑張ろうとすると、Contains が O(n) だったり、重複チェックで何度もループしたりして、
コードもパフォーマンスも微妙になりがちです。
そこで出てくるのが HashSet<T> です。
「重複を許さない集合」を高速に扱うためのコレクションで、
LINQ と組み合わせると“業務ユーティリティの主役級”になります。
ここから、初心者向けに
HashSet の基本イメージ
高速な「含まれているか判定」
LINQ と組み合わせた重複排除・集合演算
EqualityComparer と組み合わせた“業務的な一意性”の扱い
を、例題付きでかみ砕いて説明していきます。
HashSet の基本:重複なし+高速 Contains
「順番は気にしないけど、重複は絶対いらない」コレクション
HashSet<T> は、ざっくり言うと
順序は保証しない
同じ値は 1 回しか入らないContains がとても速い(平均 O(1))
という性質を持ったコレクションです。
var set = new HashSet<int>();
set.Add(1);
set.Add(2);
set.Add(2); // 無視される(false が返る)
Console.WriteLine(set.Count); // 2
C#List<int> で同じことをすると、Count は 3 になりますが、HashSet<int> は「重複を許さない」ので 2 のままです。
ここでの重要ポイントは、「“重複を許さない”ことと、“Contains が速い”ことがセットになっている」ということです。
この 2 つを意識して使うと、一気にコードがスッキリします。
高速な「含まれているか判定」に使う
List の Contains と HashSet の Contains の違い
例えば、「NG ワード一覧に含まれているか」をチェックする処理を考えます。
var ngWords = new List<string> { "NG1", "NG2", "NG3", /* ... */ };
bool IsNg(string word)
{
return ngWords.Contains(word); // List の Contains は O(n)
}
C#NG ワードが 10 件なら気になりませんが、
1000 件、10000 件と増えてくると、Contains のコストが効いてきます。
これを HashSet<string> に変えると、こうなります。
var ngWords = new HashSet<string> { "NG1", "NG2", "NG3" /* ... */ };
bool IsNg(string word)
{
return ngWords.Contains(word); // HashSet の Contains は平均 O(1)
}
C#呼び出し側のコードは同じですが、
内部的には「ハッシュテーブル」で検索しているので、
要素数が増えてもほぼ一定時間で判定できます。
ここでの重要ポイントは、「“ある値が含まれているかどうか”を何度も判定するなら、HashSet にしておくと圧倒的に有利」ということです。
LINQ と組み合わせた「重複排除」ユーティリティ
Distinct では足りない場面で HashSet を使う
もちろん、単純な重複排除なら LINQ の Distinct() で十分です。
var numbers = new[] { 1, 2, 2, 3, 3, 3 };
var distinct = numbers.Distinct().ToList(); // 1,2,3
C#ただ、「途中で自分のルールで重複を管理したい」場面では、HashSet を直接使ったほうが分かりやすくなることがあります。
例えば、「一度出てきたコードはスキップしたい」処理。
public IEnumerable<string> RemoveDuplicates(IEnumerable<string> codes)
{
var seen = new HashSet<string>();
foreach (var code in codes)
{
if (seen.Add(code))
{
// Add が true のときだけ(初めて見たときだけ)返す
yield return code;
}
}
}
C#seen.Add(code) は、
初めての値なら true(追加成功)、
すでにある値なら false(追加されない)
を返すので、それを利用しています。
ここでの重要ポイントは、「HashSet.Add の戻り値(true/false)を使うと、“初登場だけ通す”処理が簡単に書ける」ということです。
集合演算(和・積・差)を業務で使う
2 つの集合の「共通」「片方だけ」を取りたい
業務でよくあるのが、
A の一覧と B の一覧がある
共通しているものを知りたい(積集合)
A にだけあるもの、B にだけあるものを知りたい(差集合)
といった要件です。
HashSet<T> には、これを直接扱うメソッドが用意されています。
var a = new HashSet<int> { 1, 2, 3, 4 };
var b = new HashSet<int> { 3, 4, 5, 6 };
// 共通部分(積集合)
var intersect = new HashSet<int>(a);
intersect.IntersectWith(b); // intersect = {3,4}
// A にだけあるもの(差集合)
var onlyA = new HashSet<int>(a);
onlyA.ExceptWith(b); // onlyA = {1,2}
// A と B のどちらかにあるもの(和集合)
var union = new HashSet<int>(a);
union.UnionWith(b); // union = {1,2,3,4,5,6}
C#LINQ でも Intersect や Except はありますが、HashSet のメソッドは「自分自身を書き換える」スタイルなので、
大量データを扱うときに効率が良くなりやすいです。
ここでの重要ポイントは、「“集合として扱いたい”ときは、HashSet の集合演算メソッドを素直に使うと読みやすくて速い」ということです。
EqualityComparer と組み合わせて“業務的な一意性”を扱う
User の Id で一意にしたい場合
HashSet<T> は、要素の「等価性」を IEqualityComparer<T> でカスタマイズできます。
これは前に話した「比較用EqualityComparer」との組み合わせです。
User クラスを用意します。
public class User
{
public int Id { get; set; }
public string Name { get; set; } = "";
}
C#「Id が同じなら同じユーザー」とみなす Comparer を作ります。
public class UserIdEqualityComparer : IEqualityComparer<User>
{
public bool Equals(User? x, User? y)
{
if (ReferenceEquals(x, y)) return true;
if (x is null || y is null) return false;
return x.Id == y.Id;
}
public int GetHashCode(User obj)
{
return obj.Id.GetHashCode();
}
}
C#これを HashSet に渡します。
var set = new HashSet<User>(new UserIdEqualityComparer());
set.Add(new User { Id = 1, Name = "Alice" });
set.Add(new User { Id = 1, Name = "Alice (duplicate)" }); // 追加されない
set.Add(new User { Id = 2, Name = "Bob" });
Console.WriteLine(set.Count); // 2
C#ここでの重要ポイントは、「HashSet に EqualityComparer を渡すと、“業務的な一意性”で重複を管理できる」ということです。
単なる値型だけでなく、ドメインオブジェクトにも使えます。
実務での具体例 1:コード一覧の重複チェック
「マスタに存在しないコード」を高速に見つける
例えば、マスタ側のコード一覧と、入力データ側のコード一覧があるとします。
var masterCodes = new[] { "A", "B", "C" };
var inputCodes = new[] { "A", "X", "B", "Y" };
C#「入力の中で、マスタに存在しないコード」を見つけたい。
HashSet を使うと、こう書けます。
var masterSet = new HashSet<string>(masterCodes);
var invalidCodes = inputCodes
.Where(code => !masterSet.Contains(code))
.ToList(); // "X", "Y"
C#masterSet.Contains(code) が高速なので、
入力件数が多くてもスケールしやすいです。
ここでの重要ポイントは、「“片方を HashSet にして、もう片方を LINQ でなめる”というパターンが、現場でめちゃくちゃよく出てくる」ということです。
実務での具体例 2:一度処理したものを二度処理しない
「すでに処理済みの ID」を覚えておく
例えば、ログやメッセージを処理していて、
「同じ ID のメッセージは一度だけ処理したい」というケース。
var processedIds = new HashSet<string>();
void HandleMessage(Message msg)
{
if (!processedIds.Add(msg.Id))
{
// すでに処理済み
return;
}
// ここに本処理を書く
}
C#Add が false を返したら「すでに入っている=処理済み」です。
これだけで「二重処理防止」が実現できます。
ここでの重要ポイントは、「HashSet.Add の戻り値を“処理済みフラグ”として使うと、二重処理防止がシンプルに書ける」ということです。
まとめ:「HashSet活用」は“重複・集合・存在チェックを一段上のレベルで扱う”ための基礎体力
HashSet 活用の本質は、
重複を許さない集合としてデータを持ち、Contains や Add を軸に、
「存在チェック」「重複排除」「集合演算」を高速かつシンプルに書く
ことです。
押さえておきたいポイントをまとめると、
HashSet は「重複なし」「順序なし」「Contains が速い」コレクション
「含まれているか判定」を何度もするなら、List より HashSet が圧倒的に有利Add の戻り値(true/false)で「初登場かどうか」を判定できる
IntersectWith / ExceptWith / UnionWith で集合演算を直接書ける
EqualityComparer と組み合わせると、“業務的な一意性”で重複管理ができる
ここまで腹落ちしていれば、
「なんとなく List と Distinct だけで頑張る」段階から抜けて、
“重複と集合を意識して設計できる C# エンジニア”に一歩近づけます。
