C#のListを文字列IDで高速検索する3つの方法|DictionaryとLINQでインデックス化する実践テクニック

業務アプリやゲーム、制御系などで「ID から素早くオブジェクトを取り出したい」という場面は非常に多くあります。単純に List<T> を LINQ で検索しても動きますが、データ件数が増えるとすぐに限界が来ます。この記事では、C# の List を「文字列 ID でインデックス化」して高速にアクセスするための定番パターンと、ID が重複する場合や設計のコツまで、実践的な観点で詳しく解説します。

目次

C# の List を文字列 ID でインデックス化するとは?

ここでのテーマは「共通の基底クラスが string ID を持っているオブジェクトのリスト」から、ID をキーにして素早く要素を取り出すことです。例えば以下のようなイメージです。


public abstract class AbstractBase
{
    public string ID { get; init; } = string.Empty;
    public int Value { get; set; }
    public int Tolerance { get; set; }
}

public class TemperatureSetting : AbstractBase
{
    public double TargetCelsius { get; set; }
}

public class SpeedSetting : AbstractBase
{
    public int Rpm { get; set; }
}

このようなクラスを使って、設定情報をひとまとめに List<AbstractBase> で持っているとします。よくある処理は次のようなものです。

  • 外部から「ID=TEMP01 の設定を取ってきて」と言われる
  • 画面で ID を指定して検索し、対応するデータを表示する
  • 通信やファイルから ID だけが渡ってきて、対応するオブジェクトを探す

この時、単純に List を走査し続けると、データ件数が増えるほど処理時間が直線的に増えてしまいます。そこで出てくるのが、Dictionary<string, T> や ILookup<string, T> を使ったインデックス化です。

コレクション検索コスト(概念)ID 重複コメント
List<T> + LINQO(n)許可(扱いは実装次第)簡単だが大量データには不向き
Dictionary<string, T>平均 O(1)不可(キーは一意)最速。ID がユニークなら第一候補
ILookup<string, T>平均 O(1)許可(各キーに複数要素)重複 ID をまとめて扱いたい場合に便利
Dictionary<string, List<T>>平均 O(1)許可複数要素を自分で管理したい場合

最速パターン:Dictionary<string, T> で文字列 ID をキーにする

Dictionary で O(1) アクセスを実現する

ID が必ず一意であることが保証できるなら、Dictionary<string, AbstractBase> に変換するのが最も簡単かつ高速な方法です。典型的なコードは次の 1 行です。


List&lt;AbstractBase&gt; list = GetItems();

// ID をキーにしたインデックスを作成
Dictionary&lt;string, AbstractBase&gt; index = list.ToDictionary(a =&gt; a.ID);

これで、ID が分かっていれば次のように O(1) でアクセスできます。


AbstractBase item = index["TEMP01"];  // 見つからない場合は KeyNotFoundException

存在しない ID でも安全に扱いたい場合は TryGetValue パターンを使うのが定番です。


if (index.TryGetValue("TEMP01", out var item))
{
    // 見つかった場合の処理
}
else
{
    // 見つからなかった場合の処理
}

基本コード例:List から Dictionary への変換と使用例

もう少し現実に近いサンプルにすると、例えば以下のようになります。


public abstract class AbstractBase
{
    public string ID { get; init; } = string.Empty;
    public int Value { get; set; }
    public int Tolerance { get; set; }
}

public class TemperatureSetting : AbstractBase
{
    public double TargetCelsius { get; set; }
}

public class SpeedSetting : AbstractBase
{
    public int Rpm { get; set; }
}

public class SettingsRepository
{
    private readonly List&lt;AbstractBase&gt; _items;
    private readonly Dictionary&lt;string, AbstractBase&gt; _index;

    public SettingsRepository(IEnumerable&lt;AbstractBase&gt; items)
    {
        _items = items.ToList();
        _index = _items.ToDictionary(x =&gt; x.ID);
    }

    public AbstractBase? Find(string id)
    {
        return _index.TryGetValue(id, out var item) ? item : null;
    }

    public IReadOnlyList&lt;AbstractBase&gt; Items =&gt; _items;
}

このようにリポジトリクラスの内部でインデックスを持たせてしまうと、呼び出し側は List を意識せずに高速検索だけを利用できます。内部実装を後から変えやすくなる点でもおすすめの設計です。

