C# の List<T> は「順番に並んだ要素を番号(インデックス)で扱う」コレクション、Dictionary<TKey, TValue> は「キーから値をすばやく引く」コレクションです。 順番や位置が大事なら List、ID や名前で1件を取り出したいなら Dictionary を選びます。どちらも System.Collections.Generic 名前空間にあり、要素数を気にせず追加・削除できます。

この記事では、List と Dictionary を「同じ操作」で横に並べた対照表を最初に置き、そのあとで全コードを .NET 8 で実行した結果を載せています。foreach の中で削除したときの InvalidOperationException、存在しないキーを読んだときの KeyNotFoundException など、つまずきやすい例外も実際のメッセージつきで再現しました。

ListとDictionaryの早見表

同じ操作を並べた対照表

操作 List<T> Dictionary<TKey, TValue>
作成 var list = new List<string>(); var dict = new Dictionary<string, int>();
初期値つき作成 new List<string> { "a", "b" } / List<string> list = ["a", "b"];(C# 12) new Dictionary<string, int> { ["a"] = 1, ["b"] = 2 }
追加 list.Add(x) / list.AddRange(xs) / list.Insert(i, x) dict.Add(key, value) / dict[key] = value / dict.TryAdd(key, value)
取得 list[0] / list[^1](末尾) dict[key] / dict.TryGetValue(key, out var v) / dict.GetValueOrDefault(key)
更新 list[0] = x dict[key] = value
削除 list.Remove(x) / list.RemoveAt(i) / list.RemoveAll(条件) dict.Remove(key) / dict.Remove(key, out var v)
全削除 list.Clear() dict.Clear()
検索(あるか) list.Contains(x) / list.Exists(条件) dict.ContainsKey(key) / dict.ContainsValue(value)
検索(探す) list.IndexOf(x) / list.Find(条件) / list.FindAll(条件) dict.Where(kv => 条件)
ループ foreach (var x in list) / for (int i = 0; i < list.Count; i++) foreach (var (key, value) in dict) / dict.Keys / dict.Values
並べ替え list.Sort() / list.OrderBy(x => ...) dict.OrderBy(kv => kv.Key)(結果は並んだ列。Dictionary 自体は並ばない)
件数 list.Count dict.Count
相互変換 list.ToDictionary(x => x.Id) dict.Keys.ToList() / dict.Values.ToList()

計算量の表

「O(1)」は件数が増えても時間がほぼ変わらない、「O(n)」は件数に比例して時間がかかる、という目安です(Microsoft Learn の各メソッドの解説に基づく一般に知られた仕様。実測は後半に載せています)。

操作 List<T> Dictionary<TKey, TValue>
位置やキーで取得 list[i]:O(1) dict[key]:O(1) に近い
末尾への追加 Add:O(1)(内部配列を広げるときだけ O(n)) Add / dict[key] = v:O(1) に近い(広げるときだけ O(n))
途中への挿入 Insert(i, x):O(n) (位置の考え方がない)
値で探す Contains / IndexOf / Find:O(n) ContainsKey / TryGetValue:O(1) に近い、ContainsValue:O(n)
削除 Remove(x) / RemoveAt(i):O(n)(末尾の RemoveAt は O(1)) Remove(key):O(1) に近い
並べ替え Sort:平均 O(n log n) OrderBy:O(n log n)

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「C# ListとDictionaryの使い方|追加・削除・検索・ループ早見表」

この記事のコードを動かす準備

検証環境は .NET SDK 8.0.131(ランタイム 8.0.31)、C# 12、Linux です。 次のコマンドでコンソールアプリを作り、Program.cs を各セクションのコードに置き換えて実行します。

dotnet new console -o CollectionDemo
cd CollectionDemo
# Program.cs を書き換えてから
dotnet run

using System.Collections.Generic; と using System.Linq; は、テンプレートの ImplicitUsings が有効なので書かなくても使えます。コードの先頭にある J(...) は、中身を [a, b, c] の形で表示するための小さな補助メソッドです。

Listの基本操作をまとめて実行する

List は「追加した順に並び、0 から始まる番号で読み書きする」コレクションです。 作成から全削除まで、ひと通りの操作を1つのプログラムにしました。

// Program.cs(.NET 8 / C# 12)
static string J<T>(IEnumerable<T> items) => "[" + string.Join(", ", items) + "]";

// 作成・追加
var fruits = new List<string> { "りんご", "みかん" };
fruits.Add("ぶどう");                          // 末尾に1件
fruits.AddRange(new[] { "もも", "なし" });     // 末尾にまとめて
fruits.Insert(1, "いちご");                    // 位置を指定して挿入
Console.WriteLine($"追加後 : {J(fruits)}");

// 取得・件数・更新
Console.WriteLine($"取得   : fruits[0]={fruits[0]}, fruits[^1]={fruits[^1]}, Count={fruits.Count}");
fruits[0] = "青りんご";
Console.WriteLine($"更新後 : {J(fruits)}");

// 削除
Console.WriteLine($"Remove(\"もも\")={fruits.Remove("もも")}, Remove(\"バナナ\")={fruits.Remove("バナナ")}");
fruits.RemoveAt(0);                            // 位置を指定して削除
Console.WriteLine($"削除後 : {J(fruits)}");

// 検索
Console.WriteLine($"Contains(\"ぶどう\")={fruits.Contains("ぶどう")}, IndexOf(\"ぶどう\")={fruits.IndexOf("ぶどう")}, IndexOf(\"バナナ\")={fruits.IndexOf("バナナ")}");
var numbers = new List<int> { 5, 12, 8, 21, 3, 12 };
Console.WriteLine($"Find(>10)={numbers.Find(n => n > 10)}, Find(>100)={numbers.Find(n => n > 100)}, FindIndex(>10)={numbers.FindIndex(n => n > 10)}");
Console.WriteLine($"FindAll(>10)={J(numbers.FindAll(n => n > 10))}, Exists(>20)={numbers.Exists(n => n > 20)}");
Console.WriteLine($"Where(偶数)={J(numbers.Where(n => n % 2 == 0))}, Any(>20)={numbers.Any(n => n > 20)}");

// 並べ替え
numbers.Sort();                                // リスト自身を昇順に
Console.WriteLine($"Sort()     : {J(numbers)}");
numbers.Sort((x, y) => y.CompareTo(x));        // 比較を渡して降順に
Console.WriteLine($"Sort(降順) : {J(numbers)}");
List<int> sorted = numbers.OrderBy(n => n).ToList();   // 元は変えず新しいリスト
Console.WriteLine($"OrderBy    : {J(sorted)} / 元のリスト {J(numbers)}");
Console.WriteLine($"Distinct   : {J(numbers.Distinct())}");
Console.WriteLine($"Sum={numbers.Sum()} Max={numbers.Max()} Min={numbers.Min()} Average={numbers.Average():F2}");

// ループ
foreach (string fruit in fruits) Console.Write($"{fruit} ");
Console.WriteLine();
for (int i = 0; i < fruits.Count; i++) Console.Write($"{i}:{fruits[i]} ");
Console.WriteLine();
foreach (var (fruit, index) in fruits.Select((fruit, index) => (fruit, index))) Console.Write($"{index}:{fruit} ");
Console.WriteLine();

// 全削除
fruits.Clear();
Console.WriteLine($"Clear 後 Count={fruits.Count}");

実行結果(.NET 8):

追加後 : [りんご, いちご, みかん, ぶどう, もも, なし]
取得   : fruits[0]=りんご, fruits[^1]=なし, Count=6
更新後 : [青りんご, いちご, みかん, ぶどう, もも, なし]
Remove("もも")=True, Remove("バナナ")=False
削除後 : [いちご, みかん, ぶどう, なし]
Contains("ぶどう")=True, IndexOf("ぶどう")=2, IndexOf("バナナ")=-1
Find(>10)=12, Find(>100)=0, FindIndex(>10)=1
FindAll(>10)=[12, 21, 12], Exists(>20)=True
Where(偶数)=[12, 8, 12], Any(>20)=True
Sort()     : [3, 5, 8, 12, 12, 21]
Sort(降順) : [21, 12, 12, 8, 5, 3]
OrderBy    : [3, 5, 8, 12, 12, 21] / 元のリスト [21, 12, 12, 8, 5, 3]
Distinct   : [21, 12, 8, 5, 3]
Sum=61 Max=21 Min=3 Average=10.17
いちご みかん ぶどう なし 
0:いちご 1:みかん 2:ぶどう 3:なし 
0:いちご 1:みかん 2:ぶどう 3:なし 
Clear 後 Count=0

読み取るポイントを表にまとめます。

操作 結果から分かること
Insert(1, "いちご") 指定した位置に入り、後ろの要素が1つずつずれる
fruits[^1] ^1 は「末尾から1番目」。fruits[fruits.Count - 1] と同じ
Remove("バナナ") 見つからなくても例外にならず False を返す
IndexOf("バナナ") 見つからないと -1
Find(n => n > 100) 見つからないと型の既定値(int なら 0、参照型なら null)。0 が正しい値にもなり得るときは FindIndex が -1 かどうかで判定する
Sort() と OrderBy() Sort はリスト自身を書き換える。OrderBy は元を変えず、並べた結果を新しく返す
foreach と for 番号が要らなければ foreach、番号が要るなら for。Select((x, i) => ...) でも番号を取れる

Dictionaryの基本操作をまとめて実行する

Dictionary は「キー」と「値」の組を持ち、キーを指定して値を取り出します。 キーは重複できません。List と同じ順番で操作を並べます。

// Program.cs(.NET 8 / C# 12)
static string J<T>(IEnumerable<T> items) => "[" + string.Join(", ", items) + "]";

// 作成・追加
var stock = new Dictionary<string, int> { ["りんご"] = 10, ["みかん"] = 25 };
stock.Add("ぶどう", 5);                        // 新しいキーを追加(重複すると例外)
stock["もも"] = 8;                             // 無ければ追加、あれば上書き
Console.WriteLine($"TryAdd(\"もも\", 99)={stock.TryAdd("もも", 99)}, TryAdd(\"なし\", 3)={stock.TryAdd("なし", 3)}");
Console.WriteLine($"追加後 : {J(stock)}");

// 取得・件数・更新
Console.WriteLine($"取得   : stock[\"りんご\"]={stock["りんご"]}, Count={stock.Count}");
stock["りんご"] = 12;
stock["みかん"] += 5;
Console.WriteLine($"更新後 : りんご={stock["りんご"]}, みかん={stock["みかん"]}");

// 安全な取得
if (stock.TryGetValue("ぶどう", out int grape)) Console.WriteLine($"TryGetValue(\"ぶどう\") → {grape}");
Console.WriteLine($"TryGetValue(\"バナナ\")={stock.TryGetValue("バナナ", out int banana)}, out の値={banana}");
Console.WriteLine($"GetValueOrDefault(\"バナナ\")={stock.GetValueOrDefault("バナナ")}, GetValueOrDefault(\"バナナ\", -1)={stock.GetValueOrDefault("バナナ", -1)}");

// 検索
Console.WriteLine($"ContainsKey(\"もも\")={stock.ContainsKey("もも")}, ContainsValue(8)={stock.ContainsValue(8)}");
Console.WriteLine($"値が10以上のキー: {J(stock.Where(kv => kv.Value >= 10).Select(kv => kv.Key))}");

// 削除
Console.WriteLine($"Remove(\"もも\")={stock.Remove("もも")}, Remove(\"バナナ\")={stock.Remove("バナナ")}");
Console.WriteLine($"Remove(\"なし\", out v)={stock.Remove("なし", out int removed)}, v={removed}");

// ループ
foreach (var (name, quantity) in stock) Console.Write($"{name}={quantity} ");
Console.WriteLine();
foreach (KeyValuePair<string, int> pair in stock) Console.Write($"{pair.Key}={pair.Value} ");
Console.WriteLine();
Console.WriteLine($"Keys={J(stock.Keys)} Values={J(stock.Values)}");

// 並べ替え(Dictionary 自体は並べ替えられないので LINQ で取り出す)
Console.WriteLine($"キー順   : {J(stock.OrderBy(kv => kv.Key))}");
Console.WriteLine($"値の降順 : {J(stock.OrderByDescending(kv => kv.Value))}");

// 全削除
stock.Clear();
Console.WriteLine($"Clear 後 Count={stock.Count}");

実行結果(.NET 8):

TryAdd("もも", 99)=False, TryAdd("なし", 3)=True
追加後 : [[りんご, 10], [みかん, 25], [ぶどう, 5], [もも, 8], [なし, 3]]
取得   : stock["りんご"]=10, Count=5
更新後 : りんご=12, みかん=30
TryGetValue("ぶどう") → 5
TryGetValue("バナナ")=False, out の値=0
GetValueOrDefault("バナナ")=0, GetValueOrDefault("バナナ", -1)=-1
ContainsKey("もも")=True, ContainsValue(8)=True
値が10以上のキー: [りんご, みかん]
Remove("もも")=True, Remove("バナナ")=False
Remove("なし", out v)=True, v=3
りんご=12 みかん=30 ぶどう=5 
りんご=12 みかん=30 ぶどう=5 
Keys=[りんご, みかん, ぶどう] Values=[12, 30, 5]
キー順   : [[ぶどう, 5], [みかん, 30], [りんご, 12]]
値の降順 : [[みかん, 30], [りんご, 12], [ぶどう, 5]]
Clear 後 Count=0
操作 結果から分かること
Add と dict[key] = value Add は新規専用(重複すると例外)。インデクサ代入は「無ければ追加、あれば上書き」
TryAdd("もも", 99) すでにあるキーなら何もせず False。値は 8 のまま
TryGetValue 見つかれば True と値、見つからなければ False と既定値(0)
GetValueOrDefault(key, -1) 無いときに返す値を第2引数で決められる
Remove(key, out v) 削除と同時に、消した値を受け取れる
foreach (var (name, quantity) in stock) KeyValuePair を分解して、キーと値を別々の変数で受け取れる
OrderBy(kv => kv.Key) Dictionary 自体に Sort はない。LINQ で並べた結果を取り出す

foreachの中で削除するとInvalidOperationExceptionになる

foreach で回している最中に、同じ List へ追加・削除をすると InvalidOperationException が発生します。 直す前と、3通りの直し方を続けて実行します。

static string J<T>(IEnumerable<T> items) => "[" + string.Join(", ", items) + "]";
static void Try(string label, Action action)
{
    try { action(); Console.WriteLine($"{label}: 例外なし"); }
    catch (Exception ex) { Console.WriteLine($"{label}: {ex.GetType().Name}: {ex.Message}"); }
}

// 直す前:foreach の中で同じリストから削除する
Try("foreach 中に List.Remove", () =>
{
    var list = new List<int> { 1, 2, 3, 4, 5, 6 };
    foreach (int n in list)
        if (n % 2 == 0) list.Remove(n);
});

// 直した後 (1):RemoveAll に条件を渡す
{
    var list = new List<int> { 1, 2, 3, 4, 5, 6 };
    int count = list.RemoveAll(n => n % 2 == 0);
    Console.WriteLine($"RemoveAll           : {count} 件削除 → {J(list)}");
}
// 直した後 (2):for で後ろから回す
{
    var list = new List<int> { 1, 2, 3, 4, 5, 6 };
    for (int i = list.Count - 1; i >= 0; i--)
        if (list[i] % 2 == 0) list.RemoveAt(i);
    Console.WriteLine($"逆順 for            : {J(list)}");
}
// 直した後 (3):ToList() でコピーを作り、コピーのほうを回す
{
    var list = new List<int> { 1, 2, 3, 4, 5, 6 };
    foreach (int n in list.ToList())
        if (n % 2 == 0) list.Remove(n);
    Console.WriteLine($"ToList() のコピー   : {J(list)}");
}
// 参考:for を前から回すと、例外は出ないが取りこぼす
{
    var list = new List<int> { 1, 2, 2, 3, 4, 4, 5 };
    for (int i = 0; i < list.Count; i++)
        if (list[i] % 2 == 0) list.RemoveAt(i);
    Console.WriteLine($"前から for          : {J(list)}  ← 2 と 4 が残る");
}

// Dictionary の場合
Try("foreach 中に Dictionary へ追加", () =>
{
    var dict = new Dictionary<string, int> { ["a"] = 1, ["b"] = 2 };
    foreach (var (key, _) in dict) dict["new-" + key] = 0;
});
Try("foreach 中に Dictionary.Remove", () =>
{
    var dict = new Dictionary<string, int> { ["a"] = 1, ["b"] = 2, ["c"] = 3 };
    foreach (var (key, value) in dict)
        if (value % 2 == 1) dict.Remove(key);
    Console.WriteLine($"  残り: {J(dict)}");
});
Try("foreach 中に既存キーの値を上書き", () =>
{
    var dict = new Dictionary<string, int> { ["a"] = 1, ["b"] = 2 };
    foreach (string key in dict.Keys) dict[key] *= 10;
    Console.WriteLine($"  結果: {J(dict)}");
});

実行結果(.NET 8):

foreach 中に List.Remove: InvalidOperationException: Collection was modified; enumeration operation may not execute.
RemoveAll           : 3 件削除 → [1, 3, 5]
逆順 for            : [1, 3, 5]
ToList() のコピー   : [1, 3, 5]
前から for          : [1, 2, 3, 4, 5]  ← 2 と 4 が残る
foreach 中に Dictionary へ追加: InvalidOperationException: Collection was modified; enumeration operation may not execute.
  残り: [[b, 2]]
foreach 中に Dictionary.Remove: 例外なし
  結果: [[a, 10], [b, 20]]
foreach 中に既存キーの値を上書き: 例外なし

エラー文 Collection was modified; enumeration operation may not execute. は「列挙の途中でコレクションが変更されたので、続けられません」という意味です。List は変更のたびに内部の版数を進めており、foreach が次の要素へ進むときに版数の変化を見つけて知らせてくれます。

直し方 書き方 向いている場面
RemoveAll list.RemoveAll(n => n % 2 == 0) 条件に合うものをまとめて消す。いちばん短く、速い(削除件数も返る)
逆順の for for (int i = list.Count - 1; i >= 0; i--) 消すかどうかの判断に、前後の要素や番号が必要なとき
コピーを回す foreach (var n in list.ToList()) 既存の foreach を少ない変更で直したいとき。コピーのぶんメモリを使う

for を前から回す書き方は、例外は出ませんが取りこぼします。 上の実行でも [1, 2, 2, 3, 4, 4, 5] から偶数を消したはずが、2 と 4 が1つずつ残りました。要素を消すと後ろが前に詰まり、次の i++ で1つ飛ばしてしまうためです。後ろから回せば、詰まる範囲はすでに確認済みなので影響を受けません。

Dictionary の結果も確認しておきます(出力では、残り: や 結果: の行が先に表示され、その次の行に判定が出ています)。

列挙中の操作 .NET 8 での実行結果
新しいキーを追加 InvalidOperationException
Remove で削除 例外なし(a と c が消え、b が残った)
既存キーの値を上書き 例外なし(値が 10 倍になった)

Dictionary の Remove と既存キーの上書きは、.NET 8 では列挙中でも例外になりませんでした。一方で、動作する .NET のバージョンによって結果が変わる部分でもあるため(.NET Framework では例外になることが一般に知られています。今回は未実行)、どの環境でも通る書き方として、消したいキーを先に ToList() で集めてから消す方法をおすすめします。

var dict = new Dictionary<string, int> { ["a"] = 1, ["b"] = 2, ["c"] = 3 };
foreach (string key in dict.Where(kv => kv.Value % 2 == 1).Select(kv => kv.Key).ToList())
    dict.Remove(key);
Console.WriteLine(string.Join(", ", dict.Keys));   // b

キーや番号の指定で出る例外と、出さない書き方

「無いキーを読む」「同じキーを Add する」「範囲外の番号を指定する」の3つが、List と Dictionary で出会いやすい例外です。 実際のメッセージを出力します。

static void Try(string label, Action action)
{
    try { action(); Console.WriteLine($"{label}: 例外なし"); }
    catch (Exception ex) { Console.WriteLine($"{label}\n    → {ex.GetType().Name}: {ex.Message}"); }
}

var stock = new Dictionary<string, int> { ["りんご"] = 10, ["みかん"] = 25 };
var list = new List<int> { 1, 2, 3 };
int[] array = new int[3];

Try("stock[\"バナナ\"](無いキーを読む)", () => { int v = stock["バナナ"]; });
Try("stock.Add(\"りんご\", 1)(重複キー)", () => stock.Add("りんご", 1));
Try("new Dictionary<string,int>().Add(null, 1)", () => new Dictionary<string, int>().Add(null!, 1));
Try("list[10](範囲外を読む)", () => { int v = list[10]; });
Try("list[3] = 4(Count と同じ位置に代入)", () => list[3] = 4);
Try("list.RemoveAt(-1)", () => list.RemoveAt(-1));
Try("list.Insert(5, 0)", () => list.Insert(5, 0));
Try("array[3] = 1(配列の範囲外)", () => array[3] = 1);
Try("new List<int>().First()", () => new List<int>().First());

// 例外を出さない書き方
Console.WriteLine("--- 例外を出さない書き方 ---");
Console.WriteLine(stock.TryGetValue("バナナ", out int qty) ? $"在庫 {qty}" : "バナナは登録されていません");
Console.WriteLine($"GetValueOrDefault(\"バナナ\") = {stock.GetValueOrDefault("バナナ")}");
stock["りんご"] = 1;                                   // インデクサ代入は重複しても上書き
Console.WriteLine($"stock[\"りんご\"] = 1 のあと: {stock["りんご"]}");
Console.WriteLine($"TryAdd(\"りんご\", 99) = {stock.TryAdd("りんご", 99)}(値は {stock["りんご"]} のまま)");
int index = 10;
Console.WriteLine(index >= 0 && index < list.Count ? $"{list[index]}" : $"index {index} は範囲外(Count={list.Count})");
Console.WriteLine($"ElementAtOrDefault(10) = {list.ElementAtOrDefault(10)}, FirstOrDefault() = {new List<int>().FirstOrDefault()}");
list.Add(4);                                           // 末尾に足すときは Add
Console.WriteLine($"Add(4) のあと Count = {list.Count}");

実行結果(.NET 8):

stock["バナナ"](無いキーを読む)
    → KeyNotFoundException: The given key 'バナナ' was not present in the dictionary.
stock.Add("りんご", 1)(重複キー)
    → ArgumentException: An item with the same key has already been added. Key: りんご
new Dictionary<string,int>().Add(null, 1)
    → ArgumentNullException: Value cannot be null. (Parameter 'key')
list[10](範囲外を読む)
    → ArgumentOutOfRangeException: Index was out of range. Must be non-negative and less than the size of the collection. (Parameter 'index')
list[3] = 4(Count と同じ位置に代入)
    → ArgumentOutOfRangeException: Index was out of range. Must be non-negative and less than the size of the collection. (Parameter 'index')
list.RemoveAt(-1)
    → ArgumentOutOfRangeException: Index was out of range. Must be non-negative and less than the size of the collection. (Parameter 'index')
list.Insert(5, 0)
    → ArgumentOutOfRangeException: Index must be within the bounds of the List. (Parameter 'index')
array[3] = 1(配列の範囲外)
    → IndexOutOfRangeException: Index was outside the bounds of the array.
new List<int>().First()
    → InvalidOperationException: Sequence contains no elements
--- 例外を出さない書き方 ---
バナナは登録されていません
GetValueOrDefault("バナナ") = 0
stock["りんご"] = 1 のあと: 1
TryAdd("りんご", 99) = False(値は 1 のまま)
index 10 は範囲外(Count=3)
ElementAtOrDefault(10) = 0, FirstOrDefault() = 0
Add(4) のあと Count = 4
例外 起きる操作 出さない書き方
KeyNotFoundException dict[key] で無いキーを読む TryGetValue(key, out var v) / GetValueOrDefault(key) / ContainsKey(key) で確認
ArgumentException(same key) dict.Add(key, v) や ToDictionary でキーが重複 上書きでよいなら dict[key] = v、先勝ちなら TryAdd(key, v)
ArgumentNullException キーに null キーが null にならないことを先に確認する
ArgumentOutOfRangeException list[i] / RemoveAt(i) / Insert(i, x) で i が範囲外 i >= 0 && i < list.Count を確認。末尾に足すのは Add
IndexOutOfRangeException 配列の範囲外 i < array.Length を確認
InvalidOperationException(no elements) 空のリストに First() / Single() / Max() FirstOrDefault() や Count > 0 の確認

値を読むときは、「あるかどうか」で処理を分けるなら TryGetValue、無ければ既定値でよいなら GetValueOrDefault が基本です。ContainsKey で確認してから dict[key] を読む書き方も動きますが、検索が2回になるので、値も使うなら TryGetValue に置き換えられます。

list[3] = 4 は「4番目に追加」にはなりません。 インデクサは既にある位置の読み書き専用で、Count と同じ番号を指定すると ArgumentOutOfRangeException になります。追加は Add を使います。また、List は ArgumentOutOfRangeException、配列は IndexOutOfRangeException と、例外の型が違う点も検索するときの手がかりになります。

C# 12のコレクション式で短く書く

C# 12(.NET 8)からは、[1, 2, 3] という「コレクション式」で配列・List・HashSet などを同じ書き方で作れます。 ..(スプレッド要素)で別のコレクションを展開して結合することもできます。

static string J<T>(IEnumerable<T> items) => "[" + string.Join(", ", items) + "]";

// C# 12 のコレクション式:[ ] で配列・List・HashSet などを同じ書き方で作る
List<int> list = [1, 2, 3];
int[] array = [4, 5];
HashSet<string> set = ["a", "b", "a"];
List<string> empty = [];
List<int> merged = [.. list, .. array, 6];        // スプレッド要素 .. で展開して結合

Console.WriteLine($"list={J(list)} array={J(array)} set={J(set)} empty.Count={empty.Count}");
Console.WriteLine($"merged={J(merged)}");

// Dictionary は C# 12 のコレクション式の対象外。初期化子で書く
var byIndexer = new Dictionary<string, int> { ["りんご"] = 10, ["みかん"] = 25 };   // インデックス初期化子
var byAdd = new Dictionary<string, int> { { "りんご", 10 }, { "みかん", 25 } };     // コレクション初期化子(Add 呼び出し)
Dictionary<string, int> targetTyped = new() { ["りんご"] = 10 };                    // 型推論付き new()
Console.WriteLine($"{J(byIndexer)} {J(byAdd)} {J(targetTyped)}");

// 引数にもそのまま渡せる
static int Total(IEnumerable<int> values) => values.Sum();
Console.WriteLine($"Total([10, 20, 30]) = {Total([10, 20, 30])}");

実行結果(.NET 8):

list=[1, 2, 3] array=[4, 5] set=[a, b] empty.Count=0
merged=[1, 2, 3, 4, 5, 6]
[[りんご, 10], [みかん, 25]] [[りんご, 10], [みかん, 25]] [[りんご, 10]]
Total([10, 20, 30]) = 60
  • 左辺に型が必要です(var list = [1, 2, 3]; とは書けません)。
  • Dictionary のインデックス初期化子 { ["a"] = 1, ["a"] = 2 } は後の値で上書き、コレクション初期化子 { { "a", 1 }, { "a", 2 } } は Add の呼び出しになるため ArgumentException になります(どちらも .NET 8 で実行して確認)。

Dictionary は C# 12 のコレクション式では作れないので、初期化子で書きます(より新しい C# での対応状況は今回確認しておらず、未実行です)。

Dictionaryの列挙順は保証されない

Dictionary を foreach で回したときの順番は、仕様として決まっていません。 追加した順に出てくるように見えることが多いのですが、削除と追加を挟むと変わります。

static string J<T>(IEnumerable<T> items) => "[" + string.Join(", ", items) + "]";

var dict = new Dictionary<string, int> { ["a"] = 1, ["b"] = 2, ["c"] = 3 };
Console.WriteLine($"追加した直後           : {J(dict.Keys)}");

dict.Remove("b");
dict.Add("d", 4);
Console.WriteLine($"b を削除して d を追加  : {J(dict.Keys)}");

// 順序が必要なときの選択肢
Console.WriteLine($"OrderBy でキー順に     : {J(dict.OrderBy(kv => kv.Key).Select(kv => kv.Key))}");

var sorted = new SortedDictionary<string, int> { ["c"] = 3, ["a"] = 1, ["d"] = 4 };
sorted.Add("b", 2);
Console.WriteLine($"SortedDictionary       : {J(sorted.Keys)}");

var keysInOrder = new List<string> { "a", "c", "d" };      // 追加順を別の List で持つ
Console.WriteLine($"List で順序を持つ      : {J(keysInOrder.Select(k => $"{k}={dict[k]}"))}");

実行結果(.NET 8):

追加した直後           : [a, b, c]
b を削除して d を追加  : [a, d, c]
OrderBy でキー順に     : [a, c, d]
SortedDictionary       : [a, b, c, d]
List で順序を持つ      : [a=1, c=3, d=4]

b を削除してから d を追加すると、[a, c, d] ではなく [a, d, c] になりました。d が、空いた b の場所に入ったためです。これは内部の作りによる結果で、バージョンによって変わる可能性があります。

順番の要件 選ぶもの
順番はどうでもよい Dictionary のまま
表示のときだけキー順・値順にしたい dict.OrderBy(kv => kv.Key)
常にキー順で持ちたい SortedDictionary<TKey, TValue>
追加した順を保ちたい キーの List を別に持つ、または List<(TKey, TValue)> にする

配列・HashSet・SortedDictionaryとの選び方

「番号で扱うか、キーで引くか、重複を許すか、順番が要るか」の4点で決まります。

コレクション 特徴 向いている場面
配列 T[] 長さ固定。いちばん軽い 件数が決まっている、API が配列を要求する
List<T> 長さ可変。番号で読み書き 順番に並べて持つ、件数が増減する。迷ったらまずこれ
Dictionary<TKey, TValue> キー → 値。キーは重複不可 ID や名前で1件をすばやく取り出す
HashSet<T> 値の集まり。重複不可 「含まれるか」の判定、重複の除去、集合の演算
SortedDictionary<TKey, TValue> 常にキー順 キー順に列挙したい。追加・検索は O(log n)

HashSet<T> の Add は、すでにある値なら追加せず false を返します。「追加できたら初めて見た値」という判定にそのまま使えます(HashSet は後半の実測コードで使っています。SortedDictionary は前の節のコードで実行しています)。

ListとDictionaryを相互に変換する

一覧(List)を ID で引けるようにしたいときは ToDictionary、キーが重複するなら GroupBy で値を List にまとめます。

static string J<T>(IEnumerable<T> items) => "[" + string.Join(", ", items) + "]";

var members = new List<Member>
{
    new(1, "佐藤", "A"), new(2, "鈴木", "B"), new(3, "高橋", "A"),
};

// List → Dictionary:ID で引けるようにする
Dictionary<int, Member> byId = members.ToDictionary(m => m.Id);
Console.WriteLine($"byId[2].Name = {byId[2].Name}");

// キーが重複すると ToDictionary は例外になる
try { members.ToDictionary(m => m.Team); }
catch (ArgumentException ex) { Console.WriteLine($"ToDictionary(Team): {ex.GetType().Name}: {ex.Message}"); }

// 重複するキーでまとめたいときは GroupBy → 値を List にする
Dictionary<string, List<string>> byTeam = members
    .GroupBy(m => m.Team)
    .ToDictionary(g => g.Key, g => g.Select(m => m.Name).ToList());
foreach (var (team, names) in byTeam) Console.WriteLine($"  チーム{team}: {J(names)}");

// 値が List の Dictionary に1件ずつ足す
var multi = new Dictionary<string, List<string>>();
foreach (Member m in members)
{
    if (!multi.TryGetValue(m.Team, out List<string>? names))
        multi[m.Team] = names = new List<string>();
    names.Add(m.Name);
}
Console.WriteLine($"手で組み立て: A={J(multi["A"])} B={J(multi["B"])}");

// 出現回数を数える
var counts = new Dictionary<string, int>();
foreach (string word in "to be or not to be".Split(' '))
    counts[word] = counts.GetValueOrDefault(word) + 1;
Console.WriteLine($"単語カウント: {J(counts)}");

// Dictionary → List
List<string> keys = byTeam.Keys.ToList();
List<Member> values = byId.Values.ToList();
List<KeyValuePair<string, int>> pairs = counts.OrderByDescending(kv => kv.Value).ToList();
Console.WriteLine($"Keys={J(keys)} Values.Count={values.Count} 最多={pairs[0].Key}({pairs[0].Value}回)");

record Member(int Id, string Name, string Team);

実行結果(.NET 8):

byId[2].Name = 鈴木
ToDictionary(Team): ArgumentException: An item with the same key has already been added. Key: A
  チームA: [佐藤, 高橋]
  チームB: [鈴木]
手で組み立て: A=[佐藤, 高橋] B=[鈴木]
単語カウント: [[to, 2], [be, 2], [or, 1], [not, 1]]
Keys=[A, B] Values.Count=3 最多=to(2回)

ToDictionary はキーが重複すると ArgumentException になる点が、出力の2行目で確認できます。値まで指定したいときは list.ToDictionary(x => x.Id, x => x.Name) と第2引数を足します。

件数が多いときの速さを実測する

「探す」操作は、件数が増えるほど List と Dictionary の差が開きます。 10 万件のデータで計りました。

using System.Diagnostics;

const int N = 100_000;
List<int> list = Enumerable.Range(0, N).ToList();
HashSet<int> set = list.ToHashSet();
Dictionary<int, int> dict = list.ToDictionary(i => i, i => i);

// 10万件の中から 1万回さがす
var sw = Stopwatch.StartNew();
int hit = 0;
for (int i = 0; i < 10_000; i++) if (list.Contains(N - 1 - i)) hit++;
Console.WriteLine($"List.Contains          x 10,000 回: {sw.Elapsed.TotalMilliseconds,9:F3} ms (hit={hit})");

sw.Restart(); hit = 0;
for (int i = 0; i < 10_000; i++) if (dict.ContainsKey(N - 1 - i)) hit++;
Console.WriteLine($"Dictionary.ContainsKey x 10,000 回: {sw.Elapsed.TotalMilliseconds,9:F3} ms (hit={hit})");

sw.Restart(); hit = 0;
for (int i = 0; i < 10_000; i++) if (set.Contains(N - 1 - i)) hit++;
Console.WriteLine($"HashSet.Contains       x 10,000 回: {sw.Elapsed.TotalMilliseconds,9:F3} ms (hit={hit})");

// 10万件を追加する:末尾と先頭
sw.Restart();
var tail = new List<int>();
for (int i = 0; i < N; i++) tail.Add(i);
Console.WriteLine($"List.Add(末尾)       x100,000 回: {sw.Elapsed.TotalMilliseconds,9:F3} ms");

sw.Restart();
var head = new List<int>();
for (int i = 0; i < N; i++) head.Insert(0, i);
Console.WriteLine($"List.Insert(0, x)      x100,000 回: {sw.Elapsed.TotalMilliseconds,9:F3} ms");

// Capacity(内部配列の大きさ)は足りなくなるたびに2倍になる
var growing = new List<int>();
int last = -1;
Console.Write("Capacity の変化: ");
for (int i = 0; i < 20; i++)
{
    growing.Add(i);
    if (growing.Capacity != last) { Console.Write($"{growing.Capacity} "); last = growing.Capacity; }
}
Console.WriteLine();
Console.WriteLine($"new List<int>(1000).Capacity = {new List<int>(1000).Capacity}");

実行結果(.NET 8、Debug ビルド、CPU 2 コアの検証環境。数値は実行のたびに変わります):

List.Contains          x 10,000 回:   167.032 ms (hit=10000)
Dictionary.ContainsKey x 10,000 回:     1.184 ms (hit=10000)
HashSet.Contains       x 10,000 回:     0.556 ms (hit=10000)
List.Add(末尾)       x100,000 回:     1.252 ms
List.Insert(0, x)      x100,000 回:   437.627 ms
Capacity の変化: 4 8 16 32 
new List<int>(1000).Capacity = 1000
比べたもの 実測 読み取れること
List.Contains と Dictionary.ContainsKey 167 ms と 1.2 ms List は先頭から順に比べるので、件数に比例して時間がかかる
List.Add と List.Insert(0, x) 1.3 ms と 438 ms 先頭への挿入は、毎回すべての要素を後ろへずらす
Capacity の変化 4 → 8 → 16 → 32 足りなくなるたびに内部配列が2倍になる
  • 「ループの中で list.Contains」を見つけたら、HashSet か Dictionary に置き換えられないか考えると、処理が軽くなることがあります。

よくある質問

Q. List と配列はどちらを使えばよいですか? A. 件数が増減するなら List、長さが決まっているなら配列です。迷ったら List を選び、必要なときだけ ToArray() で変換します。

Q. Dictionary のキーに自作のクラスは使えますか? A. 使えます。ただし「同じキー」と判定されるには Equals と GetHashCode が中身で比較するように作られている必要があります。record 型は中身で比較する実装が自動で用意されるため、キーに使いやすい型です。文字列キーで大文字・小文字を区別したくないときは、new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase) のように比較方法を渡します(record をキーにした取得と OrdinalIgnoreCase での取得は、どちらも .NET 8 で実行して確認しました)。

