コンテンツへスキップ
Topos Uranos

要約

多くのデータは一列には並ばず、表の形をなす。学級の学生ごと・試験ごとの成績、盤面、行列がそうである。このレッスンでは、二次元の構造である表を、C++ における二つの形で紹介する。実行時に大きさが決まるベクターのベクターと、コンパイル時に大きさがわかる配列の配列である。続いて、行ごとの走査と列ごとの走査、3 乗のコストをもつ行列の転置と積、端に気を配る必要のあるマスの隣接マス、そしてメモリから見たときの姿である、添字 i * columns + j によって一つのベクターに収めた表を扱う。

学習目標

  1. 表を std::vector<std::vector<T>> として、または std::array の std::array として表現し、大きさがコンパイル時にわかるかどうかに応じて両者を選ぶことができる。
  2. 表を行ごとおよび列ごとに走査し、各行と各列の合計と平均を計算することができる。
  3. どの添字も端からはみ出さないように注意しながら、表の操作(転置、行列の積、あるマスの隣接マスの数え上げ)を実装することができる。
  4. 添字 i * columns + j によって表を一次元化したベクターで表現し、マスとその位置の対応を例を挙げて説明することができる。

表

行と列

mm 行 nn 列の表とは、同じ型の m⋅nm \cdot n 個のデータの集まりであり、各データは二つの添字で指定される。行の添字 ii(0≤i<m0 \leq i < m)と、列の添字 jj(0≤j<n0 \leq j < n)である。数学ではこれを行列と呼び、aija_{ij} と書く。代数と幾何 II のコースは行列とその演算を学び、このレッスンはそのいくつかをプログラムとして書く。添字の順序に例外はない。先が行、後が列である。これを取り違えるのは表についてもっともよくある誤りであり、コンパイラはそれを警告できない。どちらの添字も同じ型だからである。

レッスン ベクター は、大きさの変わる要素の並びを紹介した。そうであれば、表は行の並びであり、各行もまた要素の並びである。すなわち、要素がベクターであるベクター、std::vector<std::vector<int>> である。

C++ のプログラム
#include <print>#include <vector>​int main(){    std::vector<std::vector<int>> table{{1, 2, 3}, {4, 5, 6}};    std::println("{}", table);    std::println("行数 {}、列数 {}", table.size(), table[0].size());    std::println("第 1 行:{}、第 1 行第 2 列:{}", table[1], table[1][2]);    table[0][1] = 20;    for (const std::vector<int>& row : table) {        for (const int value : row) {            std::print("{:>4}", value);        }        std::println("");    }    const std::vector<std::vector<int>> zeros(3, std::vector<int>(4));    std::println("{}", zeros);}
出力
[[1, 2, 3], [4, 5, 6]]行数 2、列数 3第 1 行:[4, 5, 6]、第 1 行第 2 列:6   1  20   3   4   5   6[[0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0]]

6 行目は表を行のリストとして書き、各行をそれぞれの波かっこで囲む。範囲の書式化は、それを角かっこで同じように出力する。table.size() は行数であり、table[0].size() は最初の行の長さで、形の整った表ではそれが列数になる。table[1] は第 1 行全体、すなわち一つの std::vector<int> であり、table[1][2] はその行の要素 2 である。二重の添字は新しい演算ではなく、続けて行う二回のアクセスであり、一回目が行を選び、二回目がその行のなかで列を選ぶ。11 行目から 16 行目は、入れ子にした二つの範囲 for 文で表を四角く並べて書く。行は、周回ごとにコピーしないよう const 参照で受け取る。17 行目は、0 で埋めた 3 行 4 列の表を作る。レッスン ベクター と同じく丸かっこを使うと、0 が四つ並んだ行のコピーを 3 個求めることになる。

例行ごとの走査と列ごとの走査

3 行 4 列の表を、まず一行ずつ(外側のループに i)、そのあと一列ずつ(ループを入れ替えて)走査する。各マスには訪問の順番が表示される。

証明

  1. 表。行の添字を左に、列の添字を上に示す。

  2. 行ごと:i=0i = 0 のとき、内側のループは j=0,1,2,3j = 0, 1, 2, 3 を走査する。

  3. i=1i = 1 のとき、内側のループはふたたび j=0j = 0 から始まる。

  4. i=2i = 2 のとき:表は、文章を読むように一行ずつ走査された。

  5. 列ごと:j=0j = 0 のとき、内側のループは i=0,1,2i = 0, 1, 2 を走査する。

  6. j=1j = 1 のとき、2 番目の列。

  7. j=2j = 2 のとき。

  8. j=3j = 3 のとき。各マスはまたちょうど一回ずつ訪問された。ループを入れ替えても、何を訪問するかは変わらず、変わるのはいつ訪問するかである。

