【C#】Dictionary完全ガイド|内部構造・安全な操作・IEqualityComparer・ConcurrentDictionary・SortedDictionaryまで

【C#】Dictionaryの基本操作|キーと値の扱い方 C#

Dictionary<TKey, TValue> は C# で最も頻繁に使うコレクションの一つです。基本操作はシンプルですが、内部のハッシュテーブル構造・カスタム等値比較器・スレッドセーフな操作・ソート済みコレクションとの使い分けを知ることで、パフォーマンス問題やバグを未然に防げます。

本記事では Dictionary の内部構造から始まり、安全な操作パターン・カスタム IEqualityComparer・ConcurrentDictionary・SortedDictionary・ImmutableDictionary/FrozenDictionary・よくある落とし穴まで体系的に解説します。

スポンサーリンク

Dictionary の内部構造 — ハッシュテーブルとバケット

Dictionary<TKey, TValue> の内部はハッシュテーブルで実装されています。キーの GetHashCode() でバケット(インデックス)を決定し、衝突はチェーン方式(_buckets 配列 + _entries 配列の Next インデックスによる連結)で解決します。これにより検索・追加・削除の平均時間計算量は O(1)です。

操作 平均 最悪(ハッシュ衝突集中時) 備考
[](取得・設定) O(1) O(n) ハッシュ関数が偏ると低下
Add / TryAdd O(1) O(n) 容量超過でリサイズ発生時 O(n)
Remove O(1) O(n)
ContainsKey O(1) O(n)
ContainsValue O(n) O(n) 線形探索になるため遅い
foreach(全列挙) O(n) O(n)
キャパシティと Load Factor の仕組み
// Dictionary は内部に Entry[] 配列を持つ
// 要素数が 容量 × LoadFactor(約0.72)を超えると自動リサイズが発生する
// → 新しい配列を確保してすべての要素を再ハッシュ(O(n) のコスト)

// 初期容量を指定して再リサイズを抑制する
var scores = new Dictionary<string, int>(capacity: 1000);
// 1000件を入れても自動リサイズが起きにくい

// 現在の容量を確認(公開 API は Count のみ)
Console.WriteLine(scores.Count); // 0(要素数)

// 大量データを事前に読み込む場合は初期容量を指定するとパフォーマンスが向上する
var userIds = Enumerable.Range(1, 100_000);
var userCache = new Dictionary<int, string>(capacity: userIds.Count());
foreach (var id in userIds)
    userCache[id] = $"User_{id}";
GetHashCode() と Equals() の一貫性が必須
Dictionary のキー型は GetHashCode()Equals() が一貫している必要があります。「Equals() が true を返す2つのオブジェクトは同じ GetHashCode() を返す」というルールが破られると、同じキーを2回追加できる・検索できないなどの不可解なバグが発生します。独自クラスをキーにする場合は 必ず両方をオーバーライドしてください。

基本操作 — 追加・取得・更新・削除

Dictionary の作成と基本 CRUD
// 初期化
var inventory = new Dictionary<string, int>
{
    ["Apple"]  = 50,
    ["Banana"] = 30,
    ["Cherry"] = 100,
};

// ① 追加: Add(重複キーは ArgumentException)
inventory.Add("Durian", 5);

// ② 追加: TryAdd(重複キーは false を返すだけで例外なし)— .NET Core 2.0+
bool added = inventory.TryAdd("Apple", 99); // false(既に存在するため追加されない)
Console.WriteLine(added);                    // False

// ③ 取得: インデックサー(キーがなければ KeyNotFoundException)
int appleCount = inventory["Apple"];         // 50

// ④ 更新: インデックサー(キーがなければ新規追加、あれば上書き)
inventory["Apple"] = 55;
inventory["Elderberry"] = 20; // 新規追加

// ⑤ 削除: Remove(戻り値 bool = 削除できたか)
bool removed = inventory.Remove("Durian");   // true

// ⑥ 削除して値も取得(out パラメーター)— .NET Core 2.0+
bool gotAndRemoved = inventory.Remove("Banana", out int removedValue);
Console.WriteLine(removedValue);             // 30

// ⑦ 件数確認
Console.WriteLine(inventory.Count);         // 3

// ⑧ 全削除
inventory.Clear();

安全な値の取得 — TryGetValue・GetValueOrDefault・ContainsKey

