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

C语言学习笔记——数组与字符串
Kanwuqing流程控制那篇里,
猜数字游戏最多让用户猜 5 次. 要是把 max_try 改成 100,
循环本身没问题, 但”把用户猜过的数都存下来”这件事就做不到了 —— 总不能写
100 个变量 guess1 到 guess100
数据类型讲过, 一个变量就是一个盒子. 现在需要的是一排编好号的盒子, 用的时候拿编号去取. 这个东西就是数组
0 综述
1 | 数组与字符串 |
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 | int n = 5; |
初始化有几种写法, 用途不同:
1 | int a[5] = {1, 2, 3, 4, 5}; // 全部初始化 |
第二行和第三行值得实测一下, 因为”没写的那部分是什么”很容易记错:
1 | int a[5] = {1, 2, 3}; |
输出是:
1 | 1 2 3 0 0 |
没显式给值的元素自动补 0. 所以
int c[5] = {0}; 是”全清零”的惯用写法 —— 只写了第一个 0,
剩下的靠这条规则补齐
int a[5]; 不初始化的时候,
五个元素全是内存里的垃圾值, 和数据类型里讲局部变量不定值是同一回事
int a[5] = {0}; 和 int a[5]; 看起来差不多,
前者是干净的全零, 后者全是随机数. 这个差别不写出来基本看不出来,
但对程序结果影响巨大
访问元素用下标, 下标从 0 开始:
1 | int a[5] = {10, 20, 30, 40, 50}; |
a[5] 是最经典的错误: 5 个元素的数组, 合法下标是
0 到 4. 写 a[5] 编译器不会报错,
它会老老实实去算第 6 个位置的地址, 读出来的东西完全不可预测
C 语言不检查数组越界. 这是它”信任程序员”的一贯作风, 也是很多安全漏洞的来源. 考试里这类题就是问”程序输出什么”, 答案是”不确定”或”可能崩溃”
顺带说一句, a[5] 这种写法在语法上等价于
*(a + 5), 下标运算符 []
本质上是指针运算的语法糖. 这一点第 11 节会讲清楚
3 数组在内存里是连续的
存储器与进制里说存储单元是按地址编号排的. 数组正好用到这一点: 它的所有元素在内存里紧挨着放
1 | int a[5] = {1, 2, 3, 4, 5}; |
输出 20. 因为 int 占 4 字节, 5 个就是 20
字节
既然连续, 那就能用 sizeof 反推出长度:
1 | int a[5]; |
sizeof(a) 是整个数组的大小, sizeof(a[0])
是单个元素的大小, 一除就是元素个数. 这个写法在遍历和排序里很常用,
好处是改数组长度时不用改别的地方
连续存放带来一个实际好处: 知道第一个元素的地址, 就能算出任何一个元素的地址. 第 n 个元素的位置就是”首地址 + n × 元素大小”. 这正是下标访问能这么快的原因 —— 它不是一个个找过去的, 是直接算出来的
4 二维数组
一排盒子不够用的时候, 可以摆成表格:
1 | int b[2][3] = { |
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 | b[0][0] b[0][1] b[0][2] b[1][0] b[1][1] b[1][2] |
算总大小也一样: 2 行 × 3 列 × 4 字节 = 24 字节, 实测
sizeof(b) 确实得 24
二维数组遍历要两层循环:
1 | int i, j; |
只有第一维的长度可以省.
int b[][3] = {...}合法(编译器按初始化的数据数出行数), 但int b[2][]不行 —— 因为它算不出每行有多长, 也就没法定位到第 n 行. 这一点在函数那篇讲数组参数时还会遇到
5 字符数组与字符串
C 语言里没有专门的”字符串类型”. 所谓字符串, 就是一串字符存在字符数组里
所以先要分清两个东西:
1 | char c = 'a'; // 单个字符, 用单引号 |
'a' 是一个 char, 占 1 字节.
"a" 是两个字节 —— 除了 'a'
自己, 后面还跟着一个 \0
字符数组的初始化和普通数组差不多, 但字符串可以直接用双引号:
1 | char s1[] = "hello"; // 用字符串初始化, 长度 6 |
这两个看起来一样, 实际不一样. 实测:
1 | char s1[] = "hello"; |
输出:
1 | sizeof s1 = 6, strlen s1 = 5 |
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 | char s[] = "hello"; |
单引号写法是 5 个字符, 双引号写法是 6 个字节, 多的那个就是
s[5], 值是 0
于是 C 语言里所有”字符串长度”的概念都有两种:
| 写法 | 含义 | 对 "hello" 的结果 |
|---|---|---|
sizeof(s) |
数组占多少字节 | 6 |
strlen(s) |
到 \0
为止有多少字符 |
5 |
strlen 不算 \0 本身,
sizeof 算. 这个差别考得非常多
还有个更大的区别: sizeof 是运算符,
在编译时就算好了; strlen 是函数,
运行时才一个个数过去. 所以 sizeof
对指针只会给出指针本身的大小, 而 strlen 会老老实实去数
实测一个容易踩的:
1 | char s[20] = "hi"; |
输出:
1 | sizeof = 20, strlen = 2 |
数组开了 20 字节, 但里面只放了 2 个字符. sizeof
报的是容量, strlen
报的是实际内容长度
这个区别在实际写代码时很有用:
sizeof(s)用来当fgets的缓冲区大小上限,strlen(s)用来处理真实内容. 用错了要么浪费要么溢出
7 字符串处理函数
C 标准库提供了一组字符串函数, 都在 <string.h> 里,
用之前要 #include
strlen 上面讲过了, 数到 \0
为止
strcpy 复制字符串:
1 | char a[20]; |
它把右边的字符串(连同 \0)整个搬到左边.
注意不能用 a = "hello" 这种写法 ——
数组名不是变量, 不能整体赋值
strcat 拼接:
1 | char a[20] = "hello"; |
把 b 接到 a 的末尾. 前提是 a
装得下 —— strcat 不检查容量,
装不下就直接越界写
strcmp 比较:
1 | printf("%d\n", strcmp("hello", "world")); // -1 |
它的返回值不是”真/假”, 而是三态:
- 返回 0 表示两个字符串相等
- 返回 负数 表示左边小
- 返回 正数 表示左边大
两个字符串不能用 == 比较:
1 | char a[] = "abc"; |
数组名 a 在表达式里会变成首地址. 所以
a == b 比的是”两个数组是不是同一个位置”, 当然是假.
要比较内容必须用 strcmp
判断 strcmp 的结果时, 不要写 == 1
或 == -1. 标准只保证”正/零/负”, 具体是 1 还是 27
由实现决定. 写 strcmp(a,b) > 0 才稳妥
实测一组结果:
1 | char a[20] = "hello"; |
比较规则是从左往右逐字符比 ASCII 码, 第一个不同的字符定胜负. 如果前面全一样, 那就长的那个更大
8 输入输出: gets / puts / fgets
输出字符串有 puts, 输入有 gets 和
fgets
puts 自动换行:
1 | char s[] = "hello"; |
比 printf("%s\n", s) 少打几个字符, 就这点区别
gets 读一整行. 它有个致命问题 ——
不检查缓冲区大小:
1 | char s[10]; |
只要输入够长, gets 就会一直往后写, 把相邻内存覆盖掉.
这个漏洞太严重, 所以 C11 标准已经把 gets
删掉了. 但考试环境(VC++ 2010)还认它, 考试里照写能过
fgets 是安全版本, 需要指定最大长度:
1 | char s[10]; |
第二个参数是”最多读多少个字节”(含 \0),
所以 fgets(s, 10, stdin) 最多读 9 个字符, 不会溢出
fgets 有个和 gets 不一样的地方:
它会把换行符也读进去
实测输入 stop 回车:
1 | char line[10]; |
输出是:
1 | 读到:[stop |
长度是 5 而不是 4 —— 那个 \n
被一起存进去了, strlen 把它也数上了
所以用 fgets 读完后, 经常需要手动去掉末尾的换行:
1 | line[strcspn(line, "\n")] = '\0'; // 找到 \n 并替换成 \0 |
strcspn 返回”从开头到第一个 \n
之间的字符数”. 如果没找到换行, 它会返回整串长度, 也就是把原来那个
\0 又写了一遍, 不会出错 —— 这个写法是安全的
9 数组作函数参数
数组可以传给函数. 但传的是地址, 不是整个数组的副本, 这一点必须搞清
1 | void print(int arr[], int n) { |
形参写成 int arr[] 或者 int *arr 都行,
两者完全等价. 因为传进来的本来就是个地址
这就带来两个重要后果:
第一, 函数里改数组, 会影响原来的数组:
1 | void setzero(int arr[]) { |
因为函数拿到的是地址, 顺着地址改的就是原来那块内存
第二, 函数里 sizeof
数组得不到正确结果:
1 | void f(int arr[]) { |
函数里的 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 |
|
这个程序把本篇的东西基本都用上了:
- 字符数组
str[100], 用fgets读入并手动去掉换行 - 用
str[i] != '\0'作为循环结束条件 —— 这是遍历字符串的标准写法 strlen(str)拿长度, 反转时首尾配对交换, 只走len/2次- 数组作参数传给
bubble_sort, 因为传的是地址, 函数里的交换直接影响main里的a - 冒泡排序的外层是
n-1趟, 内层是n-1-i次 —— 每趟结束最大的那个已经沉到最后, 不用再比
实测运行结果:
1 | 输入一行文字: hello world |
这段代码里有个顺序上的坑:
scanf("%d", &a[i]) 读整数时会跳过空白,
但 fgets 不会
如果反过来先 scanf 再 fgets, 那个
fgets 会把 scanf
留在缓冲区里的换行符读走,
于是”输入一行文字”这一步直接被跳过, str 是空的
本文的顺序(fgets 在前)刚好避开这个问题. 这也是数据类型里提过的
%c 读走回车的同一类毛病 ——
输入函数之间会互相留垃圾
11 拓展: 数组名到底是什么
运算符那篇提过
&(取地址)和 sizeof,
这里把它们和数组名串起来
先做个测试:
1 | int a[5] = {1, 2, 3, 4, 5}; |
输出是 1, 也就是“第一个元素的地址”和”数组名”相等. 所以数组名在大多数场合就是首地址
这也解释了 a[0] 为什么是第一个元素 —— 下标从 0
开始, 因为它是”从首地址偏移 0 个元素”
下标运算符 [] 的本质是加法:
1 | a[i] 等价于 *(a + i) |
a 是首地址, +i 往后偏移 i 个元素,
* 取出那个位置的值. 既然是加法,
那交换顺序也一样:
1 | printf("%d\n", a[2]); // 30 |
2[a] 能编译过, 就是因为它等价于 *(2 + a).
这个写法只适合拿来确认理解, 实际代码里别用
但数组名和普通指针有一个关键差别: 数组名是常量, 不能被赋值
1 | int a[5]; |
还有一处例外, 就是sizeof 和
&:
1 | int a[5]; |
sizeof(a) 得 20, 说明在 sizeof 眼里
a 是整个数组, 而不是”一个地址”.
这是数组名”退化”成指针规则的两个例外之一(另一个是
&a). 前面第 9 节函数参数里 sizeof(arr)
只有 8, 就是因为作为函数参数时数组名已经退化成指针了,
不受这条例外保护
所谓”退化”(decay), 指的就是数组名在表达式里自动变成指向首元素的指针. 记住有三件事是例外:
sizeof、&、以及用字符串字面量初始化数组. 其余场合, 数组名都当指针用
