C#を用いたアプリケーション開発において、データの集合であるコレクションを特定のルールに基づいて並び替える「ソート」は、避けては通れない非常に重要な処理です。
ユーザーインターフェースでの一覧表示、データ分析、アルゴリズムの最適化など、さまざまな場面でソートが必要となります。
C#には、標準ライブラリとして「List.Sortメソッド」と「LINQ(OrderBy)」という2つの強力なソート手法が用意されています。
これらは一見すると同じように思えますが、「破壊的か非破壊的か」、あるいは「実行速度やメモリ効率」といった側面で大きな違いがあります。
本記事では、C#におけるListのソート方法について、基礎から応用、そしてパフォーマンスを考慮した使い分けまでをプロの視点で徹底的に解説します。
List.Sortメソッドの基本
List<T>.Sortメソッドは、System.Collections.Generic名前空間に含まれるList<T>クラスのインスタンスメソッドです。
このメソッドの最大の特徴は、元のリストの内容を直接書き換える「破壊的な処理」である点にあります。
数値や文字列の昇順ソート
最もシンプルな使い方は、引数なしでSort()を呼び出す方法です。
これにより、要素の型がデフォルトで持っている比較ルール(IComparableインターフェース)に従って昇順に並び替えられます。
using System;
using System.Collections.Generic;
class Program
{
static void Main()
{
// 整数のリストを作成
List<int> numbers = new List<int> { 5, 2, 8, 1, 9 };
// List.Sort()を使用して昇順にソート
numbers.Sort();
Console.WriteLine("昇順ソート後の結果:");
foreach (var n in numbers)
{
Console.WriteLine(n);
}
}
}
昇順ソート後の結果:
1
2
5
8
9
降順ソートの実装方法
List.Sortには直接「降順」を指定する引数はありません。
そのため、一般的には一度昇順にソートした後にReverseメソッドを使用するか、比較ロジックを反転させて渡す必要があります。
using System;
using System.Collections.Generic;
class Program
{
static void Main()
{
List<int> numbers = new List<int> { 5, 2, 8, 1, 9 };
// 1. Sortした後にReverseする
numbers.Sort();
numbers.Reverse();
// 2. 比較ロジックをラムダ式で渡す (yとxを入れ替えて比較)
List<int> numbers2 = new List<int> { 5, 2, 8, 1, 9 };
numbers2.Sort((x, y) => y.CompareTo(x));
Console.WriteLine("比較ロジック反転による降順:");
numbers2.ForEach(n => Console.WriteLine(n));
}
}
比較ロジック反転による降順:
9
8
5
2
1
LINQを使用した柔軟なソート
LINQ(Language Integrated Query)を利用したソートは、現代的なC#開発において主流となっている手法です。
OrderByやOrderByDescendingを使用します。
LINQは「非破壊的」な処理であり、元のリストを変更せずに、並び替えられた新しいシーケンスを返します。
LINQによる昇順・降順ソート
LINQを使用するには、using System.Linq;が必要です。
メソッドチェーンの形式で記述できるため、可読性が非常に高いのがメリットです。
using System;
using System.Collections.Generic;
using System.Linq;
class Program
{
static void Main()
{
List<int> numbers = new List<int> { 10, 3, 7, 1, 5 };
// 昇順ソート(新しいIEnumerableを返す)
var sortedAsc = numbers.OrderBy(n => n);
// 降順ソート
var sortedDesc = numbers.OrderByDescending(n => n).ToList();
Console.WriteLine("LINQによる降順ソート:");
foreach (var n in sortedDesc)
{
Console.WriteLine(n);
}
// 元のリストは変更されていない
Console.WriteLine($"元のリストの先頭: {numbers[0]}");
}
}
LINQによる降順ソート:
10
7
5
3
1
元のリストの先頭: 10
LINQの遅延評価と即時評価
LINQのソートで注意すべき点は、「遅延評価」の仕組みです。
OrderByを呼び出した時点ではソート処理は実行されず、foreachで回したりToList()を呼び出したりしたタイミングで初めて計算が行われます。
大量のデータを扱う際、何度も繰り返し列挙する場合は、一度ToList()で実体化しておくことで再計算を防ぐことができます。
カスタムオブジェクト(クラス)のソート
実務では、単純な数値ではなく、独自に定義したクラスのリストをソートすることがほとんどです。
例えば、名前と年齢を持つ「Userクラス」を年齢順に並び替えるといったケースです。
ラムダ式を用いたプロパティ指定
List.SortでもLINQでも、ラムダ式を使うことで特定のプロパティを基準にしたソートが簡単に行えます。
using System;
using System.Collections.Generic;
using System.Linq;
public class Employee
{
public int Id { get; set; }
public string Name { get; set; }
public int Salary { get; set; }
}
class Program
{
static void Main()
{
List<Employee> employees = new List<Employee>
{
new Employee { Id = 1, Name = "田中", Salary = 300000 },
new Employee { Id = 2, Name = "佐藤", Salary = 450000 },
new Employee { Id = 3, Name = "鈴木", Salary = 250000 }
};
// 給与(Salary)の昇順でソート
employees.Sort((a, b) => a.Salary.CompareTo(b.Salary));
Console.WriteLine("給与昇順(List.Sort):");
employees.ForEach(e => Console.WriteLine($"{e.Name}: {e.Salary}"));
}
}
給与昇順(List.Sort):
鈴木: 250000
田中: 300000
佐藤: 450000
複数条件でのソート
「第1条件が年齢、第2条件が名前」といった複合的なソートを行う場合、LINQのThenByを使用するのが最もスマートです。
// LINQによる複数条件ソート
var sortedEmployees = employees
.OrderBy(e => e.Salary)
.ThenBy(e => e.Name)
.ToList();
List.Sortで行う場合は、比較関数の中で条件分岐を記述します。
employees.Sort((a, b) => {
int result = a.Salary.CompareTo(b.Salary);
if (result == 0) {
result = a.Name.CompareTo(b.Name);
}
return result;
});
List.Sort と LINQ (OrderBy) の使い分け
どちらの手法を使うべきかは、状況によって明確に分かれます。
以下の比較表を参考にしてください。
| 特徴 | List.Sort | LINQ (OrderBy) |
|---|---|---|
| 処理の種類 | 破壊的(元のリストを書き換える) | 非破壊的(新しいシーケンスを作成) |
| メモリ効率 | 高い(追加のメモリ消費が少ない) | 低い(新しいオブジェクトを生成する) |
| 実行速度 | 高速 | 比較的低速(オーバーヘッドがある) |
| 記述の簡潔さ | 普通 | 非常に高い(メソッドチェーンが可能) |
| 戻り値 | void(なし) | IOrderedEnumerable |
List.Sort を選ぶべきケース
- パフォーマンスが最優先される場合
数万件〜数百万件規模のデータを扱うデスクトップアプリやゲームエンジン(Unityなど)では、ガベージコレクション(GC)の発生を抑えるために
List.SortのようなGCを発生させにくいソート手法が推奨されます。- メモリ消費を抑えたい場合
元のリストを保持しておく必要がないのであれば、要素を直接並べ替える
インプレースソート(その場でのソート)を使うとメモリ使用量を抑えられます。コピーを作らないためメモリ効率は良いですが、元データが上書きされる点に注意してください。
LINQ (OrderBy) を選ぶべきケース
- 元のデータを保護したい場合
関数の引数で受け取ったリストをソートして返す際、呼び出し元のデータを壊さないのは重要な設計原則です。
- フィルタリングや変換を同時に行う場合
Whereで絞り込み、OrderByでソートし、Selectで特定の型に変換するという一連の流れを1つの文で記述できます。- コードの可読性を高めたい場合
ビジネスロジックが中心のWebアプリケーションなどでは、宣言的な
LINQの方が意図を伝えやすくなります。
応用:IComparerインターフェースによる高度なソート
複雑な比較ルールを再利用したい場合や、文字列の並び替えで大文字小文字を区別したくない場合などは、IComparer<T>インターフェースを実装したクラスを作成します。
文字列の自然順ソートや文化設定の考慮
例えば、日本語の文字列リストを「あいうえお順」に正しく並べたい場合や、大文字小文字を無視したい場合は、StringComparerを利用します。
using System;
using System.Collections.Generic;
class Program
{
static void Main()
{
List<string> fruits = new List<string> { "apple", "Orange", "banana", "Apple" };
// 大文字小文字を区別せずにソート
fruits.Sort(StringComparer.OrdinalIgnoreCase);
Console.WriteLine("大文字小文字無視ソート:");
fruits.ForEach(f => Console.WriteLine(f));
}
}
大文字小文字無視ソート:
apple
Apple
banana
Orange
ソートのアルゴリズムと内部動作
C#のList.Sortは内部的にどのようなアルゴリズムを使用しているのでしょうか。
現在の.NET実装では、「イントロソート(Introspective Sort)」が採用されています。
イントロソートは、以下の3つのアルゴリズムを組み合わせたハイブリッドな手法です。
- クイックソート: 基本的な分割統治。
- ヒープソート: クイックソートの再帰が深くなりすぎた(最悪のケースに近い)場合に切り替え、$O(n \log n)$を保証する。
- 挿入ソート: 分割された要素数が少なくなった(一般的に16以下)場合に、定数倍の速さを求めて切り替える。
このように、C#の標準ソートは非常に洗練されており、開発者が自分で複雑なアルゴリズムを実装する必要はほとんどありません。
「安定ソート(要素の順序が維持されるソート)」が必要な場合は、LINQのOrderByが安定ソートであることを利用するのがベストプラクティスです。
なお、List.Sortは「不安定ソート」であるため、同じ値を持つ要素の前後関係が入れ替わる可能性があります。
まとめ
C#におけるListのソートは、目的や用途に応じて適切な道具を選ぶことが成功の鍵となります。
- List.Sortは、元のデータを書き換えても問題なく、パフォーマンスやメモリ効率を追求したい場面に最適です。
- LINQ (OrderBy)は、元のデータを保持しつつ、可読性の高いコードを書きたい場面や安定ソートが必要な場面に最適です。
- カスタムクラスのソートには、ラムダ式を用いてプロパティを指定する方法が最も手軽です。
- 複雑な比較ルールがある場合は
IComparer、複数条件にはThenByを活用しましょう。
これらの特性を理解し、プロジェクトの要件に合わせて使い分けることで、バグの少ない効率的なコードを記述できるようになります。
まずは手元のコードで、LINQによる宣言的な記述から試してみることをお勧めします。