StringComparer を指定して大文字小文字を制御する

文字列 ID をキーに使う場合、大文字・小文字の扱いをはっきり決めておくと後々トラブルが減ります。ToDictionary の第 2 引数に StringComparer を渡すことで、この挙動を制御できます。


// 大文字小文字を区別しない(英語 ID など)
var indexIgnoreCase = list.ToDictionary(
    a =&gt; a.ID,
    StringComparer.OrdinalIgnoreCase);

// 大文字小文字を厳密に区別する(デフォルト相当)
var indexCaseSensitive = list.ToDictionary(
    a =&gt; a.ID,
    StringComparer.Ordinal);

業務システムで「ユーザーが手入力する ID」を扱う場合は、大文字小文字を区別しないことも多いですが、プロトコルやファイル仕様で厳密な ID が決まっている場合は Ordinal を使って区別したほうが安全です。

ID が重複していると ToDictionary は例外になる

注意点として、ToDictionary は同じキーが 2 回以上出てくると ArgumentException を投げます。ID が本当にユニークであることが前提です。


var index = list.ToDictionary(a =&gt; a.ID); // 重複 ID があると例外

もし「基本的にはユニークなはずだが、万一の重複はログに出したい」というような要件なら、自前でループを回してチェックするのも手です。


var index = new Dictionary&lt;string, AbstractBase&gt;();

foreach (var item in list)
{
    if (index.ContainsKey(item.ID))
    {
        // ログを出すなど、重複時の扱いを明示
        Console.WriteLine($"重複 ID: {item.ID}");
        continue;
    }

    index[item.ID] = item;
}

ID が重複する場合:ToLookup と Dictionary<string, List<T>>

「ID はユニークじゃない。むしろ同じ ID の設定が複数ある」というケースもよくあります。例えば、LineID ごとに複数のセンサー設定がぶら下がっているようなケースです。この場合は Dictionary<string, List<T>> や ILookup<string, T> を使って、「1 つのキーに複数要素」をぶら下げるのが定石です。

ToLookup で「読み取り専用のグルーピング」を作る

最も手軽なのは ToLookup を使って ILookup<string, AbstractBase> を作る方法です。ILookup は「キーごとの要素の集合」を表すインターフェースで、LINQ の GroupBy 結果に近い概念です。


var lookup = list.ToLookup(a =&gt; a.ID);

foreach (var item in lookup["DUP"])
{
    // ID = "DUP" の要素をすべて処理
}

存在しないキーを指定した場合でも、lookup["UNKNOWN"] は「空のシーケンス」を返すので、 null チェックなしで foreach できるのが特徴です。読み取り専用でよければ、非常に扱いやすい構造です。

Dictionary<string, List<T>> で書き込みも含めて柔軟に扱う

「あとから要素を追加・削除したい」「List の変更に合わせて動的に更新したい」といった要件なら、Dictionary<string, List<AbstractBase>> を自前で構築するパターンが向きます。


var index = new Dictionary&lt;string, List&lt;AbstractBase&gt;&gt;();

foreach (var item in list)
{
    if (!index.TryGetValue(item.ID, out var bucket))
    {
        bucket = new List&lt;AbstractBase&gt;();
        index[item.ID] = bucket;
    }

    bucket.Add(item);
}

// 参照側:ID に紐づく要素を列挙
if (index.TryGetValue("DUP", out var dupItems))
{
    foreach (var item in dupItems)
    {
        // 何らかの処理
    }
}

このパターンなら、あとから個別に追加・削除することも容易です。


void AddItem(AbstractBase item)
{
    if (!index.TryGetValue(item.ID, out var bucket))
    {
        bucket = new List&lt;AbstractBase&gt;();
        index[item.ID] = bucket;
    }

    bucket.Add(item);
}

void RemoveItem(AbstractBase item)
{
    if (!index.TryGetValue(item.ID, out var bucket)) return;

    bucket.Remove(item);

    if (bucket.Count == 0)
    {
        index.Remove(item.ID);
    }
}

ToLookup と Dictionary<string, List<T>> の比較

