7.5 多维数组

7.5.1 二维数组的定义

例题:把下表中上下对齐的两个数加起来并输出。

7.5 多维数组 - 图1

在这个问题中,数据不但成组,而且呈现出一种二维的排列方式。这种问题用二维数组描述问题中的数据非常方便。

二维数组描绘的依然是成组的类型一样的数据。在上面的题目中你可以把其中的数据看成两个一维数组,也可以看成是两个一维数组组成的一个二维数组。

和一维数组一样,二维数组的定义如下。

■ 为全体元素取一个共同的名字。

■ 告诉编译器这个数组有几个元素。

■ 告诉编译器这个数组中每个元素的类型是什么。

上面表中共有2×4个数据,假如为全体元素取名为jiashu,由于每个元素都可以表示为int类型,所以这个二维数组可以定义为:

7.5 多维数组 - 图2

先来解读一下这个定义。

如果把这称为定义了一个由2×4个元素构成的名为jiashu的二维数组,就完全没有品会到C语言的那种细腻的妙味。这个定义应该这样解读。

首先它是定义jiashu这个标识符的,也就是说描述的是jiashu这个标识符的性质并为其开辟存储空间的。

由于jiashu后面紧跟着[2],这里[]是类型说明符,因此这说明jiashu是一个数组([])且是由2个元素组成的,如图7-5所示。

7.5 多维数组 - 图3

图7-5 jiashu的左值含义

由此可见,jiashu是一个数组,这就是jiashu这个表达式的左值含义,其类型是int [2][4];其右值是jiashu[0]这块内存起始单元的地址,类型是int [][4]。

“int jiashu[2][4];”中其余的部分用于说明这2个元素都是什么类型的,这2个元素的类型是int [4],也就是说是一维数组(因为有一个[4]),且这个一维数组是由4个元素组成的,这4个元素都是int类型,如图7-6所示。

7.5 多维数组 - 图4

图7-6 jiashu[0]和jiashu[1]是一维数组

由图7-5可见,jiashu[0]的左值含义也是一个数组,类型是int[4];其右值是jiashu[0][0]这块内存起始单元的地址,类型是int []。

这种一层套一层的关系反映了数组是一种构造类型数据,也在某种程度上反映了C语言构造数据的构造原则。比如在定义中有两个[]类型说明符,这两个当中应该先看左面的,因为[]运算的结合性是从左到右。尽管结合性是运算符的一种性质,但在类型说明符中依然适用。

因此,这个二维数组实际是由两个一维数组构成的一个一维数组,这个一维数组的两个元素分别是jiashu[0]和jiashu[1],由于它们都是一维数组,所以它们分别相当于这两个一维数组的数组名。

对这两个一维数组名进行[]运算得到的将是一个int类型的量。

也就是说从

7.5 多维数组 - 图5

这个定义中我们还可以读出这样的结果,即:jiashu进行[]运算得到的是一个由4个int构成的一维数组int[4],而jiashu进行两次[]运算得到的是一个int。

以上就是从代码反映出的二维数组的字面含义。其内存含义是:将为这个二维数组开辟一块连续的内存空间,大小为2*sizeof(int [4])。而且这2个int [4]类型的数据是依照下面顺序排列的

7.5 多维数组 - 图6

而在jiashu[0]中是4个int:jiashu[0][0]、jiashu[0][1]……jiashu[0][3],jiashu[1]中是jiashu[1][0]、jiashu[1][1]……jiashu[1][3]。

这个下标排列顺序和我们平时从小到大地数数一样:00、01、02、03、10、11、12、13。个位到头进位然后从头再来。

7.5.2 二维数组元素的赋初值

赋初值:

7.5 多维数组 - 图7

不提倡的形式:

7.5 多维数组 - 图8

这两种形式之所以不被提倡的原因是,数组元素初值被写多或写少,可能引起很严重的错误,而且这种错误很准查找、改正。所以还是老话,编程是为了漂亮地解决问题,不是为了用烦恼来折磨自己。

错误的形式:

7.5 多维数组 - 图9

最后一个定义的错误在于,对于数组来说只有左边第一个[]内的数是可以省略的。之所以强调这点是因为在写函数形参的时候也有类似的规则,至于其深层的原因,要到指针部分之后才能详细讨论。