Q. Dictionary に同じキーで複数の値を持たせたいときは? A. 値の型を List<T> にして Dictionary<string, List<string>> とします。本文の「値が List の Dictionary に1件ずつ足す」のコードがその形です。

まとめ

  • 順番・番号で扱うなら List<T>、キーで引くなら Dictionary<TKey, TValue>
  • Dictionary の追加は3通り。 Add は重複で例外、dict[key] = v は上書き、TryAdd は先勝ち
  • 読み取りは TryGetValue / GetValueOrDefault を使うと KeyNotFoundException を避けられる
  • foreach 中の削除は InvalidOperationException。 RemoveAll・逆順 for・ToList() のコピーで直せる
  • C# 12 のコレクション式 [1, 2, 3] で List・配列・HashSet を同じ書き方で作れる(Dictionary は初期化子)
  • Dictionary の列挙順は保証されない。 順番が要るなら OrderBy か SortedDictionary
  • 10 万件からの検索は List 167 ms、Dictionary 1.2 ms。 「探す」が多いなら Dictionary や HashSet を選ぶ

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「C# ListとDictionaryの使い方|追加・削除・検索・ループ早見表」

株式会社RJCでは、C# のコレクションの選び方や使い方を仲間と学び合っています。こうした知識を共有しながら、日々技術を磨いています。

株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。

WE ARE HIRING

RJCで一緒に開発しながら、
成長を楽しみませんか?

RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!

  • 127日年間休日
  • 12時間平均残業時間
  • 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!

ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。

ABOUT RJC RJCがどんな会社か知る 考え方、研修、働き方、福利厚生、社員の前職まで。RJCのことが丸わかり! RJC丸わかりページへ JOB DESCRIPTION 仕事内容・待遇を見てみる 仕事内容、給与・待遇、選考の流れ。経験者も未経験も!応募前に知りたいこと、まとめました! 募集要項を見る ENTRY エントリーする エントリーは1〜2分・履歴書不要です。まずは話を聞いてみたい、という方でも歓迎です! エントリーフォームへ

RJCで一緒に開発しながら、 成長を楽しみませんか?