Dictionary で最も多いバグは「存在しないキーにアクセスして KeyNotFoundException」です。状況に応じて3つの方法を使い分けてください。

3つの安全アクセスパターン
var scores = new Dictionary<string, int>
{
    ["Alice"] = 90, ["Bob"] = 85
};

// ① TryGetValue: 「存在確認+取得」を1回の操作で行う(最も一般的)
if (scores.TryGetValue("Charlie", out int charlieScore))
{
    Console.WriteLine($"Charlie: {charlieScore}");
}
else
{
    Console.WriteLine("Charlie は登録されていません");
}

// NG パターン: ContainsKey → インデックサーは2回ハッシュ計算が走る
if (scores.ContainsKey("Charlie"))          // 1回目のハッシュ計算
    Console.WriteLine(scores["Charlie"]);   // 2回目(無駄)

// ② GetValueOrDefault: キーがなければデフォルト値を返す(C# 8 / .NET Core 2.0+)
int aliceScore = scores.GetValueOrDefault("Alice", defaultValue: 0);  // 90
int daveScore  = scores.GetValueOrDefault("Dave",  defaultValue: 0);  // 0

// ③ CollectionsMarshal.GetValueRefOrNullRef(.NET 6+): 大量更新の高速化
// 通常の TryGetValue→Remove→Add よりも 1 回のルックアップで済む
using System.Runtime.InteropServices;
ref int aliceRef = ref CollectionsMarshal.GetValueRefOrNullRef(scores, "Alice");
if (!Unsafe.IsNullRef(ref aliceRef))
    aliceRef += 10;  // 直接インプレース更新(ボクシングなし)
TryGetValue vs ContainsKey の使い分け
値が必要な場合は常に TryGetValue() を使ってください。ContainsKey() のあとに [] でアクセスするパターンは、ハッシュルックアップが2回走るうえ、マルチスレッド環境ではContainsKey と [] アクセスの間にキーが削除される TOCTOU 問題も起きます。「存在確認のみ」が目的なら ContainsKey()、「存在確認+値取得」なら TryGetValue() が正解です。
GetOrAdd パターン(キーがなければ追加)
// GetOrAdd 相当のイディオム(Dictionary には GetOrAdd がない)
static TValue GetOrAdd<TKey, TValue>(
    Dictionary<TKey, TValue> dict,
    TKey key,
    Func<TKey, TValue> factory) where TKey : notnull
{
    if (!dict.TryGetValue(key, out TValue? value))
    {
        value = factory(key);
        dict[key] = value;
    }
    return value;
}

// 使い方: キャッシュ辞書
var cache = new Dictionary<string, ExpensiveObject>();
var obj   = GetOrAdd(cache, "key1", k => new ExpensiveObject(k));

// .NET 8+ 以降: Dictionary.GetAlternateLookup でさらに効率よく扱える
// ConcurrentDictionary には GetOrAdd が標準搭載(後述)

Dictionary の作成パターン

各種初期化方法
// ① コレクション初期化子(推奨)
var config = new Dictionary<string, string>
{
    { "host", "localhost" },
    { "port", "5432" },
};

// ② インデックス初期化子(C# 6+)— より読みやすい
var httpStatus = new Dictionary<int, string>
{
    [200] = "OK",
    [404] = "Not Found",
    [500] = "Internal Server Error",
};

// ③ LINQ から作成: ToDictionary
var students = new[] { ("Alice", 90), ("Bob", 85), ("Charlie", 78) };
var scoreMap = students.ToDictionary(
    keySelector:   s => s.Item1,   // "Alice" → キー
    elementSelector: s => s.Item2); // 90 → 値

// ④ LINQ から作成: ToLookup(同じキーに複数値を持てる辞書)
var orders = new[]
{
    (UserId: 1, Item: "Book"),
    (UserId: 1, Item: "Pen"),
    (UserId: 2, Item: "Notebook"),
};
var ordersByUser = orders.ToLookup(o => o.UserId, o => o.Item);
Console.WriteLine(ordersByUser[1].Count()); // 2(Book, Pen)

// ⑤ 既存 Dictionary からコピー
var original = new Dictionary<string, int> { ["a"] = 1 };
var copy = new Dictionary<string, int>(original);          // シャロウコピー

// ⑥ 空の読み取り専用(デフォルト辞書を返すとき)
IReadOnlyDictionary<string, int> empty = new Dictionary<string, int>();

イテレーション — Keys・Values・分解構文