对于二维数组或更高维的数组,这种赋初值的方法意义不是很大,尤其是在数组元素很多的情况下。因为一旦用这种方法指定了初值,代码就失去了一般性。所以,对于数据较多的情况,建议还是使用7.4.7小节的方法。

7.5.3 二维数组元素的引用和遍历

对于

7.5 多维数组 - 图10

所定义的二维数组,尽管可以把jiashu看成是由2个皆由4个int类型量所构成的一维数组构成的数组,但是由于在C语言中没有定义直接对数组总体进行加减乘除或赋值等运算,所以对二维数组的运算通常只能针对二维数组内的各个int数据逐个进行(这和一维数组的情况类似)。这些int类型的数据,需要对二维数组名进行两次“[]”运算才能得到。

此外,二维数组名作为函数的实参时,对应的形参的类型为int [][4],其中第二个“[]”中的“4”是必不可少的,否则会由于实参与形参类型不一致而引起错误。下面是前面题目的程序代码及输出结果。

程序代码7-11

7.5 多维数组 - 图11

7.5 多维数组 - 图12

7.5 多维数组 - 图13

这段代码至少有一点需要改进,就是其中的4应该使用符号常量。

练习

1.求一个二维数组中元素的最大、最小值,二维数组的大小和其中的元素初值自己指定。

2.在上下对齐的数中用大数减小数并输出。

7.5 多维数组 - 图14

7.5.4 更高维的数组

更高维的数组没有更多的语法内容,其构造方法和二维数组的构造方法在原则上是一致的。赋初值的方法也一样,只是可能要多用到几个{}。在多维数组做实参时,同样要注意到对应形参的类型,仅仅是形参后第一个[]内不写内容,后面的其余[]中应写与定义多维数组时[]内同样的内容。

多维数组在内存中元素的排列同样和日常生活中的那种记数方法类似,其下标总是低位的先变化。

在使用多维数组时同样要注意,只能对构成数组的基本元素(基本数据类型)进行操作。C语言没有对数组整体进行赋值、加、减、乘、除等运算,只能对数组的基本元素逐个地进行这种运算。尽管数组名有值,C语言也有关于数组名的相关运算,但那是比较复杂的语法内容,在后面将会详细讨论。

7.5.5 生命游戏(Game of Life)

1968年,剑桥大学的英国数学家John Horton Conway发明了一个生命游戏。一个二维矩形的M×N网格世界,这个世界中的每个方格居住着一个活着的或死了的细胞。一个细胞在下一个时刻的生死取决于相邻8个方格中活着的细胞的数量。如果相邻方格活着的细胞数量过多,这个细胞会因为资源匮乏而在下一个时刻死去;相反,如果周围的活细胞过少,这个细胞会因太孤单而死去。游戏的规则就是:当一个方格周围有2或3个活细胞时,方格中的状态不变;当一个方格周围有3个活细胞时,即使这个时刻方格中没有活细胞,在下一个时刻也会“诞生”一个活细胞。

这个游戏的有趣之处在于它能惊人地模拟出生命的生息演化:由于最初图案的不同,可以演化出现许多惊人和鲜活的图案,有的保持不动,有的则仿佛在不断地迁徙,有的在不停地闪烁,有的在不断衰变……,反复、翻转、蠕动、聚合、分裂、震荡、吞并、爆发、湮灭,似乎生命世界的所有现象在这里都可以得到通俗直观的演示。

据说,生命游戏是所有高级程序员的必修课。UNIX世界中的许多Hacker喜欢玩这个游戏,他们用字符代表一个细胞,在一个计算机屏幕上进行演化。著名的GNU Emacs编辑器中就包括这样一个小游戏。

写出这个游戏。

【分析】

首先考虑main()函数,这是一个源程序中最主要的部分,是思考的起点。这就是所谓结构化程序设计思想的具体体现。

显然,可以用一个二维char数组(shijie)在代码中表示这个虚拟的“网格世界”,而且只要把这个字符数组及其变化情况逐次地输出到标准输出设备,就可以成功地演示这个“世界”的变化。

二维char数组(shijie)的初值可以用不同的方式获得:在代码中赋值;通过调用标准函数rand()产生;用0介绍的输入重定向的办法从文件输入。

在代码中赋值的方法将使得代码仅对一种初始状态有效,要模拟其他初始状态必须修改代码里的初始数据然后再重新编译。对于数据较多的情况,这种办法烦琐、容易出错且难于检查。

