C语言学习笔记——数组与字符串

流程控制那篇里, 猜数字游戏最多让用户猜 5 次. 要是把 max_try 改成 100, 循环本身没问题, 但”把用户猜过的数都存下来”这件事就做不到了 —— 总不能写 100 个变量 guess1 到 guess100

数据类型讲过, 一个变量就是一个盒子. 现在需要的是一排编好号的盒子, 用的时候拿编号去取. 这个东西就是数组

0 综述

1
2
3
4
5
6
7
8
9
10
11
12
数组与字符串
├── 1 从"存100个数"说起
├── 2 一维数组: 定义、初始化、引用
├── 3 数组在内存里是连续的
├── 4 二维数组
├── 5 字符数组与字符串
├── 6 字符串的结束标志 \0
├── 7 字符串处理函数
├── 8 输入输出: gets / puts / fgets
├── 9 数组作函数参数
├── 10 一个完整的例子: 统计与排序
└── 11 拓展: 数组名到底是什么

1 从”存100个数”说起

回到猜数字那个需求. 现在想让它记录每一次猜的数, 最后一起打印出来. 按数据类型的写法:

1
int guess1, guess2, guess3, guess4, guess5;

如果只猜 5 次, 忍忍也就过去了. 但需求一改成”最多猜 100 次”, 这个写法立刻崩溃. 而且就算真写了 100 个变量, 后面想遍历它们也没办法 —— 变量名不能拼出来, 你不能写 guess + i 来表示”第 i 个变量”

需要的东西满足三个条件: 一批同类型的盒子, 有连续的编号, 可以拿编号来访问. 这就是数组

1
int guess[100];      // 100 个 int 盒子, 编号 0 到 99

2 一维数组: 定义、初始化、引用

定义数组的写法是:

1
类型 数组名[长度];

比如 int a[5]; 就是”5 个 int”. 这里长度必须是常量, 不能是变量(考试环境按 C89, 不支持变长数组):

1
2
3
int n = 5;
int a[n]; // VC++ 2010 里编译不过
int a[5]; // 正确

初始化有几种写法, 用途不同:

1
2
3
4
int a[5] = {1, 2, 3, 4, 5};    // 全部初始化
int b[5] = {1, 2, 3}; // 只给前三个, 后面自动补 0
int c[5] = {0}; // 全部清零的常用写法
int d[] = {1, 2, 3, 4, 5}; // 不写长度, 编译器数出来是 5

第二行和第三行值得实测一下, 因为”没写的那部分是什么”很容易记错:

1
2
3
int a[5] = {1, 2, 3};

printf("%d %d %d %d %d\n", a[0], a[1], a[2], a[3], a[4]);

输出是:

1
1 2 3 0 0

没显式给值的元素自动补 0. 所以 int c[5] = {0}; 是”全清零”的惯用写法 —— 只写了第一个 0, 剩下的靠这条规则补齐

int a[5]; 不初始化的时候, 五个元素全是内存里的垃圾值, 和数据类型里讲局部变量不定值是同一回事

int a[5] = {0}; 和 int a[5]; 看起来差不多, 前者是干净的全零, 后者全是随机数. 这个差别不写出来基本看不出来, 但对程序结果影响巨大

访问元素用下标, 下标从 0 开始:

1
2
3
4
int a[5] = {10, 20, 30, 40, 50};
printf("%d\n", a[0]); // 10
printf("%d\n", a[4]); // 50
printf("%d\n", a[5]); // 越界! 不是 5 号元素, 因为根本没有

a[5] 是最经典的错误: 5 个元素的数组, 合法下标是 0 到 4. 写 a[5] 编译器不会报错, 它会老老实实去算第 6 个位置的地址, 读出来的东西完全不可预测

C 语言不检查数组越界. 这是它”信任程序员”的一贯作风, 也是很多安全漏洞的来源. 考试里这类题就是问”程序输出什么”, 答案是”不确定”或”可能崩溃”

顺带说一句, a[5] 这种写法在语法上等价于 *(a + 5), 下标运算符 [] 本质上是指针运算的语法糖. 这一点第 11 节会讲清楚

3 数组在内存里是连续的

存储器与进制里说存储单元是按地址编号排的. 数组正好用到这一点: 它的所有元素在内存里紧挨着放

