字符串数组是 C 语言里最容易让人绕晕的知识点之一。很多初学者学到数组时觉得没问题,学到指针时也勉强能跟上,但一碰到“字符串数组”这个概念,就开始分不清char name[3][20]和char *name[3]到底有什么区别,更不用说在写用户名单、命令解析、文件读取这类程序时该怎么选。这次我们就把字符串数组彻底讲透,先看它到底是什么、有哪几种实现方式,再通过可运行的代码示例把它跑通。
字符串数组在 C 语言中没有一个独立的“字符串”类型,它本质上仍然是字符数组,或者是指向字符的指针数组。核心区别在于你要存的是“一组固定长度的字符串”,还是“一组长度不确定的字符串”;是“可以原地修改内容”,还是“只需要读取和排序”。这两种需求对应两种完全不同的写法,内存布局、修改方式、传参方式都不一样。本文会围绕二维字符数组和指针数组两条主线展开,配合完整的编译运行示例、常见错误分析和排查方法,帮你一次性搞定字符串数组的定义、初始化、遍历、修改、传参和排序。
如果你是正在学 C 语言的初学者,或者准备应对期末考试、计算机等级考试、PTA 习题和洛谷题目,这篇文章可以直接收藏。下面先给出一张核心能力速览表,把字符串数组的两种实现方式、适用场景和关键区别一次说清。
1. 字符串数组核心概念速览
| 实现方式 | 定义形式 | 存储位置 | 字符串内容可否修改 | 适合场景 | 典型示例 |
|---|---|---|---|---|---|
| 二维字符数组 | char arr[行数][列数] | 栈区或全局区,空间连续 | 可以修改 | 字符串数量固定、每条最长长度可知 | 学生姓名表、固定选项菜单 |
| 指针数组 | char *arr[行数] | 数组本身在栈区,字符串常量或堆区 | 取决于指针指向哪里 | 字符串数量固定、长度差异大 | 命令词表、星期名称、错误信息表 |
| 指向指针的指针 | char **arr | 多用于函数传参和动态分配 | 可以修改 | 数量不固定的字符串集合 | 命令行参数、动态读取文件内容 |
三种方式里,前两种是字符串数组最经典的写法,第三种本质上是第二种的传参和动态扩展形式。理解前两种,后面遇到char **就不会慌。
从内存角度看,二维字符数组是“一块完整的内存区域”,每行长度固定,编译器直接分配一整块空间。指针数组则是“一组指针变量”,每个指针指向一个字符串的首地址,字符串本身可能不在数组的连续空间里。这个差异决定了所有后续操作:scanf、strcpy、strcmp、排序交换、传参,都会因为选择的不同而写法不同。
还有一个常见误区是混淆“字符数组”和“字符串数组”。char str[20]是一个能存放一条字符串的字符数组;char strs[10][20]是能存放 10 条字符串的字符串数组。区别就是多了一维。这一维让程序可以同时管理多条字符串,这也是字符串数组存在的意义。
2. 适用场景与实际使用边界
字符串数组在 C 语言项目里非常常用,但搞清楚“什么时候该用哪种”比背语法更重要。
适合用二维字符数组的场景:
- 存储固定数量的用户名、学号、商品名,每条长度有上限。
- 需要频繁修改某一行的字符内容,比如循环读入数据后改写某个字段。
- 做冒泡排序或选择排序时希望直接交换整行内容,虽然可以用
strcpy实现,但注意行长度固定不会越界。 - 函数内部临时定义一个小型字符串表,不涉及复杂的内存生命周期管理。
适合用指针数组的场景:
- 存储一组字符串常量,如星期几的英文名、月份名、错误提示信息。
- 字符串长度差异极大,例如有的字符串只占几个字节,有的几百字节,用二维数组会浪费空间。
- 只需要遍历、比较、排序输出,不需要修改字符串内容。
- 从外部读入未知长度的字符串,配合
malloc动态分配后让指针数组的元素指向堆区内存。
不适合或需要小心的场景:
- 用指针数组存放字符串常量后,试图通过
arr[i][j] = 'A'修改内容,这会导致未定义行为,程序可能崩溃。 - 二维字符数组定义过大,例如
char arr[10000][1024],会占用约 10MB 栈空间,在部分嵌入式环境或 Windows 默认栈大小下可能直接栈溢出。这种场景应该使用malloc动态分配。 - 函数返回局部二维字符数组名,返回后数组已销毁,指针悬空。此时应使用动态分配或指针数组。
合规方面,字符串数组本身不涉及版权和隐私问题,但在处理用户输入、读取外部文件字符串时,要注意缓冲区溢出风险。所有scanf、gets操作都要限制长度,strcpy要能确保目标空间足够,建议使用strncpy或snprintf替代。
3. 环境准备与前置条件
C 语言字符串数组的验证不需要特殊硬件,普通 CPU 即可,也没有显存、GPU 的要求。需要准备的是:
| 工具 | 说明 |
|---|---|
| C 语言编译器 | GCC、Clang、MSVC 均可 |
| 代码编辑器 | VS Code、Visual Studio、CLion、Dev-C++ 均可 |
| 操作系统 | Windows、Linux、macOS 均可 |
| 命令行终端 | Windows 用 CMD 或 PowerShell,Linux/macOS 用 Terminal |
Windows 下可以使用 MinGW-w64 或 Visual Studio 的开发者命令提示符。Linux 下一般自带 GCC,直接执行gcc --version检查。macOS 执行clang --version。
编译运行的基本命令格式:
# GCC 编译并运行 gcc -std=c11 -Wall -Wextra -o string_array_demo string_array_demo.c ./string_array_demo# Windows 下如果使用 MinGW gcc -std=c11 -Wall -Wextra -o string_array_demo.exe string_array_demo.c .\string_array_demo.exe-Wall -Wextra建议一直加上,字符串数组的很多问题编译器都会给出警告,早发现早处理。
4. 二维字符数组定义、初始化与遍历
二维字符数组的定义形式是char 数组名[行数][列数]。其中行数代表能存多少条字符串,列数代表每条字符串的最大字符数,注意要包含结尾的\0。
4.1 定义和初始化
#include <stdio.h> int main(void) { // 方式一:完全初始化,编译器自动计算行数 char students[][20] = { "zhangsan", "lisi", "wangwu" }; // 方式二:指定行数和列数 char weeks[7][10] = { "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday" }; // 方式三:先定义后逐条赋值 char fruits[3][20]; snprintf(fruits[0], sizeof(fruits[0]), "apple"); snprintf(fruits[1], sizeof(fruits[1]), "banana"); snprintf(fruits[2], sizeof(fruits[2]), "cherry"); int row = sizeof(students) / sizeof(students[0]); printf("students 数组行数: %d\n", row); printf("students 数组总大小: %zu 字节\n", sizeof(students)); printf("每一行大小: %zu 字节\n", sizeof(students[0])); for (int i = 0; i < row; i++) { printf("%s\n", students[i]); } return 0; }这里要注意sizeof(students) / sizeof(students[0])是 C 语言中获取数组行数的通用技巧。sizeof(students)是整个二维数组占用的字节数,sizeof(students[0])是每一行占用的字节数,两者相除就是行数。这种方式在函数外部与数组定义在同一个作用域内有效,一旦传入函数,数组会退化为指针,sizeof就得不到行数了,这点后面会单独讲。
snprintf(fruits[0], sizeof(fruits[0]), "apple")是安全写法,限制了拷贝长度,避免strcpy造成的缓冲区溢出。
4.2 遍历与输出
遍历二维字符数组,本质上是逐行取出字符串,对字符串本身的操作和单条字符串一致。
#include <stdio.h> #include <string.h> int main(void) { char names[][20] = {"alice", "bob", "carol", "dave"}; int count = sizeof(names) / sizeof(names[0]); // 基本遍历输出 for (int i = 0; i < count; i++) { printf("第 %d 个名字: %s\n", i, names[i]); } // 计算每条字符串的长度 for (int i = 0; i < count; i++) { printf("%s 的长度是 %zu\n", names[i], strlen(names[i])); } // 修改某一行的内容 strcpy(names[1], "robert"); printf("修改后: %s\n", names[1]); return 0; }运行结果:
第 0 个名字: alice 第 1 个名字: bob 第 2 个名字: carol 第 3 个名字: dave alice 的长度是 5 bob 的长度是 3 carol 的长度是 5 dave 的长度是 4 修改后: robert因为二维字符数组每一行是连续内存,strcpy(names[1], "robert")可以直接覆盖内容,前提是目标字符串长度不超过20 - 1 = 19个字符。使用strncpy更稳妥:
strncpy(names[1], "robert", sizeof(names[1]) - 1); names[1][sizeof(names[1]) - 1] = '\0';strncpy不会自动补结尾\0,如果源字符串长度等于或超过指定长度,目标字符串可能没有终止符,所以需要手动置\0。这是初学者最容易踩的坑。
4.3 使用 scanf 读入字符串数组
输入场景更接近实际编程:
#include <stdio.h> int main(void) { char students[3][20]; printf("请输入 3 个名字,用空格或回车分隔:\n"); for (int i = 0; i < 3; i++) { scanf("%19s", students[i]); } printf("\n你输入的名字是:\n"); for (int i = 0; i < 3; i++) { printf("%s\n", students[i]); } return 0; }scanf("%19s", students[i])中的19是宽度限制,防止输入超过 19 个字符导致缓冲区溢出。students[i]本身是char[20]类型,传给scanf时自动退化为指向首元素的指针,所以不需要再加&。很多初学者会写成scanf("%s", &students[i]),这虽然可能碰巧工作,但类型是错误的,极不推荐。
可以看到,二维字符数组的读入、修改、遍历都围绕“行”这个概念展开,students[i]可以当作一条普通的字符串使用。
5. 指针数组定义、初始化和使用
指针数组的定义形式是char *数组名[行数]。这个数组里的每个元素都是一个char *指针,指向一条字符串。
5.1 定义和初始化
#include <stdio.h> int main(void) { // 指向字符串常量 char *weekdays[] = { "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday" }; int count = sizeof(weekdays) / sizeof(weekdays[0]); printf("weekdays 数组元素个数: %d\n", count); printf("weekdays 数组总大小: %zu 字节\n", sizeof(weekdays)); printf("每个指针元素大小: %zu 字节\n", sizeof(weekdays[0])); for (int i = 0; i < count; i++) { printf("%s\n", weekdays[i]); } return 0; }在 64 位系统上,sizeof(weekdays[0])是 8 字节,因为一个指针占 8 字节。这就是指针数组和二维字符数组最大的差异:二维字符数组的sizeof(students[0])是列数,比如 20;指针数组的sizeof(weekdays[0])是指针大小,8 或 4。两者虽然都可以用数组名[下标]访问字符串,但底层完全不同。
5.2 指针指向字符串常量的修改限制
这是最容易翻车的地方之一:
#include <stdio.h> int main(void) { char *arr[] = {"hello", "world"}; // 试图修改字符串常量 // arr[0][0] = 'H'; // 未定义行为,可能会崩溃或不起作用 // 输出字符串常量没问题 printf("%s %s\n", arr[0], arr[1]); // 让指针指向另一条字符串常量,这是允许的 arr[0] = "HELLO"; printf("重新指向后: %s\n", arr[0]); return 0; }把注释解除,程序可能直接崩溃,也可能静默失败,甚至在某些编译器上看起来正常。这属于典型的未定义行为,任何结果都可能出现。原因在于"hello"是字符串字面量,存储在只读区,通过指针修改它是非法的。
而arr[0] = "HELLO"只是修改指针变量本身的值,让指针指向另一块只读区,这是合法的。理解“修改指针”和“修改指针指向的内容”的区别,是掌握指针数组的关键。
5.3 指针数组指向可修改内存
如果需要在指针数组中修改字符串内容,可以让指针指向栈区数组或堆区内存:
#include <stdio.h> #include <string.h> #include <stdlib.h> int main(void) { char temp[][20] = {"red", "green", "blue"}; char *colors[3]; for (int i = 0; i < 3; i++) { colors[i] = temp[i]; // 指向栈区数组 } printf("访问栈区字符串: %s\n", colors[0]); // 修改栈区内容 strcpy(colors[0], "yellow"); printf("修改成功: %s\n", colors[0]); // 动态分配堆区内存 char *dynamic[2]; dynamic[0] = (char *)malloc(20 * sizeof(char)); dynamic[1] = (char *)malloc(20 * sizeof(char)); if (dynamic[0] == NULL || dynamic[1] == NULL) { fprintf(stderr, "内存分配失败\n"); return 1; } strcpy(dynamic[0], "heap string 1"); strcpy(dynamic[1], "heap string 2"); printf("%s\n", dynamic[0]); printf("%s\n", dynamic[1]); free(dynamic[0]); free(dynamic[1]); return 0; }这里展示了两种让指针数组指向可修改内存的方式。第一种是让指针指向已有的栈区二维数组,本质上是把temp[i]的首地址赋给colors[i]。第二种是动态分配堆区内存,适合字符串长度在运行时才能确定的场景。
动态分配时必须检查malloc的返回值,返回NULL表示分配失败,直接使用会空指针崩溃。使用完必须free,并且只能free动态分配的内存,不能free指向栈区数组的指针。
5.4 用指针数组管理命令词表
指针数组最常见的使用场景之一是管理固定集合的字符串表:
#include <stdio.h> #include <string.h> int main(void) { char *commands[] = { "help", "version", "quit", "list", "unknown" }; char input[20]; printf("输入一个命令: "); scanf("%19s", input); int found = 0; int cmd_count = sizeof(commands) / sizeof(commands[0]); for (int i = 0; i < cmd_count; i++) { if (strcmp(input, commands[i]) == 0) { printf("命令匹配: %s\n", commands[i]); found = 1; break; } } if (!found) { printf("未知命令\n"); } return 0; }这种命令解析写法在嵌入式固件、简单的 CLI 工具里很常见。commands这个指针数组只保存字符串常量的地址,不分配额外空间,程序体积小、访问快。
6. 字符串数组的排序、查找与函数传参
6.1 字符串数组冒泡排序
排序是字符串数组最常见的操作。如果使用二维字符数组,交换两行要借助strcpy逐字符拷贝;如果使用指针数组,交换两个指针即可,效率更高。
先看二维字符数组版本的冒泡排序:
#include <stdio.h> #include <string.h> void sort_strings_2d(char arr[][20], int n) { char temp[20]; for (int i = 0; i < n - 1; i++) { for (int j = 0; j < n - 1 - i; j++) { if (strcmp(arr[j], arr[j + 1]) > 0) { strcpy(temp, arr[j]); strcpy(arr[j], arr[j + 1]); strcpy(arr[j + 1], temp); } } } } int main(void) { char fruits[][20] = {"pear", "apple", "orange", "banana", "grape"}; int n = sizeof(fruits) / sizeof(fruits[0]); sort_strings_2d(fruits, n); printf("排序结果:\n"); for (int i = 0; i < n; i++) { printf("%s\n", fruits[i]); } return 0; }注意函数参数里的char arr[][20],列数必须写出来,因为编译器需要知道每行的步长才能计算arr[j]的地址。如果改成char arr[][]或char arr[][ ]都会编译错误。
再看指针数组版本:
#include <stdio.h> #include <string.h> void sort_strings_ptr(char *arr[], int n) { char *temp; for (int i = 0; i < n - 1; i++) { for (int j = 0; j < n - 1 - i; j++) { if (strcmp(arr[j], arr[j + 1]) > 0) { temp = arr[j]; arr[j] = arr[j + 1]; arr[j + 1] = temp; } } } } int main(void) { char *fruits[] = {"pear", "apple", "orange", "banana", "grape"}; int n = sizeof(fruits) / sizeof(fruits[0]); sort_strings_ptr(fruits, n); printf("排序结果:\n"); for (int i = 0; i < n; i++) { printf("%s\n", fruits[i]); } return 0; }这个版本里,排序交换的只是指针变量的值,指向的字符串常量本身没有发生任何移动。如果字符串长度差异很大,指针数组不知道高效到哪里去了。但注意,如果原来的指针数组指向的是不可修改的字符串常量,排序只是改变数组元素的指向顺序,这完全合法。如果指向的是动态分配的内存,交换指针时要小心原指针的所有权,避免内存泄漏和重复释放。
6.2 函数传参的几种写法
字符串数组作为函数参数,有以下常见写法:
#include <stdio.h> // 方式一:二维字符数组参数,列数必须写 void print_2d(char arr[][20], int rows) { for (int i = 0; i < rows; i++) { printf("%s\n", arr[i]); } } // 方式二:指针数组参数 void print_ptr(char *arr[], int rows) { for (int i = 0; i < rows; i++) { printf("%s\n", arr[i]); } } // 方式三:指向指针的指针,本质同上 void print_ptr2(char **arr, int rows) { for (int i = 0; i < rows; i++) { printf("%s\n", arr[i]); } } int main(void) { char names[][20] = {"a", "bb", "ccc"}; char *week[] = {"mon", "tue", "wed"}; print_2d(names, 3); print_ptr(week, 3); print_ptr2(week, 3); return 0; }方式二和方式三在函数形参上是等价的,char *arr[]会退化为char **arr。很多刚学 C 语言的同学看到char **就害怕,其实它就是“指向指针的指针”,在字符串数组场景里就是“字符串数组参数”的另一种写法。
6.3 字符串数组传参后求行数的问题
在函数内部不能用sizeof(arr) / sizeof(arr[0])求行数,因为数组参数已经退化为指针。
#include <stdio.h> void bad_size(char arr[][20]) { // 错误示例:arr 是指针类型,不是数组 // int n = sizeof(arr) / sizeof(arr[0]); // 得到的是 (8 / 20) = 0,64 位系统上 printf("在函数内部无法通过 sizeof 求行数\n"); } int main(void) { char data[][20] = {"x", "y"}; bad_size(data); return 0; }解决方案是像前面的示例一样,在调用函数时把行数作为参数传入,或者使用宏定义数组大小。这是 C 语言数组传参的基本规则,字符串数组也不例外。
7. 字符串数组接口设计思路
这里说的“接口”不是网络 API,而是函数接口。字符串数组在真实项目中经常要暴露给多个模块使用。
7.1 结构体封装字符串表
当字符串数组和它的数量需要一起传递时,用结构体封装是更好的设计:
#include <stdio.h> #include <stdlib.h> typedef struct { char **items; int count; } StringList; void print_string_list(const StringList *list) { if (list == NULL || list->items == NULL) { return; } for (int i = 0; i < list->count; i++) { printf("%s\n", list->items[i]); } } int main(void) { char *data[] = {"one", "two", "three"}; StringList list; list.items = data; list.count = 3; print_string_list(&list); return 0; }这种设计避免了“数组和长度分离”导致的问题。在更大的项目里,StringList 可以继续扩展,比如增加 capacity 字段支持动态追加字符串,增加 sort 函数指针等。这个思路和 C 语言标准库中一些字符串数组处理场景很接近,也是面试中常考的“手写动态字符串数组”的基础。
7.2 批量处理字符串数组的思路
批量处理场景,例如从文件读取多行文本存入字符串数组,再逐行处理:
#include <stdio.h> #include <stdlib.h> #include <string.h> #define MAX_LINES 100 #define MAX_LINE_LEN 256 int main(void) { FILE *fp = fopen("input.txt", "r"); if (fp == NULL) { perror("无法打开文件"); return 1; } char *lines[MAX_LINES]; char buffer[MAX_LINE_LEN]; int count = 0; while (count < MAX_LINES && fgets(buffer, sizeof(buffer), fp) != NULL) { // 去掉换行符 buffer[strcspn(buffer, "\n")] = '\0'; lines[count] = (char *)malloc((strlen(buffer) + 1) * sizeof(char)); if (lines[count] == NULL) { fprintf(stderr, "内存分配失败\n"); // 释放已分配的内存 for (int i = 0; i < count; i++) { free(lines[i]); } fclose(fp); return 1; } strcpy(lines[count], buffer); count++; } fclose(fp); printf("读取了 %d 行:\n", count); for (int i = 0; i < count; i++) { printf("%s\n", lines[i]); } for (int i = 0; i < count; i++) { free(lines[i]); } return 0; }这是指针数组配合动态内存分配的典型批量处理流程:按行读取、动态分配、保存指针、最后统一释放。strcspn(buffer, "\n")找到换行符的位置,直接替换成\0,是去掉fgets自带换行符的标准写法。动态分配的内存必须一一对应释放,释放后再把指针置为NULL是一个好习惯,但注意这里的局部数组在函数结束时就会销毁,置空是防御性写法,主要意义在于防止重复释放。
实际批量处理时,还要考虑:如果超过MAX_LINES行怎么办,遍历后是否允许重复读取,是否需要排序去重。可以扩展成动态扩容的二维结构,但那就是“动态字符串数组”的进阶话题了。
8. 常见错误与排查方法
字符串数组的报错和运行问题,多数集中在类型不匹配、缓冲区溢出、内存错误和sizeof误用上。下面整理成一张排查表。
| 问题现象 | 可能原因 | 排查方式 | 解决方案 |
|---|---|---|---|
编译错误:array type has incomplete element type | 二维数组函数参数缺少列数 | 检查函数声明里的形参 | 写完整char arr[][20] |
编译错误:assignment to expression with array type | 试图直接给数组名赋值 | 检查是否写了arr = something | 用strcpy或snprintf逐字符拷贝 |
| 运行时崩溃或卡死 | 越界写入、栈溢出 | 使用-fsanitize=address编译检查 | 限制输入长度,检查数组维度 |
| 输出乱码或带换行符 | fgets保留了\n | 打印字符串长度 | 用strcspn去掉换行符 |
sizeof(arr)结果不对 | 数组已退化为指针 | 检查是否在函数内部使用sizeof | 把行数作为参数传入 |
| 排序结果没有变化 | 字符串内容未被真正交换 | 检查排序时使用strcpy还是指针交换 | 二维数组用strcpy,指针数组交换指针 |
| 程序提示段错误 | 指针数组指向只读区后被修改 | 检查是否写了arr[i][j] = ... | 改为重新指向,或指向可修改内存 |
malloc后内存泄漏 | 动态分配未被free | 使用 Valgrind 检查内存泄漏 | 保证每个malloc都有对应的free |
8.1 使用 AddressSanitizer 定位越界问题
Linux 或 macOS 下,可以用 GCC/Clang 的 AddressSanitizer 快速定位与字符串数组相关的越界和栈问题:
gcc -std=c11 -Wall -Wextra -fsanitize=address -g -o demo demo.c ./demo如果程序有数组越界或非法访问,编译器插入的检测代码会在崩溃前打印出具体位置和调用栈,排查效率远超肉眼看代码。Windows 下 Visual Studio 也有类似功能,但配置略繁琐,建议在 Linux 环境或 WSL 里做这类检查。
8.2 使用 Valgrind 检查内存错误
对于指针数组和动态内存分配场景,Valgrind 是另一个利器:
gcc -std=c11 -Wall -Wextra -g -o demo demo.c valgrind --leak-check=full ./demoValgrind 会报告未初始化内存、非法读写、内存泄漏等。在涉及字符串数组的动态内存分配、批量读取文件、排序交换指针时,用 Valgrind 验证一遍基本可以放心。
8.3 指针数组和二维数组的互换使用错误
一个常见错误是以为char *arr[]和char arr[][20]可以随意互换。实际上它们作为函数参数时的内存布局完全不同:
// 错误:把二维数组传给 char ** 参数 // char data[3][20]; // print_ptr2(data, 3); // 编译警告或错误:类型不兼容 // 正确: // 指针数组传给 char ** 参数 // char *data[] = {"a", "b", "c"}; // print_ptr2(data, 3); // OK从严格类型角度,char **的参数期望的是一个指针数组,而char [3][20]的类型是“含有 3 个元素、每个元素是含有 20 个 char 的数组”的数组,二者不能隐式转换。想传给二维数组,必须写成char arr[][20]。
如果确实需要用统一的接口处理两种情况,可以借助void *强制转换,但这样做的类型安全性较差,不建议初学者使用。更稳妥的做法是明确两种数组类型,写两套函数,或者统一使用指针数组加动态分配。
9. 字符串数组最佳实践与代码规范
写 C 语言字符串数组时,以下实践能显著减少 Bug。
第一,所有读入操作必须限定长度。scanf("%19s", buf)而不是scanf("%s", buf),fgets(buf, sizeof(buf), fp)而不是gets(buf)。gets在 C11 标准中已经移除,绝对不要使用。这是防止缓冲区溢出最主要的措施。
第二,定义二维字符数组时,列数留足余量。比如学生姓名最长可能 10 个字符,别定义char name[10],要定义成char name[32]或char name[64],给\0和可能的扩展留空间。定义完可以用#define或enum管理维度,避免魔法数字。
#define MAX_NAME_LEN 32 #define MAX_STUDENTS 50 char students[MAX_STUDENTS][MAX_NAME_LEN];第三,区分“修改指针”和“修改指针指向的内容”。用指针数组管理字符串常量时,可以重新给指针赋值,但不能通过指针修改字符串常量。如果确实需要修改内容,就使用二维数组或者动态分配内存。
第四,动态分配必须配对释放。每个malloc都要有对应的free,释放后建议把指针置为NULL。释放顺序与分配顺序相反,避免悬垂指针。
第五,函数参数边界清晰。如果传入字符串数组的函数需要知道行数和列数,就全部通过参数传入,别依赖sizeof。如下所示:
void process_strings(char arr[][MAX_LEN], int rows) { for (int i = 0; i < rows; i++) { // 处理 arr[i] } }第六,优先使用snprintf、strncpy、strncat等限定长度的函数,而不是sprintf、strcpy、strcat。虽然strncpy有补零和截断的细节要注意,但总体比无界拷贝安全得多。
第七,涉及指针数组排序时,交换的是指针而不是整个字符串。如果确实需要交换二维数组行内容,使用strcpy到临时数组,但要注意临时数组大小与列数一致。
第八,遇到程序崩溃不要慌,优先怀疑字符串越界。编译时开-Wall -Wextra -fsanitize=address,运行时用 Valgrind,基本能定位 90% 的字符串数组问题。
10. 练习题与验证示例
为了验证是否真的理解了字符串数组,下面几道题可以直接上手练习。
题目一:定义一个包含 5 个城市名的字符串数组,把用户输入的城市名替换到指定位置,再遍历输出。
#include <stdio.h> #include <string.h> int main(void) { char cities[5][20] = {"Beijing", "Shanghai", "Guangzhou", "Shenzhen", "Chengdu"}; int index; char newName[20]; printf("要替换的下标(0-4): "); scanf("%d", &index); if (index < 0 || index >= 5) { printf("下标越界\n"); return 1; } printf("输入新城市名: "); scanf("%19s", newName); strncpy(cities[index], newName, sizeof(cities[index]) - 1); cities[index][sizeof(cities[index]) - 1] = '\0'; for (int i = 0; i < 5; i++) { printf("%s\n", cities[i]); } return 0; }题目二:使用指针数组保存{"C", "Python", "Java", "Go", "Rust"},用选择排序按字典序输出。
#include <stdio.h> #include <string.h> void selection_sort(char *arr[], int n) { for (int i = 0; i < n - 1; i++) { int min_idx = i; for (int j = i + 1; j < n; j++) { if (strcmp(arr[j], arr[min_idx]) < 0) { min_idx = j; } } if (min_idx != i) { char *temp = arr[i]; arr[i] = arr[min_idx]; arr[min_idx] = temp; } } } int main(void) { char *langs[] = {"C", "Python", "Java", "Go", "Rust"}; int n = sizeof(langs) / sizeof(langs[0]); selection_sort(langs, n); for (int i = 0; i < n; i++) { printf("%s\n", langs[i]); } return 0; }题目三:定义函数count_string(char *arr[], int n, const char *target),统计指针数组中等于target的字符串个数。这个题考察指针数组遍历和strcmp的用法,能写出来说明基本掌握。
题目四:从键盘读入若干行字符串存入指针数组,当输入end时停止,然后逆序输出所有行。这个题结合动态内存、strcmp、逆序遍历,是综合性较强的练习。
题目五:写一个函数,把二维字符数组的所有字符串首字母转为大写,并返回转换的字符串个数。这个题考察二维数组逐行修改和字符判断。
上面的题目都能在本地完成编译和运行验证。如果你在 PTA、洛谷或学校 OJ 里遇到字符串数组相关题目,比如字符串逆序、统计单词数量、按字典序排序输出,思路都可以从本文的例子迁移过去。
11. 总结与下一步
字符串数组这块,最值得记住的判断标准就是三条:字符串数量是否固定、字符串长度差异大不大、是否要修改字符串内容。数量固定且长度有上限,优先用二维字符数组;数量固定但长度差异大或只是读取,优先用指针数组;数量不固定需要动态扩展,就用char **配合malloc。
最先应该动手验证的是二维字符数组的读入和遍历流程,因为这个流程覆盖了大部分日常场景。你可以在本地新建一个.c文件,把本文第 4 小节的示例粘贴进去,编译运行,然后改成从键盘读入 5 个字符串再输出,确认scanf("%19s", arr[i])的写法没问题。
最容易踩的坑是试图修改指针数组指向的字符串常量。写代码前先问自己:这个指针指向的内存可写吗?如果指向的是字符串字面量,只能读取和重新指向,不能逐字符修改。第二个坑是函数内部用sizeof求数组长度,这个在数组参数退化为指针的情况下绝对会出错。
下一步拓展方向有以下几条:
- 研究动态字符串数组,即
char **配合realloc实现自动扩容,这是实现 C 语言动态列表的基础。 - 结合
qsort对字符串数组排序,理解函数指针作为比较回调的用法。 - 结合结构体,构建
StringList之类的字符串集合工具,封装增删改查。 - 结合文件操作,实现从文本文件读入多行内容到字符串数组并统计分析。
- 结合 C 语言库函数,如
strtok拆分字符串后存入数组,处理命令解析和配置解析场景。
字符串数组是 C 语言从“会语法”到“能写程序”之间必须跨过的一道坎。二维字符数组和指针数组这两种形态搞清楚了,后面看任何 C 语言项目里的字符串处理逻辑都会顺畅很多。建议把本文中的示例代码挨个编译运行一遍,再去做几道在线评测题,这个知识点就能彻底稳住了。