C# プログラミングにおいて、表形式のデータや行列計算、あるいはゲームのマップデータなどを扱う際、2次元構造のデータ保持は避けて通れない要素です。

C# には、固定長の「2次元配列」、行ごとに長さが変えられる「ジャグ配列」、そして動的に要素数を変更できる「Listのネスト」という、主に3つの選択肢が存在します。

これらの手法は、一見すると似たような役割を果たしますが、メモリの配置やアクセス速度、コードの記述性において大きな違いがあります。

本記事では、プロの視点からこれら3つの手法の初期化・操作・相互変換、さらにはパフォーマンスを考慮した使い分けについて、網羅的に解説していきます。

C#における2次元データの3つの形式

C# で多次元的なデータを扱う場合、用途に応じて最適なデータ構造を選択することが重要です。

まずは、代表的な3つの形式についてその特徴を整理しましょう。

2次元配列 (Multidimensional Array)

すべての行の長さが等しい「矩形配列」。

型例は int[,]

要素は行・列でアクセスし、例:array[row, col]

サイズは固定で、数学的な行列や均一なグリッド表現に適しています。

固定長であるため、頻繁な挿入・削除には向きません。

ジャグ配列 (Jagged Array)

「配列の配列」で、型例は int[][]

各行が独立した配列であるため、行ごとに長さが異なります。

アクセスは二重添字で array[row][col]

不揃いなデータや各行ごとにサイズが異なる場合に便利で、各行を個別に再割り当てできます。

Listのネスト (Nested List)

ジェネリックコレクションをネストした構造で、型例は List<List<int>>

要素の追加・削除が頻繁に発生するケースに適し、柔軟に行ごとのサイズ変更や挿入が可能です。

アクセスは list[row][col] のように行えますが、配列に比べてオーバーヘッドがあります。

それぞれの性質を理解することで、メモリ効率と開発効率のバランスを最適化できるようになります。

2次元配列 (T[,]) の基本操作

2次元配列は、メモリ上に連続した領域として確保されるため、特定のインデックスへのアクセスが非常に高速です。

すべての行と列の数が固定されている場合に最も適した選択肢となります。

初期化と宣言

2次元配列を宣言する際は、new T[行数, 列数] という構文を使用します。

C#
using System;

class Program
{
    static void Main()
    {
        // 3行4列の2次元配列を宣言
        int[,] matrix = new int[3, 4];

        // 値を指定して初期化
        int[,] data = {
            { 1, 2, 3 },
            { 4, 5, 6 },
            { 7, 8, 9 }
        };

        // 値の代入
        matrix[0, 0] = 10;
        
        // 値の取得
        int val = matrix[0, 0];

        Console.WriteLine($"dataの0行1列目の値: {data[0, 1]}");
        Console.WriteLine($"matrixの0行0列目の値: {val}");
    }
}
実行結果
dataの0行1列目の値: 2
matrixの0行0列目の値: 10

2次元配列のループ処理

2次元配列の要素を走査する場合、GetLength(n) メソッドを使用して各次元の要素数を取得するのが一般的です。

C#
int[,] grid = {
    { 10, 20 },
    { 30, 40 },
    { 50, 60 }
};

// GetLength(0) は行数、GetLength(1) は列数を返す
for (int i = 0; i < grid.GetLength(0); i++)
{
    for (int j = 0; j < grid.GetLength(1); j++)
    {
        Console.Write($"{grid[i, j]} ");
    }
    Console.WriteLine();
}

注意点として、2次元配列に対して Length プロパティを使用すると、全要素の合計数(この場合は 6)が返されるため、多重ループの境界条件には適しません。

ジャグ配列 (T[][]) の柔軟な利用

ジャグ配列は「配列を要素に持つ配列」であり、各行が異なる長さを持つことができます。

これを「ギザギザな配列」という意味でジャグ(Jagged)と呼びます。

初期化の特殊性

