数组
本专题介绍 C 语言中一维数组和多维数组的声明与使用,并讨论 C99 中的变长数组。
参考资料:《C 语言程序设计 · 现代方法 第 2 2 2 版》
前面专题中所见的变量都只是标量( scalar \text{scalar} scalar),标量具有保存单一数据项的能力。C 语言也支持聚合( aggregate \text{aggregate} aggregate)变量,这类变量可以存储一组一组的数值。在 C 语言中一共有两种聚合类型:数组( array \text{array} array)和结构( structure \text{structure} structure)。
1. 一维数组
数组是含有多个数据值的数据结构,并且每个数据值具有相同的数据类型。这些数据值称为元素( element \text{element} element),可以根据元素在数组中所处的位置把它们一个个地选出来。
最简单的数组类型就是一维数组,一维数组中的元素一个接一个地编排在单独一行内。这里可以假设有一个名为 a
的一维数组:
为了声明数组,需要指明数组元素的类型和数量。例如,为了声明数组 a
有
10
10
10 个 int
类型的元素,可以写成
int a[10];
数组的元素可以是任何类型,数组的长度可以用任何(整数)常量表达式(见专题四)指定。因为程序以后改变时可能需要调整数组的长度,所以较好的方法是用宏来定义数组的长度:
#define N 10
...
int a[N];
1.1 数组下标
为了存取特定的数组元素,可以在写数组名的同时在后边加上一个用方括号围绕的整数值,称这是对数组取下标(
subscripting
\text{subscripting}
subscripting)或进行索引(
indexing
\text{indexing}
indexing)。数组元素始终从
0
0
0 开始,所以长度为
n
n
n 的数组元素的索引是从
0
0
0 到
n
−
1
n - 1
n−1。例如,如果 a
是含有
10
10
10 个元素的数组,那么这些元素可以如下所示依次标记:
形如 a[i]
的表达式是左值(见专题三),所以数组元素可以像普通变量一样使用。一般说来,如果数组包含
T
T
T 类型的元素,那么数组中的每个元素均视为
T
T
T 类型的变量:
a[0] = 1;
printf("%d\n", a[5]);
++a[i];
数组通常和 for
循环结合在一起使用。下面给出了在长度为
N
N
N 的数组 a
上的一些常见操作示例:
for (i = 0; i < N; i++)
a[i] = 0; /* clears a */
for (i = 0; i < N; i++)
scanf("%d", &a[i]); /* reads data into a */
for (i = 0; i < N; i++)
sum += a[i]; /* sums the elements of a */
C 语言不要求检查下标的范围。当下标超出范围时,程序可能执行不可预知的行为。下标超出范围的原因之一是:忘记了 n n n 元数组的索引是从 0 0 0 到 n − 1 n - 1 n−1,而不是从 1 1 1 到 n n n。考虑下面这段代码:
int a[10], i;
for (i = 1; i <= 10; i++)
a[i] = 0;
对于某些编译器来说,这个表面上正确的 for
语句却产生了一个无限循环!当变量 i
的值变为
10
10
10 时,程序将数值
0
0
0 存储在 a[10]
中。但是 a[10]
这个元素并不存在,所以在元素 a[9]
后数值
0
0
0 立刻进入内存。如果内存中变量 i
放置在 a[9]
的后边,那么变量 i
将会被重置为
0
0
0,进而导致循环重新开始。
数组下标可以是任何整数表达式:
a[i+j*10] = 0;
表达式甚至可能会有副作用:
i = 0;
while (i < N)
a[i++] = 0;
当数组下标有副作用时一定要注意。例如,下面这个循环想把数组 b
中的元素复制到数组 a
中,但它可能无法正常工作:
i = 0;
while (i < N)
a[i] = b[i++];
表达式 a[i] = b[i++]
会导致未定义的行为。通过从下标中移走自增操作可以很容易避免此类问题的发生:
for (int i = 0; i < N; i++)
a[i] = b[i];
程序
reverse.c
:数列反向
要求用户录入一串数,然后按反向顺序输出这些数。
/* Reverses a series of numbers */
#include <stdio.h>
#define N 10
int main(void)
{
int a[N], i;
printf("Enter %d numbers: ", N);
for (int i = 0; i < N; i++)
scanf("%d", &a[i]);
printf("In reverse order:");
for (int i = N - 1; i >= 0; i--)
printf(" %d", a[i]);
printf("\n");
return 0;
}
这个程序的运行过程如下(用户的输入用下划线标注):
Enter 10 numbers:
34
82
49
102
7
94
23
11
50
31
‾
\underline{34\ 82\ 49\ 102\ 7\ 94\ 23\ 11\ 50\ 31}
34 82 49 102 7 94 23 11 50 31
In reverse order: 31 50 11 23 94 7 102 49 82 34
1.2 数组初始化
数组初始化式( array initializer \text{array initializer} array initializer)最常见的格式是用一个大括号括起来的常量表达式列表,常量表达式之间用逗号进行分隔:
int a[10] = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
如果初始化式比数组短,那么数组中剩余的元素赋值为 0 0 0:
int a[10] = {1, 2, 3, 4, 5, 6};
/* initial value of a is {1, 2, 3, 4, 5, 6, 0, 0, 0, 0} */
利用这一特性,可以很容易地把数组初始化为全 0 0 0:
int a[10] = {0};
/* initial value of a is {0, 0, 0, 0, 0, 0, 0, 0, 0, 0} */
初始化式完全为空是非法的,所以要在大括号内放上一个
0
0
0。初始化式比要初始化的数组长也是非法的。
如果给定了初始化式,可以省略掉数组的长度:
int a[] = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
编译器利用初始化式的长度来确定数组的大小。数组仍然有固定数量的元素,这跟明确地指定长度效果一样。
1.3 指定初始化式(C99)
经常有这样的情况:数组中只有相对较少的元素需要进行显式的初始化,而其他元素可以进行默认赋值。考虑下面这个例子:
int a[15] = {0, 0, 29, 0, 0, 0, 0, 0, 0, 7, 0, 0, 0, 0, 48};
我们希望数组元素 2 2 2 为 29 29 29,元素 9 9 9 为 7 7 7,元素 14 14 14 为 48 48 48,而其他元素为 0 0 0。对于大数组,如果使用这种方式赋值,将是冗长和容易出错的。
C99 中的指定初始化式可以用于解决这一问题。上面的例子可以使用指定初始化式写为:
int a[15] = {[2] = 29, [9] = 7, [14] = 48};
括号中的数字称为指示符。
除了可以使赋值变得更简短、更易读之外,指定初始化式还有一个优点:赋值的顺序不再是一个问题,我们可以将上面的例子重新写为:
int a[15] = {[14] = 48, [2] = 29, [9] = 7};
指示符必须是整型常量表达式。如果待初始化的数组长度为 n n n,则每个指示符的值都必须在 0 0 0 到 n − 1 n - 1 n−1 之间。但是,如果数组的长度是省略的,指示符可以是任意非负整数,此时编译器将根据最大的指示符推断出数组的长度。
初始化式中可以同时使用老方法(逐个元素初始化)和新方法(指定初始化式):
int c[10] = {5, 1, 9, [4] = 3, 7, 2, [8] = 6};
这个初始化式指定数组的前三个元素值为 5 5 5、 1 1 1 和 9 9 9,元素 4 4 4 的值为 3 3 3,其后两个元素为 7 7 7 和 2 2 2,最后元素 8 8 8 的值为 6 6 6,而没有指定值的元素均赋予默认值 0 0 0。
程序
repdigit.c
:检查数中重复出现的数字
该程序用来检查数中是否有出现多于一次的数字。
/* Checks numbers for repeated digits */
#include <stdbool.h> /* C99 only */
#include <stdio.h>
int main(void)
{
bool digit_seen[10] = {false};
int digit;
long n;
printf("Enter a number: ");
scanf("%ld", &n);
while (n > 0) {
digit = n % 10;
if (digit_seen[digit])
break;
digit_seen[digit] = true;
n /= 10;
}
if (n > 0)
printf("Repeated digit\n");
else
printf("No repeated digit\n");
return 0;
}
这个程序的运行过程如下(用户的输入用下划线标注):
Enter a number:
28212
‾
\underline{28212}
28212
Repeated digit
1.4 对数组使用 sizeof
运算符
运算符 sizeof
可以确定数组的大小(字节数)。如果数组 a
有
10
10
10 个整数,那么 sizeof(a)
通常为
40
40
40(假定每个整数占
4
4
4 个字节)。
还可以用 sizeof
来计算数组元素(如 a[0]
)的大小。用数组的大小除以数组元素的大小可以得到数组的长度:sizeof(a) / sizeof(a[0])
。例如,数组 a
的清零操作可以写成如下形式:
for (i = 0; i < sizeof(a) / sizeof(a[0]); i++)
a[i] = 0;
有些编译器会对表达式 i < sizeof(a) / sizeof(a[0])
给出一条警告消息,因为变量 i
的类型可能是 int
,而 sizeof
返回的值类型为 size_t
(一种无符号类型)。为了避免出现这一警告,可以把 sizeof(a) / sizeof(a[0])
强制转换为有符号整数:
for (i = 0; i < (int) (sizeof(a) / sizeof(a[0])); i++)
a[i] = 0;
表达式 (int) (sizeof(a) / sizeof(a[0]))
写起来不太方便,定义一个宏来表示它常常是很有帮助的:
#define SIZE ((int) (sizeof(a) / sizeof(a[0])))
for (i = 0; i < SIZE; i++)
a[i] = 0;
程序
interest.c
:计算利息
下面这个程序显示一个表格,该表格显示了在几年时间内 100 100 100 美元投资在不同利率下的价值。
/* Prints a table of compound interest */
#include <stdio.h>
#define NUM_RATES ((int) (sizeof(value) / sizeof(value[0])))
#define INITIAL_BALANCE 100.00
int main(void)
{
int i, low_rate, num_years, year;
double value[5];
printf("Enter interest rate: ");
scanf("%d", &low_rate);
printf("Enter number of years: ");
scanf("%d", &num_years);
printf("\nYears");
for (i = 0; i < NUM_RATES; i++) {
printf("%6d%%", low_rate + i);
value[i] = INITIAL_BALANCE;
}
printf("\n");
for (year = 1; year <= num_years; year++) {
printf("%3d ", year);
for (i = 0; i < NUM_RATES; i++) {
value[i] += (low_rate + i) / 100.0 * value[i];
printf("%7.2f", value[i]);
}
printf("\n");
}
return 0;
}
这个程序的运行过程如下(用户的输入用下划线标注):
Enter interest rate:
6
‾
\underline{6}
6
Enter number of years:
5
‾
\underline{5}
5
Years 6% 7% 8% 9% 10%
1 106.00 107.00 108.00 109.00 110.00
2 112.36 114.49 116.64 118.81 121.00
3 119.10 122.50 125.97 129.50 133.10
4 126.25 131.08 136.05 141.16 146.41
5 133.82 140.26 146.93 153.86 161.05
2. 多维数组
数组可以有任意维数。下面的声明产生一个二维数组(数学上的术语称为矩阵):
int m[5][9];
数组 m
有
5
5
5 行
9
9
9 列。如下所示,数组的行和列下标都从
0
0
0 开始索引:
为了访问 i
行 j
列的元素,需要写成 m[i][j]
的形式。表达式 m[i]
指明了数组 m
的第 i
行,而 m[i][j]
则选择了此行中的第 j
个元素。
虽然我们以表格形式显示二维数组,但是实际上它们在计算机的内存中不是这样存储的。C 语言是按照行主序存储数组的,也就是从第
0
0
0 行开始,接着第
1
1
1 行,依次类推。例如,下面显示了数组 m
的存储:
就像 for
循环和一维数组紧密结合一样,嵌套的 for
循环是处理多维数组的理想选择。例如,初始化用作单位矩阵的数组:
#define N 10
double ident[N][N];
int row, col;
for (row = 0; row < N; row++)
for (col = 0; col < N; col++)
if (row == col)
ident[row][col] = 1.0;
else
ident[row][col] = 0.0;
C 语言中的多维数组扮演的角色相对较弱,因为 C 语言为存储多维数组提供了更加灵活的方法:指针数组(见专题十二)。
2.1 多维数组初始化
通过嵌套一维初始化式的方法可以产生二维数组的初始化式:
int m[5][9] = {{1, 1, 1, 1, 1, 0, 1, 1, 1},
{0, 1, 0, 1, 0, 1, 0, 1, 0},
{0, 1, 0, 1, 1, 0, 0, 1, 0},
{1, 1, 0, 1, 0, 0, 0, 1, 0},
{1, 1, 0, 1, 0, 0, 1, 1, 1}};
每一个内部初始化式提供了矩阵中一行的值。为高维数组构造初始化式可采用类似的方法。C 语言为多维数组提供了多种方法来缩写初始化式。
- 如果初始化式没有大到足以填满整个多维数组,那么把数组中剩余的元素赋值为 0 0 0。
- 如果内层的列表没有大到足以填满数组的一行,那么把此行剩余的元素初始化为 0 0 0。
- 甚至可以省略掉内层的花括号,此时编译器会按照行主序对多维数组进行填充。
在多维数组中省略掉内层的花括号可能是很危险的,因为额外的元素(更糟的情况是丢失的元素)将会影响剩下的初始化式。省略花括号会引起某些编译器产生类似 “ missing braces around initializer \text{missing braces around initializer} missing braces around initializer” 这样的警告消息。
C99 的指定初始化式对多维数组也有效。例如,可以这样创建 2 × 2 2 \times 2 2×2 的单位矩阵:
double ident[2][2] = {[0][0] = 1.0, [1][1] = 1.0};
2.2 常量数组
无论一维数组还是多维数组,都可以通过在声明的最开始处加上单词 const
而成为 “常量”。程序不应该对声明为 const
的数组进行修改,编译器能够检测到直接修改某个元素的意图。
把数组声明为 const
有两个主要的好处。它表明程序不会改变数组,这对于以后阅读程序的人可能是有价值的信息。它还有助于编译器发现错误 —— const
会告诉编译器我们不打算修改数组。
const
类型限定符(见专题十七)不限于数组,它可以和任何变量一起使用。
程序
deal.c
:发牌
程序负责从一副标准纸牌中随机抽取用户所需的数量并发放给用户。该程序需要解决三个问题:
- 如何从一副牌中随机抽取纸牌?
- 如何避免两次抽到同一张牌?
- 如何把纸牌的等级和花色翻译成字符格式?
为了随机抽取纸牌,可以采用一些 C 语言的库函数(见专题二五)。time
函数返回当前的时间,用一个数表示。srand
函数初始化 C 语言的随机数生成器。通过把 time
函数的返回值传递给函数 srand
可以避免程序在每次运行时发同样的牌。rand
函数在每次调用时会产生一个看似随机的数。通过采用运算符 %
,可以缩放 rand
函数的返回值,使其落在
0
∼
3
0 \sim 3
0∼3(用于表示花色)的范围内,或者是落在
0
∼
12
0 \sim 12
0∼12(用于表示等级)的范围内。
为了避免两次都拿到同一张牌,需要记录已经选择过的牌。程序将采用一个名为 in_hand
的二维数组,数组有
4
4
4 行(每行表示一种花色)和
13
13
13 列(每列表示一种等级)。换句话说,数组中的每个元素对应着
52
52
52 张纸牌中的一张。在程序开始时,所有数组元素都为假。每次随机抽取一张纸牌时,将检查数组 in_hand
中的对应元素为真还是为假。如果为真,那么就需要抽取其他纸牌;如果为假,则把 true
存储到与这张纸牌相对应的数组元素中,以提醒我们这张纸牌已经抽取过了。
一旦证实纸牌是 “新” 的(还没有选取过),就需要把牌的等级和花色数值翻译成字符,然后显示出来。为了把纸牌的等级和花色翻译成字符格式,程序将设置两个字符数组(一个用于纸牌的等级,另一个用于纸牌的花色),然后用等级和花色对数组取下标。这两个字符数组在程序执行期间不会发生改变,所以也可以把它们声明为 const
。
/* Deals a random hand of cards */
#include <stdbool.h>
#include <stdio.h>
#include <stdlib.h>
#include <time.h>
#define NUM_SUITS 4
#define NUM_RANKS 13
int main(void)
{
bool in_hand[NUM_SUITS][NUM_RANKS] = {false};
int num_cards, rank, suit;
const char rank_code[] = {'2', '3', '4', '5', '6', '7',
'8', '9', 'T', 'J', 'Q', 'K', 'A'};
const char suit_code[] = {'C', 'D', 'H', 'S'};
srand((unsigned) time(NULL));
printf("Enter number of cards in hand: ");
scanf("%d", &num_cards);
printf("Your hand:");
while (num_cards > 0) {
suit = rand() % NUM_SUITS; /* picks a random suit */
rank = rand() % NUM_RANKS; /* picks a random rank */
if (!in_hand[suit][rank]) {
in_hand[suit][rank] = true;
num_cards--;
printf(" %c%c", rank_code[rank], suit_code[suit]);
}
}
printf("\n");
return 0;
}
这个程序的运行过程如下(用户的输入用下划线标注):
Enter number of cards in hand:
5
‾
\underline{5}
5
Your hand: 5D TS 2D JC TH
3. C99 中的变长数组
前面说到,数组变量的长度必须用常量表达式进行定义。但是在 C99 中,有时候也可以使用非常量表达式。
程序
reverse2.c
:数列反向(改进版)
/* Reverses a series of numbers using a variable - length array */
#include <stdio.h>
int main(void)
{
int i, n;
printf("How many numbers do you want to reverse? ");
scanf("%d", &n);
int a[n];
printf("Enter %d numbers: ", n);
for (int i = 0; i < n; i++)
scanf("%d", &a[i]);
printf("In reverse order:");
for (int i = n - 1; i >= 0; i--)
printf(" %d", a[i]);
printf("\n");
return 0;
}
上面程序中的数组 a
是一个变长数组(
variable-length array
\text{variable-length array}
variable-length array,简称
VLA
\text{VLA}
VLA)。变长数组的主要优点是程序员不必在构造数组时随便给定一个长度,程序在执行时可以准确地计算出所需的元素个数。如果让程序员来指定长度,数组可能过长(浪费内存)或过短(导致程序出错)。
变长数组的长度不一定要用变量来指定,任意表达式(可以含有运算符)都可以。例如:
int a[3*i+5];
int b[j+k];
像其他数组一样,变长数组也可以是多维的:
int c[m][n];
变长数组的主要限制是它们没有静态存储期限(见专题十七),另一个限制是变长数组没有初始化式。