C#を用いたアプリケーション開発において、データの集合を効率的に管理することは非常に重要です。

その中でも HashSet<T> クラスは、データの重複を許さず、高速な検索性能を持つコレクション として、多くの開発者に利用されています。

本記事では、HashSet<T> の基本的な使い方から、List<T> とのパフォーマンス比較、さらには高度な集合演算やカスタムオブジェクトの扱い方まで、テクニカルな視点で徹底的に解説します。

HashSet<T> とは何か

HashSet<T> は、.NETの System.Collections.Generic 名前空間に属するコレクションであり、数学的な「集合」の概念をプログラム上で実現したものです。

このクラスはハッシュテーブルをベースにしており、要素の格納や検索において 平均 O(1) という驚異的な計算効率 を提供します。

HashSet<T> の主な特徴は、以下の3点に集約されます。

  1. 一意性の保証:同じ値(重複する要素)を複数保持することができません。
  2. 順序の非保持:要素が追加された順番は保証されません。内部的なハッシュ値に基づいて配置されるため、インデックス(添え字)によるアクセスは不可能です。
  3. 検索の高速性:特定の要素が含まれているかどうかを判定する操作が非常に高速です。

これらの特性を理解することで、単なる「データの入れ物」としてではなく、アルゴリズムの最適化手段として HashSet<T> を活用できるようになります。

HashSet<T> の基本的な使い方

まずは、HashSet<T> の初期化と、要素の追加・削除・検索といった基本操作について見ていきましょう。

初期化と要素の追加

HashSet<T> は、インスタンス化の際に初期データを渡すことも、動的に追加することも可能です。

C#
using System;
using System.Collections.Generic;

class Program
{
    static void Main()
    {
        // HashSetの初期化
        var fruits = new HashSet<string>() { "Apple", "Banana", "Orange" };

        // 要素の追加
        bool isAdded1 = fruits.Add("Grape");  // True (新規追加)
        bool isAdded2 = fruits.Add("Apple");  // False (重複しているため追加されない)

        Console.WriteLine($"現在の要素数: {fruits.Count}");

        foreach (var fruit in fruits)
        {
            Console.WriteLine(fruit);
        }
    }
}
実行結果
現在の要素数: 4
Apple
Banana
Orange
Grape

Add メソッドは、要素の追加に成功したかどうかを bool 値で返す という特徴があります。

既に同じ要素が存在する場合は false を返すため、条件分岐のフラグとして利用することも可能です。

要素の削除と検索

特定の要素を削除したり、存在を確認したりする操作も非常にシンプルです。

C#
// 要素の検索
if (fruits.Contains("Banana"))
{
    Console.WriteLine("バナナが含まれています");
}

// 要素の削除
fruits.Remove("Banana");

// すべての要素を削除
fruits.Clear();

Contains メソッドは、後述する List<T> の検索と比較して圧倒的に高速です。

大量のデータから特定の存在有無を確認する処理では、HashSet<T> の利用が推奨されます。

強力な集合演算の活用

HashSet<T> の真骨頂は、複数の集合同士を比較・統合する 集合演算メソッド にあります。

これらを使用することで、複雑なフィルタリングロジックを簡潔に記述できます。

和集合、積集合、差集合

以下のコードは、2つのセットに対して代表的な集合演算を行う例です。

C#
using System;
using System.Collections.Generic;
using System.Linq;