ジャグ配列は2次元配列と異なり、2段階の初期化が必要です。

まず「行」を確保し、その後に各行の「列」を個別に初期化します。

C#
// 3行のジャグ配列を宣言
int[][] jaggedArray = new int[3][];

// 各行の長さを個別に設定
jaggedArray[0] = new int[2] { 1, 2 };
jaggedArray[1] = new int[4] { 3, 4, 5, 6 };
jaggedArray[2] = new int[3] { 7, 8, 9 };

// アクセス方法
Console.WriteLine($"1行目の3要素目: {jaggedArray[1][2]}");

ジャグ配列のメリット

ジャグ配列は、行ごとにメモリが独立して割り当てられるため、行の入れ替え(スワップ)が高速に行えます。

また、.NET の内部実装においては、ジャグ配列の方が2次元配列(矩形配列)よりも最適化が効きやすく、アクセス速度がわずかに上回ることが多いという特性があります。

List<List<T>> による動的データの管理

要素数が事前に決まっていない場合や、実行中に動的に行や列を増やしたい場合は、List<List<T>> を使用します。

動的な追加と削除

List を使用すると、AddRemoveAt といった便利なメソッドを利用できます。

C#
using System;
using System.Collections.Generic;

class Program
{
    static void Main()
    {
        // Listのネスト構造を定義
        List<List<string>> table = new List<List<string>>();

        // 新しい行を追加
        table.Add(new List<string> { "Name", "Score" });
        table.Add(new List<string> { "Alice", "90" });
        table.Add(new List<string> { "Bob", "85" });

        // 特定の要素にアクセス
        Console.WriteLine($"1行目の名前: {table[1][0]}");

        // 列の追加(Aliceの行にGradeを追加)
        table[1].Add("A");

        // 全要素の表示
        foreach (var row in table)
        {
            Console.WriteLine(string.Join(", ", row));
        }
    }
}
実行結果
1行目の名前: Alice
Name, Score
Alice, 90, A
Bob, 85

List<List<T>> は非常に柔軟ですが、参照の階層が深くなるため、大量のデータを扱う際にはメモリのオーバーヘッドに注意が必要です。

2次元配列、ジャグ配列、Listの比較表

それぞれの特性を比較した表を以下に示します。

特徴2次元配列 (T[,])ジャグ配列 (T[][])Listのネスト (List<List<T>>)
形状常に矩形(固定)行ごとに可変行・列ともに動的に可変
メモリ配置連続した単一ブロック行ごとに分散オブジェクトの参照連鎖
アクセス速度高速非常に高速(CLR最適化)比較的に低速(メソッド呼出等)
初期化の容易さ容易やや複雑冗長(都度 new が必要)
主な用途行列計算、固定マップ疎行列、行のスワップデータベースの結果セット

2次元配列とListの相互変換

実務では、「計算処理は高速な配列で行い、結果を柔軟なListで返却する」といったように、相互変換が必要になる場面が多々あります。

1. 2次元配列から List<List<T>> への変換

2次元配列を List に変換する場合、多重ループを使用するのが最も直感的です。

C#
public static List<List<T>> ToNestedList<T>(T[,] array)
{
    int rows = array.GetLength(0);
    int cols = array.GetLength(1);
    var list = new List<List<T>>();

    for (int i = 0; i < rows; i++)
    {
        var row = new List<T>();
        for (int j = 0; j < cols; j++)
        {
            row.Add(array[i, j]);
        }
        list.Add(row);
    }
    return list;
}

2. List<List<T>> から 2次元配列への変換

List の各要素が不揃い(ジャグ状態)である可能性があるため、変換時には最大列数を取得するなどの処理が必要です。

C#
public static T[,] To2DArray<T>(List<List<T>> list)
{
    if (list.Count == 0) return new T[0, 0];

    int rows = list.Count;
    int cols = 0;
    // 最大の列数を探す
    foreach (var row in list)
    {
        if (row.Count > cols) cols = row.Count;
    }

    T[,] result = new T[rows, cols];

    for (int i = 0; i < rows; i++)
    {
        for (int j = 0; j < list[i].Count; j++)
        {
            result[i, j] = list[i][j];
        }
    }
    return result;
}