Dictionary の列挙方法
var capitals = new Dictionary<string, string>
{
    ["Japan"]  = "Tokyo",
    ["France"] = "Paris",
    ["USA"]    = "Washington D.C.",
};

// ① キーと値を同時に列挙(KeyValuePair<K,V> の foreach)
foreach (KeyValuePair<string, string> pair in capitals)
    Console.WriteLine($"{pair.Key}: {pair.Value}");

// ② C# 7+ の分解構文(var (key, value))
foreach (var (country, capital) in capitals)
    Console.WriteLine($"{country} の首都は {capital}");

// ③ キーのみ列挙
foreach (string country in capitals.Keys)
    Console.WriteLine(country);

// ④ 値のみ列挙
foreach (string capital in capitals.Values)
    Console.WriteLine(capital);

// ⑤ LINQ をそのまま適用できる
var euCapitals = capitals
    .Where(kv => kv.Key is "France" or "Germany")
    .Select(kv => kv.Value)
    .ToList();
イテレーション中に Dictionary を変更するとランタイム例外
foreach でループ中に AddRemove[](新規追加)を行うとInvalidOperationException: Collection was modified; enumeration operation may not executeがスローされます。ループ中に変更が必要な場合は、① キーを別リストに収集してからループ または ② 変更を別の辞書に貯めてループ後にマージする方法を使ってください。
イテレーション中の変更パターン(NG と OK)
var dict = new Dictionary<string, int>
{
    ["a"] = 1, ["b"] = 2, ["c"] = 3
};

// NG: ループ中に Remove → InvalidOperationException
foreach (var key in dict.Keys)
{
    if (dict[key] < 2)
        dict.Remove(key); // 例外!
}

// OK ①: Keys を ToList() でスナップショット化
foreach (var key in dict.Keys.ToList()) // スナップショット
{
    if (dict[key] < 2)
        dict.Remove(key); // OK: 元の dict.Keys ではなくリストを列挙
}

// OK ②: LINQ でフィルタリングして新しい辞書を作成
dict = dict
    .Where(kv => kv.Value >= 2)
    .ToDictionary(kv => kv.Key, kv => kv.Value);

カスタム等値比較器 — IEqualityComparer と StringComparer

デフォルトでは string キーは大文字小文字を区別します。StringComparer を使うと大文字小文字・カルチャを制御でき、独自クラスをキーにする場合は IEqualityComparer<T> を実装します。

StringComparer を使ったケースインセンシティブ辞書
// デフォルト: 大文字小文字を区別する
var caseSensitive = new Dictionary<string, int>
{
    ["User"] = 1, ["user"] = 2  // 別々のキーとして扱われる
};

// OrdinalIgnoreCase: 大文字小文字を無視(パフォーマンス最優先)
var headers = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
    ["Content-Type"] = "application/json"
};
Console.WriteLine(headers["content-type"]); // "application/json" ✓
Console.WriteLine(headers["CONTENT-TYPE"]); // "application/json" ✓

// InvariantCultureIgnoreCase: カルチャ中立の大文字小文字無視
var envVars = new Dictionary<string, string>(StringComparer.InvariantCultureIgnoreCase);

// CurrentCultureIgnoreCase: 実行環境のカルチャに依存(ロケール対応 UI 用)
var localizedKeys = new Dictionary<string, int>(StringComparer.CurrentCultureIgnoreCase);

// StringComparer の選び方:
// 内部識別子(ヘッダー名・設定キー・ファイル名) → OrdinalIgnoreCase
// ユーザー向け表示文字列                           → CurrentCultureIgnoreCase
// 文化依存を避けたいグローバル対応                 → InvariantCultureIgnoreCase
独自クラスをキーにする — IEqualityComparer
// Point 構造体をキーにする場合
public readonly record struct Point(int X, int Y);
// record struct は Equals・GetHashCode を自動生成するのでそのままキーにできる

var pointMap = new Dictionary<Point, string>
{
    [new Point(0, 0)] = "原点",
    [new Point(1, 0)] = "X軸上",
};
Console.WriteLine(pointMap[new Point(0, 0)]); // "原点"(値型は値で比較)

// 複合キーをカスタム比較器で扱う例
public class PersonNameComparer : IEqualityComparer<(string First, string Last)>
{
    public bool Equals((string First, string Last) x, (string First, string Last) y)
        => string.Equals(x.First, y.First, StringComparison.OrdinalIgnoreCase)
        && string.Equals(x.Last,  y.Last,  StringComparison.OrdinalIgnoreCase);