1
2
int a[5] = {1, 2, 3, 4, 5};
printf("总大小 = %d 字节\n", (int)sizeof(a));

输出 20. 因为 int 占 4 字节, 5 个就是 20 字节

既然连续, 那就能用 sizeof 反推出长度:

1
2
int a[5];
int n = sizeof(a) / sizeof(a[0]); // 20 / 4 = 5

sizeof(a) 是整个数组的大小, sizeof(a[0]) 是单个元素的大小, 一除就是元素个数. 这个写法在遍历和排序里很常用, 好处是改数组长度时不用改别的地方

连续存放带来一个实际好处: 知道第一个元素的地址, 就能算出任何一个元素的地址. 第 n 个元素的位置就是”首地址 + n × 元素大小”. 这正是下标访问能这么快的原因 —— 它不是一个个找过去的, 是直接算出来的

4 二维数组

一排盒子不够用的时候, 可以摆成表格:

1
2
3
4
int b[2][3] = {
{1, 2, 3},
{4, 5, 6}
};

b[2][3] 读作”2 行 3 列”. 访问方式和一维一样, 给两个下标就行:

1
printf("%d\n", b[1][2]);     // 6

行下标和列下标都从 0 开始. 所以 b[1][2] 是第 2 行第 3 列

初始化可以嵌套写, 也可以拉平成一行:

1
int b[2][3] = {1, 2, 3, 4, 5, 6};    // 效果完全一样

因为内存里本来就是按行排成一条直线的, 二维只是个方便看的写法. 这叫行优先存储. 上面这个 2×3 的数组, 在内存里就是:

1
2
b[0][0] b[0][1] b[0][2] b[1][0] b[1][1] b[1][2]
1 2 3 4 5 6

算总大小也一样: 2 行 × 3 列 × 4 字节 = 24 字节, 实测 sizeof(b) 确实得 24

二维数组遍历要两层循环:

1
2
3
4
5
6
7
8
int i, j;

for (i = 0; i < 2; i++) {
for (j = 0; j < 3; j++) {
printf("%d ", b[i][j]);
}
printf("\n");
}

只有第一维的长度可以省. int b[][3] = {...} 合法(编译器按初始化的数据数出行数), 但 int b[2][] 不行 —— 因为它算不出每行有多长, 也就没法定位到第 n 行. 这一点在函数那篇讲数组参数时还会遇到

5 字符数组与字符串

C 语言里没有专门的”字符串类型”. 所谓字符串, 就是一串字符存在字符数组里

所以先要分清两个东西:

1
2
char c = 'a';        // 单个字符, 用单引号
char s[] = "a"; // 字符串, 用双引号

'a' 是一个 char, 占 1 字节. "a" 是两个字节 —— 除了 'a' 自己, 后面还跟着一个 \0

字符数组的初始化和普通数组差不多, 但字符串可以直接用双引号:

1
2
char s1[] = "hello";                    // 用字符串初始化, 长度 6
char s2[] = {'h','e','l','l','o'}; // 逐个字符初始化, 长度 5

这两个看起来一样, 实际不一样. 实测:

1
2
3
4
5
char s1[] = "hello";
char s2[] = {'h','e','l','l','o'};

printf("sizeof s1 = %d, strlen s1 = %d\n", (int)sizeof(s1), (int)strlen(s1));
printf("sizeof s2 = %d, strlen s2 = %d\n", (int)sizeof(s2), (int)strlen(s2));

输出:

1
2
sizeof s1 = 6, strlen s1 = 5
sizeof s2 = 5, strlen s2 = 10

s1 是 6 字节, 因为双引号写法自动加了 \0. s2 只有 5 字节, 没有结束标志

而 strlen(s2) 打出个 10 —— 这个是错的. strlen 靠找 \0 来数长度, s2 里没有 \0, 它就一直往后读, 读到内存里别的地方的 0 才停. 这个 10 是碰巧读出来的垃圾, 换台机器或改下代码就变了

strlen 只认 \0, 不认数组长度. 对没有 \0 的字符数组用 strlen, 结果是未定义行为 —— 可能读到崩溃, 可能读出个莫名其妙的数字

同样地, 用 printf("%s", s2) 打印这种数组也会一路读下去, 直到撞见某个 \0. 这就是为什么用双引号初始化更省心 —— 它会自动补上结束标志

