拓冰建站拓冰建站
首页 / 资讯中心 / 正文

C语言存储5个学生名字并遍历:二维数组、指针数组与动态分配全解析

1. 从一个入门题说起题目背后的真实需求各位写代码的朋友应该都有体会越是简单的题目越能看出一个人对语言基础的理解。就拿“定义一个数组存储5个学生的名字并遍历”这道题来说乍一看是个再普通不过的C语言入门练习但真拿到键盘前很多人反而会愣一下名字是字符串C语言里字符串怎么存用二维数组还是指针数组循环遍历的时候到底怎么取这题之所以经典是因为它同时踩中了C语言数组学习的几个关键点数组的声明与初始化、字符串在C语言中的存储方式、二维数组与一维数组的关系、循环遍历的边界控制。如果你能把这题吃透后面遇到“动态数组”“字符串数组排序”“数组去重”这些进阶话题时会顺很多。这篇文章我就从这道题出发把二维字符数组、指针数组、动态分配数组、不同语言的遍历写法以及高频踩坑点全部拆开讲一遍争取让刚入门的同学看完能直接上手也让有基础的同学在细节上查漏补缺。2. 最朴素的实现二维字符数组方案2.1 为什么名字要用二维字符数组先想一个问题一个学生的名字是字符串字符串本质上是字符序列在末尾以\0结尾。C语言没有像 Python、Java 那样内置的字符串类型所以只能用一个字符数组来存放。一个名字就是一个数组那5个名字就是5个数组的集合——在C语言里这就自然而然地构成了二维字符数组。char names[5][20];这行声明表示定义一个二维字符数组第一维是5代表5个学生第二维是20代表每个名字最长能容纳 19 个字符最后一位留给\0。为什么是20不是10这是实际开发里最常见的“留余量”思维。你永远不知道用户会不会输入一个特别长的名字如果定死了10一不小心就溢出了。2.2 完整代码示例直接看完整的可运行代码#include stdio.h #include string.h int main() { // 方式一声明后逐个赋值 char names[5][20]; strcpy(names[0], Alice); strcpy(names[1], Bob); strcpy(names[2], Charlie); strcpy(names[3], David); strcpy(names[4], Ella); // 方式二定义的同时初始化更推荐 char names2[5][20] { Alice, Bob, Charlie, David, Ella }; // 遍历输出 printf(学生名单如下\n); for (int i 0; i 5; i) { printf(第%d位学生%s\n, i 1, names2[i]); } return 0; }这里有两个操作值得停下来细讲。第一个是strcpy。为什么不能直接写names[0] Alice因为数组名在表达式中会退化为指针而names[0]是一个数组名它不是左值不能整体赋值。字符串字面量也不能直接赋给字符数组。这是新手最容易犯的错。解决方案就是strcpy或者定义时直接初始化。第二个是遍历时的%s格式说明符。printf(%s, names2[i])中的names2[i]是一个一维字符数组在传给printf时自动退化为指向首元素的指针%s就顺着这个指针一直读到\0为止。这里不需要加加了反而错。2.3 输入场景怎么处理如果是运行时由用户输入学生名字代码也很简单#include stdio.h int main() { char names[5][20]; printf(请输入5位学生的名字\n); for (int i 0; i 5; i) { printf(第%d位学生, i 1); scanf(%19s, names[i]); // 限定最大长度防止溢出 } printf(\n学生名单如下\n); for (int i 0; i 5; i) { printf(第%d位学生%s\n, i 1, names[i]); } return 0; }注意scanf(%19s, names[i])中的%19s。这里有两个细节第一names[i]本身就是地址所以不需要加第二限制宽度为19是防止用户输入超过19个字符导致缓冲区溢出。很多教材在这个细节上都是一笔带过但这恰恰是实际工程里必须养成的习惯。提示scanf中指定宽度时宽度值应该比数组长度小1因为还要给末尾的\0留位置。比如数组长度是20就写%19s。3. 为什么要学指针数组一个更接近实战的姿势3.1 指针数组与二维数组的区别用二维字符数组能解决问题但不够优雅也不够贴近实战。在真正的项目代码里处理字符串数组更常见的做法是指针数组char *names[5];这行声明表示定义一个数组数组里有5个元素每个元素是一个指向char类型的指针。也就是说每个元素只存一个地址指向一块存放字符串的内存。两者的内存布局差异很大维度二维字符数组指针数组存储位置数组元素连续存放每行固定长度指针连续存放字符串实际内容散落在各处空间利用每行按最大长度分配可能浪费空间指针只占4或8字节字符串按实际长度存储初始化方式定义时直接拷贝内容指针指向字符串字面量或动态分配的内存修改内容可以直接改names[i][j]需要看指针指向的是常量区还是堆区灵活性长度固定不易扩展可以动态改变指向更灵活3.2 指针数组的初始化与遍历#include stdio.h int main() { // 直接指向字符串字面量 char *names[5] { Alice, Bob, Charlie, David, Ella }; for (int i 0; i 5; i) { printf(第%d位学生%s\n, i 1, names[i]); } return 0; }这段代码看起来和二维字符数组版本差不多但背后的机制完全不同。char *names[5]中的每个指针都直接指向了字符串字面量所在的静态存储区所以整个数组的内存开销非常小不需要像二维字符数组那样每个元素分配20字节。但这里有一个重要陷阱字符串字面量存放在只读区域不能通过指针去修改内容。如果在你的业务场景里需要修改某个学生的名字比如把 Bob 改成 Robert用这种初始化的指针数组是行不通的运行时直接崩溃。这也正是下节要讲的动态分配方案存在的原因之一。3.3 指针数组与二维数组的本质辨析对很多初学者来说“指针数组”和“数组指针”是绕不过去的坎。我见过太多人把这两者搞混。指针数组char *names[5]本质是一个数组数组的每个元素是指针。读法names 是数组有5个元素每个元素是char *类型。数组指针char (*p)[20]本质是一个指针指向一个包含20个char元素的数组。读法*p是一个有20个char元素的数组所以 p 是指向数组的指针。怎么记靠优先级[]的优先级高于*。在char *names[5]里names先和[5]结合所以是数组在char (*p)[20]里(*p)先用括号包起来所以 p 先是一个指针。理解了这一点你就明白了为什么遍历二维字符数组时names[i]能直接传给printf(%s, ...)——因为names[i]是一维数组退化成指针后指向该行的第一个字符而如果定义一个char (*p)[20] names那p是行指针p 1会直接跳过一整行也就是跳过20个字符。4. 动态内存版本真正能应对变化需求的方案4.1 为什么要用动态分配二维字符数组固定了每行20字节如果业务调整学生数量从5变成500或者单个名字长度可能超过30个字符固定大小的数组就显得很呆。更灵活的做法是动态分配。先简单说明一下原理。malloc和free是C语言标准库里非常核心的内存管理函数。malloc在堆上分配一块连续的内存返回指向这块内存的指针用完之后必须free把内存还给操作系统。只要注意这三点动态数组用起来很顺手分配的内存要检查是否成功、用完记得释放、释放后指针置空防止悬垂。4.2 动态存储5个学生名字的完整代码#include stdio.h #include stdlib.h #include string.h #define MAX_NAME_LEN 50 int main() { int count 5; // 1. 为指针数组分配内存 char **names (char **)malloc(sizeof(char *) * count); if (names NULL) { printf(内存分配失败\n); return 1; } // 2. 为每个名字分配内存并赋值 const char *students[] {Alice, Bob, Charlie, David, Ella}; for (int i 0; i count; i) { names[i] (char *)malloc(sizeof(char) * (strlen(students[i]) 1)); if (names[i] NULL) { printf(第%d个名字内存分配失败\n, i); // 释放已分配的内存 for (int j 0; j i; j) { free(names[j]); } free(names); return 1; } strcpy(names[i], students[i]); } // 3. 遍历输出 for (int i 0; i count; i) { printf(第%d位学生%s\n, i 1, names[i]); } // 4. 释放内存先释放每个名字再释放指针数组本身 for (int i 0; i count; i) { free(names[i]); } free(names); return 0; }这段代码看起来比前面复杂但逻辑很清晰分四步走先分配外层指针数组再逐个为每个名字分配内存然后正常使用最后释放内存。释放的顺序尤其重要一定要先释放内层再释放外层。如果先free(names)后面就找不着每一行的地址了那些内存就永久泄漏了。这里我特别写了一个小函数来初始化数据实际场景中完全可以把这一步替换成用户输入或者从文件读取灵活性就出来了。4.3 动态版本和前面两个版本怎么选如果你只是做一道作业题用二维字符数组就够了如果你在写一个长期维护的项目需要处理数量可变的字符串就用指针数组加动态分配如果你明确知道字符串的内容不会变用char *names[]直接指字符串字面量是最省事的。我在实际工作中遇到过很多次“初期图省事用固定数组后来需求一变就要重构”的情况。建议从第一版就按动态的思路去设计虽然代码看起来稍微长一点但后面改起来的成本低得多。5. 遍历的各种姿势不只是 for 循环5.1 从 for 到 while 和 do-while“遍历”这个词听起来很高端其实本质就是“把每个元素都访问一遍”。最标准的写法是 forfor (int i 0; i 5; i) { printf(%s\n, names[i]); }但实际写代码的时候也可以用 whileint i 0; while (i 5) { printf(%s\n, names[i]); i; }还有 do-whileint i 0; do { printf(%s\n, names[i]); i; } while (i 5);三种写法在功能上没有差别但适用的语义场景不同。for 适合“我知道总共要循环多少次”的情况也是遍历数组最直观的写法while 适合“循环次数在开始时不确定靠某个条件控制”的情况do-while 适合“无论如何先执行一次”的情况。以这道题来说for 就是最优解用 while 反而显得啰嗦。5.2 通过指针遍历数组数组名在表达式中会退化为指向首元素的指针所以遍历还有另一种写法char *p names[0]; // 指向第一个学生的名字 for (int i 0; i 5; i) { printf(第%d位学生%s\n, i 1, p i); }这里p i的效果等同于names[i]。因为names数组在内存中是连续存放的每行占20个字节二维字符数组的情况所以p i就跳了 i 个20字节正好指向第 i 行的起始位置。当然如果你用的是指针数组每个元素是独立的指针它们指向的内存不连续就不能这样用指针加减来遍历了。必须老老实实通过下标访问。5.3 遍历时最容易被忽略的边界问题遍历数组时最常见的bug就是数组越界。C语言的数组下标从0开始长度为n的数组有效下标是0到n-1。如果你写for (int i 0; i 5; i)当 i 等于5时就越界了。这个bug之所以隐蔽是因为C语言不像Java或者Python那样会在运行时自动检查数组下标。越界访问可能不会立刻崩溃而是读到内存中数组旁边的“垃圾数据”或者更糟——往数组边界外写了数据悄悄污染了别的变量。这种bug极难排查因为它不是每次都触发而且往往等程序运行到很远的地方才报错。提示遍历数组时条件判断要么写i 5要么写i 4后者虽然意思一样但很别扭约定俗成写 数组长度即可。6. 从这道题延伸出去数组操作10个高频场景掌握这道入门题之后你会发现跟数组相关的高频操作其实有一大串。我把日常开发和面试里最常碰到的几个场景列出来每个都附上核心思路。6.1 数组去重题目如果改成“存储5个学生的名字去掉重复的并输出”这个难度就上来了。最简单的方法是把每个元素和它之前的所有元素比较#include stdio.h #include string.h int main() { char names[5][20] {Alice, Bob, Alice, David, Bob}; int printed[5] {0}; for (int i 0; i 5; i) { int isDuplicate 0; for (int j 0; j i; j) { if (strcmp(names[i], names[j]) 0) { isDuplicate 1; break; } } if (!isDuplicate) { printf(%s\n, names[i]); } } return 0; }这种双重循环的写法时间复杂度是 O(n²)数据量小的时候完全够用。数据量大了可以先把数组排序再去重就只需要一趟遍历。6.2 数组排序对5个名字进行排序最容易理解的是选择排序法。核心思想是每次从待排序的部分中选出最小的元素放到正确的位置。#include stdio.h #include string.h int main() { char names[5][20] {Ella, Bob, Charlie, David, Alice}; for (int i 0; i 4; i) { int minIndex i; for (int j i 1; j 5; j) { if (strcmp(names[j], names[minIndex]) 0) { minIndex j; } } if (minIndex ! i) { char temp[20]; strcpy(temp, names[i]); strcpy(names[i], names[minIndex]); strcpy(names[minIndex], temp); } } for (int i 0; i 5; i) { printf(%s\n, names[i]); } return 0; }strcmp返回负数、0、正数分别表示第一个字符串小于、等于、大于第二个字符串。注意交换两个字符串时不能直接temp names[i]必须用strcpy逐字符拷贝。如果题目要求用C语言的库函数那qsort是更专业的做法#include stdio.h #include stdlib.h #include string.h int compare(const void *a, const void *b) { return strcmp((const char *)a, (const char *)b); } int main() { char names[5][20] {Ella, Bob, Charlie, David, Alice}; qsort(names, 5, sizeof(names[0]), compare); for (int i 0; i 5; i) { printf(%s\n, names[i]); } return 0; }这里qsort接受一个比较函数比较函数的参数是const void *所以要强制转换成const char *再交给strcmp。6.3 查找某个名字是否存在查找是数组最基础的操作之一线性查找的代码非常直接#include stdio.h #include string.h int main() { char names[5][20] {Alice, Bob, Charlie, David, Ella}; char target[20]; printf(请输入要查找的名字); scanf(%19s, target); int found 0; for (int i 0; i 5; i) { if (strcmp(names[i], target) 0) { printf(找到了%s 是第%d位学生\n, target, i 1); found 1; break; } } if (!found) { printf(未找到该学生\n); } return 0; }注意比较字符串用strcmp不用。比较的是两个字符数组的首地址不是内容这在C语言里是新手最容易掉进去的坑。如果你已经对数组排过序查找可以用二分法。二分查找的思路是先看中间元素如果目标比中间元素小就在左半边继续找否则在右半边继续找每次排除一半的数据效率远高于线性查找。6.4 其他高频场景速查场景核心思路参考函数/写法数组转字符串逐元素拼接到目标字符数组sprintf或strcat取出数组中的一部分通过指定起始下标和长度复制memcpy取数组最大值/最小值遍历时用一个变量保存当前极值朴素比较数组初始化为全部0定义时{0}或memsetmemset(arr, 0, sizeof(arr))获取数组长度sizeof(a) / sizeof(a[0])注意只在数组作用域内有效合并两个有序数组双指针法归并排序的思想提取数组对象的一部分属性遍历条件判断构建新数组类似过滤操作7. 其他语言里怎么做一通百通的遍历思路这道题用C语言写是二维数组换成其他语言思路完全不同但核心逻辑殊途同归。有的人在学习时容易陷入“只会一种语言”的思维惯性我建议把这几种写法都看一遍会让你对“数组”和“遍历”的本质理解更深。Java 版本的字符串数组用起来就很省心public class Main { public static void main(String[] args) { String[] names {Alice, Bob, Charlie, David, Ella}; // 传统的for循环 for (int i 0; i names.length; i) { System.out.println(第 (i 1) 位学生 names[i]); } // 增强for循环 for (String name : names) { System.out.println(name); } } }Java 的String是真正意义上的字符串类型不需要管\0结尾也不需要strcpy直接赋值就行。names.length是数组属性不用像C那样用sizeof计算。JavaScript 则更灵活数组本身就是动态的const names [Alice, Bob, Charlie, David, Ella]; names.forEach((name, index) { console.log(第${index 1}位学生${name}); }); // 或者用 for...of for (const name of names) { console.log(name); }Python 的写法是最简洁的names [Alice, Bob, Charlie, David, Ella] for i, name in enumerate(names): print(f第{i 1}位学生{name})如果你想把这些语言和C语言对照着学我的建议是先理解C语言这种“底层视角”明白字符串本质上是字符数组、数组名会退化成指针、遍历就是控制下标的移动然后再看高级语言如何把这些细节都封装好。有了底层认知再用高级语言心里踏实很多遇到问题也能猜到内部到底发生了什么。8. 高频报错与调试心得这题虽然简单但我在教学和带新人的过程中见过太多在这个题上卡住的情况。把最高频的几个问题整理成表格方便你对照排查。报错/现象原因解决方案error: assignment to expression with array type对数组整体赋值用strcpy或定义时初始化程序一运行就崩溃Segmentation fault指针未初始化就使用或越界写入初始化指针检查数组边界printf输出乱码字符串末尾没有\0确保每个字符串都以\0结尾scanf输入带空格的名字出错%s遇空格就停止读取用fgets读取带空格的字符串释放内存时崩溃重复释放同一个指针或释放了栈上的指针检查free的地址是否来自malloc遍历输出第一行正常后面的乱套二维数组第二维长度不够或行指针偏移错误增大第二维长度检查指针运算fgets读取带空格的名字是一个常见需求比如“Li Ming”这种含空格的名字#include stdio.h #include string.h int main() { char names[5][50]; printf(请输入5位学生的姓名可含空格\n); for (int i 0; i 5; i) { printf(第%d位学生, i 1); fgets(names[i], sizeof(names[i]), stdin); // 去掉 fgets 自动读入的换行符 size_t len strlen(names[i]); if (names[i][len - 1] \n) { names[i][len - 1] \0; } } printf(\n学生名单如下\n); for (int i 0; i 5; i) { printf(第%d位学生%s\n, i 1, names[i]); } return 0; }fgets会把换行符也读进来所以存完后要手动把最后的\n替换成\0这个小细节很多教材没讲但实际中特别常见。调试的时候如果你用 IDE比如 Visual Studio 或者 CLion可以直接在循环里打断点然后逐行看变量变化观察i的值和names[i]的内容。如果用的是命令行加几个临时的printf输出中间结果也是好办法。等代码稳定后再把调试输出删掉。9. 从固定数组到更灵活的数据组织方式搞定了5个学生名字的存储和遍历后面的路就很清晰了。实际开发中你很快会遇到新的需求学生数量不固定、每个学生的信息不仅有名字还有学号成绩、需要在任意位置插入删除学生、需要按名字快速查找……这时候数组就不再是最适合的数据结构了。一个常见的演进路径是这样的学生数量固定不变 - 普通数组足够学生数量运行前不确定 - 动态分配数组需要频繁在中间插入、删除 - 链表更合适需要按名字快速查找 - 哈希表或二叉搜索树需要维护学生信息的多种属性 - 结构体数组搜索引擎热搜词里出现的“层序遍历”“二叉树的遍历”“链表遍历”“树状数组上二分”都是在这条路的更远处。学习的时候不用慌抓住一个核心认知所有数据结构都是用来解决特定问题的存储与访问方案数组擅长连续存储和随机访问链表擅长频繁插入删除树适合层级数据哈希表适合快速查找。这道5个学生的题目就是你理解这些深厚概念的起点。我个人在实际操作中的体会是学数组的时候一定要亲手把内存布局画一遍。拿一张白纸画20个小方格代表一个数组元素再画5行代表二维数组用箭头标出每个指针指向哪里。画过之后很多抽象的报错信息一下子就通透了。最后再分享一个小技巧以后遇到任何“存储N个XX并遍历”的题目都可以先用C语言写一遍再用你熟悉的脚本语言写一遍对比两种写法的差异。这个习惯能帮你同时提升底层理解和开发效率两不耽误。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门