Debug 2025 Freshman - C Programing
Chapter03 - Array, String and Function
Array
回顾我们第一次练习的 G 题:这个问题要求我我们对输入的一组数据去掉最大值与最小值之后计算平均值。在生活中,我们也常常会碰到诸如此类的需要对多组、多个数据的处理,例如处理我们在高中学过的集合。
而在这个时候,直接通过我们上面所学的每个数据对应一个变量名进行存储的形式显然就不够好用了:既不能体现数据之间的分组关系又不能将数据集中起来统一处理。显然,我们需要一种更加合理的结构维护这些数据,而**数组(Array)**在这样的需求背景下应运而生。
数组,顾名思义,就是一组数字,可以理解为数学中的集合。在 C 语言中,我们这样表示数组:
// 定义了一个数组,能存放 100 个 int 类型的数组
int arr[100];
在这里我们定义了一个能存放 100 个 int
类型数据的数组,这里的 100 我们称为数组的大小或者容量,数组的大小是固定的,在数组初始化的时候就会被定义。
那么要如何使用数组呢?也很简单:
// 定义一个存放三个元素的数组
int arr[3];
// 将数组 arr 的前三个数分别赋值为 0, 1, 2
arr[0] = 0;
arr[1] = 1;
arr[2] = 2;
其中,方括号 [ ]
内的值被称为数组的下标,在 C 语言中,数组的下标从 0 开始,也就是说 arr[0]
是数组的第一个元素。
刚才我们已经学会了如何一个个地给数组元素赋值,但如果数组元素很多,一个一个写显然会非常麻烦。对于诸如此类的批量化操作,C 语言提供了一种不同于变量赋值的全新语法来帮助我们更好地操作数组,初始化列表:
// 定义并初始化一个包含 5 个整数的数组
int a[5] = {1, 2, 3, 4, 5};
在这行代码中,我们不仅定义了一个数组,还同时为它赋了初值。 如果我们不给出数组大小,编译器会自动根据初始化的数量来推断数组长度:
// 编译器自动推断 b 的大小为 5
int b[] = {2, 4, 6, 8, 10};
另外,当数组在定义时使用初始化列表,但提供的初始值数量少于数组长度时,其余未指定的元素会自动初始化为 0,也就是说:
// 所有元素都初始化为 0
int a[100] = {0};
可以通过这种方式将数组都初始化为 0
光有数据,但是不会处理当然是不行的。我们通常需要对这组数据做统一的处理,比如求和、求最大值、最小值、平均值等。
这时我们就需要循环来遍历数组的每一个元素:
int a[5] = {1, 2, 3, 4, 5};
int sum = 0;
for (int i = 0; i < 5; i++) {
// 每次循环将第 i 个元素累加到 sum 中
sum += a[i];
}
printf("sum = %d\n", sum);
在实际编程中,我们通常不会直接写死数组内容,而是从输入中读取:
int n;
// 读入数组长度
scanf("%d", &n);
// 定义一个最大长度为 100 的数组
int a[100];
for (int i = 0; i < n; i++) {
// 依次输入数组元素
scanf("%d", &a[i]);
}
printf("input:\n");
for (int i = 0; i < n; i++) {
printf("%d ", a[i]);
}
也就是在遍历的基础上进行输入。
需要注意的是,数组的下标,需要根据数组的定义使用,过大或者过小的下标都会导致下标越界,会出现一些意料之外的后果。
int a[3] = {1, 2, 3};
// 合法下标是 0, 1, 2
printf("%d", a[3]);
和变量同样,未初始化数组就使用数组也是致命的:
int a[5];
// 未赋值的数组元素是“随机值”
printf("%d", a[0]);
在生活中,很多数据并不是单一维度的,而是表格形式的:
姓名 | 语文 | 数学 | 英语 |
---|---|---|---|
张三 | 90 | 85 | 88 |
李四 | 78 | 92 | 81 |
王五 | 84 | 76 | 90 |
这个时候,使用只有一个下标的数组 $ a_i $ 表示就不太合适,C 语言中,这样的数据就天然地适合用二维数组表示。
二维数组本质上就是数组的数组,类比数学中集合的集合。 它可以看成是一张表格:有行(row)和列(column)。
int a[3][4];
这行代码定义了一个 3 行 4 列 的整数数组,总共有 3 × 4 = 12
个元素。
a[0][0]
表示第一行第一列;a[2][3]
表示第三行第四列;- 行下标、列下标都从 0 开始。
二维数组也可以像一维数组那样用花括号初始化:
int a[2][3] = {
{1, 2, 3},
{4, 5, 6}
};
二维数组最常见的使用方式是双重循环:
int a[2][3] = {{1, 2, 3}, {4, 5, 6}};
// 外层循环遍历行
for (int i = 0; i < 2; i++) {
// 内层循环遍历列
for (int j = 0; j < 3; j++) {
printf("(%d, %d) = %d ", i, j, a[i][j]);
}
printf("\n");
}
虽然我们用行和列来理解二维数组,但在计算机内存中,它仍然是连续存储的。
例如:
int a[2][3] = {
{1, 2, 3},
{4, 5, 6}
};
在内存中其实是这样的:
内存顺序 | 元素值 | 下标 |
---|---|---|
第1个 | 1 | (0, 0) |
第2个 | 2 | (0, 1) |
第3个 | 3 | (0, 2) |
第4个 | 4 | (1, 0) |
第5个 | 5 | (1, 1) |
第6个 | 6 | (1, 2) |
也就是说,按行连续存储(Row-major order)。因此根据数据的存储规则,先遍历行再遍历列的写法比先遍历列再遍历行的写法访问数据要快的多。
同样的,我们也有更多的高维数组。
String
数组和普通变量一样有着不同的类型,而 char
型数组则最为特殊。
在生活中,我们经常需要处理文字数据,比如:
“Hello, World!” “I love C language.”
这些由字符(char)组成的一串文本,就是字符串(String)。
在 C 语言中,我们定义一个以字符 \0
(空字符)结尾的字符型数组为一个字符串 (String):
char str[] = {'H', 'e', 'l', 'l', 'o', '\0'};
注意最后的 \0
,它不是数字 0,也不是字符 '0'
,而是一个特殊的结束标志,告诉程序:字符串到这里就结束了。
C 语言为我们提供了一种更方便的写法来定义字符串:
// 自动在末尾加上 '\0'
char str1[] = "Hello";
// 剩下的元素会自动补 '\0'
char str2[10] = "Hi";
这两种写法是等价的:
char str1[] = {'H', 'e', 'l', 'l', 'o', '\0'};
C 语言标准库提供了两种常用的字符串输入方式:
char name[20];
// 注意这里不需要写 `&`,尽管可以写
// 读取时会在遇到空格、换行或制表符时停止
scanf("%s", name);
printf("Hello, %s!\n", name);
input:
Alice
output:
Hello, Alice!
或者使用 gets()
或者 fgets()
:
char line[100];
// 从标准输入读取一整行
fgets(line, sizeof(line), stdin);
printf("input:%s", line);
gets()
与 fgets()
等价,但是 fgets()
更为安全。
使用字符串也非常容易,因为字符串其实就是一个字符数组,因此可以像数组一样访问:
char str[] = "Hello";
// 字符串的结尾是 '\0' 因此可以通过这个特征判断字符串的终点
for (int i = 0; str[i] != '\0'; i++) {
printf("%c ", str[i]);
}
就可以得到:
H e l l o
C 标准库中提供了许多处理字符串的函数,它们都在 <string.h>
头文件里。
函数 | 功能 | 示例 |
---|---|---|
strlen(s) |
计算字符串长度(不含 \0 ) |
strlen("abc") → 3 |
strcpy(dest, src) |
把 src 拷贝到 dest |
strcpy(a, "hello") |
strcat(dest, src) |
把 src 拼接到 dest 后面 |
strcat(a, "world") |
strcmp(a, b) |
比较字符串大小(按字典序) | strcmp("abc", "abd") < 0 |
更多详细内容可以看:string.h | 菜鸟教程
需要注意的是,在 C 语言中,使用单引号 ' '
表示字符,而双引号表示字符串 " "
,也就是说,'A'
表示字符常量,"A"
表示字符串常量。
数字可以比较,那么字符串可以比较吗?当然是可以的。我们定义字符串的大小顺序为字典序,也就是按照字典内单词的排列顺序排序:
字典序的规则非常简单:
从第一个字符开始,一个一个地比较,直到找到第一个不同的字符,谁的那个字符“更小”,谁就更靠前。
例如
比较对象 | 结果 |
---|---|
"apple" vs "banana" |
"apple" 更小 |
"hello" vs "hi" |
"hello" 更小 |
"abc" vs "abcd" |
"abc" 更小 |
这个比较可以通过上面提到的 strcmp(a, b)
完成。
Function
到现在为止,我们已经学会了数组来存放一组数据,也学会了字符串来处理文字。 那如果我们把这些知识组合起来写一个程序,就会发现——代码开始越来越长了。
例如,我们要写一个程序,输入一组数,然后输出它们的平均值。我们可能会这样写:
#include <stdio.h>
int main() {
int n;
scanf("%d", &n);
int a[100];
int sum = 0;
for (int i = 0; i < n; i++) {
scanf("%d", &a[i]);
sum += a[i];
}
double avg = sum * 1.0 / n;
printf("Average = %.2f\n", avg);
return 0;
}
很好,没问题。
但是如果我们接下来又要对这一组数据的前半段、后半段、掐头去尾的数据求平均值呢?是不是又得重复写一堆几乎一样的循环?这显然不够简单。
我们当然希望能将求平均值这样的过程“封装”起来,写一次就能反复用。这个时候就需要用到 C 语言中**函数(Function)**的语法了。
函数可以理解成是一个小工具、或者一个能完成特定任务的机器。它帮我们把一段可重复的代码封装起来,在需要时直接调用它就行。
形式化的,在 C 语言中,一个函数的基本结构是这样的:
返回值类型 函数名(参数列表) {
// 函数体(要执行的代码)
return 返回值;
}
例如:
long long pow(int a, int n) {
long long res = 1;
for (int i = 0; i < n; i++) {
res *= a
}
return res;
}
这个函数的意思是:定义一个叫 pow
的函数,它接收两个 int
类型的参数 a
和 n
,计算 $ a^n $ 的结果,然后返回一个 long long
类型的结果。
我们就可以像这样使用它:
#include <stdio.h>
int add(int a, int b) {
return a + b;
}
int main() {
// add(3, 4) 的真实值会被替换为函数的返回值
int res = pow(3, 4);
// 输出 7
printf("pow = %d\n", res);
return 0;
}
也就是说,我们写了一个求幂工具,帮我们完成幂运算。
当然如果一个函数不需要返回任何结果,我们就用 void
来声明它。
比如:
void printHello() {
printf("Hello, World!\n");
}
它什么也不返回,只是负责输出一行字。
调用时直接:
printHello();
就可以了。
在 C 语言中,代码是一行一行语句从上往下执行的。因此,函数在使用之前必须先进行声明,也就是在使用函数之前,必须先知道这个函数长什么样。
#include <stdio.h>
// 声明 add 函数,不具体实现
int add(int a, int b);
int main() {
printf("add = %d\n", add(3, 4));
return 0;
}
// 实现具体函数
int add(int a, int b) {
return a + b;
}
当然直接在使用函数之前就对函数进行实现也是可以行的:
#include <stdio.h>
// 直接实现具体函数
int add(int a, int b) {
return a + b;
}
int main() {
printf("add = %d\n", add(3, 4));
return 0;
}
知道了函数的基本概念之后,我们可以回头看看之前的 a + b
程序了:
#include <stdio.h>
int main() {
int a, b;
scanf("%d %d", &a, &b);
printf("%d\n", a + b);
return 0;
}
其实,int main()
就是一个返回值为 int
类型的函数,我们称为主函数(Main Function)。
而 scanf()
和 printf()
则是 C 语言的库函数,提供了输入与输出的相关操作。
我们平时都这样使用 scanf
:
int a, b;
scanf("%d %d", &a, &b);
它可以顺利读取两个整数。是你有没有想过:如果用户输入了奇怪的内容,比如字母、空格不对、或者只输入了一个数字,会发生什么?
这时候,就要用到 scanf
的返回值。
scanf
的返回值是成功读取并赋值的输入项(items)的数量。换句话说,它会告诉你成功读到了多少个数据。
#include <stdio.h>
int main() {
int a, b;
int result = scanf("%d %d", &a, &b);
printf("return = %d\n", result);
return 0;
}
Input
10 20
Output
return = 2
但是如果出现输入不匹配的情况:
Input
10 abc
Output
return = 1
而如果碰到结束标志 EOF
(End Of File),例如在输入时按下 Ctrl + D(Linux/macOS) 或 Ctrl + Z(Windows) 表示文件结束
我们可以通过这点来判断输入是否结束:
int x;
while (scanf("%d", &x) != EOF) {
printf("read:%d\n", x);
}
你会发现,如果使用函数,我们就不需要关注函数的具体实现,而只需要了解函数的功能:你不需要知道输入输出函数是怎么实现的,只需要知道输入输出函数如何使用。
在一个大程序中使用多个函数抽离逻辑、简化程序,将程序的整体逻辑与局部逻辑分离开,这就是程序设计中经典的**封装(package)**思想。
Scope and lifetime
在我们写过的程序里,变量无处不在:
我们在 main()
里定义过变量,在函数里定义过变量。
但你有没有想过:不同函数里的变量能不能互相访问?如果两个变量名字一样,会不会冲突?
这些问题,其实都和**作用域(scope)与生命周期(lifetime)**有关。
作用域(scope), 指的是一个变量在程序中 可以被访问的范围。
换句话说,就是变量在哪些地方能被看见、能用。
例如:
#include <stdio.h>
void say_hello() {
// 局部变量
int x = 10;
printf("x = %d\n", x);
}
int main() {
say_hello();
// printf("%d", x); // 错误:x 在 main 中不可见
return 0;
}
这段代码定义的 x
就只能作用于 say_hello
函数,在主函数中不可使用,这样的变量我们称为局部变量。
而在这段代码中:
#include <stdio.h>
// 全局变量
int g = 100;
void print_g() {
printf("g = %d\n", g);
}
int main() {
printf("%d\n", g);
print_g();
return 0;
}
变量的声明在函数以外,在 main()
和 print_g()
这两个函数中都可以使用,这样的变量我们称为全局变量。
简单来说,在两个花括号 { }
之间的定义的变量,只能在花括号之间的区域使用,这就是变量的作用域。
如果局部变量和全局变量同名,会发生什么?
#include <stdio.h>
// 全局变量
int x = 10;
int main() {
// 局部变量,屏蔽全局变量
int x = 5;
printf("%d\n", x);
return 0;
}
输出:
5
C 语言遵循 “就近原则”:在同名冲突时,优先使用距离当前作用域最近的定义。
当然,为了不引起不必要的疑惑,我们在开发过程当中应该尽量避免同名变量的使用。
需要注意的是,#define
定义的常量作用域都在全局,而如果我们需要定义一个局部的常量,就需要使用 const
关键字:
需要注意的是,#define
定义的常量作用域都在全局,而如果我们需要定义一个局部的常量,就需要使用 const
关键字:
#include <stdio.h>
int main() {
const int N = 100; // 定义一个局部常量
printf("%d\n", N);
// N = 200; // 错误:常量不可被修改
return 0;
}
这里的 const
表示“只读”,即变量一旦被初始化之后,就不能再被修改。它可以出现在任何作用域里,因此我们可以有局部常量或全局常量。
作用域决定“变量能从哪里访问”,而生命周期决定“变量什么时候存在”。
首先我们看一个例子:
void counter() {
// 每次调用都会初始化一个新的 cnt 并设置值为 0
int cnt = 0;
cnt++;
printf("%d\n", cnt);
}
int main() {
counter();
counter();
counter();
return 0;
}
可以得到输出:
1
1
1
但是我们如果想要这个 counter
正常运行,我们就希望 cnt
只会被初始化一次,以后的每次调用都不会被重新设置为 0,这时,我们就可以使用 static
关键字:
void counter() {
// 只初始化一次,后面都会保留第一次运行的值
static int cnt = 0;
cnt++;
printf("%d\n", cnt);
}
int main() {
counter();
counter();
counter();
return 0;
}
输出:
1
2
3
这样程序就能按照期望运行了。
形式化的,我们有以下的生命周期定义:
类型 | 作用域 | 生命周期 |
---|---|---|
局部变量 | 函数或代码块内部 | 从定义处开始,到代码块结束时销毁 |
全局变量 | 整个程序 | 从程序开始到程序结束 |
静态局部变量(static ) |
函数内部 | 在整个程序运行期间都存在,但只能在函数内访问 |
Comments | NOTHING