6 字符串的结束标志 \0

现在把 \0 单独说清楚, 因为它是字符串这一块的核心

\0 是数据类型转义字符表里的一个, 表示 ASCII 码为 0 的那个字符(不是字符 '0', 那个是 48). 它不可显示, 专门用来当字符串的结束标志

可以自己验一下:

1
2
char s[] = "hello";
printf("s[5] = %d\n", s[5]); // 0

单引号写法是 5 个字符, 双引号写法是 6 个字节, 多的那个就是 s[5], 值是 0

于是 C 语言里所有”字符串长度”的概念都有两种:

写法 含义 对 "hello" 的结果
sizeof(s) 数组占多少字节 6
strlen(s) 到 \0 为止有多少字符 5

strlen 不算 \0 本身, sizeof 算. 这个差别考得非常多

还有个更大的区别: sizeof 是运算符, 在编译时就算好了; strlen 是函数, 运行时才一个个数过去. 所以 sizeof 对指针只会给出指针本身的大小, 而 strlen 会老老实实去数

实测一个容易踩的:

1
2
3
char s[20] = "hi";

printf("sizeof = %d, strlen = %d\n", (int)sizeof(s), (int)strlen(s));

输出:

1
sizeof = 20, strlen = 2

数组开了 20 字节, 但里面只放了 2 个字符. sizeof 报的是容量, strlen 报的是实际内容长度

这个区别在实际写代码时很有用: sizeof(s) 用来当 fgets 的缓冲区大小上限, strlen(s) 用来处理真实内容. 用错了要么浪费要么溢出

7 字符串处理函数

C 标准库提供了一组字符串函数, 都在 <string.h> 里, 用之前要 #include

strlen 上面讲过了, 数到 \0 为止

strcpy 复制字符串:

1
2
3
char a[20];
strcpy(a, "hello");
printf("%s\n", a); // hello

它把右边的字符串(连同 \0)整个搬到左边. 注意不能用 a = "hello" 这种写法 —— 数组名不是变量, 不能整体赋值

strcat 拼接:

1
2
3
4
5
char a[20] = "hello";
char b[20] = "world";

strcat(a, b);
printf("%s\n", a); // helloworld

把 b 接到 a 的末尾. 前提是 a 装得下 —— strcat 不检查容量, 装不下就直接越界写

strcmp 比较:

1
2
3
printf("%d\n", strcmp("hello", "world"));   // -1
printf("%d\n", strcmp("abc", "abc")); // 0
printf("%d\n", strcmp("abc", "ab")); // 1

它的返回值不是”真/假”, 而是三态:

  • 返回 0 表示两个字符串相等
  • 返回 负数 表示左边小
  • 返回 正数 表示左边大

两个字符串不能用 == 比较:

1
2
3
4
5
char a[] = "abc";
char b[] = "abc";

if (a == b) // 错的! 这比的是地址, 不是内容
if (strcmp(a, b) == 0) // 正确

数组名 a 在表达式里会变成首地址. 所以 a == b 比的是”两个数组是不是同一个位置”, 当然是假. 要比较内容必须用 strcmp

判断 strcmp 的结果时, 不要写 == 1 或 == -1. 标准只保证”正/零/负”, 具体是 1 还是 27 由实现决定. 写 strcmp(a,b) > 0 才稳妥

实测一组结果:

1
2
3
4
5
6
7
char a[20] = "hello";
char b[20] = "world";

printf("%d\n", strcmp(a, b)); // -1 hello < world
printf("%d\n", strcmp("abc", "abd")); // -1 c < d
printf("%d\n", strcmp("abc", "abc")); // 0 相等
printf("%d\n", strcmp("abc", "ab")); // 1 abc 更长

比较规则是从左往右逐字符比 ASCII 码, 第一个不同的字符定胜负. 如果前面全一样, 那就长的那个更大

8 输入输出: gets / puts / fgets

输出字符串有 puts, 输入有 gets 和 fgets

puts 自动换行:

1
2
char s[] = "hello";
puts(s); // 输出 hello 并换行

比 printf("%s\n", s) 少打几个字符, 就这点区别

gets 读一整行. 它有个致命问题 —— 不检查缓冲区大小:

1
2
char s[10];
gets(s); // 输入超过 9 个字符就溢出