項目ILookup<string, T>Dictionary<string, List<T>>
作り方list.ToLookup(keySelector)ループで自前構築
読み書き読み取り専用追加・削除・更新が自由
存在しないキー空のシーケンスを返すTryGetValue で存在確認が必要
更新コスト再構築が必要局所的な更新で済む
向いている用途一括で作って読み取りのみ動的に更新されるデータ

バッチ処理や、起動時に一度だけデータを読み込んであとは読むだけ、というアプリなら ToLookup で十分です。逆に、アプリ動作中に行や設定が出入りする場合は Dictionary<string, List<T>> のほうが扱いやすくなります。

変換せずに List を都度検索するパターン(小規模データ向け)

「データ件数がどう頑張っても数十件」「検索頻度も低い」という場合は、あえてインデックスを作らず、LINQ や List の組み込みメソッドでその都度検索するだけでも十分なことがあります。

LINQ の FirstOrDefault / Where を使う

最も素直なコードは次のような形です。


var found = list.FirstOrDefault(a =&gt; a.ID == "A123");
if (found != null)
{
    // 見つかった
}

重複 ID を全部取ってきたい場合は Where を使います。


foreach (var item in list.Where(a =&gt; a.ID == "A123"))
{
    // 複数件を順に処理
}

LINQ は読みやすい一方で、毎回リスト全体を線形走査するため、データ件数が増えるとパフォーマンスが悪化します。ただし、数十件〜数百件程度であれば、実際には体感差が出ないことも多く、コードのシンプルさを優先してこのままにする判断も十分アリです。

List.Find / FindIndex との違い

LINQ を使わず、List 自体が持っている Find や FindIndex を使うこともできます。


// 最初に一致した要素を取得
var found = list.Find(a =&gt; a.ID == "A123");

// インデックスだけ取得したい場合
int index = list.FindIndex(a =&gt; a.ID == "A123");

内部的にはやはり線形探索ですが、LINQ より少しだけオーバーヘッドが少ないこともあります。とはいえ差はごくわずかなことが多いので、読みやすさやチームのコーディングスタイルに合わせて LINQ と使い分けて構いません。

いつ Dictionary に切り替えるべきかの目安

「いつまで List + LINQ で粘ってよいか?」は悩みどころですが、ざっくりした目安としては次のように考えると判断しやすくなります。

状況おすすめ
件数が最大でも数十件程度List + LINQ / Find で十分
件数が数千件以上、検索回数が多いDictionary / ILookup に切り替え
レスポンス時間がシビアな画面・API最初から Dictionary を検討
バッチ処理で何度も検索する処理の最初にインデックスを構築

特に Web API やリアルタイム UI ではレスポンスのブレがユーザー体験に直結するため、少し多めに見積もって早めに Dictionary 化しておくくらいがちょうどよいことが多いです。

抽象クラスと ID プロパティ設計のベストプラクティス

ID でインデックス化する前提なら、そもそものクラス設計の段階で「ID をどう扱うか」をきちんと決めておくと、後の変更がかなり楽になります。

共通基底クラスで ID を統一する

記事冒頭で示したように、共通の抽象クラス(またはインターフェース)に string ID を持たせるのはとてもよくあるパターンです。


public abstract class AbstractBase
{
    public string ID { get; init; } = string.Empty;
    public int Value { get; set; }
    public int Tolerance { get; set; }
}

こうしておくと、派生クラスをまとめて List<AbstractBase> に流し込み、Dictionary 化する際も a => a.ID だけで共通のキーを取れるようになります。ID の命名ルールや桁数、フォーマットのバリデーションも、この基底クラスにまとめてしまえば、すべての派生クラスで統一されたルールを強制できます。

ID を init 専用にして不変にするメリット

C# 9.0 以降では、プロパティを init アクセサにすることで、「オブジェクトの初期化時以外は変更できない」ようにできます。ID のような識別子は通常、「一度決めたら変えない」ほうが扱いやすいので、init アクセサで不変にしておくのは非常に有効です。


public abstract class AbstractBase
{
    public string ID { get; init; } = string.Empty; // 初期化時のみ設定可
    public int Value { get; set; }
    public int Tolerance { get; set; }
}

// 使用例
var setting = new TemperatureSetting
{
    ID = "TEMP01",        // OK(初期化時)
    Value = 100,
    Tolerance = 5,
    TargetCelsius = 25.0
};