class SetOperations
{
    static void Main()
    {
        var setA = new HashSet<int> { 1, 2, 3, 4, 5 };
        var setB = new HashSet<int> { 4, 5, 6, 7, 8 };

        // 積集合 (両方に共通する要素のみ残す)
        var intersectSet = new HashSet<int>(setA);
        intersectSet.IntersectWith(setB); // { 4, 5 }

        // 和集合 (すべての要素を統合する)
        var unionSet = new HashSet<int>(setA);
        unionSet.UnionWith(setB); // { 1, 2, 3, 4, 5, 6, 7, 8 }

        // 差集合 (setAからsetBに含まれる要素を除外する)
        var exceptSet = new HashSet<int>(setA);
        exceptSet.ExceptWith(setB); // { 1, 2, 3 }

        // 排他的論理和 (どちらか一方にのみ含まれる要素を残す)
        var symmetricExceptSet = new HashSet<int>(setA);
        symmetricExceptSet.SymmetricExceptWith(setB); // { 1, 2, 3, 6, 7, 8 }

        Console.WriteLine("積集合: " + string.Join(", ", intersectSet));
        Console.WriteLine("和集合: " + string.Join(", ", unionSet));
        Console.WriteLine("差集合: " + string.Join(", ", exceptSet));
        Console.WriteLine("排他的論理和: " + string.Join(", ", symmetricExceptSet));
    }
}
実行結果
積集合: 4, 5
和集合: 1, 2, 3, 4, 5, 6, 7, 8
差集合: 1, 2, 3
排他的論理和: 1, 2, 3, 6, 7, 8

これらのメソッドは 元のインスタンスを直接書き換える 点に注意してください。

元のセットを保持したい場合は、例のようにコンストラクタでコピーを作成してから演算を行うのがベストプラクティスです。

HashSet vs List:パフォーマンスと使い分け

多くの開発者が迷うポイントは、List<T>HashSet<T> のどちらを選択すべきかという点です。

これを理解するためには、計算量 (Big O notation) の違いを把握する必要があります。

計算量の比較

操作List<T>HashSet<T>
要素の追加 (末尾)O(1)O(1) (平均)
特定要素の検索 (Contains)O(n)O(1) (平均)
特定要素の削除 (Remove)O(n)O(1) (平均)
インデックスによるアクセスO(1)不可

List<T> の場合、Contains メソッドはリストの先頭から順番に要素をチェックするため、データ量が増えれば増えるほど処理時間が線形的に増加します。

一方、HashSet<T> はハッシュ値を計算して格納場所を特定するため、データ量が増えても検索速度がほとんど変わりません

どちらを使うべきか?

List<T> を使うべきケース

順序を維持したい場合や、重複を保持したい場合に適しています。

インデックスによるランダムアクセスが必要(例: 位置指定で要素を頻繁に取得する)なケースに向きます。

データ量が非常に小さく、ハッシュ計算のオーバーヘッドが無視できない場合は、List<T>の方が効率的です。

HashSet<T> を使うべきケース

要素の一意性を保証したい(重複を自動で排除したい)場合に適しています。

大規模データに対して特定要素の存在確認を頻繁に行う場合は、平均O(1)の探索が可能なHashSet<T>が有利です。

集合演算(積集合・差集合など)を多用する場合にも向きます。

パフォーマンスを最大化するための秘訣

HashSet<T> は高速ですが、使い方を誤ると本来の性能を発揮できません。

パフォーマンス向上のためのテクニックを紹介します。

初期容量 (Capacity) の指定

HashSet<T> は、内部のストレージが不足すると自動的に拡張(リサイズ)を行います。

このリサイズ処理にはコストがかかるため、あらかじめ要素数が予想できる場合は コンストラクタで初期容量を指定 することが推奨されます。

C#
// 10,000個の要素を格納することが分かっている場合
var largeSet = new HashSet<int>(10000);

これにより、動的なメモリ割り当ての回数を減らし、パフォーマンスを安定させることができます。

GetHashCode と Equals の重要性

HashSet<T> が正しく動作するかどうかは、格納するオブジェクトの GetHashCodeEquals メソッドの実装にかかっています。

特に カスタムクラス(ユーザー定義型)を要素にする場合、これらを適切にオーバーライドしないと、同じデータを持つオブジェクトであっても「別物」として扱われ、重複排除が機能しません。

カスタムオブジェクトの例

C#
public class User
{
    public int Id { get; set; }
    public string Name { get; set; }

    // Idが同じなら同一人物とみなす実装
    public override bool Equals(object obj)
    {
        if (obj is User other)
        {
            return this.Id == other.Id;
        }
        return false;
    }

    public override int GetHashCode()
    {
        return Id.GetHashCode();
    }
}

ハッシュ値が衝突(異なるオブジェクトが同じハッシュ値を持つこと)しすぎると、HashSet<T> のパフォーマンスは O(1) から O(n) に低下 します。

