很多刚开始学C语言的同学,学到数组这一章就开始犯迷糊了。前面几天明明已经把变量、运算符、流程控制、函数都摸得差不多了,怎么一碰到字符数组和二维整型数组,写出来的代码就各种崩、各种乱码、各种莫名其妙的段错误?我带的几个学弟学妹几乎都在这个节点来问我同一个问题:为什么我的字符串存不进数组?为什么二维数组遍历出来是一堆大数字?说实话,这些问题的根子不在“语法没背熟”,而在于没有真正理解数组在内存里到底是怎么摆的。
DAY6这一篇,我就把字符数组(也就是字符串的载体)和二维整型数组这两块掰开揉碎讲一遍。内容面向刚学完函数、正准备进入数组环节的初学者,也适合那些学完数组但总是踩边界、越界、地址混淆坑的同学。今天不讲那些花里胡哨的高级玩法,就讲最底层、最核心、最容易出错的东西,把这些吃透,后面指针、动态内存、字符串处理函数你都会顺很多。
1. 字符数组为什么总离不开一个"\0"?内存角度拆解字符串的存储结构
1.1 定义与初始化的多种写法
先说一个最简单的问题:字符数组到底是什么?其实它就是一个数组,只不过每个元素是char类型,一个字节。比如char str[5];就是声明了一个能放5个字符的连续内存区域,每个坑位占1字节。
但“字符串”和“字符数组”之间不能直接画等号。C语言里约定:一个字符串必须以'\0'(空字符,ASCII码是0)结尾。也就是说,凡是能被printf用%s正常打印出来的字符数组,末尾必须有一个'\0'。这个'\0'不占逻辑上的字符串长度,但占数组的物理空间。
初始化方式非常多,我列几种最常见的:
c复制// 方式1:逐个字符初始化,必须手动补'\0'
char str1[6] = {'H', 'e', 'l', 'l', 'o', '\0'};
// 方式2:用字符串字面量整体赋值(仅限初始化阶段)
char str2[6] = "Hello";
// 方式3:不写数组大小,让编译器自己数
char str3[] = "Hello"; // 自动分配6个字节,5个字符加1个'\0'
很多新手会犯一个错:觉得char str[5] = "Hello";没问题。实际上这是典型的越界初始化,"Hello"一共占6个字节(5个字符+末尾'\0'),你只给了5个坑,那么'\0'就会被写到数组外面,轻则编译器警告,重则运行时报错,甚至悄悄破坏相邻内存的数据。
这里要强调一个记忆锚点:字符串长度和数组容量是两回事。 字符串长度是strlen测出来的,遇到'\0'就停下;数组容量是sizeof测出来的,是这块内存一共能装多少字节。举个例子:
c复制char buf[20] = "hello";
int len = strlen(buf); // 结果是5
int cap = sizeof(buf); // 结果是20
strlen数的是内容,从开头一路数到'\0'为止;sizeof在编译期就知道数组总共20个字节。这个区别后面几乎所有字符串操作都要用到,必须一开始就分清楚。
1.2 sizeof与strlen:一个测盒子,一个测内容
继续深入一点,sizeof和strlen的差异值得单独拿出来讲,因为它直接关系到你之后写字符串函数、拷贝数组、判断数组大小时会不会差一个字节。
sizeof是运算符,不是函数。它对数组名求值时,返回的是整个数组占用的字节数,这个数值在编译期就固定了。strlen是标准库函数,它对字符串求值时,会从你给的地址开始,一个字节一个字节地扫描,直到遇到'\0'才停,返回的是中间走过的字符个数,这是运行时行为。
所以你看这段代码:
c复制char s[100] = "abc";
printf("%zu\n", sizeof(s)); // 100,数组总共100字节
printf("%zu\n", strlen(s)); // 3,字符串内容是3个字符
区分这俩最直接的意义在于:当你要把一个字符串拷贝进数组时,必须先确认strlen(src) + 1小于等于sizeof(dest),那个+1就是给'\0'留的位置。我在实际带人的时候发现,很多人拷贝完字符串打印出来一切正常,就以为没事了,其实'\0'已经把数组后面的内存踩了一脚,只是暂时还没爆。这种“潜伏型”bug最坑,因为它在最简单的小例子下不会崩,等你把数组挪到函数里、结构体里、堆内存里,哪天就崩得莫名其妙。
1.3 字符串字面量的隐藏特性与只读风险
还有一类初学者会踩的坑:用指针指向字符串字面量,然后试图修改它。
c复制char *p = "hello";
p[0] = 'H'; // 危险!在多数环境下会引发运行时错误
这里要理解:"hello"这种字符串字面量,一般存放在只读区。你用char *p去指向它,只是拿到了这块只读内存的首地址。想通过指针去改它,就是往只读区写数据,系统会直接不答应。
而char arr[] = "hello";不一样,它做的事是:在栈上开辟一块新的、可读写的数组空间,然后把"hello"的内容逐个复制进去。所以arr[0] = 'H';是合法操作。
我的建议是:DAY6这个阶段,大家规规矩矩用数组方式存储字符串,不要提前去碰指针版本。指针方式等学到字符串和指针综合训练时会有专门篇幅,到时候配合const关键字再细聊。现在你只需要记住一个结论:能用char arr[]就不要用char *p去存需要修改的字符串。 这能帮你避开一大批崩溃现场。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 字符串的输入输出与传统库函数中的经典坑
2.1 scanf配合%s的边界:读不了空格只是开始
初学者第一次接触字符串输入,基本都是scanf("%s", str);。这个用法的第一个大坑是:%s遇到空格、制表符、换行就停了。也就是说,你输入hello world,scanf只把hello存进数组,world还留在输入缓冲区里,等着坑你下一个读取语句。
还有第二个坑:scanf("%s", str);不检查数组边界。你要是输入了一个超长的字符串,它照样一个字符一个字符地往数组里塞,直到塞完为止。数组只有20字节,你输入了50字节的内容,后面30字节就踩到别人的地盘去了。C语言不像某些语言会自动检查越界,它完全不设防,这就是为什么C程序的字符串输入漏洞在安全领域被反复提及。
所以,如果非要坚持用scanf读字符串,有两个习惯要养成:
c复制char str[20];
scanf("%19s", str); // 最多读19个字符,第20位留给'\0'
这里的19写在%和s之间,意思是限制最多读入19个字符。这个细节在教材里经常被略过,但实际工程里它是保命级别的。
2.2 为什么教学环境仍在用gets,但你应该提前使用fgets
gets这个函数在C标准里已经被标记为废弃,但在一些老教材、老OJ、教学示例里依然频繁出现。它的作用是从标准输入读一整行,包括空格,直到换行才停。
问题来了:gets完全不限制输入长度。用户输入多少,它就往数组里塞多少,哪怕数组只有16字节,它也能给你塞200字节进去。这种缺陷导致的缓冲区溢出,是很多安全漏洞的根源。所以我在这里直接给一个非常明确的建议:从今天开始,你的代码里永远不要出现gets,改用fgets。
fgets的用法是:
c复制char buf[50];
fgets(buf, sizeof(buf), stdin);
它的逻辑是:最多读sizeof(buf) - 1个字符,或者读到换行就停,然后在末尾自动补'\0'。这是C语言提供的自带上限保护的输入方式,既安全又简单。
不过fgets有一个让人别扭的地方:如果输入的那一行比数组短,换行符'\n'会被一并存进数组。换句话说,你输入hello再按回车,buf里存的是"hello\n",而不是"hello"。处理办法很简单,读完之后找一下末尾有没有'\n',有就替换成'\0':
c复制size_t len = strlen(buf);
if (len > 0 && buf[len - 1] == '\n') {
buf[len - 1] = '\0';
}
这段代码值得记下来,几乎每次用fgets读字符串都得配上。
2.3 strlen、strcpy、strcat、strcmp逐个拆解
字符串操作四大函数,今天全都过一遍。先说strlen,原型是size_t strlen(const char *s);,前面已经说过它的运行原理,这里不再重复。
然后是strcpy(dest, src),作用是把源字符串完整复制到目标数组。它的问题在于:它只认'\0',不会管dest数组到底够不够大。源字符串有100字节,目标只有20字节,它照样全部拷贝过去。所以使用前你必须自己保证sizeof(dest) >= strlen(src) + 1。写代码的时候可以加一道防护:
c复制char dest[20];
const char *src = "hello world";
if (strlen(src) + 1 <= sizeof(dest)) {
strcpy(dest, src);
} else {
// 处理空间不足的情况
printf("目标空间不足,无法拷贝\n");
}
strcat(dest, src)负责拼接,作用是把src接到dest后面,而且会覆盖掉dest末尾的'\0'。它和strcpy有同样的问题——不检查空间。而且要额外多算一步:拼接后的总长度是strlen(dest) + strlen(src) + 1,必须小于等于sizeof(dest)。
strcmp(str1, str2)用来比较两个字符串,返回值如果是0说明相等,负数说明str1小于str2,正数说明str1大于str2。这里我必须重点强调一个最反直觉的点:很多新手写成if (strcmp(str1, str2))判断相等,这在C语言里是恰好反过来的——strcmp返回0才表示等于,而if (0)是假。正确的写法应该是:
c复制if (strcmp(str1, str2) == 0) {
printf("两个字符串相等\n");
}
这个细节值得你用笔写在笔记的第一行:比较字符串相等,永远用strcmp的结果是否等于0来判断,不要用==,也直接用if(strcmp(...))来表示“不相等”。
2.4 缓冲区溢出教训与安全版本选择
关于缓冲区溢出,我还想多说几句。我在辅导某同学调试一个班级成绩管理系统时,发现程序运行五六次后就开始随机崩溃,有时是打印出乱码,有时是直接卡死。排查了很久才发现,他在循环里用strcat循环拼接字符串,每次都没检查拼接后的长度,最后把栈上相邻数组的'\0'全给覆盖了,整个栈上的数据一片混乱。
这种问题的可怕之处在于:它不一定会立刻崩溃,但会在某个你不注意的时刻突然发作,而且发作的位置往往离真正的错误代码很远。这也是C语言入门者最需要建立的意识:数组越界和缓冲区溢出不是“可能出错”,而是“必定出错,只是时间问题”。
除了上面那些标准函数,C标准还提供了带限制的版本strncpy、strncat、strncmp,它们多了一个长度参数。但注意,strncpy也有一个非常坑的细节:如果源字符串长度大于n,它不会自动补'\0'。也就是说,strncpy(dest, src, n)只有在strlen(src) < n时才保证dest以'\0'结尾,否则你得手动在dest[n] = '\0';收尾。这个细节很多人不留意,导致打印的时候整个缓冲区全是乱码。
所以我的建议是:与其依赖这些半生不熟的安全版本,不如先老老实实用前文说的长度检查方式,确保每一次拷贝、拼接前都验证空间够不够。等你对内存管理熟练了,再考虑用更精细的库函数去优化。
3. 二维整型数组:一维本质与行列读写的心智模型
3.1 内存里的连续排布与"行优先"规则
很多人第一次接触int a[3][4]时,把它想象成一个三行四列的“表格”。这个想象对理解逻辑结构有帮助,但对理解底层运作没有帮助——实际上,C语言的二维数组在内存里就是一段连续的一维空间,一共3 * 4 = 12个int,每个int占4字节,总共48字节,一个接一个排得整整齐齐。
排列顺序是行优先:先放第0行的4个元素,再放第1行的4个元素,再放第2行的4个元素。用文字描述就是:
code复制a[0][0], a[0][1], a[0][2], a[0][3],
a[1][0], a[1][1], a[1][2], a[1][3],
a[2][0], a[2][1], a[2][2], a[2][3]
它们在内存里是紧挨着的。你甚至可以写出*(a[0] + 5)去访问a[1][1],因为从a[0][0]往后数第5个元素正好是a[1][1]。做法虽然不推荐,但可以帮你建立“数组就是连续内存”的直觉。
这个连续性的意义在于:遍历二维数组时,按行遍历比按列遍历在缓存友好度上要好得多。因为内存是连续的,按行顺序读就是顺序读,CPU缓存能很好地预取;按列读则每次都要跨行跳着读,效率明显差一截。初学阶段不必纠结性能,但如果你以后去做矩阵运算、图像处理,这个意识会很值钱。
3.2 定义、初始化与行列省略规则
二维数组的定义很好理解:类型 数组名[行数][列数];。初始化方式同样有讲究。
c复制// 完全初始化
int matrix[2][3] = {
{1, 2, 3},
{4, 5, 6}
};
// 部分初始化,剩余元素自动补0
int partial[2][3] = {
{1, 2},
{3}
};
// 实际上:第0行是{1, 2, 0},第1行是{3, 0, 0}
// 只给连续值,编译器自动按行优先填充
int flat[2][3] = {1, 2, 3, 4, 5, 6};
// 等价于{ {1,2,3}, {4,5,6} }
还有一个很多教材讲得不透的规则:定义二维数组时可以省略行数,但不能省略列数。 比如int b[][3] = {...};是合法的,你可以让编译器根据初始化列表推断出行数。但int c[2][] = {...};是非法的,因为编译器必须知道每一行多长,才能计算c[i][j]在内存里的偏移量。这是前面说的“连续内存”的必然推论:已知列数、已知起始地址,才能算出某个元素的位置。
这个规则的实用场景是:函数定义中声明二维数组形参时,同样要写清楚列数。后面第5章我会再展开讲为什么。
3.3 双重循环遍历的性能直觉
遍历二维数组最常用的方式就是双重循环。这里要形成肌肉记忆:外层循环走行,内层循环走列。
c复制for (int i = 0; i < 3; i++) {
for (int j = 0; j < 4; j++) {
printf("%4d ", matrix[i][j]);
}
printf("\n");
}
初学阶段还要养成一个习惯:把行数和列数用宏或者常量定义出来,而不是在循环里写死数字。比如:
c复制#define ROWS 3
#define COLS 4
这看起来是个很琐碎的规范,但它能救命。当你要把矩阵大小从3×4改成10×20时,只改两行宏定义就够了。如果你在代码里到处写死数字,改到后面漏改一处,数组访问就会静默越界,出来的数据乱七八糟。
4. 二维数组实战案例:成绩表统计与矩阵转置
4.1 学生成绩表:按行求总分、按列求平均分
理论说再多,不如来一个完整的案例。假设现在有一个小组的成绩表:三个学生,每个人考四门课。数据存在一个3行4列的二维整型数组里,我们要计算每个学生的总分,以及每门课的平均分。
代码可以这样写:
c复制#define STUDENTS 3
#define COURSES 4
int main() {
int scores[STUDENTS][COURSES] = {
{78, 85, 90, 66},
{88, 91, 73, 80},
{69, 76, 85, 95}
};
// 每个学生的总分(按行累加)
for (int i = 0; i < STUDENTS; i++) {
int total = 0;
for (int j = 0; j < COURSES; j++) {
total += scores[i][j];
}
printf("第%d个学生总分: %d\n", i + 1, total);
}
// 每门课的平均分(按列累加)
for (int j = 0; j < COURSES; j++) {
int sum = 0;
for (int i = 0; i < STUDENTS; i++) {
sum += scores[i][j];
}
printf("第%d门课平均分: %.2f\n", j + 1, (double)sum / STUDENTS);
}
return 0;
}
这段代码把“行优先遍历”和“列优先遍历”都用到了。第一个双重循环是外层行内层列,也就是顺着每一行的课程去累加;第二个循环反过来,外层列内层行,也就是固定课程、遍历所有学生。两种遍历方向代表了两种完全不同的数据视角,建议你自己动手把循环变量换一换,体会一下区别。
另外可以看到,数组下标的习惯是[行][列],但在某些场景里你会发现行列表示哪种含义并不绝对,关键是遵循约定并保持一致。比如你也可以用row和col来做变量名,而不是笼统的i和j,这样会让代码更容易读。
4.2 方阵转置的误区与正确做法
矩阵转置是一个经典的二维数组练习:把行列互换。对于一个方阵来说,也就是元素a[i][j]和a[j][i]互换位置。
很多同学第一次写转置时,会直接写出这种代码:
c复制for (int i = 0; i < N; i++) {
for (int j = 0; j < N; j++) {
int temp = a[i][j];
a[i][j] = a[j][i];
a[j][i] = temp;
}
}
这个写法的问题在于:当i和j遍历到相同下标时没问题,但当循环跑到比如i=0, j=1时,它先把a[0][1]和a[1][0]交换了;等循环跑到i=1, j=0时,又把这两个元素交换回来了。等于白换了一遍。如果碰巧次数是奇数,数组反而被修改成错误状态。
正确的做法是只遍历上三角或者下三角。对于方阵,只需交换j > i的那部分元素:
c复制for (int i = 0; i < N; i++) {
for (int j = i + 1; j < N; j++) {
int temp = a[i][j];
a[i][j] = a[j][i];
a[j][i] = temp;
}
}
如果矩阵不是方阵,就不能在原数组上直接转置,必须先创建一个新数组,让它的行数等于原列数,列数等于原行数,然后再逐个填入。比如:
c复制int src[2][3] = {{1, 2, 3}, {4, 5, 6}};
int dst[3][2];
for (int i = 0; i < 2; i++) {
for (int j = 0; j < 3; j++) {
dst[j][i] = src[i][j];
}
}
这里dst[j][i]恰好是转置的定义:原矩阵的行变成新矩阵的列,原矩阵的列变成新矩阵的行。写这种遍历时下标尤其容易乱,我的经验是先用小规模数据推算一遍,比如2×3的矩阵,在纸上画出目标矩阵的形状,再回过去检查循环变量。纸上推演一遍,胜过在代码里反复试错半小时。
5. 数组与指针的交叉地带:现在不搞懂,后面一定绕远路
5.1 数组名不是变量:地址与自增的困惑
很多人学到后面会疑惑:数组名到底能不能改?结论是:数组名在表达式中会被当成指向首元素的指针常量的值,但它不是变量,不能执行arr++、arr = ...这种操作。因为数组名代表的是“整个数组对象本身”,不是一个孤立的指针变量。
举个对比:
c复制int a[5] = {1, 2, 3, 4, 5};
printf("%p\n", a); // 数组首元素地址
printf("%p\n", &a[0]); // 同样也是首元素地址
你可能会说“既然它们的值相同,那数组名不就是指针吗?”从语法行为上看,确实很像,但性质上完全不同。指针变量可以改指向,数组名不能改“指向”任何别的地方。你可以在函数里写int *p = a; p++;,但你不能写a++。记住这个区别就够了,更深入的“数组名退化”话题,留到指针专题再展开。
5.2 二维数组传参时,列数为什么不能省略
这是初学者写函数时很容易踩坑的一个点。当你把一个二维数组传给函数时,形参可以这样写:
c复制void printMatrix(int matrix[][COLS], int rows);
注意,COLS必须在声明里写出来,rows反而可以省略。原因很实在:函数里访问matrix[i][j]时,编译器需要知道每行多少个int元素,才能计算出第i行第j列相对首地址的偏移量。偏移公式是i * COLS + j。如果你不告诉它COLS,它根本无从计算。
所以二位数传参的原则是:必须显式保留列数,行数可以通过参数传入。 前面第3章说过定义二维数组不能省略列数,这里的道理是同一个,只是从函数形参的角度再验证了一次。
5.3 新手最容易犯的int**错误直觉
最后一个要说的常见误区,是用int **p去接收int a[3][4]。很多新手觉得“二维数组嘛,那一定就是二级指针”,于是写出:
c复制int a[3][4];
int **p = a; // 错误!编译器会警告或报错
为什么错?因为a的类型是“指向含4个int的数组的指针”,也就是int (*)[4],不是int **。int **表示“指向一个int*变量的指针”,它和连续排布的二维数组是完全不同的东西。a在内存里的每个“元素”是4个int组成的一行,而int **指向的对象是“一个存放指针的地址”。
理解这个需要一点抽象能力,现在你可以先记住结论:二维数组名和二级指针不能直接互换。 等学到后面处理动态二维数组时,你可能会真的使用int **,那是因为动态分配的“数组”本质上是一个存放指针的数组,每行又由指针指向一维数组,和静态二维数组的内存布局完全不同。这两种结构各有各的用途,不要混着用。
如果现在觉得这部分有点绕,不用担心。你只需要做到两点:第一,知道二维数组名不是int **;第二,能用“行指针”int (*p)[4]的方式去指向它。至于更复杂的多级指针,留到后面的专题再说。
写到这里,今天的核心内容就差不多了。最后再啰嗦几句自己的教学体会:学数组这一章,最忌讳的是“只敲代码不画图”。今天讲的内存连续排布、'\0'背后的存储逻辑、二维数组的行列偏移,你如果只在代码里看,永远隔着一层。拿起笔,在一个格子里画一画数组每个元素的内存位置,标好下标、标好地址,画完两三个例子,很多困惑会一下子畅通。学完今天的内容,我建议你给自己出一个自测题:写一个程序,从键盘读若干个字符串,存进一个二维字符数组,再按字典序输出。这个练习把字符数组、二维数组、输入输出、字符串比较函数全串起来了,做完你会发现自己对数组的掌控感上升一个台阶。
