C言語における多次元配列は、行列計算や画像処理、ゲームプログラミングのグリッド管理など、多岐にわたる分野で利用される極めて重要なデータ構造です。

しかし、その正体は単なるデータの羅列に過ぎず、メモリ上での物理的な配置とポインタ演算の仕組みを正しく理解していないと、バグの温床になりやすい側面もあります。本記事では、多次元配列の基礎から、上級者へのステップアップに不可欠なメモリ構造の理解、そして実務で多用される動的確保の実践テクニックまで、詳細に解説していきます。

多次元配列の基礎と宣言方法

C言語における多次元配列は、配列の要素自体がさらに配列である構造を指します。

最も一般的な形式は2次元配列ですが、理論上は3次元、4次元と次元を増やしていくことが可能です。

2次元配列の宣言と初期化

2次元配列を宣言する際は、型名 配列名[行数][列数]; という形式をとります。

例えば、3行4列の整数型配列を宣言する場合は以下のようになります。

C言語
#include <stdio.h>

int main() {
    // 3行4列の配列を宣言し、初期化する
    int matrix[3][4] = {
        {10, 11, 12, 13},
        {20, 21, 22, 23},
        {30, 31, 32, 33}
    };

    // 特定の要素にアクセス
    printf("matrix[1][2]の値: %d\n", matrix[1][2]);

    return 0;
}
実行結果
matrix[1][2]の値: 22

配列の添え字は常に 0から始まる 点に注意してください。

上記の例では、matrix[1][2] は「2行目の3番目の要素」を指しています。

多次元配列の初期化バリエーション

初期化時には、内側の波括弧を省略することも可能ですが、コードの可読性を保つためには明示的に記述することが推奨されます。

また、第一次元の要素数(行数)に限っては、初期化子がある場合に限り省略可能です。

C言語
// 行数をコンパイラに委ねる記述
int table[][3] = {
    {1, 2, 3},
    {4, 5, 6}
};

このように記述すると、コンパイラは初期化データから自動的に「2行」であると判断します。

しかし、列数(第2次元以降のサイズ)は必ず明示しなければなりません。

これは、後述するメモリ上のアドレス計算に列の情報が不可欠だからです。

メモリ構造の深い理解:行優先順序

C言語の多次元配列を使いこなす上で最も重要な知識は、多次元配列がメモリ上では1次元の連続した領域として配置されているという事実です。

行優先順序 (Row-Major Order)

C言語は「行優先順序」を採用しています。

これは、2次元配列 arr[M][N] において、まず1行目の全要素が並び、その直後に2行目の全要素が並ぶという形式です。

論理的なインデックスメモリ上の配置順序
matrix[0][0]1番目
matrix[0][1]2番目
matrix[0][2]3番目
matrix[1][0]4番目
matrix[1][1]5番目

この構造を理解すると、なぜ列数の指定が必須なのかが分かります。

コンピュータが matrix[i][j] のアドレスを計算する際、以下の計算式が内部的に使用されます。

アドレス = ベースアドレス + (i * 列数 + j) * 要素のサイズ

この式から明らかなように、「列数」が分からなければ、次の行がどこから始まるかを計算できないのです。

メモリの連続性を確認する

実際にアドレスを表示して、メモリが連続していることを確認してみましょう。

C言語
#include <stdio.h>

int main() {
    int arr[2][3] = {{1, 2, 3}, {4, 5, 6}};

    for (int i = 0; i < 2; i++) {
        for (int j = 0; j < 3; j++) {
            printf("arr[%d][%d] アドレス: %p 値: %d\n", i, j, (void*)&arr[i][j], arr[i][j]);
        }
    }
    return 0;
}
実行結果
arr[0][0] アドレス: 0x7ffd5e3a1a10 値: 1
arr[0][1] アドレス: 0x7ffd5e3a1a14 値: 2
arr[0][2] アドレス: 0x7ffd5e3a1a18 値: 3
arr[1][0] アドレス: 0x7ffd5e3a1a1c 値: 4
arr[1][1] アドレス: 0x7ffd5e3a1a20 値: 5
arr[1][2] アドレス: 0x7ffd5e3a1a24 値: 6

int 型が4バイトの環境では、各要素のアドレスが4バイトずつ規則正しく増加していることがわかります。

arr[0][2] の直後のアドレスに arr[1][0] が位置している点に注目してください。

ポインタと多次元配列の関係

「配列はポインタに書き換えられる」という言葉はC言語学習でよく聞かれますが、多次元配列の場合は少し複雑です。

配列ポインタ (Pointer to Array)

2次元配列の名前は、その「最初の行」を指すポインタとして機能します。

しかし、それは単なる int* ではなく、「特定の要素数を持つ配列へのポインタ」という特殊な型になります。

C言語
int matrix[3][4];
int (*p)[4] = matrix; // 4つのintを持つ配列へのポインタ

ここで int *p[4](ポインタの配列)と混同しないように注意してください。

括弧の位置が重要です。

  • int (*p)[4] : 4つの要素を持つ配列を指す、1つのポインタ。
  • int *p[4] : int 型を指すポインタが4つ集まった配列。

ポインタ演算によるアクセス

多次元配列の要素アクセス matrix[i][j] は、ポインタ記法では *(*(matrix + i) + j) と等価です。

  1. matrix + i は、i 行目の配列のアドレスを指します。
  2. *(matrix + i) は、i 行目の先頭要素を指すポインタに減衰します。
  3. そこに j を足してデリファレンスすることで、目的の要素に到達します。

多次元配列を関数に渡す方法

関数に多次元配列を渡す際、引数の宣言にはいくつかのパターンがあります。

1. 固定サイズの指定

最も単純な方法は、列サイズを固定して宣言することです。

