多维 C 风格数组

以井字棋(Tic-tac-toe)为例,其标准棋盘为一个 3×3 的方格,两名玩家轮流在格内放置 “X” 和 “O”,率先连成三子者获胜。

虽然可以用 9 个独立变量来存储棋盘数据,但当存在大量同类元素时,数组更为合适:

int ttt[9]; // 一个包含 9 个整数的 C 风格数组(0 表示空,1 表示玩家 1,2 表示玩家 2)

该语句定义了一个顺序存储的 C 风格数组,元素在内存中排成一行:

// ttt[0] ttt[1] ttt[2] ttt[3] ttt[4] ttt[5] ttt[6] ttt[7] ttt[8]

数组的维度是指选取元素所需的下标数量。仅需一个下标的数组称为一维数组(1d 数组)。上例中的 ttt 即为一维数组,因为只需一个索引即可访问元素(如 ttt[2])。

然而,一维数组在视觉上与二维的井字棋盘并不相符。我们可以做得更好。

二维数组

在本章中我们提到,数组元素可以是任意对象类型,因此元素本身也可以是另一个数组。定义方式如下:

int a[3][5]; // 3 个元素,每个元素是包含 5 个 int 的数组

由数组组成的数组称为二维数组(2d 数组),因为它需要两个下标。

使用二维数组时,通常将第一个(左侧)下标视为行选择器,第二个(右侧)下标视为列选择器。概念上可将其排布为:

// 列 0 列 1 列 2 列 3 列 4
// a[0][0] a[0][1] a[0][2] a[0][3] a[0][4] 第 0 行
// a[1][0] a[1][1] a[1][2] a[1][3] a[1][4] 第 1 行
// a[2][0] a[2][1] a[2][2] a[2][3] a[2][4] 第 2 行

访问二维数组元素时只需使用两个下标:

a[2][3] = 7; // a[row][col],此处 row = 2,col = 3

因此,井字棋盘可定义为:

int ttt[3][3];

现在我们得到了一个 3×3 的网格,可通过行列下标轻松操作!

多维数组

维度超过一的数组统称为多维数组。

C++ 还支持三维及以上的多维数组:

int threedee[4][4][4]; // 4×4×4 数组(4 个数组,每个数组含 4 个数组,每个数组含 4 个 int)

例如,《我的世界》(Minecraft)的地形被划分为 16×16×16 的区块段(chunk sections)。

高于三维的数组同样受支持,但使用场景罕见。

二维数组在内存中的排布

内存本身是线性的(一维),因此多维数组在内存中需按顺序线性展开。

对于如下数组,有两种可能的存储方式:

// 列 0 列 1 列 2 列 3 列 4
// [0][0] [0][1] [0][2] [0][3] [0][4] 第 0 行
// [1][0] [1][1] [1][2] [1][3] [1][4] 第 1 行
// [2][0] [2][1] [2][2] [2][3] [2][4] 第 2 行

C++ 采用行优先(row-major order):元素按行依次连续存放,从左到右,自上而下:

[0][0] [0][1] [0][2] [0][3] [0][4] [1][0] [1][1] [1][2] [1][3] [1][4] [2][0] [2][1] [2][2] [2][3] [2][4]

某些其他语言(如 Fortran)采用列优先(column-major order):元素按列依次连续存放,从上到下,从左到右:

[0][0] [1][0] [2][0] [0][1] [1][1] [2][1] [0][2] [1][2] [2][2] [0][3] [1][3] [2][3] [0][4] [1][4] [2][4]

在 C++ 中,初始化数组时元素按行优先顺序赋值;遍历时若按内存顺序访问,效率最高。

二维数组的初始化

初始化二维数组最直观的方式是使用嵌套花括号,每一组数值代表一行:

int array[3][5]
{
  { 1, 2, 3, 4, 5 },     // 第 0 行  
  { 6, 7, 8, 9, 10 },    // 第 1 行  
  { 11, 12, 13, 14, 15 } // 第 2 行  
};

尽管某些编译器允许省略内层花括号,我们仍强烈建议保留,以提升可读性。

使用内层花括号时,缺失的初始值将执行值初始化:

int array[3][5]
{
  { 1, 2 },          // 第 0 行:1, 2, 0, 0, 0  
  { 6, 7, 8 },       // 第 1 行:6, 7, 8, 0, 0  
  { 11, 12, 13, 14 } // 第 2 行:11, 12, 13, 14, 0  
};

初始化多维数组时,可省略最左侧的维度长度:

int array[][5]
{
  { 1, 2, 3, 4, 5 },  
  { 6, 7, 8, 9, 10 },  
  { 11, 12, 13, 14, 15 }  
};

此时编译器可根据初始值数量自动计算最左侧维度。非最左侧维度不可省略:

int array[][]
{
  { 1, 2, 3, 4 },  
  { 5, 6, 7, 8 }  
}; // 错误

