今天这次咱们就初步看看 C 语言的一些复合数据类型。
想象一下,如果你要搬运一本书,你可以直接用手拿着走,这就像是我们之前学过的 int 或 char,一次处理一个数据,简单直接。但如果你要搬运一百本书呢?你肯定不会一本一本地跑一百趟,而是会找个纸箱子,把书整整齐齐地装进去,然后一次性搬走。
在 C 语言中,复合数据类型就是这个“箱子”。它允许我们把多个数据打包在一起,从而更方便地管理和处理复杂信息。今天,我们先来聊聊最基础、也是最常用的两种“箱子”:数组(包括一维和二维)以及特殊的数组——字符串。
C 语言数组与 Python 列表(list)虽然功能相似,但在底层实现逻辑上存在本质区别。C 语言数组严格遵循同构原则,要求所有元素必须属于同一数据类型,而 Python 列表则允许容纳不同类型的数据。在内存管理方面,C 数组通常在编译时分配固定大小的连续内存空间,这种静态特性虽然缺乏 Python 列表自动扩容的灵活性,却换来了更高效的访问速度。此外,C 语言不提供类似 Python 中 append 或 slice 等内置高级方法,对数据的操作需要依靠循环、索引或指针手动完成。这种显式的管理方式虽然增加了编码复杂度,却能提供对底层内存更精细的控制能力。
一、数组(Array)
先说数组。数组是 C 语言中最基础的复合数据类型,你可以把它想象成一排连续的储物柜。每个柜子上都有编号(索引),柜子里放的东西必须是一模一样的(比如全是整数,或者全是字符)。
如何定义一个数组?
定义数组时,我们需要告诉编译器三件事:存什么?叫什么?有多少?
语法格式:类型 数组名[数量];
例如 int arr[10];,这句话的意思就是:“帮我在内存里划出一块地盘,要能挨个儿放下 10 个整型数字。”
这里有两个关键点:
1. 同构性:这 10 个格子必须全都是 int,不能混进去一个 double。
2. 连续性:这 10 个格子在内存里是肩并肩、紧挨着的,中间没有空隙。
怎么知道数组有多大?
C 语言比较“硬核”,它不像 Python 那样可以直接用 len() 函数来问数组有多长。在 C 语言里,我们需要用 sizeof 运算符来自己动手“量”一下:
sizeof(arr):量出整个数组一共占了多少字节(比如 10 个 int,每个 4 字节,总共就是 40 字节)。sizeof(arr[0]):量出其中一个元素占多少字节(一个 int 是 4 字节)。- 元素个数 = 总大小 / 单个元素大小。即
sizeof(arr) / sizeof(arr[0])。这是 C 语言程序员必须掌握的“心法口诀”。
示例 1:一维数组基本操作
下面的代码展示了数组的定义、初始化、长度计算以及如何像切蛋糕一样访问其中的一部分数据。
#include <stdio.h>
int main() {
// 定义并初始化一个包含10个整数的数组
int arr[10] = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
printf("=== 一维数组操作演示 ===\n");
printf("原始数组: ");
for (int i = 0; i < 10; i++) printf("%d ", arr[i]);
printf("\n");
// 基本信息统计
// sizeof(arr) 返回整个数组占用的字节数(40)
// sizeof(arr[0]) 返回单个元素占用的字节数(4)
printf("数组大小: %lu 字节\n", sizeof(arr));
printf("元素个数: %lu\n", sizeof(arr) / sizeof(arr[0]));
// 模拟“切片”操作(打印前5个元素)
printf("\n前5个元素: ");
for (int i = 0; i < 5; i++) printf("%d ", arr[i]);
// 条件筛选(只打印偶数)
printf("\n偶数元素: ");
int count = 0;
for (int i = 0; i < 10; i++) {
if (arr[i] % 2 == 0) {
printf("%d ", arr[i]);
count++;
}
}
printf("(共%d个)\n", count);
return 0;
}
运行结果:
=== 一维数组操作演示 ===
原始数组: 1 2 3 4 5 6 7 8 9 10
数组大小: 40 字节
元素个数: 10
前5个元素: 1 2 3 4 5
偶数元素: 2 4 6 8 10 (共5个)
二、二维数组
生活中的数据往往不止一行。比如电影院的座位表、Excel 的电子表格,它们既有行又有列。为了表示这种数据,我们需要用到二维数组。
你可以把二维数组看作是“数组的数组”——也就是一个大数组,里面的每一个元素本身又是一个小数组。
示例 2:二维数组定义与遍历
定义二维数组时,我们需要指定行数和列数,格式是 类型 数组名[行数][列数]。
#include <stdio.h>
int main() {
int matrix[3][4]; // 定义一个 3 行 4 列的矩阵
// 初始化并显示
printf("=== 二维数组演示 ===\n");
int value = 1;
printf("初始化为递增序列:\n");
// 外层循环控制行,内层循环控制列
for (int i = 0; i < 3; i++) {
for (int j = 0; j < 4; j++) {
matrix[i][j] = value++;
}
}
// 格式化显示(打印成矩阵形状)
for (int i = 0; i < 3; i++) {
for (int j = 0; j < 4; j++) {
printf("%3d ", matrix[i][j]);
}
printf("\n"); // 每打印完一行后换行
}
// 内存布局信息
printf("\n内存布局信息:\n");
printf("总大小: %lu 字节\n", sizeof(matrix));
printf("行数×列数×元素大小 = %lu×%lu×%lu = %lu\n",
sizeof(matrix)/sizeof(matrix[0]), // 行数 = 总大小 / 一行的大小
sizeof(matrix[0])/sizeof(matrix[0][0]), // 列数 = 一行的大小 / 单个元素大小
sizeof(matrix[0][0]), // 单个元素大小
sizeof(matrix)); // 总大小
return 0;
}
运行结果:
=== 二维数组演示 ===
初始化为递增序列:
1 2 3 4
5 6 7 8
9 10 11 12
内存布局信息:
总大小: 48 字节
行数×列数×元素大小 = 3×4×4 = 48
2.2 为什么遍历顺序很重要?
这里有个非常关键的概念:行优先存储(Row-Major Order)。
虽然我们在脑海里把二维数组想象成一个方方正正的表格,但在计算机的内存条里,并没有“表格”这种东西,内存是一条长长的直线。C 语言在存储二维数组时,是按行来存的:先把第一行的数据排好,紧接着排第二行,然后是第三行……
也就是说,matrix[0][3](第一行最后一个)和 matrix[1][0](第二行第一个)在内存里是紧紧挨着的邻居。
理解这一点对性能至关重要。CPU 读取内存时,不是只读一个数,而是会把相邻的一大块数据都读进高速缓存(Cache)。如果你顺着内存的顺序读(先读第一行,再读第二行),CPU 就会很高兴,因为数据已经在缓存里准备好了;反之,如果你跳着读(先读第一行的第一个,再读第二行的第一个),CPU 就得频繁地去内存里重新搬运数据,速度就会慢很多。
让我们用一个实验来验证一下:
示例 3:二维数组形状对缓存的影响
#include <stdio.h>
#include <time.h>
int main() {
clock_t start, end;
double time1, time2;
printf("=== 二维数组访问效率对比 ===\n");
// 情况一:3行1000列(扁长的数组)
// 这种形状下,内层循环(列)很长,行数很少
int a[3][1000];
start = clock();
// 重复多次以放大差异
for (int k = 0; k < 10000; k++) {
for (int i = 0; i < 3; i++) {
for (int j = 0; j < 1000; j++) {
a[i][j] = i + j;
}
}
}
end = clock();
time1 = ((double)(end - start)) / CLOCKS_PER_SEC;
// 情况二:1000行3列(瘦高的数组)
// 这种形状下,内层循环(列)很短,换行非常频繁
int b[1000][3];
start = clock();
for (int k = 0; k < 10000; k++) {
for (int i = 0; i < 1000; i++) {
for (int j = 0; j < 3; j++) {
b[i][j] = i + j;
}
}
}
end = clock();
time2 = ((double)(end - start)) / CLOCKS_PER_SEC;
// 结果对比
printf("3×1000数组耗时: %.6f 秒\n", time1);
printf("1000×3数组耗时: %.6f 秒\n", time2);
if (time1 > 0)
printf("效率差异: %.2f倍\n", time2/time1);
else
printf("耗时过短,无法计算倍数\n");
return 0;
}
运行结果(示例):
=== 二维数组访问效率对比 ===
3×1000数组耗时: 0.025000 秒
1000×3数组耗时: 0.057000 秒
效率差异: 2.28倍
这说明了什么?即使元素总数一样,“频繁换行”是有代价的。
更夸张的情况是,如果你完全无视内存布局,故意颠倒循环顺序(先遍历列,再遍历行),性能损失会更严重:
示例 4:遍历顺序对性能的影响
#include <stdio.h>
#include <time.h>
int main() {
clock_t start, end;
double time_correct, time_wrong;
printf("=== 访问顺序对性能的影响 ===\n");
static int a[3][1000];
// 正确:外层循环遍历行,内层循环遍历列
// 这符合“行优先”的内存布局
start = clock();
for (int k = 0; k < 10000; k++) {
for (int i = 0; i < 3; i++) {
for (int j = 0; j < 1000; j++) {
a[i][j] = i + j;
}
}
}
end = clock();
time_correct = ((double)(end - start)) / CLOCKS_PER_SEC;
// 错误:外层循环遍历列,内层循环遍历行
// 这会导致内存访问在不同行之间反复跳跃
static int a_wrong[3][1000];
start = clock();
for (int k = 0; k < 10000; k++) {
for (int j = 0; j < 1000; j++) {
for (int i = 0; i < 3; i++) {
a_wrong[i][j] = i + j;
}
}
}
end = clock();
time_wrong = ((double)(end - start)) / CLOCKS_PER_SEC;
// 性能对比
printf("行优先访问: %.6f 秒\n", time_correct);
printf("列优先访问: %.6f 秒\n", time_wrong);
if (time_correct > 0)
printf("性能损失: %.2f倍\n", time_wrong/time_correct);
printf("\n原因:行优先访问符合内存连续布局,缓存命中率更高\n");
return 0;
}
运行结果(示例):
=== 访问顺序对性能的影响 ===
行优先访问: 0.024000 秒
列优先访问: 0.047000 秒
性能损失: 1.96倍
原因:行优先访问符合内存连续布局,缓存命中率更高
三、字符串
字符串在 C 语言里其实不是一种独立的“类型”,它本质上就是一个字符数组。但它有一个非常特殊的规定:必须以空字符 \0 结尾。
3.1 为什么需要 \0?
计算机很笨,它不知道你的字符串“hello”是到 'o' 结束,还是后面紧跟着的乱码也是字符串的一部分。所以 C 语言约定:只要遇到 \0(ASCII 码为 0 的字符),就表示字符串结束了。
这就像我们在写句子时,最后必须打个句号一样。如果忘了打句号(\0),程序打印字符串时就会一直往后打,直到遇到内存里随机出现的 0 为止,这往往会导致乱码甚至程序崩溃。
示例 5:字符串定义与操作
#include <stdio.h>
#include <string.h>
int main() {
// 两种定义方式的对比
// 方式一:直接用双引号。编译器会自动在末尾帮你加上 '\0'
char str1[] = "hello";
// 方式二:像定义普通数组一样,逐个字符赋值。
// 注意:必须手动加上 '\0',否则它只是个字符数组,不是合法的字符串!
char str2[6] = {'h', 'e', 'l', 'l', 'o', '\0'};
printf("=== 字符串基本操作 ===\n");
printf("str1内容: '%s'\n", str1);
printf("str2内容: '%s'\n", str2);
// strlen 计算的是有效字符的长度(不包含 \0)
printf("长度比较: str1=%lu, str2=%lu\n", strlen(str1), strlen(str2));
// sizeof 计算的是整个数组占用的内存大小(包含 \0)
printf("内存大小: str1=%lu字节, str2=%lu字节\n", sizeof(str1), sizeof(str2));
// 比较两个字符串的内容是否相同
printf("内容相等: %s\n", strcmp(str1, str2) == 0 ? "是" : "否");
// 字符级遍历展示:看看内存里到底存了什么
printf("\n=== 字符级详细展示 ===\n");
for (int i = 0; i < sizeof(str1); i++) {
printf("str1[%d] = '%c' (ASCII: %d)", i, str1[i], str1[i]);
if (str1[i] == '\0') printf(" <- 这里的 0 就是字符串的终止符");
printf("\n");
}
return 0;
}
运行结果:
=== 字符串基本操作 ===
str1内容: 'hello'
str2内容: 'hello'
长度比较: str1=5, str2=5
内存大小: str1=6字节, str2=6字节
内容相等: 是
=== 字符级详细展示 ===
str1[0] = 'h' (ASCII: 104)
str1[1] = 'e' (ASCII: 101)
str1[2] = 'l' (ASCII: 108)
str1[3] = 'l' (ASCII: 108)
str1[4] = 'o' (ASCII: 111)
str1[5] = ' ' (ASCII: 0) <- 这里的 0 就是字符串的终止符
总结
今天我们学习了 C 语言中最重要的“收纳工具”:
- 一维数组:像一排储物柜,大小固定,类型统一。记住公式
sizeof(arr)/sizeof(arr[0])来计算它的容量。 - 二维数组:像一个表格,但在内存里是按行“压扁”了存放的。所以,按行遍历(先读第一行,再读第二行)永远比按列遍历要快,这是编写高性能代码的第一步。
- 字符串:本质上是字符数组,但多了一个隐藏的尾巴
\0。千万别忘了这个尾巴,它是字符串结束的唯一标志。
理解了这些内存布局的细节,你不仅能写出正确的 C 代码,更能写出高效的 C 代码。这也是学习 C 语言最大的乐趣所在——能够让人通过它来窥探计算机底层的工作原理。
CycleUser