C++基础教程
——
C语言部分卷2数组
节2二维数组

最新版本V2.0
王道C++团队
COPYRIGHT ⓒ 2021-2024. 王道版权所有

概述

Gn!

在C语言中,数组可以是任意维度的。但最常用的还是一维数组,偶尔会用到一下二维数组,更高维度的数组则建议不要考虑,几乎不会使用。

所以本小节当中,我们一起来探讨一下二维数组。

二维数组的本质

Gn!

理解C语言中二维数组的本质是掌握其应用的基础前提。要深入了解二维数组的本质,我们可以从以下两个角度出发:

数学角度

Gn!

从数学角度看,二维数组就是数学中的矩阵。矩阵具有行和列的结构,其中每个元素都可以通过其行和列的索引进行定位。

例如,声明初始化一个arr[5][9]的二维数组,就可以把该二维数组看成是一个具有5行9列的矩阵。

如下图所示:

二维数组的本质-矩阵

当我们使用下标arr[i][j]访问二维数组元素时,可以看作是访问矩阵的第i行第j列的元素。

所以在声明二维数组时,我们把arr[i][j]中的i称之为行数,j称之为列数。

内存角度

Gn!

从内存的角度看,二维数组其实是一个“元素是一维数组的一维数组”。

在内存中,它是一系列连续的一维数组。每个一维数组(或称为“子数组”)都有相同的长度,并在内存中连续存储。

例如,int arr[3][4]实际上是3个长度为4的int数组的连续存储。

这种存储方式被称为“行主序”,因为最外层的数组索引(即行索引)变化的频率低于内部的数组索引(列索引)。

因此,arr[0][0]、arr[0][1]、arr[0][2]...是连续存储的,然后是arr[1][0]、arr[1][1]....

如下图所示:

行数序存储方式

因此,二维数组的数据在物理存储上仍然是线性的,但为了更好地组织和访问,我们逻辑上将其视为二维结构。

总之在C语言中,理解二维数组有两个关键视角:数学与内存布局。

  1. 数学角度看,将其视为矩阵。这非常有助于我们学习二维数组的语法以及各种操作。

  2. 内存布局角度,要知道二维数组本质还是使用连续的内存空间存储,相当于把多个一维数组拼在一起使用。明确这一点,使得我们可以更好的理解C语言中多维数组的访问模式,以及写出性能更好的代码。

结合这两个角度,我们不仅能更有效地操作二维数组,还能更好地优化二维数组的相关代码。

二维数组的使用

Gn!

使用一个二维数组和一维数组在流程上并没有太大区别,依然需要经历声明和初始化的过程。如果是函数体内部声明,那么此二维数组仍然是一个局部变量数组。

声明

Gn!

二维数组的声明格式为:

二维数组的声明语法

例如,想要声明一个3行4列的int二维数组,可以这样写:

如果是局部变量数组,那么仅声明的情况下,元素都是随机值。此时数组是不可用的,还需要经过初始化。

初始化

Gn!

在声明的同时,可以为二维数组的元素赋值初始化。初始化可以是部分的,也可以是完整的,主要有以下形式:

  1. 完整初始化:

    二维数组完整初始化-语法示例
  2. 省略(矩阵)行数:

    在初始化时,你可以不指定行数,让编译器根据提供的数据自动计算:

    二维数组省略行数初始化-语法示例

    编译器会自动计算出这是一个3x4的二维数组(矩阵)。

  3. 部分初始化:

    如果只初始化部分元素,其他未指定的元素会默认初始化为0(对于基本数据类型)。

    二维数组部分初始化-语法示例

    这里,只有arr[0][0]、arr[0][1]、arr[1][0]、arr[1][1]被初始化,其他元素的值都是0。(arr[2]的所有元素都是0)

  4. 初始化为0值:

    如果希望所有元素都初始化为0值,则按照下列方式:

    二维数组全部初始化0-语法示例

    至少给出一个初始化式0,不能全部都省略。

同样的,除了上面给出的初始化形式,其余初始化形式都是错误的。不要随意尝试别的方式。

为什么在声明时可省略行,不能省略列?

Gn!

通过上述初始化形式,我们不难发现在初始化二维数组的过程:

  1. 可以省略行,但是不能省略列

  2. 不仅不可以省略列,而且列必须明确一个唯一值。

从内存的角度考虑,这意味着:

初始化一个二维数组时,必须明确指出子数组(列)的长度,且每个子数组(列)的长度是一致的。