通过调用标准函数rand()产生二维char数组(shijie)的初值,无法对初始状态进行选择与修改,只能听之任之。

所以相比之下,从文件输入的办法最为适宜。更何况数据与代码的分离本来就是现代程序设计的一个重要的基本原则。

解决了初值问题后,首先需要考虑的是main()函数。这个函数的主要任务是显示这个模拟的“世界”,并计算出下一时刻这个“世界”的状态,然后继续显示……

可以看出,这基本上构成了一个循环。所以还必须要设计循环的控制条件。

循环的控制条件也有多种选择,本例中选择人工控制,即每次输出后向程序使用者询问是否继续显示,如程序使用者选择[CR]则继续(这个对于程序使用者最方便),否则循环终止,程序结束。

这样就不难搭建出main()的基本框架,其N-S图如图7-7所示。

7.5 多维数组 - 图15

图7-7 main()的基本框架

在反复思考后,如果没有发现什么漏洞,可以给出main()的伪代码。最好是给出完全符合语言要求的伪代码,因为这样可以很方便地进行测试。

程序代码7-12

7.5 多维数组 - 图16

代码中把行数与列数用符号常量表示,体现的是一种专业素养。此外,这段代码还表明了什么是“自顶向下”。不难看出,这需要一定的抽象、概括思维能力。如果说,函数提供了进行这种思想概括的必要手段,那么,数组类型则是概括性地从总体上把握数据的一种技术保证。

尽管距离完成尚早,但不难发现程序代码7-12是完全可以运行的(死循环可以用[Ctrl+C]或[Ctrl+Break]组合键终止)。

main()有4个功能模块需要完成,通常先写最简单的或者最重要的部分。由于询问是否继续部分非常简单,所以先考虑这个部分。

这部分的功能是接受程序用户输入的一个字符,判断是否为[CR]。这部分代码非常简单,在此就不详述了。

读初始数据按照7.4.7介绍的方法进行。有两点需要说明:一是数据文件被设计成每行都如下所示

1234567890123456789023467890123456789012345678901234567890

其中的“*”表示细胞所在方格,其余的皆为没有细胞的方格,这样的数据文件非常容易编辑也特别容易在指定的方格处布置细胞。此外还应该注意到每行的最后都有一个看不见的'\n'。

另一点需要说明的是,在完成读入数据功能的函数的内部有一个字符常量的宏定义,这个宏定义被放在函数内部是考虑到它应该只在这个局部有效。但是所有的宏的有效区间都是从被定义处开始到源程序的结束,所以为了真正使得这个宏只在这个函数内部有效,还必须加一条取消宏定义的预处理命令#undef。这样做的好处是,以后修改这个宏对全局没有任何影响。

在屏幕上显示“世界”的函数很简单,逐行显示字符即可。唯一值得提一句的是“system("CLS");”,这是在调用操作系统的一个内部命令“CLS”清屏,每次都把上次显示的内容全部清空,再重新显示一幅新画面。实际上,电视机或电影显示的原理都是如此。

在计算下一时刻“世界”的样子的时候,由于涉及了两个时刻的“世界”,所以必须先制作一个先前世界状况的副本,然后根据这个副本计算下一时刻世界的状况。

程序代码7-13

7.5 多维数组 - 图17

7.5 多维数组 - 图18

7.5 多维数组 - 图19

7.5 多维数组 - 图20

这段代码有一个明显的不足之处,就是计算数组元素个数的表达式“sizeof sj[0]/sizeof sj[0][0]”显得有些繁复。改进的办法有两个。一是使用函数,但由于要计算一维数组和二维数组两种情况下的数组元素的个数,所以至少要写两个函数,函数方案的另一个缺点是引起了效率的损失。第二种办法是使用带参数的宏定义,具体如下:

7.5 多维数组 - 图21

这样,代码中的“sizeof sj[0]/sizeof sj[0][0]”可以写成GESHU(sj[0]),而“sizeof dqsj/sizeof dqsj[0]”可以写成GESHU(sj)。由于宏定义是在编译前对特定字符序列进行替换,所以这样的写法和原先的是一样的,不存在损失效率的问题。但这种写法的缺点是,在参数是比较复杂的表达式的情况下容易出错。