    public int GetHashCode((string First, string Last) obj)
    {
        // ハッシュ組み合わせは HashCode.Combine を使う(C# 8 / .NET Core 2.1+)
        return HashCode.Combine(
            obj.First.ToUpperInvariant(),
            obj.Last.ToUpperInvariant());
    }
}

var personDict = new Dictionary<(string, string), int>(new PersonNameComparer())
{
    [("alice", "Smith")] = 1
};
Console.WriteLine(personDict[("ALICE", "SMITH")]); // 1(大文字小文字無視で一致)

ネストされた Dictionary

ネストした辞書の操作パターン
// Dictionary<string, Dictionary<string, int>>: グループ別カウント
var salesData = new Dictionary<string, Dictionary<string, int>>();

// GetOrAdd パターンで安全にネストされた辞書を追加
static Dictionary<string, int> GetOrAddInner(
    Dictionary<string, Dictionary<string, int>> outer,
    string outerKey)
{
    if (!outer.TryGetValue(outerKey, out var inner))
    {
        inner = new Dictionary<string, int>();
        outer[outerKey] = inner;
    }
    return inner;
}

// 使い方
GetOrAddInner(salesData, "Tokyo")["Electronics"] = 1500;
GetOrAddInner(salesData, "Tokyo")["Books"]       = 300;
GetOrAddInner(salesData, "Osaka")["Electronics"] = 900;

// ネストした辞書の列挙
foreach (var (region, products) in salesData)
{
    Console.WriteLine($"== {region} ==");
    foreach (var (product, sales) in products)
        Console.WriteLine($"  {product}: {sales}");
}

// LINQ で平坦化
var allSales = salesData
    .SelectMany(outer => outer.Value.Select(inner =>
        (Region: outer.Key, Product: inner.Key, Sales: inner.Value)))
    .OrderByDescending(x => x.Sales);

スレッドセーフな辞書 — ConcurrentDictionary

Dictionary<TKey, TValue> はスレッドセーフではありません。複数スレッドから同時に書き込むと内部状態が壊れます。マルチスレッド環境では ConcurrentDictionary<TKey, TValue> を使います。

Dictionary vs ConcurrentDictionary
using System.Collections.Concurrent;

// NG: 複数スレッドから Dictionary へ同時書き込み
var unsafeDict = new Dictionary<int, int>();
var tasks = Enumerable.Range(0, 1000).Select(i => Task.Run(() =>
{
    unsafeDict[i] = i; // データ競合 → クラッシュまたは不正な状態
}));
// → NullReferenceException や ArgumentException が非決定論的に発生

// OK: ConcurrentDictionary
var safeDict = new ConcurrentDictionary<int, int>();
var tasks2 = Enumerable.Range(0, 1000).Select(i => Task.Run(() =>
{
    safeDict[i] = i;   // スレッドセーフ
    safeDict.TryAdd(i, i); // 追加(既に存在すれば false を返すだけ)
}));
await Task.WhenAll(tasks2);
ConcurrentDictionary の GetOrAdd と AddOrUpdate
var counters = new ConcurrentDictionary<string, int>();

// GetOrAdd: キーがなければ追加。アトミックに実行される
int count1 = counters.GetOrAdd("requests", 0);    // 初回: 0 を追加して返す
int count2 = counters.GetOrAdd("requests", 0);    // 2回目: 既存の 0 を返す

// GetOrAdd with factory: ファクトリーは複数スレッドが同時に呼ぶことがある
var cache = new ConcurrentDictionary<string, ExpensiveResult>();
var result = cache.GetOrAdd("key", k => ComputeExpensive(k));
// ※ factory は複数スレッドが同時に呼ばれる可能性があるため冪等であること

// AddOrUpdate: 「なければ追加・あれば更新」をアトミックに
counters.AddOrUpdate(
    key:             "requests",
    addValue:        1,              // なければ 1 で追加
    updateValueFactory: (key, old) => old + 1); // あれば +1

// アトミックなインクリメント
counters.AddOrUpdate("hits", 1, (_, old) => old + 1);

// TryRemove でキーと値を同時に取得して削除
if (counters.TryRemove("requests", out int requestCount))
    Console.WriteLine($"削除: requests = {requestCount}");
