跳至内容
Topos Uranos

摘要

许多数据构成的不是一行,而是一张方格表:一个班级里每名学生每次考试的成绩、一个棋盘、一个矩阵。本课介绍表格这种二维结构在 C++ 中的两种形式:向量的向量,其大小在运行时决定;数组的数组,其大小在编译时已知。接着是按行与按列的遍历;矩阵的转置与乘积,其代价为立方阶;一个格子的相邻格,它们要求注意边缘;以及用下标 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 把表格排成矩形打印出来;行按常量引用取出,免得每一轮都复制它。代码第 17 行创建一个 3 行 4 列、填满零的表格:使用圆括号,就像在向量一课中那样,请求的是 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 时,第二列。

  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 个整数”。以 3 阶幻方为例,它的每行、每列和两条对角线之和都是 15。

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,第二层属于那个内部数组,只有第三层才属于每一行。若只写两层花括号,第一行会被当作内部数组的初始化器,而第二行就没有东西可初始化了。

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。
  • 如果某个维度只有在运行时才知道,就用向量的向量,并有义务检查各行长度相同;或者用最后一节中的扁平向量。