1. はじめに
想像してみて:ネットショップのシステムを作ってて、今まで売れた全商品のユニークなコードを保存したいとき。あるいは、SNSアプリを作ってて、ユーザー名がすでに使われてるか一瞬でチェックしたいとき。もしくは、大きなテキストの中でユニークな単語の数を数えたいとき。
こういう場面では、各要素が一度だけ現れるセットが必要だよね。そこで登場するのがHashSet<T>。
HashSet<T>は、順序なしのユニークな要素のセットを保存するコレクションだよ。ここで大事なのはユニークってこと。同じ要素をHashSetに追加しようとしても、すでに入ってたら無視されて、重複は追加されない。まるで「ユニークな人だけのクラブ」みたいな感じ。一度入ったら、二度目は入れない。
HashSet<T>の主な特徴:
- ユニーク性: コレクション内の各要素が一度だけ存在することを保証する。
- パフォーマンス: 要素の存在チェック、追加、削除がめっちゃ速い。平均してこれらの操作は定数時間(O(1))でできる!これはハッシュ化って仕組みのおかげ。
- 順序なし: List<T>と違って、HashSet<T>の要素には順番がない。インデックス(例えば「5番目の要素」)でアクセスできない。
- ハッシュテーブルベース: HashSet<T>の内部ではハッシュテーブルで要素を保存してるから、超高速。ハッシュテーブルの仕組みはここでは深く説明しないけど(それはもっと上級のレクチャーで!)、ざっくり言うと、各要素が特別な数値コード(ハッシュ)に「変換」されて、それで一瞬で探せるって感じ。
もしユニークな要素を保存するのにList<T>を使ったら、毎回リスト全体を見て、なかったら追加…ってやらなきゃいけない。大きなリストだと超遅い。HashSet<T>なら一瞬で終わる!
2. HashSet<T>ってプログラマーに何の役に立つの?
ユニークなコレクションの秘密
プログラミングでは「重複なしで要素を保存したい」って課題がよくある。例えば、アプリのユーザーのemailアドレス一覧をパースして、重複がないか確認したいときとか、フォルダから読み取ったファイル名のユニークなリストを作りたいときとか。一番シンプルな解決法は、「すでにあるものは二度と追加できないコレクション」を使うこと。
もちろん、List<T>で手動で「すでにあるか」チェックしてから追加する方法もあるけど:
var users = new List<string>();
if (!users.Contains("vasya@example.com"))
users.Add("vasya@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> { "りんご", "バナナ", "なし", "バナナ" };
// "バナナ"は2回あるけど、1回しか保存されない!
要素の追加
要素の追加はAddメソッドでやる。まだなかったらtrueを返す。すでにあったら何も起きず、falseを返す。
bool added = emails.Add("vasya@example.com"); // true, 追加された
added = emails.Add("vasya@example.com"); // false, すでにあるから追加されない
面白いポイント: 同じ値でAddを100回呼んでも、HashSetは怒らず、重複は全部無視してくれる。
存在チェック: Contains
要素が入ってるか調べるにはContainsメソッドを使う:
if (emails.Contains("vasya@example.com"))
Console.WriteLine("このemailはすでにあるよ!");
要素の削除
削除も速いよ:
emails.Remove("vasya@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の要素を削除する。 |
|
どちらか一方だけにある要素だけ残す(両方にあるのは消える)。 |
例: 積集合と和集合
例で見てみよう。2つの名前セットがあるとする:
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();
}
}
// これでOK:
var users = new HashSet<User>();
users.Add(new User { Login = "vasya" });
users.Add(new User { Login = "petya" });
users.Add(new User { Login = "vasya" }); // 追加されない!
EqualsとGetHashCodeをオーバーライドしないと、HashSetは全部のインスタンスを別物扱いしちゃう(たとえログイン名が同じでも)。デフォルトだとメモリアドレスで比較するからね。
GO TO FULL VERSION