現代の C# では、HashCode.Combine メソッドを使用して、複数のプロパティを組み合わせた堅牢なハッシュ値を生成するのが一般的です。

カスタム比較器 (IEqualityComparer) の利用

既存のクラスを変更できない場合や、特定の条件下でのみ一意性を定義したい場合は、IEqualityComparer<T> インターフェースを実装したクラスを作成し、HashSet<T> のコンストラクタに渡します。

C#
using System;
using System.Collections.Generic;

public class CaseInsensitiveComparer : IEqualityComparer<string>
{
    public bool Equals(string x, string y) => string.Equals(x, y, StringComparison.OrdinalIgnoreCase);
    public int GetHashCode(string obj) => obj.ToLower().GetHashCode();
}

class Program
{
    static void Main()
    {
        // 大文字小文字を区別しないHashSet
        var set = new HashSet<string>(new CaseInsensitiveComparer());
        
        set.Add("HELLO");
        set.Add("hello"); // 重複とみなされ追加されない

        Console.WriteLine($"要素数: {set.Count}"); // 結果: 1
    }
}

このように、外部から比較ロジックを注入できる柔軟性HashSet<T> の大きな魅力です。

HashSet と LINQ の連携

.NET の LINQ (Language Integrated Query) には Distinct() メソッドがありますが、これも内部的には HashSet<T> に似た仕組みを利用しています。

しかし、明示的に HashSet<T> を作成してから操作する方が効率的な場合もあります。

たとえば、巨大なリストから重複を除去して繰り返し利用する場合、毎回 Distinct() を呼び出すのではなく、一度 HashSet<T> に変換しておくことで、その後の検索や演算が劇的に高速化されます。

C#
var largeList = GetLargeData(); // 大量データ
var uniqueItems = new HashSet<int>(largeList); // 高速化のための変換

// 何度も検索を行う場合はHashSetが有利
if (uniqueItems.Contains(999)) { /* ... */ }

また、.NET 6 以降では TryAdd は存在しませんが、Add の戻り値をチェックすることで同様のロジックが組めます。

さらに、最新の .NET バージョンでは、スパン (ReadOnlySpan<T>) との親和性も高まっており、メモリ効率を意識した開発が可能です。

注意点とベストプラクティス

HashSet<T> を使用する際に避けるべきアンチパターンと、守るべきルールについて解説します。

1. 格納後の要素の変更を避ける

HashSet<T> に格納したオブジェクトのプロパティを変更し、その結果として GetHashCode の値が変わってしまうと、その要素を二度と検索・削除できなくなる 可能性があります。

ハッシュテーブル内のバケット位置と実際のハッシュ値が不一致になるためです。

コレクションに入れる要素は、可能な限りイミュータブル(不変)に設計しましょう。

2. スレッドセーフではない

HashSet<T> はスレッドセーフではありません。

複数のスレッドから同時に書き込みを行う場合は、lock 文による同期を行うか、ConcurrentDictionary<T, byte> を代用して擬似的なセットを作るなどの対策が必要です。

3. メモリ使用量

HashSet<T> は高速な検索を実現するために、List<T> よりも多くのメモリを消費します。

ハッシュバケットの管理や要素のリンク保持などのオーバーヘッドがあるため、極端にメモリ制約が厳しい環境では、計算量とメモリ消費のトレードオフを検討してください。

まとめ

HashSet<T> は、C# におけるコレクションの中でも非常に強力かつ汎用性の高いツールです。

  • 重複を自動的に排除 し、データの一意性を保つことができる。
  • 検索速度が O(1) であり、大量データの存在チェックにおいて List<T> を圧倒する。
  • 集合演算(和、積、差) を標準メソッドで簡単に実装できる。
  • パフォーマンスを最大化するには、初期容量の指定適切なハッシュアルゴリズムの実装 が重要。

「順序は問わないが、とにかく高速に一意なデータを扱いたい」というシナリオにおいて、HashSet<T> は最適な選択肢となります。

本記事で紹介した特性やテクニックを活用し、より効率的で堅牢な C# アプリケーションの開発に役立ててください。