第5章 数组
5.1 问题从哪来#
上一章写了函数,能把"判断等级"这类逻辑打包复用。函数解决的是"一段逻辑反复使用"的问题。
还有一个问题没有解决:循环里读到的分数,处理完就丢了。以后想再排序、找中位数、重新打印成绩单,就没有地方把这些分数取回来。
所以这一章先处理一个更基础的问题:
30 个学生的成绩,怎么存?
一种笨办法:
int score1, score2, score3, score4, score5;
// ...一直写到 score30
30 个变量,光是声明就占半个屏幕。求平均分要写 score1 + score2 + ... + score30,改起来更痛苦。
另一种笨办法:每读一个分数,直接累加,不存下来。
int sum = 0; // 累加器初始化为 0
for (int i = 0; i < 30; i++) { // 循环 30 次
int score; // 临时存放每次读入的分数
scanf("%d", &score); // 读入一个整数
sum = sum + score; // 累加到总分
}平均分算得出来,但回头找最高分就没办法了——分数已经读过去,没有留下来。
程序需要一个地方,把一批同类型的数据整整齐齐地放在一起,随时可以回头查看。这就是数组。
5.2 先看一个例子#
假设输入 5 个分数:
72 55 88 43 91程序要做的事:
- 把 5 个分数存进数组。
- 遍历数组,找到最高分、最低分。
- 算出平均分。
- 打印结果。
5.3 最小实验#
先跑一个小实验,然后一段一段拆。
#include <stdio.h>
int main(void)
{
int scores[100]; // 最多存 100 个分数
int count = 0; // 当前实际存了多少个
int n; // 要读几个分数
printf("How many scores? ");
if (scanf("%d", &n) != 1) {
printf("Invalid input\n");
return 1;
}
if (n < 1 || n > 100) {
printf("Count must be between 1 and 100\n");
return 1;
}
// 逐个读入分数,放进数组
for (int i = 0; i < n; i++) {
printf("Score %d: ", i + 1);
if (scanf("%d", &scores[i]) != 1) {
printf("Invalid input\n");
return 1;
}
count = count + 1; // 已用格数加 1
}
// 遍历数组,找最高分、最低分,算总分
int max = scores[0]; // 先假设第一个最高
int min = scores[0]; // 先假设第一个最低
int sum = 0;
for (int i = 0; i < count; i++) {
if (scores[i] > max) {
max = scores[i]; // 发现更大的,更新
}
if (scores[i] < min) {
min = scores[i]; // 发现更小的,更新
}
sum = sum + scores[i]; // 累加总分
}
printf("Max: %d\n", max);
printf("Min: %d\n", min);
printf("Average: %d\n", sum / count);
return 0;
}这段程序比前面的片段多了几个输入检查。scanf("%d", &n) 成功读到一个整数时,返回值是 1;如果用户输入的不是整数,返回值就不是 1。这时程序打印提示并 return 1,表示没有正常算完,提前结束。
人数也要检查。scores 只有 100 格,n 不能超过 100;后面要用 scores[0] 做最高分和最低分的初始值,n 也不能是 0。
5.4 编译运行#
保存成 scores.c,编译:
$ gcc scores.c -o scores
运行,输入 5,再依次输入 72 55 88 43 91:
How many scores?
$ 5
Score 1:
$ 72
Score 2:
$ 55
Score 3:
$ 88
Score 4:
$ 43
Score 5:
$ 91
Max: 91
Min: 43
Average: 69
5.5 代码拆解#
5.5.1 声明数组#
int scores[100]; // 声明一个可存 100 个 int 的数组
这行代码告诉 C:我要一排 100 个格子,每格放一个 int。
可以把它想象成这样:
100 个格子排成一排。在常见环境中,一个 int 占 4 个字节,所以 int scores[100] 通常占 400 字节。严格地说,int 的大小要以 sizeof(int) 为准。它们在内存里是连续的:第 1 格紧接着第 0 格,第 2 格紧接着第 1 格。
5.5.2 下标:用编号访问每一格#
scores[0] = 72; // 第 0 格放 72
scores[1] = 55; // 第 1 格放 55
scores[2] = 88; // 第 2 格放 88
方括号里的数字叫下标(subscript),也叫索引(index)。它告诉程序"我要访问第几格"。
一个关键细节:下标从 0 开始,不是从 1 开始。
| 下标 | 对应第几格 | 日常说法 |
|---|---|---|
scores[0] | 第 0 格 | 第 1 个分数 |
scores[1] | 第 1 格 | 第 2 个分数 |
scores[2] | 第 2 格 | 第 3 个分数 |
scores[n-1] | 第 n-1 格 | 第 n 个分数 |
下标从 0 开始是 C 语言(以及大多数编程语言)的约定。刚开始会不习惯,用多了就自然了。
5.5.3 用循环读入数据#
for (int i = 0; i < n; i++) { // i 从 0 到 n-1
scanf("%d", &scores[i]); // 把整数读入数组第 i 格
count = count + 1; // 已用格数加 1
}i 从 0 开始,每轮递增 1。scores[i] 就依次访问 scores[0]、scores[1]、scores[2]……
如果没有数组,读 30 个分数需要 30 个变量。有了数组,一个 for 循环就够了。
5.5.4 count:记录实际用了多少格#
int scores[100]; // 声明了 100 格
int count = 0; // 实际用了 0 格
数组声明了 100 格,但不一定全用完。如果只输入了 5 个分数,那么 count 是 5。
后面遍历时,循环条件用 i < count,不是 i < 100。这样只访问真正存了数据的格子,不会读到垃圾值。
for (int i = 0; i < count; i++) {
// 只处理前 count 格
}这个模式适合表示两件事:数组一共能放多少格,以及当前实际用了多少格。
还有一个细节:后面要用 scores[0] 初始化最高分和最低分,所以这里要求至少读入 1 个分数。count 是 0 时,数组里没有“第一个有效分数”。
5.5.5 从字节看数组#
前面的图把 scores[0]、scores[1] 画成一个个大格子。再往内存里看,格子还可以拆得更细:一个地址通常对应 1 个字节。int 通常占 4 个字节,所以一个 int 数组元素会占住连续的 4 行地址。
图里用十六进制写每个字节。这里采用常见的小端表示,所以 72 这个 int 被画成 48 00 00 00。有的机器字节顺序不同,但不影响这张图要看的重点:一个 int 元素由连续几个字节组成,数组里的下一个元素紧接着上一个元素存放。
所以这段循环:
for (int i = 0; i < count; i++) { // 遍历已使用的格子
printf("%d\n", scores[i]); // 从第 i 格读取 int 并打印
}可以这样读:i = 0 时,程序从 scores[0] 开始的那 4 个字节取出一个 int;i = 1 时,再从 scores[1] 开始的那 4 个字节取出下一个 int。scores[i] 里的下标每加 1,不是让地址只加 1 个字节,而是移动到下一个 int 元素的起始位置。
数组名 scores 本身就是首元素 scores[0] 的地址。scores[i] 等价于"从 scores 开始,跳过 i 个元素,取出那个位置的值"。
scores[3] 的地址 = scores 的地址 + 3 × sizeof(int)下标从 0 开始,就是因为偏移 0 个元素就是第一个,偏移 1 个就是第二个。
这也是为什么数组作为函数参数时,函数能直接修改原数组——传进去的不是整个数组的副本,而是首元素的地址。函数顺着这个地址,就能找到并修改数组里的每一个元素。
5.5.6 遍历数组求最高分和平均分#
int max = scores[0]; // 先假设第一个最大
int min = scores[0]; // 先假设第一个最小
int sum = 0;
for (int i = 0; i < count; i++) {
if (scores[i] > max) {
max = scores[i];
}
if (scores[i] < min) {
min = scores[i];
}
sum = sum + scores[i];
}max 和 min 的初始值取 scores[0],不是取 0。原因是:如果所有分数都是负数,初始值设 0 会得到错误的最大分。只要 count > 0,用第一个有效元素做初始值更稳。
循环从 i = 0 走到 i = count - 1,把每一格都看一遍。这就是遍历——用循环逐个访问数组的每个元素。
5.6 数据/内存/流程里发生了什么#
把整个程序运行时的状态画出来,以输入 72 55 88 43 91 为例:
读入阶段:
| 轮次 | i | 读入的值 | scores[i] | count |
|---|---|---|---|---|
| 1 | 0 | 72 | scores[0] = 72 | 1 |
| 2 | 1 | 55 | scores[1] = 55 | 2 |
| 3 | 2 | 88 | scores[2] = 88 | 3 |
| 4 | 3 | 43 | scores[3] = 43 | 4 |
| 5 | 4 | 91 | scores[4] = 91 | 5 |
读入结束后,数组前 5 格是 72 55 88 43 91,下标 5 到 99 的格子还没赋过值。循环只读 count 个,不会碰后面那些格子。
遍历阶段:
| 轮次 | i | scores[i] | max | min | sum |
|---|---|---|---|---|---|
| 初始 | — | — | 72 | 72 | 0 |
| 1 | 0 | 72 | 72 | 72 | 72 |
| 2 | 1 | 55 | 72 | 55 | 127 |
| 3 | 2 | 88 | 88 | 55 | 215 |
| 4 | 3 | 43 | 88 | 43 | 258 |
| 5 | 4 | 91 | 91 | 43 | 349 |
最终:max = 91,min = 43,sum = 349,count = 5,平均分 349 / 5 = 69。
5.7 数组的几个基本规则#
5.7.1 声明时必须指定大小#
int scores[100]; // 可以:大小是 100
int n = 100;
int scores2[n]; // C99 允许:变长数组(VLA)
int scores3[]; // 错:没有大小,编译器不知道该留多少空间
数组声明时必须写出大小。最简单、最稳的写法是直接写一个整数,比如 100。
int scores2[n] 叫变长数组(VLA),C99 支持,但不是所有编译器都支持,C11 以后它也不是必须实现的特性。初学阶段先用固定大小,程序更容易在不同环境里编译通过。
5.7.2 下标越界是未定义行为#
int scores[5]; // 下标范围:0 到 4
scores[5] = 100; // 错:下标 5 越界了
scores[-1] = 50; // 错:下标 -1 也越界
C 语言本身不保证运行时检查下标是否合法。写 scores[5] 可能不会马上报错,但会访问到数组后面的内存:可能是别的变量,可能是垃圾数据,也可能直接崩溃。使用带检查的调试工具时,这类错误有机会被抓出来。
警告:下标越界是 C 语言里最常见的 bug 之一。程序可能"看起来正常",也可能莫名其妙崩溃,取决于越界访问的那块内存恰好是什么。
5.7.3 数组元素有默认值吗#
局部数组没有可用的默认值。
int scores[5];
printf("%d\n", scores[0]); // 未定义:可能是任意值
在函数里写 int scores[5];,每个格子里是什么不确定。局部数组的内容可能是内存里的旧数据。要使用这样的数组,必须先给每个元素赋值。
全局数组和 static 数组会自动初始化为 0。本章先处理函数里的局部数组。
5.7.4 可以在声明时初始化#
int scores[5] = {72, 55, 88, 43, 91}; // 声明时初始化 5 个元素
花括号里的值依次放进 scores[0] 到 scores[4]。这比逐个赋值更简洁。
如果初始化的值比数组大小少,剩下的格子自动填 0:
int scores[5] = {72, 55};
// scores: [72] [55] [0] [0] [0]
5.8 小练习:读入并打印成绩单#
把前面的知识合起来,写一个成绩单练习:
#include <stdio.h>
int main(void)
{
int scores[100]; // 最多存 100 个分数
int count = 0; // 当前已存个数
int n; // 要输入的学生数
printf("How many students? ");
if (scanf("%d", &n) != 1) { // 检查输入是否为整数
printf("Invalid input\n");
return 1; // 非正常退出
}
if (n < 1 || n > 100) { // 检查人数范围
printf("Student count must be between 1 and 100\n");
return 1;
}
for (int i = 0; i < n; i++) { // 逐个读入分数
printf("Student %d score: ", i + 1);
if (scanf("%d", &scores[i]) != 1) { // 检查输入有效性
printf("Invalid input\n");
return 1;
}
count = count + 1; // 已存个数加 1
}
// 打印成绩单
printf("\n--- Grade Report ---\n");
for (int i = 0; i < count; i++) { // 遍历所有学生
printf("Student %d: %d", i + 1, scores[i]);
if (scores[i] >= 90) { // 90 分及以上:优秀
printf(" (Excellent)");
} else if (scores[i] >= 60) { // 60 到 89 分:及格
printf(" (Pass)");
} else { // 60 分以下:不及格
printf(" (Fail)");
}
printf("\n");
}
// 统计
int max = scores[0]; // 假设第一个最高
int min = scores[0]; // 假设第一个最低
int sum = 0;
for (int i = 0; i < count; i++) { // 遍历统计
if (scores[i] > max) max = scores[i]; // 更新最高分
if (scores[i] < min) min = scores[i]; // 更新最低分
sum = sum + scores[i]; // 累加总分
}
printf("\nMax: %d\n", max);
printf("Min: %d\n", min);
printf("Average: %d\n", sum / count); // 整数除法求平均
return 0;
}运行效果:
How many students?
$ 5
Student 1 score:
$ 72
Student 2 score:
$ 55
Student 3 score:
$ 88
Student 4 score:
$ 43
Student 5 score:
$ 91
--- Grade Report ---
Student 1: 72 (Pass)
Student 2: 55 (Fail)
Student 3: 88 (Pass)
Student 4: 43 (Fail)
Student 5: 91 (Excellent)
Max: 91
Min: 43
Average: 69
这个程序用了两个循环:第一个读入数据,第二个遍历打印。读入和处理分开,逻辑更清楚。
5.9 常见坑#
坑 1:下标从 0 开始,不是从 1。
int scores[5];
scores[1] = 72; // 这是第 2 格,不是第 1 格
初学者经常把"第 1 个"写成 scores[1],其实应该是 scores[0]。
坑 2:循环条件用 <= 导致越界。
int scores[5]; // 下标 0 到 4
for (int i = 0; i <= 5; i++) { // 错:i 会到 5,越界了
scores[i] = 0;
}应该是 i < 5,不是 i <= 5。这个 bug 不会报错,但会悄悄写坏数组后面的内存。
警告:数组大小是
n,合法下标范围是0到n-1。循环条件用< n,不要用<= n。
坑 3:遍历时用数组声明大小,不是实际数据量。
int scores[100];
int count = 5; // 只读了 5 个
for (int i = 0; i < 100; i++) { // 错:后 95 格是垃圾值
printf("%d\n", scores[i]);
}遍历应该用 count,不是 100。
坑 4:max 和 min 初始值设错。
int max = 0; // 错:如果所有分数都小于 0,结果就错了
int max = scores[0]; // 对:用数组第一个有效元素做初始值
坑 5:忘记更新 count。
for (int i = 0; i < n; i++) {
scanf("%d", &scores[i]);
// 漏了 count = count + 1;
}后面用 count 做循环条件时,就会出问题。
5.10 自己试试看#
Q1:修改程序,统计高于平均分的人数。
在打印完平均分之后,再遍历一次数组,数一数有多少个分数大于平均分。
Q2:把分数从高到低打印。
提示:不一定要先排序。可以先找最大值打印,再找第二大的打印;如果有相同分数,要想清楚相同分数是否都打印。用一个“已打印”标记数组,处理重复值会更直接。
Q3:声明一个数组,用初始化列表赋值,然后打印。
int data[5] = {10, 20, 30, 40, 50}; // 声明并初始化数组
for (int i = 0; i < 5; i++) { // 遍历下标 0 到 4
printf("%d\n", data[i]); // 打印每个元素
}Q4:如果输入的分数个数超过 100 会怎样?
如果没有人数检查,输入超过 100 个分数,程序就可能写到数组外面。这里要观察的是容量边界:声明 scores[100] 时,程序最多只能安全使用 100 个格子。
Q5:写一个程序,读入 10 个整数,然后逆序打印。
输入:1 2 3 4 5 6 7 8 9 10
输出:10 9 8 7 6 5 4 3 2 1
提示:用 i 从 count - 1 递减到 0。
下一章的问题#
分数能存起来了,30 个 int 放进数组,求最高分、平均分都没问题。
但回到最开始的场景:30 个学生,每个人不只有成绩,还有名字。
名字不是数字。"Alice" 是一串字符。一个 int 数组存不下它。
怎么在 C 里表示一串字符?怎么比较两个名字是否相同?怎么把名字和成绩配对存起来?
名字是一串字符,C 语言用字符数组和结束标记来表示它。学会处理字符串,学生记录才不只有数字。