只要输入够长, gets 就会一直往后写, 把相邻内存覆盖掉. 这个漏洞太严重, 所以 C11 标准已经把 gets 删掉了. 但考试环境(VC++ 2010)还认它, 考试里照写能过

fgets 是安全版本, 需要指定最大长度:

1
2
char s[10];
fgets(s, sizeof(s), stdin);

第二个参数是”最多读多少个字节”(含 \0), 所以 fgets(s, 10, stdin) 最多读 9 个字符, 不会溢出

fgets 有个和 gets 不一样的地方: 它会把换行符也读进去

实测输入 stop 回车:

1
2
3
char line[10];
fgets(line, sizeof(line), stdin);
printf("读到:[%s] len=%d\n", line, (int)strlen(line));

输出是:

1
2
读到:[stop
] len=5

长度是 5 而不是 4 —— 那个 \n 被一起存进去了, strlen 把它也数上了

所以用 fgets 读完后, 经常需要手动去掉末尾的换行:

1
line[strcspn(line, "\n")] = '\0';    // 找到 \n 并替换成 \0

strcspn 返回”从开头到第一个 \n 之间的字符数”. 如果没找到换行, 它会返回整串长度, 也就是把原来那个 \0 又写了一遍, 不会出错 —— 这个写法是安全的

9 数组作函数参数

数组可以传给函数. 但传的是地址, 不是整个数组的副本, 这一点必须搞清

1
2
3
4
5
void print(int arr[], int n) {
int i;
for (i = 0; i < n; i++) printf("%d ", arr[i]);
printf("\n");
}

形参写成 int arr[] 或者 int *arr 都行, 两者完全等价. 因为传进来的本来就是个地址

这就带来两个重要后果:

第一, 函数里改数组, 会影响原来的数组:

1
2
3
4
5
6
7
8
9
void setzero(int arr[]) {
arr[0] = 999;
}

int main(void) {
int a[3] = {1, 2, 3};
setzero(a);
printf("%d\n", a[0]); // 999, 被改掉了
}

因为函数拿到的是地址, 顺着地址改的就是原来那块内存

第二, 函数里 sizeof 数组得不到正确结果:

1
2
3
4
5
6
7
8
9
void f(int arr[]) {
printf("%d\n", (int)sizeof(arr)); // 8, 不是 40
}

int main(void) {
int a[10];
printf("%d\n", (int)sizeof(a)); // 40
f(a);
}

函数里的 arr 已经不是数组, 而是一个指针. sizeof 一个指针得到的自然是指针的大小. 所以必须额外传一个长度参数, 就是上面 print(int arr[], int n) 里那个 n 的来历

sizeof(arr) 在函数里得到 8 还是 4, 取决于目标平台的指针宽度. 我这里测的是 64 位环境, 所以是 8

你的考试环境是 32 位, 指针是 4 字节, 那里会输出 4. 值不重要, 关键是”函数里 sizeof 数组 ≠ 数组真实大小”这件事

GCC 对这种写法会给一句警告:

1
warning: 'sizeof' on array function parameter 'arr' will return size of 'int *'

看到这个警告, 就说明该补一个长度参数了

10 一个完整的例子: 统计与排序

把这篇的东西串起来. 写一个程序, 要求:

  • 输入一行文字(可能有空格)
  • 统计其中字母和空格各有多少个
  • 输入 6 个整数到一个数组, 用冒泡排序从小到大排好再输出
  • 把输入的文字原地反转再输出
1
示例代码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
#include <stdio.h>
#include <string.h>

/* 冒泡排序: 数组作参数, 必须额外传长度 */
void bubble_sort(int a[], int n) {
int i, j, t;

for (i = 0; i < n - 1; i++) {
for (j = 0; j < n - 1 - i; j++) {
if (a[j] > a[j + 1]) { /* 前一个比后一个大就交换 */
t = a[j];
a[j] = a[j + 1];
a[j + 1] = t;
}
}
}
}