LINQを活用した高度な操作

C# の強力な機能である LINQ を活用することで、多次元データのフィルタリングや変換を簡潔に記述できます。

ジャグ配列やListのフラット化(平坦化)

2次元の構造を1次元のシーケンスに変換するには、SelectMany を使用します。

C#
using System.Linq;

List<List<int>> nestedList = new List<List<int>> {
    new List<int> { 1, 2 },
    new List<int> { 3, 4, 5 },
    new List<int> { 6 }
};

// 全要素を1次元に統合
var flatList = nestedList.SelectMany(x => x).ToList();

Console.WriteLine(string.Join(", ", flatList));
// 出力: 1, 2, 3, 4, 5, 6

特定の条件に一致する行の抽出

例えば、合計値が特定の閾値を超える行だけを抽出するといった操作も容易です。

C#
var filteredRows = nestedList.Where(row => row.Sum() > 5).ToList();

ただし、2次元配列(T[,])は IEnumerable<T> を直接実装していないため、そのままでは LINQ が使いにくいという欠点があります。

その場合は Cast<T> を経由させる必要があります。

実践的な活用シーンとパフォーマンスのヒント

行列演算における最適化

科学技術計算や画像処理などで行列を扱う場合、2次元配列(T[,])一択と考えられがちですが、実は「1次元配列を2次元として扱う」手法が最も高いパフォーマンスを発揮することがあります。

C#
// 1次元配列による2次元表現
int rows = 1000;
int cols = 1000;
int[] flatArray = new int[rows * cols];

// (row, col) へのアクセス
// index = row * cols + col
flatArray[5 * cols + 10] = 100;

この手法はメモリの局所性が非常に高く、CPUキャッシュを効率的に利用できるため、超高速な計算が求められるゲームエンジンや画像処理ライブラリで多用されています。

大規模データのシリアライズ

List<List<T>> は JSON シリアライズ(System.Text.Json や Newtonsoft.Json)との相性が非常に良く、Web API のレスポンスなどでそのまま出力できるメリットがあります。

一方、T[,] は標準的なシリアライザでは対応していないことが多いため、通信用データとしてはジャグ配列や List 形式に変換するのが一般的です。

よくあるエラーとデバッグのポイント

2次元データを扱う際、最も頻繁に遭遇するのが IndexOutOfRangeException です。

  • 境界値の確認: for ループの終了条件が < Length になっているか、<= Length になっていないかを確認してください。
  • null参照: ジャグ配列や List のネストでは、外側のコレクションを初期化しただけでは内側の各要素は null のままです。必ず各行を new する処理が含まれているか確認しましょう。

まとめ

C# における2次元データの扱いは、その用途によって最適な解が異なります。

  • 2次元配列 (T[,]) は、データの形状が固定されており、数学的な行列計算やメモリ効率を重視する場合に最適です。
  • ジャグ配列 (T[][]) は、行ごとに長さが異なるデータや、行のスワップ操作を頻繁に行う場合に高いパフォーマンスを発揮します。
  • Listのネスト (List<List<T>>) は、データの追加・削除が頻繁に行われるビジネスロジックや、可読性と柔軟性を優先するアプリケーション開発に最適です。

それぞれの特徴を理解し、相互変換や LINQ を組み合わせることで、複雑なデータ構造もシンプルかつ効率的にコーディングできるようになります。

まずは標準的な List のネストから始め、パフォーマンスがボトルネックとなった段階で配列への最適化を検討するというアプローチが、現代のソフトウェア開発においては最も生産性が高いと言えるでしょう。

本記事で紹介したコード例や比較表を参考に、プロジェクトに最適なデータ構造を選択してみてください。