ConcurrentDictionary の GetOrAdd ファクトリーは冪等にする
GetOrAdd(key, factory) のファクトリーは複数スレッドが同時に呼び出される場合があり、ファクトリーが返したオブジェクトのうち1つだけが辞書に追加されます。つまりファクトリーが副作用(DB 書き込み・外部通信)を持つと二重実行される危険があります。ファクトリーは純粋な計算か冪等な処理にとどめ、副作用が必要な場合はLazy<T> と組み合わせて遅延初期化するパターンを使ってください。

ソート済みコレクション — SortedDictionary vs SortedList

Dictionary はキーの順序を保証しません。キーでソートした状態を保ちたい場合は SortedDictionary または SortedList を使います。

コレクション 内部構造 挿入・削除 検索 メモリ 向いている場面
Dictionary ハッシュテーブル O(1)均 O(1)均 普通 順序不要な高速 CRUD
SortedDictionary 赤黒木(BST) O(log n) O(log n) 多め 常にソートされた状態が必要
SortedList 2つのソート済み配列 O(n) O(log n) 少なめ 読み取りが多く書き込みが少ない
SortedDictionary と SortedList の使い方
using System.Collections.Generic;

// SortedDictionary: 常にキーでソートされた状態を保つ
var sortedScores = new SortedDictionary<string, int>
{
    ["Charlie"] = 78,
    ["Alice"]   = 90,
    ["Bob"]     = 85,
};
// foreach は自動的にキーのアルファベット順
foreach (var (name, score) in sortedScores)
    Console.WriteLine($"{name}: {score}");
// Alice: 90 → Bob: 85 → Charlie: 78

// カスタム比較器で降順ソート
var descSorted = new SortedDictionary<int, string>(Comparer<int>.Create((a, b) => b.CompareTo(a)))
{
    [3] = "Third",
    [1] = "First",
    [2] = "Second",
};
// 3, 2, 1 の順で列挙される

// SortedList: ランダムアクセスと index ベースの操作が可能
var sortedList = new SortedList<DateTime, string>();
sortedList.Add(DateTime.Now.AddDays(-1), "Yesterday");
sortedList.Add(DateTime.Now,             "Today");
sortedList.Add(DateTime.Now.AddDays(1),  "Tomorrow");

// index ベースでアクセスできる(SortedDictionary にはない)
Console.WriteLine(sortedList.Keys[0]);    // 最も古い日付
Console.WriteLine(sortedList.Values[0]);  // "Yesterday"
int idx = sortedList.IndexOfKey(DateTime.Now.Date);

読み取り専用コレクション — ImmutableDictionary と FrozenDictionary

ImmutableDictionary(.NET / System.Collections.Immutable)
using System.Collections.Immutable;

// ImmutableDictionary: 一度作ったら変更できない辞書
// → 変更操作は新しい辞書インスタンスを返す(元は変更されない)
var original = ImmutableDictionary<string, int>.Empty
    .Add("Alice", 90)
    .Add("Bob",   85);

// Add は新しいインスタンスを返す(original は変わらない)
var updated = original.Add("Charlie", 78);
Console.WriteLine(original.Count); // 2(変わらない)
Console.WriteLine(updated.Count);  // 3

// Builder パターン: 大量に追加するときはまとめてビルドする(効率的)
var builder = ImmutableDictionary.CreateBuilder<string, int>();
foreach (var (name, score) in GetScoresFromDb())
    builder.Add(name, score);
ImmutableDictionary<string, int> immutable = builder.ToImmutable();

// スレッドセーフな読み取り(スナップショットを共有)
static ImmutableDictionary<string, string> _config = ImmutableDictionary<string, string>.Empty;

static void UpdateConfig(string key, string value)
{
    // Interlocked.CompareExchange でロックフリーな更新
    ImmutableDictionary<string, string> original, updated;
    do
    {
        original = _config;
        updated  = original.SetItem(key, value);
    } while (Interlocked.CompareExchange(ref _config, updated, original) != original);
}
FrozenDictionary(.NET 8+)— 読み取り最速
using System.Collections.Frozen;

// FrozenDictionary: 作成後は変更不可・読み取りが Dictionary より高速
// → 起動時に一度だけ作って使い回す設定・ルーティングテーブルなどに最適

// 通常の Dictionary から作成
var rawData = new Dictionary<string, int>
{
    ["read"]   = 1,
    ["write"]  = 2,
    ["delete"] = 4,
    ["admin"]  = 255,
};
FrozenDictionary<string, int> permissionMap = rawData.ToFrozenDictionary();