int main(void) {
char str[100];
int i, len, letters = 0, spaces = 0;
int a[6];
char tmp;

/* --- 第一部分: 统计字符 --- */
printf("输入一行文字: ");
fgets(str, sizeof(str), stdin);

/* 去掉 fgets 留下的换行, 否则它会计入统计 */
str[strcspn(str, "\n")] = '\0';

for (i = 0; str[i] != '\0'; i++) {
if (str[i] == ' ') {
spaces++;
} else {
letters++;
}
}
printf("字母 %d 个, 空格 %d 个\n", letters, spaces);

/* --- 第二部分: 反转 --- */
len = (int)strlen(str);

for (i = 0; i < len / 2; i++) {
tmp = str[i];
str[i] = str[len - 1 - i]; /* 首尾配对交换 */
str[len - 1 - i] = tmp;
}
printf("反转后: %s\n", str);

/* --- 第三部分: 排序 --- */
printf("输入 6 个整数: ");
for (i = 0; i < 6; i++) {
scanf("%d", &a[i]);
}

bubble_sort(a, 6);

printf("排序后: ");
for (i = 0; i < 6; i++) {
printf("%d ", a[i]);
}
printf("\n");

return 0;
}

这个程序把本篇的东西基本都用上了:

  • 字符数组 str[100], 用 fgets 读入并手动去掉换行
  • 用 str[i] != '\0' 作为循环结束条件 —— 这是遍历字符串的标准写法
  • strlen(str) 拿长度, 反转时首尾配对交换, 只走 len/2 次
  • 数组作参数传给 bubble_sort, 因为传的是地址, 函数里的交换直接影响 main 里的 a
  • 冒泡排序的外层是 n-1 趟, 内层是 n-1-i 次 —— 每趟结束最大的那个已经沉到最后, 不用再比

实测运行结果:

1
2
3
4
5
输入一行文字: hello world
字母 10 个, 空格 1 个
反转后: dlrow olleh
输入 6 个整数: 5 2 8 1 9 3
排序后: 1 2 3 5 8 9

这段代码里有个顺序上的坑: scanf("%d", &a[i]) 读整数时会跳过空白, 但 fgets 不会

如果反过来先 scanf 再 fgets, 那个 fgets 会把 scanf 留在缓冲区里的换行符读走, 于是”输入一行文字”这一步直接被跳过, str 是空的

本文的顺序(fgets 在前)刚好避开这个问题. 这也是数据类型里提过的 %c 读走回车的同一类毛病 —— 输入函数之间会互相留垃圾

11 拓展: 数组名到底是什么

运算符那篇提过 &(取地址)和 sizeof, 这里把它们和数组名串起来

先做个测试:

1
2
3
int a[5] = {1, 2, 3, 4, 5};

printf("%d\n", &a[0] == a); // 1

输出是 1, 也就是“第一个元素的地址”和”数组名”相等. 所以数组名在大多数场合就是首地址

这也解释了 a[0] 为什么是第一个元素 —— 下标从 0 开始, 因为它是”从首地址偏移 0 个元素”

下标运算符 [] 的本质是加法:

1
a[i]       等价于   *(a + i)

a 是首地址, +i 往后偏移 i 个元素, * 取出那个位置的值. 既然是加法, 那交换顺序也一样:

1
2
3
printf("%d\n", a[2]);       // 30
printf("%d\n", *(a + 2)); // 30
printf("%d\n", 2[a]); // 30 (合法, 但不建议这么写)

2[a] 能编译过, 就是因为它等价于 *(2 + a). 这个写法只适合拿来确认理解, 实际代码里别用

但数组名和普通指针有一个关键差别: 数组名是常量, 不能被赋值

1
2
3
4
5
6
int a[5];
int *p = a; // 可以, p 指向 a 的首地址

a = p; // 编译错误! 数组名不能改
a++; // 编译错误! 同理
p++; // 可以, p 只是个变量

还有一处例外, 就是sizeof 和 &:

1
2
3
4
int a[5];

printf("%d\n", sizeof(a)); // 20 整个数组的大小
printf("%d\n", sizeof(&a)); // 指针的大小, 不是 20

sizeof(a) 得 20, 说明在 sizeof 眼里 a 是整个数组, 而不是”一个地址”. 这是数组名”退化”成指针规则的两个例外之一(另一个是 &a). 前面第 9 节函数参数里 sizeof(arr) 只有 8, 就是因为作为函数参数时数组名已经退化成指针了, 不受这条例外保护

所谓”退化”(decay), 指的就是数组名在表达式里自动变成指向首元素的指针. 记住有三件事是例外: sizeof、&、以及用字符串字面量初始化数组. 其余场合, 数组名都当指针用