与普通数组一样,多维数组可整体初始化为 0:

int array[3][5] {};

二维数组与循环

对一维数组,可用单循环遍历全部元素:

#include <iostream>

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

    // 索引 for 循环  
    for (std::size_t i{0}; i < std::size(arr); ++i)  
        std::cout << arr[i] << ' ';  

    std::cout << '\n';  

    // 范围 for 循环  
    for (auto e : arr)  
        std::cout << e << ' ';  

    std::cout << '\n';  

    return 0;  
}

对二维数组,则需两层循环:外层选择行,内层选择列。

同时需决定哪层循环在外,哪层在内。为按内存顺序访问,C++ 行优先,故行下标为外层循环,列下标为内层循环:

#include <iostream>

int main()
{
    int arr[3][4]{
        { 1, 2, 3, 4 },  
        { 5, 6, 7, 8 },  
        { 9, 10, 11, 12 } };  

    // 双重索引 for 循环  
    for (std::size_t row{0}; row < std::size(arr); ++row) // std::size(arr) 返回行数  
    {
        for (std::size_t col{0}; col < std::size(arr[0]); ++col) // std::size(arr[0]) 返回列数  
            std::cout << arr[row][col] << ' ';  

        std::cout << '\n';  
    }

    // 双重范围 for 循环  
    for (const auto& arow : arr)   // 取每一行  
    {
        for (const auto& e : arow) // 取行内每个元素  
            std::cout << e << ' ';  

        std::cout << '\n';  
    }

    return 0;  
}

二维数组示例

看一个更实际的二维数组示例:

#include <iostream>

int main()
{
    constexpr int numRows{ 10 };  
    constexpr int numCols{ 10 };  

    // 声明一个 10×10 数组  
    int product[numRows][numCols]{};  

    // 计算乘法表  
    // 第 0 行与第 0 列无需计算,因乘以 0 恒为 0  
    for (std::size_t row{ 1 }; row < numRows; ++row)  
    {
        for (std::size_t col{ 1 }; col < numCols; ++col)  
        {
            product[row][col] = static_cast<int>(row * col);  
        }
    }

    for (std::size_t row{ 1 }; row < numRows; ++row)  
    {
        for (std::size_t col{ 1 }; col < numCols; ++col)  
        {
            std::cout << product[row][col] << '\t';  
        }

        std::cout << '\n';  
    }

    return 0;  
}

该程序计算并打印 1 到 9(含 1 与 9)的乘法表。注意打印时循环从 1 开始,以省略第 0 行和第 0 列,否则将输出大量 0。输出如下:

1    2    3    4    5    6    7    8    9  
2    4    6    8    10   12   14   16   18  
3    6    9    12   15   18   21   24   27  
4    8    12   16   20   24   28   32   36  
5    10   15   20   25   30   35   40   45  
6    12   18   24   30   36   42   48   54  
7    14   21   28   35   42   49   56   63  
8    16   24   32   40   48   56   64   72  
9    18   27   36   45   54   63   72   81

笛卡尔坐标与数组下标

几何学中,常用笛卡尔坐标系描述对象位置。二维情况下,有两条坐标轴,传统命名为 “x” 与 “y”。x 为水平轴,y 为垂直轴。

二维平面内,物体位置可用 { x, y } 坐标对表示,分别指示该点距 x 轴右侧多远、距 y 轴上方多远。某些场景下 y 轴会反转,使 y 坐标表示距 y 轴下方多远。
Code::Blocks安装

再看 C++ 二维数组的排布:

// 列 0 列 1 列 2 列 3 列 4
// [0][0] [0][1] [0][2] [0][3] [0][4] 第 0 行
// [1][0] [1][1] [1][2] [1][3] [1][4] 第 1 行
// [2][0] [2][1] [2][2] [2][3] [2][4] 第 2 行

这同样是一个二维坐标系,元素位置可用 [row][col] 描述(col 轴已反转)。

虽然两种坐标系各自易于理解,但从笛卡尔 { x, y } 转换到数组下标 [row][col] 却略显反直觉。

关键在于:笛卡尔 x 坐标对应数组列索引,y 坐标对应数组行索引。因此,笛卡尔 { x, y } 映射为数组 [y][x],与我们直觉中的字母顺序相反!

于是,二维循环常写成:

for (std::size_t y{0}; y < std::size(arr); ++y) // 外层循环为行 / y  
{
    for (std::size_t x{0}; x < std::size(arr[0]); ++x) // 内层循环为列 / x  
        std::cout << arr[y][x] << ' '; // 先 y(行),再 x(列)索引  
}

注意此处数组索引为 a[y][x],与字母顺序相反,可能与您最初设想不同。

关注公众号,回复"cpp-tutorial"

可领取价值199元的C++学习资料

公众号二维码

扫描上方二维码或搜索"cpp-tutorial"