C# Tips | コレクション・LINQ:HashSet活用

C# C#
スポンサーリンク

はじめに:「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 でも IntersectExcept はありますが、
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 活用の本質は、

重複を許さない集合としてデータを持ち、
ContainsAdd を軸に、
「存在チェック」「重複排除」「集合演算」を高速かつシンプルに書く

ことです。

押さえておきたいポイントをまとめると、

HashSet は「重複なし」「順序なし」「Contains が速い」コレクション
「含まれているか判定」を何度もするなら、List より HashSet が圧倒的に有利
Add の戻り値(true/false)で「初登場かどうか」を判定できる
IntersectWith / ExceptWith / UnionWith で集合演算を直接書ける
EqualityComparer と組み合わせると、“業務的な一意性”で重複管理ができる

ここまで腹落ちしていれば、
「なんとなく List と Distinct だけで頑張る」段階から抜けて、
“重複と集合を意識して設計できる C# エンジニア”に一歩近づけます。

タイトルとURLをコピーしました