入門書でのおさらい21・演習5-9、多次元配列、演習5-10

今日は130ページ演習5-9から始めたいと思います。

130ページ・演習5-9

これは簡単です

   for(int i = 0; i < 5; i++){
        b[i] = a[4-i];
    }

コピーの部分をこう書きかえました

完璧です!

b[i] = a[4-i] という書き換えで、見事に逆順コピーが実現できていますね。i が増えるにつれて 4, 3, 2, 1, 0 と減っていくインデックス計算、非常にスマートです

多次元配列

多次元配列の基礎をわたしに説明してください。できるだけわかりやすく

多次元配列(特に2次元配列)は、難しく考えず「エクセルの表」をイメージすると一瞬で理解できます。

イメージは「行」と「列」

これまでの配列は「1列に並んだロッカー」でしたが、2次元配列は「縦と横に並んだ棚」です。

  • 1次元配列: int a[4][ ][ ][ ][ ] (横一列)
  • 2次元配列: int a[3][4] →(3行 × 4列の表)
[ ][ ][ ][ ] ←0行目
[ ][ ][ ][ ] ←1行目
[ ][ ][ ][ ] ←2行目
書き方と読み方

宣言するときは 型 名前[行数][列数]; と書きます。

int score[3][2]; // 3人分、各2教科の点数を入れる表

中身にアクセスするときは、住所を指定するみたいに2つの番号を使います。
score[0][1] と書けば、「0行目の1列目」という意味になります。
※C言語なので、番号はどちらも 0から始まる のに注意してください!

メモリの中の正体(ここが少し大事)

コンピュータのメモリは実は「1列」しかありません。
そのため、2次元配列 [3][2] は、メモリの中では 1行目のデータ、2行目のデータ、3行目のデータ…と一列に並んで 保存されています。
これを「行優先」と呼びます。
まずは、「九九の表」を想像してみてください。
kuku[9][9] という配列があったら、kuku[2][4] には何が入るでしょうか?

まって2次元は理解できた。しかし解説本には3次元4次元5次元6次元といった配列もある、とあるけど3456次元の配列ってどんなものなの?説明おねがい

多次元配列は、次元が上がるごとに「箱をまとめる入れ物」が大きくなっていくイメージで考えると分かりやすいですよ!

3次元配列:2次元配列の「束」

2次元配列が「1枚の表」なら、3次元配列は「その表を何枚も重ねた束(本)」です。

  • イメージ: 学校のテスト結果
  • 型: score[クラス][出席番号][教科]
  • 例: score[3][40][5] = 「3つのクラス」があり、各クラスに「40人の生徒」がいて、それぞれ「5教科」の点数を持っている。
  • 見た目: 箱が積み重なった「立方体」のようなイメージです。
4次元配列:3次元配列の「並び」

4次元は、3次元という「本(立方体)」がさらに並んでいる状態です。

  • イメージ: 学年ごとのデータ
  • 型: score[学年][クラス][出席番号][教科]
  • 例: score[3][8][40][5] = 「3つの学年」があり、各学年に「8つのクラス」があり……(以下略)。
まとめるとこうなります
  • 1次元: 数珠つなぎのデータ(線)
  • 2次元: 1次元を並べたもの(表・面)
  • 3次元: 2次元を重ねたもの(本・立体)
  • 4次元: 3次元を並べたもの(本のシリーズ・立体の行列)
  • 5次元: 4次元を並べたもの(本棚全体)

ぶっちゃけ、プログラミングの実務で5次元や6次元を使うことは滅多にありません。4次元以上は「データの分類項目(カテゴリ)が増えていく」だけだと考えると、頭が整理されやすいですよ。

まずは「2次元配列を操作するループ(二重ループ)」に慣れるのが、多次元攻略の最短ルートです。

4次元からはこの3次元の世界では立方体をイメージとかいうのはつかえないよね

おっしゃる通りです!私たちの住む3次元の世界では、4次元以上を「形」としてそのまま見ることはできません。

そのため、プログラミングで4次元以上を扱うときは、形ではなく「分類(階層)」で捉えるのが正解です。

イメージしやすい例を出すと、「動画データ」なんかが近いですよ。

  • 2次元:1枚の画像(縦のピクセル × 横のピクセル)
  • 3次元:動画(画像の枚数 = 時間軸)
  • 4次元:動画のプレイリスト(動画1、動画2、動画3……)

「形」で想像しようとすると脳がパンクするので、「フォルダの中に、フォルダが入っていて、その中にまたフォルダがある……」という入れ子構造だと割り切ってしまうのが、プログラミングをスムーズに進めるコツです。

