第八章 数组
1.数组的基本操作
和结构体类似,数组(Array)也是一种复合数据类型,它由一系列相同类型的元素(Element)组成。例如定义一个由4个整数组成的数组count:int count[4];
和结构体成员类似,数组count的4个元素的存储空间也是相邻的。结构体的成员可以是基本数据类型,也可以是复合数据类型,数组中的元素也是如此。根据组合规则,我们可以定义一个由4个结构体元素组成的数组:
Struct Complex { double x, y; } a[4];
也可以定义一个包含数组成员的结构体:
Struct { double x, y; int count[4]; } s;
数组类型的长度应该用一个常量表达式来指定,而且这个常量表达式的值必须是整数类型的,这一点和case后面跟的常量表达式的要求相同。数组中的元素通过下标(或者叫索引,Index)来访问。例如前面定义的由4个整数组成的数组count图示如下:
整个数组占了4个整数的存储单元,这四个单元分别用count[0]、count[1]、count[2]、count[3]来访问。注意,在定义数组int count[4];时,方括号(Bracket)中的数字4表示数组的长度,而在访问数组时,方括号中的数字表示方括号中的数字表示访问数组的第几个元素。数组元素是从“第0个”开始数的。这种数组下标的表达式不仅可以表示存储位置中的值,也可以表示存储位置本身,也就是说可以做左值,因此以下语句都是正确的:
数组的下标也可以是表达式,但表达式的值必须是整型或字符型的。例如:
使用数组下标不能超出数组的长度范围,这一点在使用变量做数组下标时尤其要注意。C编译器并不检查count[-1]或是count[100]这样的访问越界错误,编译时能顺利通过,所以属于运行时错误。
数组也可以像结构体一样初始化,未赋初值的元素也是用0来初始化,例如:
int count[4] = { 3, 2, };
则count[0]等于3,count[1]等于2,后面两个元素等于0。如果定义数组的同时初始化它,也可以不指定数组的长度,例如:
int count[] = { 3, 2, 1, };
编译器会根据Initializer有三个元素确定数组的长度为3。下面举一个完整的例子:
这个例子通过循环把数组中的每个元素依次访问一边,在计算机术语中称为遍历(Traversal)。注意控制表达式i <4,如果写成i <= 4就错了,因为count[4]是访问越界。
数组和结构体虽然有很多相似之处,但也有一个显著的不同:数组不能相互赋值。例如这样是错误的:
既然不能互相赋值,也就不能用数组类型作为函数的参数或返回值。如果写出这样的函数定义:
然后这样调用:
编译器也不会报错,但这样写并不是传一个数组类型参数的意思。对于数组类型有一条特殊规则:数组名做右值使用时,自动转换成指向数组首元素的指针。所以上面的函数调用其实是传一个指针类型的参数,而不是数组类型的参数。
2.数组应用实例:统计随机数
计算机执行每一条指令的结果都是确定的,没有一条指令产生的是随机数,调用C标准库得到的随机数其实是伪随机数(Pseudorandom)数,是用数学公式算出来的确定的数,只不过这些数看起来很随机,并且从统计意义上也很接近均匀分布(Uniform Distribution)的随机数。
C标准库中生成伪随机数的是rand函数,使用这个函数需要包含头文件stdlib.h,它没有参数,返回值是一个介于0和RAND_MAX之间的接近均匀分布的整数。RAND_MAX是头文件中定义的一个常量,在不同的平台上有不同的取值,但可以肯定它是一个非常大的整数。
生成并打印随机数:
这里有一种语法:用#define定义一个常量。实际上编译器的工作分为两个步骤,先是预处理(Preprocess),然后才是编译,用gcc的-E选项可以看到预处理之后、编译之前的程序:
可见在这里预处理器做了两件事情,一是把头文件stdio.h和stdlib.h在代码中展开,二是把#define定义的标识符N替换成它的定义20(在代码中做了三处替换,分别位于数组的定义中和两个函数中)。像#include和#define这种以#号开头的语法元素称为预处理指示(Preprocessing Directive),以后我们还要学习一些预处理指示。此外,用cpp main.c命令也可以达到同样的效果,只做预处理而不编译,cpp表示C preprocessor。
#define定义的常量和枚举定义的常量有什么区别呢?首先,define不仅用于定义常量,也可以定义更复杂的语法结构,称为宏(Macro)定义;其次,define定义实在预处理阶段处理的,而枚举实在编译阶段处理的。
完整的统计随机数的分布程序如下:
注意,我们把#define N的值改为100000,相当于把整个程序中所有用到N的地方都改为100000了。与之相反的做法称为硬编码(Hard coding):在定义数组时直接写成int a[20],在每个循环中也直接使用20这个值。如果原来的代码是硬编码的,那么一旦需要把20改成100000就非常麻烦,你需要找遍整个代码,判断哪些20表示这个数组的长度就改为100000,哪些20表示别的数量则不做改动,如果代码很长,这是很容易出错的。所以写代码时应尽可能避免硬编码,这其实也是一个“提取公因式”的过程,避免一个地方的改动波及到大的范围。这个程序的运行结果如下:
3.数组应用实例:直方图
继续上面的例子,我们统一列0~9的随机数,打印每个数字出现的次数,像这样的统计结果称为直方图(Histogram)。有时候我们并不是想打印,更想把统计结果保存下来以便做后续处理。我们可以把程序改成这样:
这显然太繁琐了。要是这样的随机数有100个呢?显然这里用数组最合适不过了:
有意思的是,这里的循环变量i有两个作用,一是作为参数传给howmany函数,统计数字i出现的次数,二是做histogram的下标,也就是“把数字i出现的次数保存在数组histogram的第i个位置”。
尽管上面的方法可以准确地得到统计结果,但是效率很低,这100000个随机数需要从头到尾检查十遍,每一遍检查只统计一种数字的出现次数。其实可以把histogram中的元素当作累加器来用,这些随机数只需要从头到尾检查一遍(Single Pass)就可以得出结果:
首先把histogram的所有元素初始化为0,注意使用局部变量的值之前一定要初始化,否则值是不确定的。接下来的代码很有意思,在每次循环中,a[i]就是出现的随机数,而这个随机数同时也是histogram的下标,这个随机数每出现一次就把histogram中相应的元素加1。
把上面的程序运行几遍,你会发现每次产生的随机数都是一样的,不仅如此,在别的计算机上运行该程序产生的随机数很可能也是这样的。这正说明了这些数是伪随机数,是用一套确定的公式基于某个初值算出来的,只要初值相同,随后的整个数列就都相同。实际应用中不可能使用每次都一样的随机数,例如开发一个麻将游戏,每次运行这个游戏摸到的牌不应该是一样的。因此,C标准库允许我们自己指定一个初值,然后在此基础上生成伪随机数,这个初值称为Seed,可以用srand函数指定Seed。通常我们通过别的途径得到一个不确定的数作为Seed,例如调用time函数得到当前系统时间距1970年1月1日00:00:00的秒钟数,然后传给srand:
srand(time(NULL));
然后再调用rand,得到的随机数就和刚才完全不同了。调用time函数需要包含头文件time.h,这里的NULL表示空指针。
4.字符串
字符串可以看作一个数组,它的元素是字符型的,例如字符串“Hello,world. ”图示如下:
注意末尾有一个字符‘ ’表示字符串结束。这里的 是ASCII码的八进制表示,也就是ASCII码为0的那个字符。前面用过的数组都有一个数组名,数组元素可以通过数组名加下标的方式访问。而字符串字面值也可以像数组名一样使用,可以加下标访问其中的字符:
但是通过下标修改其中的字符却是不允许的:
这行代码会产生编译错误,说字符串字面值是只读的,不允许修改。字符串字面值还有一点和数组名类似,做右值使用时自动转换成向首元素的指针,所以printf(“hello world”)其实是传一个指针参数给printf。
前面讲过数组可以像结构体一样初始化,如果是字符数组,也可以用一个字符串字面值来初始化:
相当于:
str的后四个元素没有指定,自动初始化为0,即‘ ’字符。注意,虽然字符串字面值“Hello“是只读的,但用它初始化的数组str却是可读可写的。数组str保存了一串字符,以‘ ’结尾,也可以叫字符串。本节字符串这个概念指的是以’ ’结尾的一串字符,可能是像str这种数组,也可能是像”Hello“这种字符串字面值。
如果用于初始化的字符串字面值比数组还长,比如:
则数组str只包含字符串的前10个字符,不包含‘ ’。这种情况编译器一般会给出警告。如果要用一个字符串字面值准确地初始化一个字符数组,最好的办法是不指定数组的长度,而让编译器自动计算:
字符串字面值的长度包括‘ ’在内一共15个字符,编译器会确定数组str的长度为15。
补充一点,printf函数的格式化字符串中可以用%s表示字符串的占位符。在学字符串数组以前,我们用%s没什么意义,因为
还不如写成:
但现在字符串可以保存在一个数组里面,用%s来打印就很有必要了:
printf会从数组str的开头一直打印到‘ ’字符为止(‘ ’本身不打印)。这其实是一个危险的信号:如果数组str中没有’ ’,那么printf就会打印出界,后果和前面讲的数组访问越界一样诡异:有时候打印出乱码,有时候看起来没有错误,有时候引起程序崩溃。
5.多维数组
就像结构体可以嵌套一样,数组也可以嵌套,一个数组的元素可以是另外一个数组,这样就构成了多维数组(Multi-dimensional Array)。例如定义并初始化一个二位数组:
数组a有3个元素,a[0]、a[1]、a[2]。每个元素也是一个数组,例如a[0]是一个数组,它有两个元素a[0][0]、a[0][1],这两个元素的类型是int,值分别是1,2,同理,数组a[1]的两个元素是3、4,数组a[2]的两个元素是5、0。如下图所示:
从概念模型上看,这个二维数组是三行两列的表格,元素的两个下标分别是行号和列号。从物理模型上看,这六个元素在存储器中仍然是连续存储的,就像一维数组一样,相当于把概念模型的表格一行一行接起来拼成一串,C语言的这种存储方式称为Row-major方式,而有些编程语言(例如FORTRAN)是把概念模型的表格一列一列接起来拼起一串存储的,称为Column-major方式。
多维数组也可以像嵌套结构体一样,用嵌套Initializer初始化,例如上面的二维数组也可以这样初始化:
注意,除了第一维的长度可以由编译器自动计算而不需要指定,其余各维都必须明确指定长度。如果是字符数组,也可以嵌套使用字符串字面值做Initializer,例如:
这个程序之所以简洁,是因为用数据代替了代码。具体来说,通过下标访问字符串组成的数组可以代替一堆case分支判断,这样就可以把每个case里重复的代码(printf调用)提取出来,从而又一次达到了“提取公因式”的效果。这种方法称为数据驱动的编程(Data-driven Programming),写代码最重要的是选择正确的数字据结构来组织信息,设计控制流程和算法尚在其次,只要数据结构选择得正确,其他代码自然而然就变得容易理解和维护了,就像这里得printf自然而然就被提取出来了。【人月神话】中说过:“Show me your flowcharts and conceal your tables,and I shall continue to be mystified.Show me your tables,and I won’t usually need your flowcharts; they’ll be obvious.”