C言語
void printMatrix(int mat[3][4]) {
    for (int i = 0; i < 3; i++) {
        for (int j = 0; j < 4; j++) {
            printf("%d ", mat[i][j]);
        }
        printf("\n");
    }
}

この場合、関数は「列数が4」の配列しか受け取ることができません。

2. 配列ポインタとしての宣言

引数において mat[][4] と書くのは、(*mat)[4] と書くのと同義です。

C言語
void process(int (*p)[4], int rows) {
    // 処理
}

3. 可変長配列 (VLA: Variable Length Array) の利用

C99以降では、実行時にサイズが決まる配列を引数に取ることが可能です。

C言語
void printVLA(int rows, int cols, int mat[rows][cols]) {
    for (int i = 0; i < rows; i++) {
        for (int j = 0; j < cols; j++) {
            printf("%d ", mat[i][j]);
        }
        printf("\n");
    }
}

VLAを使用する場合は、サイズの引数(rows, cols)を配列引数よりも先に定義する必要がある点に注意してください。

実践テクニック:多次元配列の動的確保

静的な配列は宣言時にサイズが固定されますが、実際の開発では「実行時に必要な行列のサイズが決まる」ケースがほとんどです。

多次元配列を動的に確保するには、主に2つのアプローチがあります。

手法A:ポインタの配列(ダブルポインタ)を用いる方法

各行を個別に malloc する方法です。

この方法のメリットは、matrix[i][j] というおなじみの記法がそのまま使える点にあります。

C言語
#include <stdio.h>
#include <stdlib.h>

int main() {
    int rows = 3;
    int cols = 4;

    // 1. 各行の先頭アドレスを格納するポインタ配列を確保
    int **matrix = (int **)malloc(sizeof(int *) * rows);
    if (matrix == NULL) return 1;

    // 2. 各行の実体を確保
    for (int i = 0; i < rows; i++) {
        matrix[i] = (int *)malloc(sizeof(int) * cols);
        if (matrix[i] == NULL) return 1;
    }

    // データの代入
    matrix[1][2] = 99;
    printf("動的確保したmatrix[1][2]: %d\n", matrix[1][2]);

    // 3. メモリの解放(確保した逆順で行う)
    for (int i = 0; i < rows; i++) {
        free(matrix[i]);
    }
    free(matrix);

    return 0;
}

ただし、この方法にはデメリットもあります。

  • メモリの確保と解放にループが必要で、手間がかかる。
  • 各行のメモリ領域が連続している保証がないため、キャッシュ効率が悪くなる可能性がある。

手法B:1次元配列を多次元として扱う(フラット化)

プロの現場でよく使われるのが、1次元配列として一括確保し、インデックス計算で多次元をシミュレートする方法です。

C言語
#include <stdio.h>
#include <stdlib.h>

int main() {
    int rows = 3;
    int cols = 4;

    // 全要素を一括で確保
    int *data = (int *)malloc(sizeof(int) * rows * cols);
    if (data == NULL) return 1;

    // アクセス時は (i * cols + j) を使用
    int r = 1, c = 2;
    data[r * cols + c] = 55;

    printf("フラット配列でのアクセス: %d\n", data[r * cols + c]);

    free(data);
    return 0;
}

この手法は、メモリが完全に連続しているためパフォーマンスに優れ、一回のfreeで済むという利点があります。

パフォーマンス向上のための注意点

多次元配列を扱うプログラムの実行速度を最適化するには、ハードウェアの仕組みを考慮する必要があります。

キャッシュの最適化

現代のCPUは、メモリからデータを読み込む際、周辺のデータもまとめてキャッシュメモリにロードします。

C言語の行優先順序を考慮し、「内側のループで列を回す」ように設計することが重要です。

C言語
// 効率が良い例(メモリの並び順にアクセス)
for (int i = 0; i < rows; i++) {
    for (int j = 0; j < cols; j++) {
        sum += matrix[i][j];
    }
}

// 非効率な例(アクセスが飛び飛びになる)
for (int j = 0; j < cols; j++) {
    for (int i = 0; i < rows; i++) {
        sum += matrix[i][j];
    }
}

非効率な例では、キャッシュミスが頻発し、大規模な配列になればなるほど実行速度が劇的に低下します。

多次元配列の応用:3次元以上の配列

画像データのチャンネル(RGB)や、時系列の格子データなど、3次元配列が必要な場面もあります。

C言語
int cube[depth][rows][cols];

3次元になっても基本原理は同じです。

メモリ上では「奥行き -> 行 -> 列」の順で並びます。

アドレス計算式は d * (rows * cols) + i * cols + j となり、右側の添え字ほどメモリ上で隣接していることを意識しましょう。

まとめ

C言語の多次元配列は、一見すると直感的な行列構造に見えますが、その本質は「行優先順序で並んだ連続的なメモリブロック」です。

本記事で解説した以下のポイントを意識することで、より堅牢で効率的なプログラムを書くことができます。

  • メモリ構造: 多次元配列は1次元的に配置されており、列数の指定がアドレス計算に不可欠である。
  • ポインタ演算: matrix[i][j] はポインタの加算とデリファレンスに変換される。
  • 動的確保: 柔軟性を求めるならダブルポインタ、性能を求めるならフラットな1次元配列を選択する。
  • キャッシュ効率: メモリの配置順(行方向)にループを回すことで、処理速度を最大化できる。

多次元配列を自在に操るスキルは、C言語におけるメモリ管理能力の証明でもあります。

特に動的確保とポインタの関係を深く理解することは、将来的に複雑なデータ構造やアルゴリズムを実装する際の強力な武器となるでしょう。

ぜひ、実際のコードを書いて、メモリ上の挙動を確認してみてください。