那么这是为什么呢?

这是因为二维数组实际上是一种特殊的一维数组,其中每个元素都是一个一维数组:

  1. 必须先连续存储完第一个一维数组,然后存储第二个,第三个...此时必须要知道每个数组长度是多少,才可能采用这种方式存储。

  2. 二维数组的访问机制,决定了每个一维数组的长度必须是一致的。

二维数组元素的随机访问机制

那么二维数组中的元素是如何进行随机访问的呢?

实际上你既然知道二维数组本质仍然是一个一维数组,那么这一点就非常简单了,还是利用寻址公式。

例如一个二维数组的声明如下:

这意味着arr是一个有3个元素的数组,每个元素又是一个有4个整数的数组。在内存中,这个数组是这样存储的("行主序"):

行主序存储方式

当你尝试访问 arr[i][j]元素时,依然是随机访问的。对于二维数组,C 语言使用以下寻址公式计算其内存位置地址:

二维数组的寻址公式

其中:

  1. base_address是数组的基地址,实际上就是二维数组中第一个一维数组的第一个元素的地址。同样的,二维数组名在内存中就代表这个地址。

  2. cols_num是每行的元素数量,也就是列长(也就是一维数组的长度,这里是4)

  3. sizeof_element是数组元素的大小(在这里是int的大小,一般是4)。

为了保证上述计算的准确性,每个子数组(或说每一行)的长度必须保持一致。如果每行的元素数量变得不一致,该公式就无法准确地计算内存地址,从而导致错误的数组元素访问。

这就是为什么在 C 语言的标准二维数组中,每个一维数组的长度必须相同的原因。

以上,相信你对二维数组的理解在这里会更上一层楼。

二维数组元素的读/写

Gn!

当你声明并初始化一个二维数组后,就可以开始使用它了,这涉及到二维数组元素的读取和修改。

对于二维数组,你需要两个下标来定位一个特定的元素。你可以把这两个下标,理解为矩阵的行索引和列索引。

二维数组的元素读取和写入与一维数组相似,但需要两个下标:

二维数组元素的读/写-语法

其中,row_index 是元素所在的行索引,而 col_index 是元素所在的列索引。在C语言中,行索引和列索引都是从0开始计数。value是你想赋给该元素的值,当然也可以写一个表达式。

注意事项:

  1. 二维数组要通过两个索引进行访问,这使得访问非法索引的可能性增加。因此,当操作二维数组时,要特别注意确保两个索引都是合法的,因为程序并不会自动帮你进行索引的有效性检查。

  2. 与一维数组类似,二维数组中的元素,如果没有经过初始化,它们的值是不确定的。直接访问这些未经初始化的元素可能会导致程序行为异常。因此,一旦声明了二维数组,建议尽快对其所有元素进行初始化操作。

二维数组元素的循环遍历

Gn!

一维数组往往使用单层for循环遍历,那么二维数组就可以使用双层嵌套for循环来完成遍历。话虽如此,但我们还是需要思考里面的一些小细节:

  1. 如果外层循环遍历行,内层循环遍历列,这就是"行优先遍历"形式。

  2. 如果外层循环遍历列,内层循环遍历行,这就是"列优先遍历"形式。

我们应该怎么选择呢?

当然你首先要知道它们有啥区别:

  1. 行优先遍历,意味着先遍历完"矩阵"的一整行,再转而遍历下一行元素。也就是说,先遍历完二维数组中的第一个一维数组,再遍历第二个一维数组...

  2. 列优先遍历,意味着先遍历完"矩阵"的一整列,再转而遍历下一列元素。也就是说,先访问第一个子数组的第一个元素,再访问第二个子数组的第一个元素...先把所有子数组的第一个元素访问,然后再访问所有子数组的第二个元素...

很明显,列优先遍历给我们的感觉是"跳着"将整个二维数组访问完毕。实际上由于内存连续物理结构的客观条件,以及连续访问可以利用缓存加速的性能优势,行优先的效率是更高的,是更推荐的遍历方式。

假设我们有一个二维数组"int arr[3][4];"。如果想遍历每个元素,示例代码如下:

二维数组遍历-示例代码

注意代码当中的宏定义,当然你可以使用宏函数来计算数组长度,但对于二维数组这会稍嫌麻烦一些,如下:

二维数组遍历-示例代码2

循环过程中,要注意边界值,谨防不合法的索引出现。

The End