C#の開発において、キーと値のペアを管理するDictionary<TKey, TValue>は最も頻繁に利用されるコレクションの一つです。
しかし、この便利なクラスには「要素の順序を保持しない」という特性があります。
データの集計結果を表示する際や、特定のアルゴリズムにデータを渡す際など、キーや値に基づいてデータを並び替えたいケースは非常に多いでしょう。
本記事では、LINQを活用したモダンなソート手法から、パフォーマンスを意識したデータ構造の選択まで、C#におけるDictionaryのソートに関するすべてを詳しく解説します。
Dictionaryの基本的な性質とソートの必要性
C#のDictionary<TKey, TValue>は、内部的にハッシュテーブルを利用しています。
これにより、キーを用いた値の検索や追加、削除を非常に高速(平均 $O(1)$)に行うことができます。
しかし、その代償として要素が格納される順序は保証されません。
.NETのバージョンによっては、一見すると追加した順番に並んでいるように見えることもありますが、これは実装の詳細に依存するものであり、「Dictionaryは順序が不定である」という前提で設計を行う必要があります。
したがって、特定のルールに従ってデータを並び替えるには、LINQ(Language Integrated Query)などの機能を使用して、明示的にソート処理を記述しなければなりません。
LINQを使用したキー(Key)によるソート
Dictionaryをソートする最も一般的かつ柔軟な方法は、LINQのOrderByメソッドを使用することです。
まずは、Dictionaryの「キー」を基準に昇順・降順で並び替える方法を見ていきましょう。
キーで昇順にソートする
DictionaryをLINQで処理すると、各要素はKeyValuePair<TKey, TValue>構造体として扱われます。
これを利用して、キーを比較対象に指定します。
using System;
using System.Collections.Generic;
using System.Linq;
class Program
{
static void Main()
{
// サンプルデータの作成
var dict = new Dictionary<int, string>
{
{ 3, "Apple" },
{ 1, "Orange" },
{ 2, "Banana" }
};
// LINQを使用してキーで昇順にソート
var sortedDict = dict.OrderBy(x => x.Key);
Console.WriteLine("--- キーで昇順ソート ---");
foreach (var item in sortedDict)
{
Console.WriteLine($"Key: {item.Key}, Value: {item.Value}");
}
}
}
--- キーで昇順ソート ---
Key: 1, Value: Orange
Key: 2, Value: Banana
Key: 3, Value: Apple
キーで降順にソートする
降順にする場合は、OrderByDescendingメソッドを使用します。
using System;
using System.Collections.Generic;
using System.Linq;
class Program
{
static void Main()
{
var dict = new Dictionary<string, int>
{
{ "Tokyo", 1400 },
{ "Osaka", 880 },
{ "Nagoya", 230 }
};
// キー(文字列)で降順にソート
var sortedDict = dict.OrderByDescending(x => x.Key);
foreach (var item in sortedDict)
{
Console.WriteLine($"{item.Key}: {item.Value}");
}
}
}
Tokyo: 1400
Osaka: 880
Nagoya: 230
LINQを使用した値(Value)によるソート
次に、Dictionaryに格納されている「値」を基準にソートする方法を解説します。
ランキング形式でデータを表示したい場合などに非常に有効です。
値で昇順・降順にソートする
基本的な使い方はキーの場合と同じですが、ラムダ式でx.Valueを指定します。
using System;
using System.Collections.Generic;
using System.Linq;
class Program
{
static void Main()
{
var scores = new Dictionary<string, int>
{
{ "Alice", 85 },
{ "Bob", 92 },
{ "Charlie", 78 }
};
// 値(スコア)で降順にソート(高得点順)
var rankedScores = scores.OrderByDescending(x => x.Value);
Console.WriteLine("--- スコアランキング ---");
foreach (var score in rankedScores)
{
Console.WriteLine($"{score.Key}: {score.Value}点");
}
}
}
--- スコアランキング ---
Bob: 92点
Alice: 85点
Charlie: 78点
ソート結果を新しいDictionaryとして保持する
LINQのOrderByやOrderByDescendingが返す型は、IOrderedEnumerable<KeyValuePair<TKey, TValue>>です。
これは「列挙可能なソート済みのシーケンス」であり、Dictionaryそのものではありません。
もし、ソートした状態の結果を再びDictionary型として保持したい場合は、ToDictionaryメソッドを呼び出す必要があります。
ToDictionaryメソッドの活用
using System;
using System.Collections.Generic;
using System.Linq;
class Program
{
static void Main()
{
var dict = new Dictionary<int, string>
{
{ 10, "Z" },
{ 5, "M" },
{ 1, "A" }
};
// ソートして新しいDictionaryを作成
Dictionary<int, string> sortedNewDict = dict
.OrderBy(x => x.Key)
.ToDictionary(x => x.Key, x => x.Value);
// 型を確認
Console.WriteLine($"Type: {sortedNewDict.GetType()}");
}
}
ここで一点、重要な注意点があります。
「新しいDictionaryに変換しても、そのDictionary自体の順序保証はない」という点です。
ただし、.NET Core以降の現在の実装では、ToDictionaryで作成されたDictionaryをそのままforeachで回すと、追加順(つまりソートされた順)に要素が取得される挙動になります。
しかし、その後に要素を追加・削除すると、順序は再び崩れる可能性があります。
恒久的にソートされた状態を維持したい場合は、後述するSortedDictionaryの利用を検討してください。
複雑なオブジェクトを値に持つDictionaryのソート
実務では、Dictionaryの値にクラスや構造体などの複雑なオブジェクトを格納することがよくあります。
その場合でも、LINQを使えばオブジェクトの特定のプロパティを対象にソートすることが可能です。
複数条件でのソート
例えば、「点数が高い順に並べ、点数が同じなら名前のアルファベット順に並べる」といった複雑な条件も、ThenByを組み合わせることで簡潔に記述できます。
using System;
using System.Collections.Generic;
using System.Linq;
public class Student
{
public string Name { get; set; }
public int Score { get; set; }
}
class Program
{
static void Main()
{
var students = new Dictionary<int, Student>
{
{ 1, new Student { Name = "Alice", Score = 90 } },
{ 2, new Student { Name = "Bob", Score = 80 } },
{ 3, new Student { Name = "Charlie", Score = 90 } }
};
// 1. スコアで降順(高い順)
// 2. スコアが同じなら名前で昇順
var sortedStudents = students
.OrderByDescending(x => x.Value.Score)
.ThenBy(x => x.Value.Name);
foreach (var entry in sortedStudents)
{
Console.WriteLine($"ID: {entry.Key}, Name: {entry.Value.Name}, Score: {entry.Value.Score}");
}
}
}
ID: 1, Name: Alice, Score: 90
ID: 3, Name: Charlie, Score: 90
ID: 2, Name: Bob, Score: 80
このように、OrderByの後にThenBy(あるいはThenByDescending)を繋げることで、多段のソート条件を簡単に実現できます。
SortedDictionaryとSortedListの活用
データの追加や削除が行われるたびに毎回LINQでソートし直すのは、パフォーマンス上の無駄が生じる場合があります。
もし、「常にキーでソートされた状態のDictionary」が必要であれば、System.Collections.Generic名前空間にある専用のコレクションクラスを使用するのが最適です。
SortedDictionary<TKey, TValue>
SortedDictionaryは、二分探索木(赤黒木)を使用して要素を管理します。
- メリット: 要素を追加・削除しても、常にキーでソートされた状態が維持されます。
- 計算量: 挿入、削除、検索のすべてが $O(\log n)$ です。
- メモリ: 各要素を個別のノードで管理するため、メモリ使用量はやや多めです。
SortedList<TKey, TValue>
名前はListですが、キーと値のペアを保持するコレクションです。
内部的には、ソートされた配列としてデータを管理します。
- メリット: インデックスによるアクセスが可能で、メモリ使用量が少ないです。
- 計算量: 検索は二分探索により $O(\log n)$ ですが、挿入や削除は配列の再配置が必要なため $O(n)$ となります。
- 使いどころ: 初めに大量のデータを追加し、その後の変更が少ない場合に適しています。
| 特徴 | Dictionary | SortedDictionary | SortedList |
|---|---|---|---|
| 順序 | 不定 | キーによる昇順 | キーによる昇順 |
| 検索速度 | $O(1)$ (非常に高速) | $O(\log n)$ | $O(\log n)$ |
| 挿入速度 | $O(1)$ | $O(\log n)$ | $O(n)$ |
| メモリ使用量 | 中 | 大 | 小 |
パフォーマンスに関する考慮事項
Dictionaryのソートを行う際、特に大規模なデータを扱う場合にはパフォーマンスへの影響を無視できません。
LINQソートのオーバーヘッド
LINQによるソート(OrderBy)は、内部的に元のコレクションをコピーし、一時的なバッファを作成してソート処理を行います。
そのため、巨大なDictionaryに対して頻繁にOrderByを呼び出すと、メモリの消費とGC(ガベージコレクション)の負荷が増大します。
もし読み取り専用のデータであり、一度ソートすれば十分な場合は問題ありませんが、リアルタイムに更新されるデータを頻繁にソートして表示する必要がある場合は、前述のSortedDictionaryを最初から使用するか、必要な時だけリスト化してソートするなどの工夫が必要です。
DictionaryとListの使い分け
「特定のキーによる高速検索」が不要で、単に「名前と値のペアをソートして保持したい」だけならば、最初から List<KeyValuePair<TKey, TValue>> や List<(TKey, TValue)> (タプル)を使用し、Sort メソッドを呼び出す方が効率的な場合もあります。
// タプルのリストをソートする例
var list = new List<(int Id, string Name)>
{
(3, "C"),
(1, "A"),
(2, "B")
};
// インプレース(その場)でソート
list.Sort((a, b) => a.Id.CompareTo(b.Id));
この方法は、新しいコレクションを生成しないため、メモリ効率が非常に高いという特徴があります。
実践的なTips:大文字小文字を無視したソート
文字列をキーに持つDictionaryをソートする場合、デフォルトでは「大文字が小文字より先(Unicode順)」になります。
これを「アルファベット順(大文字小文字を区別しない)」にしたい場合は、StringComparerを指定します。
using System;
using System.Collections.Generic;
using System.Linq;
class Program
{
static void Main()
{
var dict = new Dictionary<string, int>
{
{ "apple", 1 },
{ "Banana", 2 },
{ "cherry", 3 }
};
// 大文字小文字を無視してキーでソート
var sorted = dict.OrderBy(x => x.Key, StringComparer.OrdinalIgnoreCase);
foreach (var item in sorted)
{
Console.WriteLine(item.Key);
}
}
}
apple
Banana
cherry
このように、LINQのOrderByには第二引数として比較子(IComparer)を渡すことができるため、柔軟な並び替えルールを適用することが可能です。
まとめ
C#においてDictionaryをソートする方法は、用途に応じていくつかのアプローチに分かれます。
- 一時的なソートや表示目的の場合:
LINQ (OrderBy / OrderByDescending)を使用するのが最も簡単で直感的です。 - 複数条件でのソートが必要な場合: LINQの
ThenByを繋げることで複雑なロジックも簡潔に記述できます。 - 常にソートされた状態を維持したい場合:
SortedDictionaryやSortedListの採用を検討してください。 - パフォーマンスを極限まで追求する場合: 新しいコレクションを作成する
ToDictionaryを避け、列挙型のまま処理するか、List.Sortを活用しましょう。
Dictionaryの特性を正しく理解し、LINQを効果的に使いこなすことで、C#でのデータ操作はより洗練されたものになります。
プログラムの要件(読み取り頻度、書き込み頻度、データ量)に合わせて最適な手法を選択してください。
