摘要
许多数据构成的不是一行,而是一张方格表:一个班级里每名学生每次考试的成绩、一个棋盘、一个矩阵。本课介绍表格这种二维结构在 C++ 中的两种形式:向量的向量,其大小在运行时决定;数组的数组,其大小在编译时已知。接着是按行与按列的遍历;矩阵的转置与乘积,其代价为立方阶;一个格子的相邻格,它们要求注意边缘;以及用下标 i * columns + j 存放在单个向量中的表格,这正是内存看待它的方式。
学习目标
- 把表格表示为
std::vector<std::vector<T>>或由std::array组成的std::array,并根据大小在编译时是否已知在两者之间作出选择。 - 按行和按列遍历表格,并计算每一行和每一列的总和与均值。
- 实现表格的运算(转置、矩阵乘积、统计一个格子的相邻格),并注意不让任何下标越出边缘。
- 用下标
i * columns + j在扁平向量中表示表格,并举例说明格子与其位置之间的对应关系。
表格
行与列
一个有 行、 列的表格,是 个同类型数据的集合,每个数据由两个下标确定:它所在行的下标 (满足 ),以及它所在列的下标 (满足 )。数学称之为矩阵,记作 ;代数与几何 II 课程研究矩阵及其运算,而本课把其中一些写成程序。下标的顺序没有例外:先行,后列。把二者弄混是使用表格时最常见的错误,而编译器无法提醒,因为两个下标的类型相同。
向量一课介绍了大小可变的序列。既然如此,表格就是由行组成的序列,而每一行本身又是一个序列:一个元素为向量的向量,即 std::vector<std::vector<int>>。
#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 在外层循环),再逐列遍历(两层循环互换)。每个格子显示它被访问的顺序号。
证明
表格,行下标在左侧,列下标在上方。
按行:当 时,内层循环遍历 。
当 时,内层循环再次从 开始。
当 时:表格像读文章一样被遍历,一行接一行。
按列:当 时,内层循环遍历 。
当 时,第二列。
当 时。
当 时。每个格子又恰好被访问了一次:互换循环改变的不是访问什么,而是何时访问。
长度不同的行
没有什么强制向量的向量的各行长度相同:每一行都是一个独立的向量。有时这正是所需要的,比如帕斯卡三角形;但在按定义为矩形的表格中,较短的一行是数据错误,而把 table[0].size() 当作列数的程序会读到这一行的范围之外。因此,来自外部的表格必须先检查。
#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。
#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,第二层属于那个内部数组,只有第三层才属于每一行。若只写两层花括号,第一行会被当作内部数组的初始化器,而第二行就没有东西可初始化了。
#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。 - 如果某个维度只有在运行时才知道,就用向量的向量,并有义务检查各行长度相同;或者用最后一节中的扁平向量。
正在加载内容…
已完成
登录账户以保存您的进度