// setting.ID = "TEMP02"; // コンパイルエラー

ID を不変にしておけば、Dictionary のキーとして使用している最中に ID が変更されて不整合が起きる、といった事故を防げます。特にインデックス構造を複数持つような設計では、この「キーが途中で変わらない」ことが非常に重要です。

record / struct で扱う場合の注意点

最近の C# では record を使って値オブジェクトを定義することも増えています。ID 付きデータを record で定義することも可能です。


public record Setting(
    string ID,
    int Value,
    int Tolerance);

この場合も、ID はコンストラクタでのみ設定可能となり、実質的に不変になります。ただし、record の場合は with 式を使って複製することで ID を変えることもできるため、設計として ID の変更を許すのかどうかをチーム内で明確に決めておくと混乱しません。

List と Dictionary を同時に持つ設計パターン

現実のアプリでは、「順序付きで全件を走査することもあるし、ID から一発で引きたいこともある」というケースが多く、List と Dictionary を両方持つパターンがよく使われます。

ラッパークラスで一元管理する

最もシンプルなやり方は、List と Dictionary を隠蔽したラッパークラスを用意することです。


public class IndexedCollection&lt;T&gt; where T : AbstractBase
{
    private readonly List&lt;T&gt; _list = new();
    private readonly Dictionary&lt;string, T&gt; _index;

    public IndexedCollection()
    {
        _index = new Dictionary&lt;string, T&gt;(StringComparer.Ordinal);
    }

    public void Add(T item)
    {
        _list.Add(item);
        _index[item.ID] = item;
    }

    public bool Remove(string id)
    {
        if (!_index.TryGetValue(id, out var item))
        {
            return false;
        }

        _index.Remove(id);
        _list.Remove(item);
        return true;
    }

    public T? Find(string id)
    {
        return _index.TryGetValue(id, out var item) ? item : null;
    }

    public IReadOnlyList&lt;T&gt; Items =&gt; _list;
}

呼び出し側は IndexedCollection<T> を経由するだけで、「順序付きの一覧」と「ID からの高速検索」の両方を意識せずに使えるようになります。List と Dictionary の整合性もこのクラスの中だけで意識すればよいので、バグを局所化できます。

Add/Remove 時にインデックスを更新するポイント

List と Dictionary を別々に扱っているコードでは、片方だけ更新してもう片方を更新し忘れるというバグが頻出します。これを防ぐために、以下のようなルールを徹底すると安全です。

  • List / Dictionary に直接アクセスさせず、必ずラッパークラス経由にする
  • 追加・削除・更新は 1 つのメソッドにまとめる(重複コードを作らない)
  • ID を変更する操作を禁止する、または専用メソッドだけに限定する

特に ID 変更を許すと、「Dictionary のキーは古い ID のままなのに、オブジェクトの ID プロパティだけ変わっている」といった状態になりがちです。可能な限り、ID は init 専用にして、「ID を変えたい場合は新しいオブジェクトを作る」という運用にしたほうが安全です。

パフォーマンスのイメージをつかむ小さな実験コード

「本当に Dictionary にしたほうが速いの?」という感覚を掴むには、簡単な計測コードを書いてみるのが一番です。以下は、List と Dictionary の検索時間をざっくり比較する例です。


using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;

public class Item : AbstractBase
{
}

class Program
{
    static void Main()
    {
        const int count = 100_000;
        var list = new List&lt;Item&gt;(capacity: count);

        for (int i = 0; i &lt; count; i++)
        {
            list.Add(new Item
            {
                ID = $"ID{i:D6}",
                Value = i,
                Tolerance = 0
            });
        }

        var dict = list.ToDictionary(x =&gt; x.ID);

        var sw = new Stopwatch();

        // List から検索(線形)
        sw.Start();
        for (int i = 0; i &lt; 10_000; i++)
        {
            var id = $"ID{(i * 7) % count:D6}";
            var found = list.FirstOrDefault(x =&gt; x.ID == id);
        }
        sw.Stop();
        Console.WriteLine($"List: {sw.ElapsedMilliseconds} ms");

        // Dictionary から検索(ハッシュ)
        sw.Restart();
        for (int i = 0; i &lt; 10_000; i++)
        {
            var id = $"ID{(i * 7) % count:D6}";
            var found = dict[id];
        }
        sw.Stop();
        Console.WriteLine($"Dictionary: {sw.ElapsedMilliseconds} ms");
    }
}

