第8章 结构体数组和表
8.1 问题从哪来#
上一章用 struct Student 把一个学生的学号、姓名、分数绑在了一起。一条记录已经清楚了,但班里不会只有一个学生。
上一章的程序只做了一件事:把数据放进去,再用循环打出来。如果要做的不只是打印,而是往表里添加新学生、按学号查找某个学生、打印所有记录,就需要一套更有组织的写法。
一个班有几十个学生,程序要支持这些操作:
| 操作 | 做什么 |
|---|---|
| 添加 | 输入学号、姓名、分数,存进表里 |
| 查找 | 给一个学号,找到对应的学生并打印 |
| 打印 | 把表里所有学生逐行输出 |
这三个操作,用结构体数组加一个 count 变量就能实现。
8.2 先看一个例子#
假设表一开始是空的,count = 0。用户连续添加三名学生:
- 学号 1,Alice,92 分
- 学号 2,Bob,78 分
- 学号 3,Carol,85 分
每添加一条,程序都把新记录写到 students[count],再把 count 加一。三次添加后,students[0] 到 students[2] 是有效记录,count = 3,下一条会写到 students[3]。
接着按学号 2 查找,程序从 students[0] 开始比对,读到 students[1] 时找到 Bob。打印全部记录时,也只打印 students[0] 到 students[2]。这两个操作只读已有记录,不会改变表里的内容,也不会改变 count。
8.3 最小实验#
这段代码带了一个简单菜单。读的时候先看三件事:students[count] 写入新记录,for 循环只扫 count 条,打印时也只打印已经使用的记录。
#include <stdio.h>
#include <string.h>
struct Student {
int id;
char name[32];
int score;
};
void clear_line(void)
{
int ch;
while ((ch = getchar()) != '\n' && ch != EOF) {
// 丢掉本行剩余字符
}
}
int main(void)
{
struct Student students[100]; // 最多 100 条记录
int count = 0; // 当前有 0 条
int choice;
while (1) {
printf("\n1-Add 2-Search 3-Print 0-Exit\n");
printf("Choice: ");
if (scanf("%d", &choice) != 1) {
clear_line();
printf("Please enter a number.\n");
continue;
}
clear_line();
if (choice == 0) {
break;
} else if (choice == 1) {
// 添加一条记录
if (count >= 100) {
printf("Table full, cannot add.\n");
continue;
}
printf("ID: ");
if (scanf("%d", &students[count].id) != 1) {
clear_line();
printf("ID must be an integer.\n");
continue;
}
clear_line();
printf("Name: ");
if (scanf("%31s", students[count].name) != 1) {
clear_line();
printf("Failed to read name.\n");
continue;
}
clear_line();
printf("Score: ");
if (scanf("%d", &students[count].score) != 1) {
clear_line();
printf("Score must be an integer.\n");
continue;
}
clear_line();
count = count + 1; // 记录数加 1
printf("Added successfully, total records: %d\n", count);
} else if (choice == 2) {
// 按学号查找
int target;
printf("Enter ID to search: ");
if (scanf("%d", &target) != 1) {
clear_line();
printf("ID must be an integer.\n");
continue;
}
clear_line();
int found = 0; // 标记是否找到
for (int i = 0; i < count; i++) {
if (students[i].id == target) {
printf("Found: ID %d, Name %s, Score %d\n",
students[i].id,
students[i].name,
students[i].score);
found = 1;
break; // 找到就停
}
}
if (!found) {
printf("Student with ID %d not found.\n", target);
}
} else if (choice == 3) {
// 打印所有记录
if (count == 0) {
printf("Table is empty.\n");
continue;
}
printf("\nID Name Score\n");
printf("----------------\n");
for (int i = 0; i < count; i++) {
printf("%d %s %d\n",
students[i].id,
students[i].name,
students[i].score);
}
printf("----------------\n");
printf("Total records: %d\n", count);
} else {
printf("Invalid choice, please try again.\n");
}
}
printf("Exited.\n");
return 0;
}8.4 编译运行#
保存成 table.c,编译:
$ gcc table.c -o table
运行,依次操作:
1-Add 2-Search 3-Print 0-Exit
Choice:
$ 1
ID:
$ 1
Name:
$ Alice
Score:
$ 92
Added successfully, total records: 1
1-Add 2-Search 3-Print 0-Exit
Choice:
$ 1
ID:
$ 2
Name:
$ Bob
Score:
$ 78
Added successfully, total records: 2
1-Add 2-Search 3-Print 0-Exit
Choice:
$ 1
ID:
$ 3
Name:
$ Carol
Score:
$ 85
Added successfully, total records: 3
1-Add 2-Search 3-Print 0-Exit
Choice:
$ 2
Enter ID to search:
$ 2
Found: ID 2, Name Bob, Score 78
1-Add 2-Search 3-Print 0-Exit
Choice:
$ 3
ID Name Score
----------------
1 Alice 92
2 Bob 78
3 Carol 85
----------------
Total records: 3
1-Add 2-Search 3-Print 0-Exit
Choice:
$ 0
Exited.
8.5 数据/内存/流程里发生了什么#
8.5.1 结构体数组是一排记录#
struct Student students[100];students 是一个数组,有 100 个元素,每个元素都是一个 struct Student。每个元素内部有三个字段:id、name、score。
可以把 students 想象成一排记录。第 0 个元素是一条学生记录,第 1 个元素又是一条学生记录,一直排到第 99 个元素:
每个记录内部的字段在内存里是相邻的,和上一章看到的一样:
8.5.2 count 指向下一条可写位置#
count 记录表里当前有多少条有效数据。它的另一个含义是:下一条新记录应该写到哪里。
count 的值 | 含义 |
|---|---|
| 0 | 表是空的,第一条记录写到 students[0] |
| 1 | 表里有 1 条,下一条写到 students[1] |
| 3 | 表里有 3 条,下一条写到 students[3] |
添加操作就是两步:
students[count].id = ...; // 写入新记录
count = count + 1; // count 往后移一格
students[i] 的地址 = students 的地址 + i × sizeof(struct Student)。这和第 5 章的 scores[i] 原理一样:数组名是首元素地址,下标就是偏移几个元素。只是每个元素从 int 的 4 字节变成了 struct Student 的约 40 字节,偏移的距离也跟着变了。
students[0] 的地址 = students + 0 × 40
students[1] 的地址 = students + 1 × 40
students[2] 的地址 = students + 2 × 40这也解释了为什么 students[i].id 能找到正确的字段:程序先算出 students[i] 的起始地址,再从这个地址偏移 id 字段的距离。地址偏移是数组和结构体在内存里工作的共同基础。
8.5.3 查找:从第 0 条扫到第 count-1 条#
查找的逻辑很简单:从第 0 条开始,逐条比对学号,找到就停下来。
for (int i = 0; i < count; i++) {
if (students[i].id == target) {
// 找到了,打印这条记录
break;
}
}假设 count = 3,表里有三条记录,查找学号 2:
| 轮次 | i | students[i].id | 和 target 比较 | 结果 |
|---|---|---|---|---|
| 1 | 0 | 1 | 1 == 2? | 不等,继续 |
| 2 | 1 | 2 | 2 == 2? | 相等,找到了 |
最多需要比较 count 次。如果要找的学号不在表里,整个循环走完也找不到。
这种从头到尾逐个检查的方法叫线性查找(linear search)。它的优点是简单、不需要排序;缺点是比较次数会随着记录数增加。1000 条记录最坏情况要比较 1000 次。
8.5.4 打印:同样是遍历#
打印和查找的遍历方式一样,只是不比较,而是逐条输出:
for (int i = 0; i < count; i++) {
printf("%d %s %d\n",
students[i].id,
students[i].name,
students[i].score);
}循环条件始终是 i < count,不是 i < 100。这样只打印有效数据,不会输出后面没用过的空记录。
8.6 常见坑#
坑 1:忘记更新 count。
// 添加学生
students[count].id = 1;
snprintf(students[count].name, sizeof(students[count].name), "%s", "Alice");
students[count].score = 92;
// 漏了 count = count + 1;
结果:下次添加时,新记录会覆盖同一条。count 始终是 0,打印和查找也会认为表是空的。
坑 2:添加前不检查 count 是否已满。
if (count >= 100) {
printf("Table full, cannot add.\n");
continue;
}数组只有 100 个位置。如果 count 已经到了 100 还继续往 students[count] 写,就是下标越界,后果不确定。
警告:C 语言本身不会替你检查数组下标是否越界。越界写入可能改坏内存里其他变量的值,导致程序行为异常。
坑 3:scanf 读姓名时用 %s,姓名不能有空格。
scanf("%31s", students[count].name);%31s 最多读 31 个字符,给末尾的 \0 留出 1 个位置。它仍然遇到空格就停。如果输入 De La Cruz,scanf 只会把 De 放进 name。
本章的完整程序在读完一个值后调用 clear_line(),会把这一行剩下的 La Cruz 丢掉。这样程序不会因为剩余输入读错后面的分数,但姓名仍然只能保存 De。要读带空格的姓名,可以用 fgets 读整行,再自己处理换行。
坑 4:查找时用 = 而不是 ==。
if (students[i].id = target) { // 错:这是赋值,不是比较
单等号 = 是赋值,双等号 == 才是比较。这个错误编译器通常会给警告,但不会报错。
坑 5:查找只找第一个就停,但没有标记"找到没有"。
for (int i = 0; i < count; i++) {
if (students[i].id == target) {
printf("Found\n");
break;
}
}
// 循环结束后,不知道是找到了 break 出来的,还是自然走完的
所以需要用一个 found 变量做标记:
int found = 0;
for (int i = 0; i < count; i++) {
if (students[i].id == target) {
// 打印记录
found = 1;
break;
}
}
if (!found) {
printf("Not found.\n");
}8.7 自己试试看#
Q1:添加一个"按姓名查找"的功能。
提示:字符串比较不能用 ==,要用 strcmp。strcmp(a, b) == 0 表示两个字符串相等。需要 #include <string.h>。
Q2:添加一个"删除"功能——输入学号,把对应记录从表中移除。
提示:找到要删除的记录后,把它后面的所有记录往前挪一格,然后 count 减一。
for (int j = i; j < count - 1; j++) {
students[j] = students[j + 1];
}
count = count - 1;Q3:修改程序,让"查找"在表为空时直接给出提示。打印功能已经会提示"表是空的",查找也可以照这个思路处理。
Q4:把添加、查找、打印分别写成独立的函数,用 main 调用它们。思考:函数怎么修改 count?
提示:如果函数接收 count 的值(而不是地址),函数里 count + 1 不会影响 main 里的 count。这和上一章"结构体传参是副本"的问题一样。要让函数修改外面的变量,需要传地址——这涉及指针。
下一章的问题#
程序运行时,students 数组保存在内存里,添加、查找、打印都没问题。
但只要程序一退出,这块内存就被操作系统回收了。下次再运行,数组是空的,count 回到 0,之前添加的学生记录全部消失。
怎样把数据保存下来,让下次启动时还能用?