// 使い方は Dictionary と同じ
int perm = permissionMap["read"]; // 1
bool has  = permissionMap.ContainsKey("write"); // true

// FrozenDictionary は内部構造を入力データに最適化して構築するため
// 検索が Dictionary より最大2〜3倍高速になることがある
// ただし作成コストが高いため、頻繁に再作成するには向かない
コレクション 読み取り速度 スレッドセーフ 変更可否 向いている場面
Dictionary 高速 No(Read は安全) Yes 一般的な用途
ConcurrentDictionary やや低下 Yes Yes 複数スレッドからの読み書き
ImmutableDictionary 中程度 Yes(完全不変) No(新インスタンス) スナップショット共有・関数型スタイル
FrozenDictionary 最速 Yes(読み取り専用) No 起動時固定の設定・ルーティング

LINQ との組み合わせ

Dictionary と LINQ の実践的な組み合わせ
var products = new Dictionary<int, (string Name, decimal Price, string Category)>
{
    [1] = ("Apple",    150m, "Fruit"),
    [2] = ("Banana",   100m, "Fruit"),
    [3] = ("Carrot",   80m,  "Vegetable"),
    [4] = ("Broccoli", 200m, "Vegetable"),
    [5] = ("Cherry",   300m, "Fruit"),
};

// フィルタリング → 新しい辞書
var fruitProducts = products
    .Where(kv => kv.Value.Category == "Fruit")
    .ToDictionary(kv => kv.Key, kv => kv.Value);

// 値を変換(例: 価格に消費税を加算)
var taxIncluded = products.ToDictionary(
    kv => kv.Key,
    kv => kv.Value with { Price = kv.Value.Price * 1.1m });

// カテゴリ別グループ集計
var summary = products.Values
    .GroupBy(p => p.Category)
    .ToDictionary(
        g => g.Key,
        g => new { Count = g.Count(), TotalPrice = g.Sum(p => p.Price) });

foreach (var (cat, stat) in summary)
    Console.WriteLine($"{cat}: {stat.Count}種 合計{stat.TotalPrice}円");

// 辞書をキー/値で並び替え(ソートした結果は List に変換)
var sortedByPrice = products
    .OrderBy(kv => kv.Value.Price)
    .ToList(); // Dictionary は順序保証なし → ソートして List に

// 辞書のマージ: 後勝ち
var dict1 = new Dictionary<string, int> { ["a"] = 1, ["b"] = 2 };
var dict2 = new Dictionary<string, int> { ["b"] = 99, ["c"] = 3 };
var merged = dict1.Concat(dict2)
    .GroupBy(kv => kv.Key)
    .ToDictionary(g => g.Key, g => g.Last().Value); // 後勝ち

よくある落とし穴と対処法

落とし穴① — mutable な struct をキーにする
// mutable な struct をキーにすると GetHashCode が変わる可能性がある
// → 追加後にフィールドを変更すると検索できなくなる

public struct MutablePoint
{
    public int X { get; set; }  // mutable!
    public int Y { get; set; }
}

var dict = new Dictionary<MutablePoint, string>();
var pt   = new MutablePoint { X = 1, Y = 2 };
dict[pt] = "start";

// ここで pt を変更しても dict のキーは変わらない
// ただし pt はローカルコピーなので dict のキーは変わっていない
// → struct はコピーで渡されるため dict のキーへの影響はないが紛らわしい

// 推奨: キーには immutable な型(string・int・record struct)を使う
public readonly record struct ImmutablePoint(int X, int Y);
// → GetHashCode/Equals が自動実装され、不変性も保証される
落とし穴② — null キー
// reference 型キーの null はデフォルトの Dictionary では ArgumentNullException
var dict = new Dictionary<string, int>();
// dict[null] = 1;      // ArgumentNullException
// dict.Add(null, 1);   // ArgumentNullException

// null キーが必要な場合は Dictionary<string?, int> + カスタム比較器を使う
// または null 合体演算子でデフォルト値を使う
string? maybeKey = null;
int value = dict.GetValueOrDefault(maybeKey ?? "default", 0);

// 注: ConcurrentDictionary も null キーは不可
落とし穴③ — ContainsValue は O(n)
// ContainsValue は内部で全要素を線形探索する → O(n)
// 値の存在確認が頻繁な場合は逆引き辞書(value → key)を別途作る