えーっとエクスプローラかな?

まさにその通りです!Windowsのエクスプローラー(フォルダ構成)は、多次元配列の構造を理解するのに最高の例えです。

多次元配列は、次元が増えるごとに「フォルダ(階層)」が深く重なっていくイメージで考えるとスムーズです。

  • 1次元配列 a[i]
    「フォルダ」の中に、複数の「ファイル」が並んでいる状態。
    (例:1つのクラスの生徒名簿)
  • 2次元配列 a[i][j]
    「親フォルダ」の中に、複数の「子フォルダ」があり、その中にファイルが入っている状態。
    (例:学年フォルダ > 各クラスフォルダ > 生徒名簿)
  • 3次元配列 a[i][j][k]
    「孫フォルダ」まで階層が深まった状態。
    (例:学校フォルダ > 学年フォルダ > クラスフォルダ > 生徒名簿)
  • 4次元配列 a[i][j][k][l]
    さらにその外側に、もう一つ大きなフォルダがある状態。
    (例:市区町村 > 学校 > 学年 > クラス > 生徒名簿)
  • 5次元配列 a[i][j][k][l][m]
    さらにさらに、もう一つ上の階層……。
    (例:都道府県 > 市区町村 > 学校 > 学年 > クラス > 生徒名簿)

次元が増えるということは、「目的のデータ(ファイル)に辿り着くまでに、開けなければいけないフォルダの数(添字)が増える」ということになります。

でメモリ内の順番はどういうイメージかな

「一番外側のフォルダから順に、中身を並べていく」というのが正解です。

イメージしやすいように、2次元配列 a[2][3](2つのフォルダの中に、それぞれ3つのファイルがある)で説明しますね。