実行環境にもよりますが、件数が増えるほど Dictionary のほうが圧倒的に高速になることが確認できるはずです。数千件程度でも、繰り返し回数が多い処理では差が積み重なり、ユーザー体感に影響が出ることもあります。

よくある疑問・つまずきポイント

そのまま List を使ってはいけないの?

もちろん、「規模が小さい」「検索頻度が低い」などの条件が揃っていれば、List + LINQ のままでも何の問題もありません。むしろ、早すぎる最適化はコードを複雑にするだけなので、「パフォーマンスに問題が出そうな箇所・出ている箇所」から優先的に Dictionary 化する、という考え方が現実的です。

ただし、ID 検索が「1 回のリクエストで数十〜数百回」行われるような設計であれば、最初から Dictionary や ILookup を使っておくと、後からの修正コストを減らせます。頻繁に使われる経路のデータ構造だけは、少し慎重に選ぶ、というバランス感覚がポイントです。

Dictionary のメモリ使用量は問題にならない?

Dictionary は内部でハッシュテーブルを持つため、List に比べると多少メモリを余分に使います。とはいえ、一般的な業務システムや Web アプリのスケールでは、その差が問題になるケースは多くありません。

  • メモリ使用量がシビアな組み込み・IoT
  • 数百万件クラスの巨大なコレクション

といった特殊なケースでなければ、まずは読みやすさとパフォーマンスを優先して Dictionary を採用し、必要になったらプロファイラで実測する、というスタンスで問題ありません。

マルチスレッド環境ではどうする?

マルチスレッド環境で List や Dictionary を更新する場合は、スレッドセーフティも考慮が必要です。

  • 読み取り専用なら、構築後に不変として扱えばロック不要
  • 更新が発生するなら、ロックか ConcurrentDictionary 等の使用を検討
  • ID を init 専用にしておけば、読み取り側の安全性が増す

特に「読み取りは頻繁だが、更新はほとんどない」パターンでは、起動時に一度だけ Dictionary を構築し、その後は読み取り専用にする設計がよく使われます。こうしておくと、ロック不要で高速な検索をマルチスレッドから安全に行えます。

まとめ:C# で ID 検索を高速・安全にするための指針

C# の List を「文字列 ID でインデックス化」して高速にアクセスしたい場合、押さえておきたいポイントは次の通りです。

  • ID がユニークなら、まずは Dictionary<string, T> に一括変換
    list.ToDictionary(x => x.ID) で簡単に O(1) アクセスが実現できます。
  • 重複 ID があるなら、ToLookup または Dictionary<string, List<T>> を選択
    読み取り専用なら ToLookup、動的更新が必要なら Dictionary<string, List<T>> が定番です。
  • 小規模データなら List + LINQ / Find も現実的
    数十件レベルならインデックスを作らないほうがコードがシンプルで保守しやすい場合もあります。
  • 共通基底クラスに string ID を定義し、init で不変にする
    Dictionary のキーとして安全に使うためにも、「ID は途中で変えない」をルール化するとトラブルを防げます。
  • List と Dictionary を同時に持つ場合はラッパークラスで一元管理
    Add/Remove のたびに両方を更新する実装を 1 箇所に集約することで、バグを減らせます。

これらのパターンを頭に入れておけば、「C# のコレクションを ID キーで扱う」場面のほとんどをカバーできます。新しい機能やライブラリを使う前に、まずはこの Dictionary / ILookup / List + LINQ の基本三パターンをしっかり使い分けられるようにしておくと、大規模化しても耐えられる堅牢なコードを書きやすくなります。

この記事を書いた人

実務の現場で詰まりがちなポイントを地図にするITブログ「IT trip」を運営。Windows/Office(Teams・Excel)からSQL、サーバ運用、ガジェットまで、再現性のある手順と“なぜそうなるか”を丁寧に解説します。読んだらすぐ試せること、そして迷った人の次の一歩が見えることを大切にしています。

コメント

コメントする

目次