var nameToId = new Dictionary<string, int>
{
    ["Alice"] = 1, ["Bob"] = 2, ["Charlie"] = 3
};

// NG: ContainsValue は遅い
bool hasId1 = nameToId.ContainsValue(1); // O(n) の線形探索

// OK: 逆引き辞書を用意
var idToName = nameToId.ToDictionary(kv => kv.Value, kv => kv.Key);
bool hasId1Fast = idToName.ContainsKey(1); // O(1)
落とし穴④ — 参照型の値は浅いコピー
// Dictionary の値に参照型を入れると、コピーしても同じオブジェクトを参照する
var original = new Dictionary<string, List<int>>
{
    ["evens"] = new List<int> { 2, 4, 6 }
};

// シャロウコピー: List<int> 自体は同じオブジェクト
var shallowCopy = new Dictionary<string, List<int>>(original);
shallowCopy["evens"].Add(8);

Console.WriteLine(original["evens"].Count); // 4(コピーのつもりが元も変わった!)

// ディープコピーが必要な場合は明示的に
var deepCopy = original.ToDictionary(
    kv => kv.Key,
    kv => new List<int>(kv.Value)); // 各 List を新規作成
deepCopy["evens"].Add(8);
Console.WriteLine(original["evens"].Count); // 3(変わらない)

よくある質問

QDictionary の列挙順序は保証されますか?
A仕様上は保証されません。.NET の現在の実装では追加順に近い順序で列挙されることが多いですが、Remove 後の挿入や内部リサイズによって順序が変わることがあります。挿入順を保証したい場合は List<KeyValuePair<K, V>> を使うか、.NET 9+ では OrderedDictionary<TKey, TValue>(System.Collections.Generic)が利用可能です。それ以前の .NET では非ジェネリックの System.Collections.Specialized.OrderedDictionarySortedDictionary で代替してください。
Qint キーと string キーではどちらが速いですか?
Aint(および long)キーは GetHashCode() が演算一発で終わるため string より高速です。string はハッシュ計算に文字列長に比例したコストがかかります。パフォーマンスが重要な場面では数値 ID を使うか、短い文字列キーを使ってください。
QDictionary のキーに Tuple を使えますか?
AValueTuple(例: (int, string))は値型なので GetHashCodeEquals が構造的に自動実装されており、そのままキーとして使えます。ただし型名がなく可読性が下がるため、readonly record struct で名前を付けることを推奨します。Tuple<T1, T2>(参照型)も使えますが Value Tuple の方が効率的です。
QDictionary を JSON にシリアライズするとどうなりますか?
ASystem.Text.Json では Dictionary<string, TValue> はそのまま JSON オブジェクトにシリアライズされます。string 以外のキー型(int 等)はデフォルトでは文字列に変換されます。Dictionary<string, object> は値の型に応じた JSON になります。非文字列キーのカスタム処理が必要な場合は JsonConverter を実装してください。

まとめ

機能・パターン ポイント
内部構造 ハッシュテーブル(O(1)均)。初期容量を指定してリサイズを抑制
安全な取得 TryGetValue(検索+取得)・GetValueOrDefault(デフォルト値)・ContainsKey(確認のみ)
TryAdd 重複キーで例外なし。Add は ArgumentException。インデックサーは上書き
イテレーション var (k, v) in dict の分解構文。ループ中の変更は .Keys.ToList() でスナップショット
IEqualityComparer StringComparer.OrdinalIgnoreCase で大文字小文字無視。独自型は IEqualityComparer 実装
ConcurrentDictionary マルチスレッド向け。GetOrAdd・AddOrUpdate でアトミック操作。ファクトリーは冪等に
SortedDictionary 赤黒木で常にキーソート済み。SortedList は配列ベースでメモリ効率◎・挿入遅い
ImmutableDictionary 変更操作は新インスタンスを返す。スレッドセーフなスナップショット共有向き
FrozenDictionary .NET 8+。作成後変更不可・読み取り最速。起動時固定の設定テーブルに最適
落とし穴 ループ中変更・null キー・ContainsValue の O(n)・参照型値のシャロウコピーに注意

コレクションの選択(Dictionary / HashSet / Queue / Stack / List の使い分け)については配列と List 完全ガイドを、Dictionary 上での LINQ 操作の詳細はLINQ 完全ガイドを参照してください。