第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

程序要做的事:

  1. 把 5 个分数存进数组。
  2. 遍历数组,找到最高分、最低分。
  3. 算出平均分。
  4. 打印结果。

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, 2 对应数组中的位置

一个关键细节:下标从 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。

count 决定循环访问到哪里

后面遍历时,循环条件用 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 行地址。

int 数组在内存里按字节连续存放,4 个字节合成一个 int

图里用十六进制写每个字节。这里采用常见的小端表示,所以 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 个字节取出一个 inti = 1 时,再从 scores[1] 开始的那 4 个字节取出下一个 intscores[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];
}

maxmin 的初始值取 scores[0],不是取 0。原因是:如果所有分数都是负数,初始值设 0 会得到错误的最大分。只要 count > 0,用第一个有效元素做初始值更稳。

循环从 i = 0 走到 i = count - 1,把每一格都看一遍。这就是遍历——用循环逐个访问数组的每个元素。


5.6 数据/内存/流程里发生了什么#

把整个程序运行时的状态画出来,以输入 72 55 88 43 91 为例:

读入阶段:

轮次i读入的值scores[i]count
1072scores[0] = 721
2155scores[1] = 552
3288scores[2] = 883
4343scores[3] = 434
5491scores[4] = 915

读入结束后,数组前 5 格是 72 55 88 43 91,下标 5 到 99 的格子还没赋过值。循环只读 count 个,不会碰后面那些格子。

遍历阶段:

轮次iscores[i]maxminsum
初始72720
1072727272
21557255127
32888855215
43438843258
54919143349

最终:max = 91min = 43sum = 349count = 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,合法下标范围是 0n-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:maxmin 初始值设错。

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

提示:用 icount - 1 递减到 0


下一章的问题#

分数能存起来了,30 个 int 放进数组,求最高分、平均分都没问题。

但回到最开始的场景:30 个学生,每个人不只有成绩,还有名字。

名字不是数字。"Alice" 是一串字符。一个 int 数组存不下它。

怎么在 C 里表示一串字符?怎么比较两个名字是否相同?怎么把名字和成绩配对存起来?

名字是一串字符,C 语言用字符数组和结束标记来表示它。学会处理字符串,学生记录才不只有数字。