C语言字符串数组,这一篇把内存模型和常见写法讲透
字符串数组是 C 语言里绕不开的一个知识点。很多初学者卡在“字符数组”和“字符串数组”的区别上,又分不清char a[10][20]和char *a[10]到底谁适合哪种场景。这次就把字符串数组拆开讲清楚:怎么定义、怎么初始化、怎么遍历、怎么排序,以及最常见的几个坑在哪。
这篇文章适合正在学 C 语言指针和数组的读者,也适合准备期末考试或 GESP、CSP 之类竞赛的同学。看完之后,你应该能区分两种字符串数组的内存布局,能写代码完成字符串排序、查找,也知道为什么有些写法会直接段错误。
1. 核心概念:C 语言里的“字符串数组”到底指什么
在 C 语言中,字符串本身就是一个以\0结尾的字符数组。所以“字符串数组”本质上是一个二维结构,只是有两种常见实现方式:
| 定义方式 | 类型 | 特点 | 典型场景 |
|---|---|---|---|
char str[5][20] | 二维字符数组 | 每行固定长度,内存连续,字符串可原地修改 | 数量固定、长度可控的字符串集合 |
char *str[5] | 字符指针数组 | 数组里存放指向字符串的指针,内存开销小,定义灵活 | 字符串数量固定,但长度变化较大 |
const char *str[5] | 指向字符串字面量的指针数组 | 只能读不能写,适合存储固定提示语、菜单项 | 只读字符串表 |
char **str | 指针的指针 | 二级指针,通常用于动态分配和函数传参 | 动态字符串列表、命令行参数 |
这里先记住一个最关键的结论:到底选二维数组还是指针数组,取决于“字符串要不要修改”和“长度是否固定”。
2. 两类主流写法的内存模型
2.1 二维字符数组:连续内存,每行固定
char names[3][16] = { "Tom", "Jerry", "Spike" };这段代码在内存中申请了一块连续空间:3 行 × 16 字节,共 48 字节。每行都固定占用 16 字节,即使 "Tom" 只需要 4 字节,剩下的 12 字节也会被占用,其中\0之后的内容是未初始化的零值。
这种方式的优点是:
- 编译器在编译时就能确定总大小。
- 可以原地修改
names[0][0]这种元素。 - 传给函数时,参数类型是
char (*)[16],边界清晰。
缺点是:
- 如果字符串长度参差不齐,浪费空间。
- 如果某一行超过 15 个有效字符,就会放不下
\0,超过部分被截断或溢出。
2.2 字符指针数组:只存指针,字符串另放
char *names[3] = { "Tom", "Jerry", "Spike" };这里names数组只存放 3 个char *指针,每个指针 8 字节(64 位系统),总共 24 字节。字符串本身存在只读数据区,数组里的指针指向对应字符串首地址。
这种写法的关键点在于:
- 字符串字面量不一定能修改。
names[0][0] = 't'在多数平台上会触发段错误,因为字面量可能被放在只读常量区。不同编译器、不同平台行为不同,但标准 C 明确规定修改字符串字面量是未定义行为。 - 适合存放长度差异很大的字符串,因为内存按需分配,不浪费固定空间。
- 适合把字符串作为整体传递、排序。排序时可以只交换指针,不需要复制整段字符数据。
2.3 一个典型对比表格
| 操作 | char a[3][16] | char *a[3] |
|---|---|---|
| 存储模型 | 连续二维数组 | 指针数组 + 独立字符串常量 |
| 单行最大长度 | 固定 15 字符可用 | 取决于字面量长度 |
| 排序交换成本 | 需要逐字节拷贝 | 只交换指针,速度快 |
| 修改字符串内容 | 可以 | 指向字面量时不可 |
| 动态读入长字符串 | 容易越界 | 需要先分配空间 |
| 函数形参 | char a[][16]或char (*a)[16] | char **a或char *a[] |
初学者最容易犯的错是把第二种写法当作第一种来修改字符串。记住原则:如果字符串是在运行时要被修改的,优先用二维数组或动态分配的内存;如果只是只读集合,用指针数组更省空间、更高效。
3. 字符串数组的定义与多种初始化方式
3.1 二维数组初始化
#include <stdio.h> int main() { // 方式一:直接初始化 char week[7][10] = { "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday" }; // 方式二:省略第一维大小,由编译器数出来 char week2[][10] = { "Monday", "Tuesday", "Wednesday" }; // 方式三:不写大小,全部由初始化决定 char week3[][10] = {"Mon", "Tue"}; printf("%zu\n", sizeof(week) / sizeof(week[0])); // 7 printf("%zu\n", sizeof(week2) / sizeof(week2[0])); // 3 printf("%s\n", week[0]); return 0; }需要特别注意的是:sizeof(week) / sizeof(week[0])这种方法只适用于数组本身,不能用于函数形参。因为形参传进去后已经退化成指针,sizeof结果是指针大小。
3.2 指针数组初始化
#include <stdio.h> int main() { // 指向字符串字面量 const char *colors[] = {"Red", "Green", "Blue", "Yellow"}; // 指向可修改的字符数组 char c1[] = "Red"; char c2[] = "Green"; char *colors2[] = {c1, c2}; // 动态分配 char *list[3]; list[0] = (char *)malloc(20); list[1] = (char *)malloc(20); list[2] = (char *)malloc(20); for (int i = 0; i < 3; i++) { sprintf(list[i], "item-%d", i); } for (int i = 0; i < 3; i++) { puts(list[i]); free(list[i]); } return 0; }这里可以清楚看到指针数组的灵活性:每个元素可以来自常量区,也可以来自栈上的字符数组,还可以来自堆上的动态内存。前提是你必须知道自己管理的是哪一类内存,否则free的时候会出错。
3.3 运行时逐行输入字符串
竞赛或者实际开发中,经常需要从标准输入读入多行字符串,存在数组里。推荐两种写法。
写法一:二维数组,适合行数和行长都已知。
#include <stdio.h> int main() { char lines[5][128]; int n = 0; printf("请输入行数:"); scanf("%d", &n); // 读取缓冲区中残留的换行符 getchar(); for (int i = 0; i < n; i++) { // fgets 会保留换行符,这里手动去掉 fgets(lines[i], sizeof(lines[i]), stdin); size_t len = strlen(lines[i]); if (len > 0 && lines[i][len - 1] == '\n') { lines[i][len - 1] = '\0'; } } for (int i = 0; i < n; i++) { printf("第%d行:%s\n", i + 1, lines[i]); } return 0; }写法二:指针数组 + 动态分配,适合行长不确定、需要节省内存。
#include <stdio.h> #include <stdlib.h> #include <string.h> int main() { int n = 3; char *lines[3]; char buffer[256]; for (int i = 0; i < n; i++) { fgets(buffer, sizeof(buffer), stdin); buffer[strcspn(buffer, "\n")] = '\0'; lines[i] = (char *)malloc(strlen(buffer) + 1); strcpy(lines[i], buffer); } for (int i = 0; i < n; i++) { puts(lines[i]); free(lines[i]); } return 0; }strcspn(buffer, "\n")是一个比较精简的写法,作用是找到第一个换行符的位置,然后替换成\0。这个函数在字符串处理中很常用,比手动遍历更简洁。
4. 字符串数组的遍历与访问
4.1 二维数组的遍历
#include <stdio.h> int main() { char names[3][16] = {"Tom", "Jerry", "Spike"}; // 按行遍历,注意每行是字符串,用 %s 输出 for (int i = 0; i < 3; i++) { printf("%s\n", names[i]); } // 按字符逐个遍历,输出每个字符的 ASCII for (int i = 0; i < 3; i++) { for (int j = 0; names[i][j] != '\0'; j++) { printf("%c ", names[i][j]); } printf("\n"); } return 0; }二维数组的遍历比较直观,names[i]得到第 i 行字符串的首地址,names[i][j]得到具体字符。
4.2 指针数组的遍历
#include <stdio.h> int main() { const char *fruits[] = {"apple", "banana", "cherry", "date"}; // 方式一:下标遍历 for (int i = 0; i < 4; i++) { printf("%s\n", fruits[i]); } // 方式二:指针自增遍历 const char **p = fruits; for (int i = 0; i < 4; i++) { printf("%s\n", *p); p++; } // 方式三:如果数组以 NULL 结尾,可以用 NULL 判断 const char *args[] = {"-h", "--version", NULL}; for (int i = 0; args[i] != NULL; i++) { printf("%s\n", args[i]); } return 0; }第三种写法在命令行参数处理里非常常见。以NULL作为结束标记,可以避免维护额外的长度变量。
4.3 用 sizeof 计算数组长度的注意事项
#include <stdio.h> void print_size(char arr[][20], int rows) { // 形参已退化,不能这样算行数 // printf("%zu\n", sizeof(arr) / sizeof(arr[0])); printf("函数内 rows = %d\n", rows); } int main() { char arr[3][20] = {"a", "bb", "ccc"}; printf("数组总大小:%zu\n", sizeof(arr)); printf("每行大小:%zu\n", sizeof(arr[0])); printf("行数:%zu\n", sizeof(arr) / sizeof(arr[0])); print_size(arr, 3); return 0; }这是 C 语言数组和指针最容易混淆的地方。数组作为参数传入函数时,第一维会退化成指针,sizeof(arr)在函数内部得到的是指针大小,不是整个数组大小。因此要在函数外算好行数,或者使用NULL结尾的约定。
5. 修改字符串数组中的内容
5.1 修改二维数组中的字符串
#include <stdio.h> #include <string.h> int main() { char colors[3][16] = {"Red", "Green", "Blue"}; // 直接改写单个字符 colors[0][0] = 'r'; printf("%s\n", colors[0]); // red // 用 strcpy 整体覆盖 strcpy(colors[1], "Dark Green"); printf("%s\n", colors[1]); // Dark Green // 只要不超过行容量,都可以写入 strcpy(colors[2], "DeepSkyBlue"); printf("%s\n", colors[2]); return 0; }二维数组“可修改”的本质在于:每一行都是存放在栈或全局区的字符数组,有独立的可写空间。只要strcpy的长度不超过行大小 - 1,就不会越界。
5.2 修改指针数组中的字符串
#include <stdio.h> #include <stdlib.h> #include <string.h> int main() { char *names[2]; // 动态分配,可修改 names[0] = (char *)malloc(32); names[1] = (char *)malloc(32); strcpy(names[0], "Alice"); strcpy(names[1], "Bob"); strcpy(names[0], "Alice Smith"); printf("%s\n", names[0]); free(names[0]); free(names[1]); return 0; }如果names[0]直接指向字符串字面量,比如char *names[2] = {"Alice", "Bob"};,那么strcpy(names[0], "Alice Smith")就是未定义行为。常见结果是段错误,因为目标地址在只读区。更稳妥的判断是:只要是运行时要修改的字符串,要么用二维数组,要么用 malloc 分配内存。
5.3 排序时交换指针与交换数组的区别
假设有一个指针数组,需要按字符串顺序排序。交换两个元素时,只需要交换指针:
#include <stdio.h> #include <string.h> int main() { const char *words[] = {"pear", "apple", "orange", "banana"}; int n = 4; // 选择排序,只交换指针 for (int i = 0; i < n - 1; i++) { int min_idx = i; for (int j = i + 1; j < n; j++) { if (strcmp(words[j], words[min_idx]) < 0) { min_idx = j; } } if (min_idx != i) { const char *tmp = words[i]; words[i] = words[min_idx]; words[min_idx] = tmp; } } for (int i = 0; i < n; i++) { printf("%s\n", words[i]); } return 0; }如果换成二维数组,交换就需要strcpy到临时数组再互相拷贝,成本高很多。
6. 字符串数组的常见操作示例
6.1 查找指定字符串
#include <stdio.h> #include <string.h> int find_string(const char *arr[], int n, const char *target) { for (int i = 0; i < n; i++) { if (strcmp(arr[i], target) == 0) { return i; } } return -1; } int main() { const char *cities[] = {"Beijing", "Shanghai", "Guangzhou", "Shenzhen"}; int idx = find_string(cities, 4, "Guangzhou"); if (idx != -1) { printf("找到,下标为:%d\n", idx); } else { printf("未找到\n"); } return 0; }6.2 按字母顺序排序并输出
#include <stdio.h> #include <stdlib.h> #include <string.h> int cmp_str(const void *a, const void *b) { // a 和 b 是 const char ** 类型 return strcmp(*(const char **)a, *(const char **)b); } int main() { const char *subjects[] = {"math", "english", "physics", "chemistry", "biology"}; int n = 5; qsort(subjects, n, sizeof(char *), cmp_str); for (int i = 0; i < n; i++) { printf("%s\n", subjects[i]); } return 0; }qsort的比较函数比较的是“数组元素”,而这里数组元素是char *指针。所以a、b实际上是被qsort传进来的指向指针的指针,先转成const char **,再解引用得到const char *,才能交给strcmp。
6.3 统计每个字符串长度并找出最长
#include <stdio.h> #include <string.h> int main() { char words[5][32] = {"cat", "elephant", "dog", "hippopotamus", "bird"}; int max_len = 0; char *longest = NULL; for (int i = 0; i < 5; i++) { int len = strlen(words[i]); if (len > max_len) { max_len = len; longest = words[i]; } } printf("最长字符串:%s,长度:%d\n", longest, max_len); return 0; }7. 字符串数组作为函数参数
7.1 二维数组传参
#include <stdio.h> // 必须指定第二维大小 void print_names(char names[][32], int rows) { for (int i = 0; i < rows; i++) { printf("%s\n", names[i]); } } // 等价写法:指针数组形式 void print_names_2(char (*names)[32], int rows) { for (int i = 0; i < rows; i++) { printf("%s\n", names[i]); } } int main() { char names[3][32] = {"Alice", "Bob", "Charlie"}; print_names(names, 3); print_names_2(names, 3); return 0; }二维数组传参有一个硬性要求:从第二维开始的所有维度必须写明。这是因为编译器需要知道每一行跨多少字节,才能计算names[i]的地址。
7.2 指针数组传参
#include <stdio.h> void print_list(char **list, int n) { for (int i = 0; i < n; i++) { printf("%s\n", list[i]); } } void print_list_2(char *list[], int n) { for (int i = 0; i < n; i++) { printf("%s\n", list[i]); } } int main() { char *arr[] = {"one", "two", "three"}; print_list(arr, 3); print_list_2(arr, 3); return 0; }这里char **和char *[]在函数形参中是等价的。从调用者角度看,传入的是一个指针数组,数组名退化之后就是char **。
7.3 二级指针作为输出参数
#include <stdio.h> #include <stdlib.h> #include <string.h> void create_strings(char ***result, int n) { *result = (char **)malloc(n * sizeof(char *)); for (int i = 0; i < n; i++) { (*result)[i] = (char *)malloc(32); sprintf((*result)[i], "string-%d", i); } } int main() { char **list = NULL; int n = 3; create_strings(&list, n); for (int i = 0; i < n; i++) { printf("%s\n", list[i]); free(list[i]); } free(list); return 0; }这种写法在封装字符串列表工具函数时很常见。调用方只声明一个char **,通过&list传地址,函数内部分配内存并填充。
8. 字符串数组最常踩的坑
8.1 修改字符串字面量
char *p = "hello"; p[0] = 'H'; // 未定义行为,常见段错误正确做法:用数组保存,或在堆上分配空间。
8.2 对未初始化的指针 strcpy
char *p; strcpy(p, "hello"); // 错误:p 指向未知地址正确做法:先分配空间,或者改用snprintf、动态内存。
8.3 fgets 保留换行符
使用fgets读入字符串时,换行符会留在缓冲区里,导致字符串后面多出一个\n。常见处理:
buffer[strcspn(buffer, "\n")] = '\0';8.4 越界写入
char names[3][10] = {"123", "456", "789"}; strcpy(names[0], "This is a very long string"); // 溢出names[0]最多容纳 9 个字符加一个\0。超过就会写入到相邻行的内存,导致难以排查的内存破坏。建议使用snprintf或strncpy,并且始终预留结束符位置。
8.5 混淆指针数组行长度
const char *p[] = {"abc", "def"}; // 错误:sizeof(p[0]) 是指针大小,不是"abc"的长度 printf("%zu\n", sizeof(p[0])); // 正确:用 strlen 获取字符串长度 printf("%zu\n", strlen(p[0]));8.6 在函数内部错误计算数组长度
void func(char *arr[]) { // sizeof(arr) = 8,不是数组总大小 }解决办法是让调用方传入元素个数,或者在数组末尾放NULL哨兵元素。
9. 字符串数组的应用场景:命令行参数与配置表
9.1 main 函数的 argv
C 语言程序的main(int argc, char *argv[])本质上就是一个字符串数组。argv[0]是程序名,argv[1]到argv[argc-1]是命令行参数。
#include <stdio.h> int main(int argc, char *argv[]) { for (int i = 0; i < argc; i++) { printf("参数 %d:%s\n", i, argv[i]); } return 0; }编译运行:
gcc test.c -o test ./test hello world 123输出:
参数 0:./test 参数 1:hello 参数 2:world 参数 3:123理解字符串数组之后,再看argv的传参方式就非常清晰:它是一个指针数组,每个元素是char *,指向一个以\0结尾的字符串。
9.2 只读配置表
#include <stdio.h> typedef struct { const char *key; const char *value; } ConfigItem; ConfigItem config[] = { {"host", "127.0.0.1"}, {"port", "8080"}, {"debug", "true"}, {NULL, NULL} }; const char *get_config(const char *key) { for (int i = 0; config[i].key != NULL; i++) { if (strcmp(config[i].key, key) == 0) { return config[i].value; } } return NULL; } int main() { const char *port = get_config("port"); if (port) { printf("port = %s\n", port); } return 0; }用字符串数组 + 哨兵元素组织配置表,代码非常干净,适合入门项目练习。
10. 两道经典练习题的拆解思路
很多刷题平台上有字符串数组的练习题,比如输入若干单词、按字典序输出。这类题目核心考察点就是二维数组和指针数组的选择。下面给一个通用练习题模板,理解后可以套到多数题目上。
题目描述:第一行输入一个整数 n,接下来 n 行每行一个字符串,按字典序升序输出。
#include <stdio.h> #include <stdlib.h> #include <string.h> #define MAX_LEN 101 #define MAX_N 1000 int main() { int n; char words[MAX_N][MAX_LEN]; scanf("%d", &n); getchar(); for (int i = 0; i < n; i++) { fgets(words[i], MAX_LEN, stdin); words[i][strcspn(words[i], "\n")] = '\0'; } // 简单选择排序 for (int i = 0; i < n - 1; i++) { int min_idx = i; for (int j = i + 1; j < n; j++) { if (strcmp(words[j], words[min_idx]) < 0) { min_idx = j; } } if (min_idx != i) { char tmp[MAX_LEN]; strcpy(tmp, words[i]); strcpy(words[i], words[min_idx]); strcpy(words[min_idx], tmp); } } for (int i = 0; i < n; i++) { puts(words[i]); } return 0; }如果改成指针数组版本,交换时只需要交换指针:
#include <stdio.h> #include <stdlib.h> #include <string.h> #define MAX_LEN 101 #define MAX_N 1000 int main() { int n; char *words[MAX_N]; char buffer[MAX_LEN]; scanf("%d", &n); getchar(); for (int i = 0; i < n; i++) { fgets(buffer, MAX_LEN, stdin); buffer[strcspn(buffer, "\n")] = '\0'; words[i] = (char *)malloc(strlen(buffer) + 1); strcpy(words[i], buffer); } // 只交换指针 for (int i = 0; i < n - 1; i++) { int min_idx = i; for (int j = i + 1; j < n; j++) { if (strcmp(words[j], words[min_idx]) < 0) { min_idx = j; } } if (min_idx != i) { char *tmp = words[i]; words[i] = words[min_idx]; words[min_idx] = tmp; } } for (int i = 0; i < n; i++) { puts(words[i]); free(words[i]); } return 0; }两种方案都能通过。二维数组版更简单,但是行数上限和行长受限;指针数组版内存利用率高,但需要手动管理释放。刷题时优先选择二维数组,够用且不容易内存泄漏。实际工程中,如果数据量很大,再考虑指针数组或链表。
11. 常见错误排查清单
| 问题现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 段错误 | 修改了字符串字面量 | 检查是否有p[0] = ... | 改为字符数组或动态内存 |
| 段错误 | 指针未初始化就strcpy | 检查目标指针是否为 NULL 或野指针 | 先 malloc 再复制 |
| 输出乱码 | fgets保留了换行符 | 打印字符串长度或逐字符输出 | 用strcspn去掉换行符 |
| 死循环 | 用scanf读完数字后未处理换行符 | 检查输入缓冲 | 加一句getchar() |
| 程序崩溃 | 字符串越界写入 | 用AddressSanitizer或检查strcpy的长度 | 换成strncpy、snprintf |
| 排序结果不对 | qsort比较函数写错类型 | 打印比较函数的参数内容 | 转成const char **再解引用 |
| 无法编过 | 形参和实参数组维度不一致 | 检查函数声明 | 第二维大小必须匹配 |
| 内存泄漏 | malloc 后没有 free | 用 valgrind 检测 | 每个 malloc 对应一个 free |
12. 最佳实践建议
第一,默认优先使用char arr[][MAX_LEN]。大多数入门和竞赛场景,字符串数量和单行长度都是有限的,二维数组简单、直观、不容易出错。
第二,如果字符串长度差异大、数量多,再考虑char *arr[]和动态分配。换排序算法、插入删除时,指针数组更高效,但必须时刻记得释放内存。
第三,写代码时习惯性留下边界余量。比如最大字符串长度是 100,数组长度就写成 128 或者 101 + 1,防止忘记\0导致越界。
第四,能用fgets就不要用scanf("%s")。scanf("%s")无法限制长度,容易缓冲区溢出;fgets有上限,配合strcspn去掉换行符。这是最稳定的标准输入读取方式。
第五,涉及字符串复制时优先用snprintf或strncpy,并手动保证结束符。
char buf[32]; snprintf(buf, sizeof(buf), "%s", source);第六,函数传参时如果参数是字符串数组,最好把数组长度也传进去,不要在函数内部用sizeof计算。
第七,修改完代码记得用编译选项排查问题:
gcc -Wall -Wextra -g test.c -o test如果怀疑内存越界,可以用-fsanitize=address:
gcc -Wall -Wextra -fsanitize=address -g test.c -o test第八,不要忘记释放动态分配的内存。可以按模块组织代码,一个模块负责创建字符串列表,另一个模块负责销毁,尽量保证每个 malloc 都有配对的 free。
13. 总结
字符串数组不是一个孤立的知识点,它把数组、指针、字符串处理函数和内存管理串在了一起。理解二维字符数组和指针数组的区别,就等于理解了 C 语言中“静态存储”和“动态引用”两种思路的差别。
建议动手做三件事:
- 用两种方式定义字符串数组,分别完成一次按字典序排序。
- 写一个函数,接收
char **,遍历并统计字符串总长度。 - 模拟
main(int argc, char *argv[])的传参方式,自己封装一个可复用的参数解析小函数。
上面这些代码可以放到同一个工程里反复编译、修改、测试。字符串数组这块通了,后面的链表、树、哈希表都会轻松不少,因为核心还是那套“数据在内存里怎么放、指针怎么指”的底子。