長さの異なる行

ベクターのベクターの行が同じ長さでなければならない理由はない。各行は独立したベクターだからである。パスカルの三角形のように、それこそが望ましい場合もある。しかし定義上長方形である表では、短い行はデータの誤りであり、table[0].size() を列数として使うプログラムはその行の外を読む。したがって、外から来た表は確かめなければならない。

C++ のプログラム
#include <print>#include <vector>​// 事前条件:なし。// 事後条件:table に少なくとも一つの行があり、そのすべての// 行が最初の行と同じ長さなら true を返す。bool isRectangular(const std::vector<std::vector<int>>& table){    if (table.empty()) {        return false;    }    for (const std::vector<int>& row : table) {        if (row.size() != table[0].size()) {            return false;        }    }    return true;}​int main(){    const std::vector<std::vector<int>> pascal{{1}, {1, 1}, {1, 2, 1}, {1, 3, 3, 1}};    const std::vector<std::vector<int>> grid{{1, 2, 3}, {4, 5, 6}};    std::println("{} {}", pascal, isRectangular(pascal));    std::println("{} {}", grid, isRectangular(grid));}
出力
[[1], [1, 1], [1, 2, 1], [1, 3, 3, 1]] false[[1, 2, 3], [4, 5, 6]] true

この関数は行のない表を除外する。そのような表では table[0] が存在しない。このレッスンの関数のうち table[0].size() を読むものは、事前条件で長方形かつ空でない表を要求し、isRectangular を使えばそれを保証できる。

配列の配列

大きさがコンパイル時にわかるとき(チェス盤、魔方陣)、表は std::array の std::array にできる。std::array<std::array<int, 3>, 3> は内側から外側へ、「3 個の整数からなる行が 3 本」と読む。例として、行、列、対角線の和がすべて 15 になる 3 次の魔方陣を使う。

C++ のプログラム
#include <array>#include <print>​int main(){    constexpr std::array<std::array<int, 3>, 3> magic{{        {2, 7, 6},        {9, 5, 1},        {4, 3, 8},    }};    std::println("{}", magic);    std::println("中央 {}、{} バイト", magic[1][1], sizeof(magic));}
出力
[[2, 7, 6], [9, 5, 1], [4, 3, 8]]中央 5、36 バイト

波かっこは両端に三つずつある。std::array のメンバーは C 言語の配列一つだけなので、いちばん外側の波かっこは std::array のもの、2 番目はその内部の配列のもの、3 番目だけが各行のものになる。波かっこが二つだと、最初の行が内部の配列の初期化子とみなされ、2 番目の行にはもう初期化するものがない。

C++ のプログラム
#include <array>#include <print>​int main(){    const std::array<std::array<int, 3>, 2> table{{1, 2, 3}, {4, 5, 6}};    std::println("{}", table);}
コンパイラのエラーメッセージ
main.cpp: In function ‘int main()’:main.cpp:6:71: error: too many initializers for ‘const std::array<std::array<int, 3>, 2>’    6 |     const std::array<std::array<int, 3>, 2> table{{1, 2, 3}, {4, 5, 6}};      |                                                                       ^

Too many initializers:リスト {4, 5, 6} が余っている。平らなリスト {1, 2, 3, 4, 5, 6} もコンパイルでき、行を順に埋めるが、表の形が見えなくなる。このコースでは波かっこを三つ書く。

sizeof の 36 バイトは 4 バイトの int 九個分(よくある大きさだが、規格は保証しない)で、それ以外には何もない。配列の配列は行を一つずつ順に一つのブロックに収めている。これに対してベクターのベクターは、行を並べるブロックのほかに、行ごとに一つずつブロックを求め、それらのブロックが隣り合っている保証はない。したがって選択は、レッスン 配列 とレッスン ベクター の規則に従う。

  • 両方の大きさがコンパイル時にわかるなら、配列の配列。ブロックは一つで、長方形の形が型によって保証され、constexpr にもできる。
  • どちらかが実行時にしかわからないなら、ベクターのベクター。ただし行が同じ長さであることを確かめる義務がある。あるいは、最後の節の一次元化したベクター。