メモリという「一本道」には、こんな順番で並びます。

  1. まず、0番目のフォルダを開ける
  2. その中のファイルを全部並べる(a[0][0], a[0][1], a[0][2]
  3. 次に、1番目のフォルダを開ける
  4. その中のファイルを全部並べる(a[1][0], a[1][1], a[1][2]

つまり、「外側の番号(添字)が固定されたまま、一番内側の番号が先に動いていく」という順番です。

3次元になっても同じです。
a[0][0][0]a[0][0][1]a[0][0][2] …(一番内側がまず動く)
その次は
a[0][1][0] …(一つ外側が動く)
という流れです。

これを専門用語で「行優先(row-major order)」と呼びます。

この「並び順」を意識しておくと、後で「ポインタ」を学んだときに「なるほど!」と繋がりますよ。

演習5-10

行列の積なんて算数では習わなかったし公式ありますか

行列の積の計算ルール

4行3列の行列Aと、3行4列の行列Bを掛けて、結果の行列Cを作るときの計算式です。

【考え方のステップ】
結果の行列Cの「i行 j列」に入る一つの数字を出すために、以下の計算を行います。

C[i][j] = (A[i][0] * B[0][j]) + (A[i][1] * B[1][j]) + (A[i][2] * B[2][j])

【イメージ】

  1. 左側の行列Aから「横一行(3個)」取り出す。
  2. 右側の行列Bから「縦一列(3個)」取り出す。
  3. 端から順番に掛け算して、その合計を出す。

これをすべての行(i = 0~3)と列(j = 0~3)の組み合わせで行うため、プログラムでは「三重ループ」を使うことになります。

プログラムの構成案
  1. 行列a[4][3] の入力(二重ループ)
  2. 行列b[3][4] の入力(二重ループ)
  3. 行列c[4][4] の計算(三重ループ)
  4. 結果の表示(二重ループ)

まずは、1番と2番の「入力部分」から作成してみてください。
配列の宣言と、値を読み込むための scanf を使ったループですね。

OKこれが公式というかこの式のように組めばいいんだね?まずは入力部分からプログラムを書いていくからそこができたら見てください。

#include<stdio.h>
int main(void){
    int a[4][3];
    int b[3][4];

    puts("行列aの要素を入力してください");
    for(int i = 0; i < 4; i++){
        printf("%d行目\n",i + 1);
        for(int j = 0; j < 3;j++){
            printf("要素%d:",j);
            do{
                scanf("%d",&a[i][j]);
                if(a[i][j] < 0){
                printf("正の整数を入力してください\n");
                }
            }while(a[i][j] < 0);
        }
        putchar('\n');
    }

    puts("行列bの要素を入力してください");
    for(int i = 0; i < 3; i++){
        printf("%d行目\n",i + 1);
        for(int j = 0; j < 4;j++){
            printf("要素%d:",j);
            do{
                scanf("%d",&b[i][j]);
                if(b[i][j] < 0){
                    printf("正の整数を入力してください\n");
                }
            }while(b[i][j] < 0);
        }
        putchar('\n');
    }
    puts("---行列a---");
    for(int i = 0; i < 4; i++){
        for(int j = 0; j < 3;j++){
        printf("%4d ",a[i][j]);
        }
        putchar('\n');
    }

    puts("---行列b---");
    for(int i = 0; i < 3; i++){
        for(int j = 0; j < 4;j++){
        printf("%4d ",b[i][j]);
        }
        putchar('\n');
    }
    return 0;
}

入力部分と確認用の表示部分、バッチリですね!

do-while文による値のチェックも入っていて、非常に丁寧な作りです。行列のサイズ(4行3列、3行4列)に合わせたループの回数も正確に書けています。

いよいよ次が本番の「行列の積の計算」です。

計算部分へのステップ

結果を格納する配列 int c[4][4] を用意して、そこに計算結果を入れていきます。

先ほどの公式を思い出してください:
C[i][j] = (A[i][0] * B[0][j]) + (A[i][1] * B[1][j]) + (A[i][2] * B[2][j])

この「0, 1, 2」と変化している部分を、3つ目のループ(変数kなど)を使って表現することになります。

積の答えとして用意する配列は2つの配列の大きいほうの数字を取って[4][4]とするのでしょうか?

行列の積の結果が何行何列になるかは、「大きい方の数字を取る」のではなく、「左側の行列の『行(縦)』」と「右側の行列の『列(横)』」の組み合わせで決まります。

行列の積の「サイズ」はどう決まる?

行列の積の結果が何行何列になるかは、「大きい方の数字を取る」のではなく、「左側の行列の『行(縦)』」と「右側の行列の『列(横)』」の組み合わせで決まります。

今回の演習問題(4行3列 × 3行4列)で考えると以下のようになります。

  1. 左側の行列(A)4 行 3 列
  2. 右側の行列(B):3 行 4
  3. 計算結果(C)4 行 4 列

このように、「外側の数字」をそのまま持ってきた形が、答えの行列のサイズになります。

じゃあ3行4列と4行3列(aとbが右左変わったら)3行3列ですか?

その通りです!大正解です。

行列の積のサイズは、「左側の行数 × 右側の列数」で決まります。

  • 左が3行4列、右が4行3列なら:3行3列 になります。

パズルがカチッとはまるように、左の「横の数」と右の「縦の数」が一致(今回の場合はどちらも4)していないと計算できないというルールがあるのですが、結果のサイズは常に「外側の数字」を取ると覚えれば間違いありません。

今回の演習問題では4行4列ですね。

kは2までなの?

はい、kは2まで(0, 1, 2の計3回)で正解です!

理由は、行列Aの「横(3列)」と行列Bの「縦(3行)」をペアにして掛けていくからです。要素が3個ずつあるので、ループは3回まわることになります。

つまり、条件式は k < 3 または k <= 2 になりますね。

これでどうかな

#include<stdio.h>
int main(void){
    int a[4][3];
    int b[3][4];

    puts("行列aの要素を入力してください");
    for(int i = 0; i < 4; i++){
        printf("%d行目\n",i + 1);
        for(int j = 0; j < 3;j++){
            printf("要素%d:",j);
            do{
                scanf("%d",&a[i][j]);
                if(a[i][j] < 0){
                printf("正の整数を入力してください\n");
                }
            }while(a[i][j] < 0);
        }
        putchar('\n');
    }

    puts("行列bの要素を入力してください");
    for(int i = 0; i < 3; i++){
        printf("%d行目\n",i + 1);
        for(int j = 0; j < 4;j++){
            printf("要素%d:",j);
            do{
                scanf("%d",&b[i][j]);
                if(b[i][j] < 0){
                    printf("正の整数を入力してください\n");
                }
            }while(b[i][j] < 0);
        }
        putchar('\n');
    }
    puts("---行列a---");
    for(int i = 0; i < 4; i++){
        for(int j = 0; j < 3;j++){
        printf("%4d ",a[i][j]);
        }
        putchar('\n');
    }

    puts("---行列b---");
    for(int i = 0; i < 3; i++){
        for(int j = 0; j < 4;j++){
        printf("%4d ",b[i][j]);
        }
        putchar('\n');
    }

    int c[4][4];
    for(int i = 0; i < 4; i++){
        for(int j = 0; j < 4; j++){
            for(int k = 0; k < 3; k++){
                c[i][j] += (a[i][k] * b[k][j]);
            }
        }
    }

    puts("---行列a×行列b---");
    for(int i = 0; i < 4; i++){      
        for(int j = 0; j < 4; j++){
            printf("%4d",c[i][j]);
        }
        putchar('\n');
    }
    return 0;
}

計算を始める前に c の中身を 0でリセット するのを忘れないでください!

どうしてa,bは初期化しなかったのにcは初期化するの?

非常に鋭い質問です!この違いを理解すると、C言語の「変数」と「メモリ」の扱いがグッと深く分かります。

結論から言うと、「その変数を『上書き』して使うか、『足し算(累計)』して使うか」の違いです。

配列a, b を初期化しなかった理由

aやbは、計算に使う前に scanf で新しい値を上書きしています。

  • メモリの中に元々どんなゴミデータ(デタラメな数値)が入っていても、scanf で上書きしてしまえば、古いゴミは消えて正しい値に置き換わります。
  • だから、わざわざ最初に 0 を入れておく必要がなかったのです。
配列c を初期化した理由

cは、c[i][j] += ... という形で計算しています。これは正確に書くと以下の意味になります。
「c[i][j] の『今の値』に、掛け算の結果を足して、また c[i][j] に戻す」

もし初期化を忘れると、次のようなことが起きます。

  • c[i][j] の中に、たまたまメモリに残っていたゴミデータ(例:54321)が入っている。
  • そこに計算結果(例:10)を足すと、結果は 54331 になってしまう。
  • これでは、正しい合計が出せません。
まとめ
  • 上書きするなら: 初期化は不要(どうせ消えるから)。
  • 足していくなら: 初期化(0セット)が絶対必要(ゴミデータに足すと計算が狂うから)。

あー最初に入ってるデータも使うってこと?

その通りです!

+=(加算代入)という演算子は、「今ある値にプラスする」という動きをします。

もし初期化を忘れると、パソコンが勝手に決めたデタラメな数字(ゴミデータ)をスタート地点にして足し算を始めてしまうので、計算結果がめちゃくちゃになってしまうんです。

だから、自分の計算を「0」から正確に始めるために、「最初は0だよ!」と教えてあげる必要があるんですね。

はい、なおしました!

#include<stdio.h>
int main(void){
    int a[4][3];
    int b[3][4];

    puts("行列aの要素を入力してください");
    for(int i = 0; i < 4; i++){
        printf("%d行目\n",i + 1);
        for(int j = 0; j < 3;j++){
            printf("要素%d:",j);
            do{
                scanf("%d",&a[i][j]);
                if(a[i][j] < 0){
                printf("正の整数を入力してください\n");
                }
            }while(a[i][j] < 0);
        }
        putchar('\n');
    }

    puts("行列bの要素を入力してください");
    for(int i = 0; i < 3; i++){
        printf("%d行目\n",i + 1);
        for(int j = 0; j < 4;j++){
            printf("要素%d:",j);
            do{
                scanf("%d",&b[i][j]);
                if(b[i][j] < 0){
                    printf("正の整数を入力してください\n");
                }
            }while(b[i][j] < 0);
        }
        putchar('\n');
    }
    puts("---行列a---");
    for(int i = 0; i < 4; i++){
        for(int j = 0; j < 3;j++){
        printf("%4d ",a[i][j]);
        }
        putchar('\n');
    }

    puts("---行列b---");
    for(int i = 0; i < 3; i++){
        for(int j = 0; j < 4;j++){
        printf("%4d ",b[i][j]);
        }
        putchar('\n');
    }

    int c[4][4] = {0};
    for(int i = 0; i < 4; i++){
        for(int j = 0; j < 4; j++){
            for(int k = 0; k < 3; k++){
                c[i][j] += (a[i][k] * b[k][j]);
            }
        }
    }

    puts("---行列a×行列b---");
    for(int i = 0; i < 4; i++){      
        for(int j = 0; j < 4; j++){
            printf("%4d",c[i][j]);
        }
        putchar('\n');
    }
    return 0;
}

完成おめでとうございます!完璧です。

int c[4][4] = {0}; でしっかり初期化し、+= を使って累計を出す。この修正によって、行列の積のロジックが正しく実装されました。

行列の積という、数学的にもプログラミング的にも難易度の高い「三重ループ」を自力で解ききったのは本当に素晴らしいです!