Menu

C 语言二维数组:多维数组、内存布局和矩阵

如何在 C 中声明、初始化和遍历二维数组,行优先布局在内存中到底意味着什么,为什么把二维数组传给函数时必须写明列数,以及一个完整的矩阵示例。

本页包含可运行的编辑器。编辑、运行,立即查看输出。

多维数组就是元素本身也是数组的数组。int grid[3][4]; 并不是一种特殊的网格类型,它是三个元素,每个元素都是一个包含四个 int 的数组,依次紧挨着存放。一旦理解了这一点,这个主题的其他内容就顺理成章了:内存布局、下标计算,以及把它们传给函数时那条乍看令人费解的规则。

二维几乎涵盖了所有实际用途:网格、表格、矩阵、游戏棋盘、图像,所以本页主要讲二维数组。

声明和初始化

int grid[3][4];        // 3 rows, 4 columns - 12 ints

第一个数字是行数,第二个是列数。初始化时可以平铺着写,也可以用内层花括号;建议使用花括号,因为它能体现数组的形状。

int e[][3] 这种形式很重要:你可以不写行数,让初始化列表来决定,但列数永远不能省略。下一节会解释原因。

行优先布局

C 按行优先顺序存储二维数组:先是完整的第 0 行,然后是完整的第 1 行,依此类推,放在一整块连续的内存中。背后并没有一个由行指针组成的数组。

int grid[3][4] = {{ 1, 2, 3, 4},
                  { 5, 6, 7, 8},
                  { 9,10,11,12}};

how you picture it            how it actually sits in memory
+----+----+----+----+
|  1 |  2 |  3 |  4 |   row 0   +--+--+--+--+--+--+--+--+--+--+--+--+
+----+----+----+----+           | 1| 2| 3| 4| 5| 6| 7| 8| 9|10|11|12|
|  5 |  6 |  7 |  8 |   row 1   +--+--+--+--+--+--+--+--+--+--+--+--+
+----+----+----+----+            \__ row 0 __/\__ row 1 __/\_ row 2 _/
|  9 | 10 | 11 | 12 |   row 2
+----+----+----+----+           grid[i][j] is at element index i*4 + j

i * columns + j 这个公式就是全部的机制,这也是为什么编译器必须知道列数才能访问任何元素。行数从不参与这个计算。

打印地址就能直接看到这种布局:

地址按 sizeof(int) 递增,中间没有空隙,一行结束、下一行开始的地方也是如此。展平后的循环证明了这一点:flat[k] 把十二个元素当作一个连续序列依次访问。

这种布局还会影响性能,值得了解:先遍历行再遍历列,会按顺序访问内存,CPU 缓存喜欢这种访问方式。如果交换循环的嵌套顺序,让内层循环沿着一列向下走,每一步都会跳过一整行,在大数组上可能慢好几倍。

嵌套循环

二维数组需要两个 for 循环:外层循环选择行,内层循环扫过这一行的所有列。

按含义给计数器命名(行用 i/row,列用 j/col),并保持顺序一致:处处都写 grid[row][col]。二维数组的 bug 有一半是因为两个下标写反了。

用 #define 定义的大小也不是装饰:这样修改数组形状时,循环边界和声明就不会对不上。

把二维数组传给函数

这条规则几乎让每个人都栽过跟头:函数的参数必须声明列数。

原因在于退化。传递 grid 时,它会被转换成指向第一个元素的指针,而它的元素是行,所以类型是 int (*)[4]:指向包含 4 个 int 的数组的指针。为了让 grid[i][j] 有意义,编译器必须知道一行有多长,也就是这个 4。行数确实不在类型中,所以它要作为单独的参数传递。

注意,int (*grid)[COLS] 和 int grid[][COLS] 是同一个参数的两种写法。圆括号是必需的,因为 int *grid[COLS] 表示的是指针数组。这个区别在“指针与数组”一节中有介绍。

如果列数只有在运行时才知道,C99 的可变修改参数允许你先传入它:

void print_any(int rows, int cols, int grid[rows][cols]);

rows 和 cols 必须在使用它们的数组参数之前声明。如果无法使用这个特性,常见的替代方法是用一维的平铺数组,再自己计算下标:

data[i * cols + j] 正是编译器在固定大小的情况下替你写出的代码。手动计算只多花一行,却适用于运行时才确定的任何形状。

矩阵示例

矩阵乘法把本页的内容全部串了起来:在行优先存储上进行三层嵌套循环。

有两个细节值得借鉴。内层的 k 循环把 a[i][k] 和 b[k][j] 配对:一个下标沿着一行走,另一个沿着一列走。转置时内层循环从 j = i + 1 开始:如果从 0 开始,每一对元素都会被交换两次,矩阵就会保持不变。

三维及更高维

这种模式可以继续扩展,函数参数的规则也一样:除了第一维,每一维都必须声明。

实际上,到了三维,固定大小的数组就开始显得笨重了,大多数代码会改用带计算下标的平铺内存块,或者使用结构体数组,用名字说明每个轴的含义。

常见错误

  • 写成 grid[i, j]。 逗号运算符会先计算 i 并丢弃它,然后用 j 作为下标。代码能编译,但它是错的。请使用 grid[i][j]。
  • 下标写反。 grid[col][row] 会从错误的位置读到一个真实存在的元素,所以没有任何报错能帮你发现它。始终保持 [row][col] 的顺序。
  • 参数中省略列数。 void f(int grid[][]) 无法编译,这是编译器在帮你。
  • 越界访问。 和所有数组一样,这里没有边界检查。在 [3][4] 的网格上访问 grid[0][5],会悄悄读到 grid[1][1],因为内存布局是连续的,下标计算并不在乎。

常见问题

C 语言中怎么声明二维数组?

在方括号中给出两个大小:int grid[3][4]; 声明了 3 行 4 列,共 12 个 int。可以把它理解为“一个包含 3 个元素的数组,每个元素都是一个包含 4 个 int 的数组”,C 实际上就是这样存储它的。

C 语言中的二维数组在内存中是怎么存储的?

按行优先顺序存储:先是第 0 行的所有元素,然后是第 1 行的所有元素,依此类推,全部放在一块连续的内存中。grid[i][j] 位于从开头算起第 i * columns + j 个元素的位置,所以编译器需要知道的是列数。

C 语言中怎么把二维数组传给函数?

参数必须声明列数:void print(int grid[][4], int rows),或者等价地写成 void print(int (*grid)[4], int rows)。行数可以省略,因为数组会退化为指向一行的指针;但没有列数,编译器就算不出每一行从哪里开始。

可以把二维数组全部初始化为零吗?

可以:int grid[3][4] = {0}; 会把每个元素都设为零,因为没有列出的元素都会被初始化为零。int grid[3][4] = {{1, 2}}; 设置了第 0 行的前两个元素,其余十个都是零。

Coddy 编程语言插图

和 Coddy 一起学编程

开始