十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

数据结构实验三单链表详解:从指针原理到工程实现与避坑指南

数据结构实验三单链表详解:从指针原理到工程实现与避坑指南 说来也巧每年到这个节点总能在群里看到同一类问题“实验3到底要写什么”“链表怎么老崩”“排序排完链表断了”。中国矿业大学的数据结构实验课进行到第三个实验基本上就到了大家集体和指针搏斗的阶段。前两个实验如果还能靠静态数组和顺序表蒙混过关实验3开始底子不扎实的同学会明显感觉到吃力。这篇东西不打算给你贴一份能直接抄的源码了事而是把实验3这类题目拆开讲清楚每一段代码为什么非得这么写、哪些地方最容易丢分、出了问题怎么定位最后再给一套能直接用的工程骨架和排查套路。我用一个在大多数学校实验3里都非常有代表性的题目来展开——学生成绩管理系统的单链表实现。只要你实验3的题目是“用链表做XX管理系统”“约瑟夫环”“多项式加减法”或者“长整数运算”中的任何一种这篇里的实现思路和避坑经验都适用。这些题目表面上各有不同内核考的是同一套东西结构体定义、动态内存管理、链表的基本操作、排序遍历可能再加一个文件读写。把这套内核嚼碎了换什么题型你都能接得住。1. 实验3到底在考什么别急着敲代码先读懂题目意图1.1 实验3在课程进度里的真实位置如果你用的是严蔚敏《数据结构C语言版》那本教材课程进度走到实验3的时候大概率刚讲完第二章线性表。教材前两章看起来内容不多但实际上埋了三个以后所有实验都会反复用到的能力结构体与指针的组合使用、动态内存的申请和释放、线性结构在各种操作下的边界处理。顺序表那张实验可能还有一些同学是“照着书敲一遍”就交差的到了链表就完全行不通了。原因很简单顺序表的下标访问符合直觉arr[5]就是第6个元素链表你得自己拿指针走到那个位置去中间隔着多少个结点完全取决于你的循环走了几次。这个思维转换是实验3和之前实验最大的分水岭。1.2 一个典型的实验3题目会长什么样假设题目是这样的从键盘录入若干学生的学号、姓名、成绩用单链表存储支持按学号插入、按学号删除、按姓名查找最后按成绩从高到低排序并把排序后的结果保存到文件下次程序启动能从文件恢复数据。乍一看函数还挺多但冷静下来拆一下其实就是五件事定义学生结构体包含学号、姓名、成绩和指向下一个结点的指针两个“增”操作尾插建表和按位置插入一个“删”操作按学号删除结点同时释放内存一个“查”操作支持精确按学号查和模糊按姓名查一个“排”操作按成绩排序这里隐含了一个复杂度分析考点一个“存”操作把整条链表写入文件并能重新读回内存你看所有高校最常见的实验3题目基本都能装进这五件事里。题目花里胡哨但骨架万年不变。1.3 老师评分时会看什么根据我听过不少同学反馈的评分细则实验3的打分通常分四块检查点基本要求常见扣分点功能完整性增删改查排序都能用删除后链表断链、插入位置越界没处理代码规范函数拆分合理、有注释、命名清晰所有代码塞在main里没有头文件组织运行健壮性非法输入不崩溃空链表删除、重复学号、文件不存在实验报告有算法思路、复杂度分析、测试截图只贴代码没有分析过程第四点看上去最虚实际上越是大面积代码雷同的实验老师越会把重心放在报告上。后面我专门用一章来讲报告和演示环节怎么准备这里先不展开。2. 环境与工程骨架把编译器和头文件问题一次解决掉2.1 VS里scanf报错C4996的处理方法几乎每个用Visual Studio写C语言的同学都会遇到这个报错error C4996: scanf: This function or variable may be unsafe.这是VS的安全警告不是你的代码有错也不是编译器坏了。处理方式有两种一种是在代码文件最顶部加一行#define _CRT_SECURE_NO_WARNINGS注意这行必须在所有#include之前否则不生效。另一种是右键项目 → 属性 → C/C → 预处理器 → 预处理器定义在末尾追加_CRT_SECURE_NO_WARNINGS。这种方式对项目里所有.c文件生效不用每个文件都写一遍。我建议用第二种因为实验代码通常拆成好几个文件每个文件都加宏有点烦。另外提醒一句别用scanf_s去改代码虽然VS里能跑但提交到OJ或者换到Code::Blocks、Dev-C、Linux环境就会编译失败这种写法绑死了你的代码。2.2 头文件与结构体定义工程骨架建议分三个文件student.h结构体定义、函数声明、宏定义student.c所有链表操作的实现main.c主函数和菜单交互逻辑student.h里有个细节值得注意用#ifndef这种老式但稳妥的防止重复包含方式#ifndef STUDENT_H #define STUDENT_H #include stdio.h #include stdlib.h #include string.h #define MAX_NAME_LEN 32 typedef struct StudentNode { int id; // 学号 char name[MAX_NAME_LEN]; // 姓名 float score; // 成绩 struct StudentNode* next; // 指向下一个结点的指针 } StudentNode; // 函数声明 StudentNode* createNode(int id, const char* name, float score); void appendNode(StudentNode** head, StudentNode* newNode); void insertAt(StudentNode** head, StudentNode* newNode, int pos); void deleteById(StudentNode** head, int id); StudentNode* findById(StudentNode* head, int id); StudentNode* findByName(StudentNode* head, const char* name); void sortByScore(StudentNode* head); void saveToFile(StudentNode* head, const char* filename); StudentNode* loadFromFile(const char* filename); void freeList(StudentNode* head); #endif关于结构体命名一个很常见的问题是有人写成这样typedef struct { int id; char name[32]; float score; struct StudentNode* next; // 编译报错StudentNode未定义 } StudentNode;这个错误特别容易在赶实验的时候出现匿名的struct里不能自己引用自己的类型名因为类型名在定义完才生效。必须像上面那样给结构体先起名struct StudentNode然后用它声明next指针最后再用typedef统一取别名StudentNode。这个顺序不能反。2.3 为什么很多操作函数要传二级指针这是实验3里最劝退的一个点。你看appendNode、insertAt、deleteById这几个函数的第一个参数都是StudentNode** head而findById、sortByScore这些函数又是StudentNode* head。为什么有的要两个星号有的只要一个判断标准很简单看这个函数要不要改变head指针本身的值。插入到第一个位置、删除第一个结点的时候链表的第一个结点变了外部那个记录链表头的指针就得跟着变。如果只传StudentNode* head函数里改的是形参拷贝外面main函数里的头指针还是原来的旧地址链表就从中间断了甚至整个链表都找不到了。打个比方你要用一根绳子和一串钥匙把门窗换了光把绳子的头递给别人不够你得告诉人家装钥匙的那个抽屉在哪。head就是那个抽屉的位置head只是绳子头。C语言里传值就是递绳子传指针才是告诉人家抽屉地址。3. 单链表的五个核心操作建表、插入、删除、查找的实现细节3.1 创建结点malloc之后必须检查返回值任何结点操作的第一步都是创建结点。这个函数是整个实验里最不起眼但是最不能省的一个StudentNode* createNode(int id, const char* name, float score) { StudentNode* node (StudentNode*)malloc(sizeof(StudentNode)); if (node NULL) { printf(内存分配失败程序退出\n); exit(1); } node-id id; strcpy(node-name, name); node-score score; node-next NULL; return node; }三点说明第一sizeof(StudentNode)是结构体的完整大小不是指针的大小。新手最容易写错的地方是把sizeof(StudentNode*)当成sizeof(StudentNode)前者只有8个字节64位系统后者至少40个字节写成前者后面所有赋值都是越界写。第二malloc返回的void*在C语言里可以隐式转换到任意指针类型所以(StudentNode*)这个强转在纯C里其实可以省略。但VS会用C编译器来编译.c文件C里void*不能隐式转换成其他指针类型所以这个强转写上兼容性更好。第三exit(1)处理失败是有意为之。链表操作里如果内存都申请不到后续任何操作都没有意义直接退出比返回一个NULL让上层一路检查下去更干净。这个取舍在实验报告里如果你主动写出来老师会觉得你考虑问题比一般人细致。3.2 尾插建表为什么不用头插法录入一组学生信息自然希望后续查找、打印的时候还是按输入顺序来所以用尾插法。代码逻辑不复杂void appendNode(StudentNode** head, StudentNode* newNode) { if (*head NULL) { *head newNode; return; } StudentNode* p *head; while (p-next ! NULL) { p p-next; } p-next newNode; }有些同学偷懒想用头插法——每次新结点都插到最前面代码短很多但结果就是数据顺序和输入顺序完全相反。如果实验题目要求“按输入顺序显示”头插法直接扣分。再提一个性能相关的点每次尾插都要从头遍历到尾部插入n个结点的时间复杂度是O(n²)。数据量小无所谓但如果测试数据有几千条能感觉到明显的卡顿。改进方案是额外维护一个尾指针或者直接用双向链表但实验3一般不要求这个你在报告里把这个复杂度问题写明白就够了。3.3 按位置插入边界条件是最容易扣分的地方题目要求“在第pos个位置插入一个结点”pos由用户输入。这个函数的边界条件特别多我直接给出完整实现void insertAt(StudentNode** head, StudentNode* newNode, int pos) { // pos 0 或链表为空时统一当作头插处理 if (pos 0 || *head NULL) { newNode-next *head; *head newNode; return; } // 找到第 pos-1 个结点注意循环条件里两个判断不能互换 StudentNode* p *head; int index 0; while (p-next ! NULL index pos - 1) { p p-next; index; } // 中间位置插入 newNode-next p-next; p-next newNode; }这里有三个隐藏考点第一个为什么pos 0要当头插处理因为用户可能输入0、-1、-3这种数字如果程序不处理后面index pos - 1这个条件在pos为负数时可能根本不进入循环然后newNode-next p-next就把新结点插到了第一个结点之后完全不是用户要的位置。稳妥做法就是直接约定小于等于0一律视为插到最前面。第二个p-next ! NULL index pos - 1这两个条件的顺序不能写反。如果写成index pos - 1 p-next ! NULL当pos非常大比如1000而链表只有5个结点时循环会一直走到p为NULL然后下一行p-next就是空指针访问程序崩溃。把p-next ! NULL放前面走到链表尾部就停下正好当作尾插。第三个走到循环结束时有两种可能要么找到了第pos-1个结点要么链表刚好走完了。这两种情况共用后面的插入代码是没问题的因为链表最后一个结点的next本来就是NULL这时候插入等价于尾插。3.4 按学号删除结点断链和释放的顺序不能乱删除操作是所有链表题里最容易写错的地方几乎所有断链bug都出在这里。完整实现void deleteById(StudentNode** head, int id) { if (*head NULL) { printf(链表为空无法删除\n); return; } StudentNode* p *head; StudentNode* prev NULL; // 找到目标结点同时保存它的前驱 while (p ! NULL p-id ! id) { prev p; p p-next; } if (p NULL) { printf(未找到学号为%d的学生\n, id); return; } // 如果删除的是第一个结点要更新头指针 if (prev NULL) { *head p-next; } else { prev-next p-next; } free(p); // 释放目标结点的内存 printf(删除成功\n); }三个必须强调的细节第一必须用prev保存前驱结点。单链表只能从头往后走目标结点的前一个结点如果不记录一旦修改了p-next就再也找不到前驱了。这就像拆火车车厢你不先断开前一节车厢的连接钩后一节车厢就拖着跑了。第二删除头结点时*head p-next这一步必不可少。你删掉的是当前链表第一个结点如果不更新头指针main函数里的head还指向一块已经被free掉的内存下次遍历程序直接崩溃。第三free(p)不是可选项。有些同学写到最后整个程序跑起来“看起来没问题”但就是忘了释放结点内存。实验规模小程序退出了操作系统会回收所以表面没问题。但这是被老师问住的高频点“你的程序频繁插入删除时间长了内存会不会越用越多”3.5 查找按学号精确查找和按姓名模糊查找查找函数不需要修改链表所以传一级指针就够了StudentNode* findById(StudentNode* head, int id) { StudentNode* p head; while (p ! NULL) { if (p-id id) { return p; } p p-next; } return NULL; } StudentNode* findByName(StudentNode* head, const char* name) { StudentNode* p head; while (p ! NULL) { if (strstr(p-name, name) ! NULL) { // 子串匹配实现模糊查找 return p; } p p-next; } return NULL; }findById没什么好说的顺序遍历对比。findByName用了一个strstr函数它的作用是判断第二个参数是不是第一个参数的子串。比如你输入“王”它能匹配到“王小明”“王芳”“小王”这些名字里带“王”的人。这就是题目里“按姓名模糊查找”的实现。实际写的时候有一种情况要处理查不到时怎么办。我上面对应的函数都是返回NULL所以main函数里调用后要先判断StudentNode* result findById(head, target); if (result NULL) { printf(查无此人\n); } else { printf(学号%d 姓名%s 成绩%.1f\n, result-id, result-name, result-score); }不判断直接result-name在查不到的时候就是空指针访问了。4. 排序与文件持久化链表上怎么玩转数据落盘4.1 链表排序用数据域交换还是指针域交换按成绩排序有两种思路一种是把结点里的数据成员交换来交换去一种是把结点之间的next指针重新链接。很多人上来就选第二种觉得“链表排序嘛肯定要改指针”结果写了两天没调通。我的建议是实验3级别用数据域交换。直接给实现void sortByScore(StudentNode* head) { if (head NULL) return; for (StudentNode* p head; p ! NULL; p p-next) { for (StudentNode* q p-next; q ! NULL; q q-next) { if (p-score q-score) { // 从高到低排序 // 交换三个数据成员 int tmpId p-id; p-id q-id; q-id tmpId; char tmpName[MAX_NAME_LEN]; strcpy(tmpName, p-name); strcpy(p-name, q-name); strcpy(q-name, tmpName); float tmpScore p-score; p-score q-score; q-score tmpScore; } } } }这个写法本质上是选择排序每次找到一个比当前结点成绩更大的交换数据。它的时间复杂度O(n²)数据交换次数较多但胜在实现简单、不出错。为什么不推荐在实验里硬啃指针域交换因为链表结点的后面所有结点都跟着那个next指针走改一处next整个链表拓扑就变了你至少需要维护四个指针前驱、当前结点、目标结点、目标的前驱边界情况多到足以让你在实验室待一晚上。实验3的考察重点是会不会用链表、有没有复杂度意识不是考察你能不能写出来一个O(n log n)的链式归并排序。如果你主动在报告里写清楚“这里用数据域交换避免指针重链接带来的复杂边界处理代价是数据交换开销较大适用于数据量较小的场景”老师不仅不会扣分反而会觉得你思路清楚。4.2 保存文件的正确姿势不要直接把整个结点写进文件文件保存这块最大的坑是把fwrite(p, sizeof(StudentNode), 1, fp)当成理所当然的保存方式。StudentNode结构体里面有next这个指针成员。如果直接把整个结构体写入文件指针的值只是一个内存地址数字比如0x00B66F20你把这个数字写进文件没有任何意义下次程序运行内存布局和这次完全不一样这个地址是无效的。更糟糕的是如果保存的结点是链表中间的某个结点指针指向的内存区域一旦被其他程序覆盖这个文件就会产生乱码。正确做法是定义一个不包含指针的“数据中转结构体”只把数据成员写入文件// 用于文件读写的数据结构不含指针 typedef struct StudentData { int id; char name[MAX_NAME_LEN]; float score; } StudentData; void saveToFile(StudentNode* head, const char* filename) { FILE* fp fopen(filename, wb); if (fp NULL) { printf(无法打开文件 %s\n, filename); return; } StudentNode* p head; StudentData data; while (p ! NULL) { data.id p-id; strcpy(data.name, p-name); data.score p-score; fwrite(data, sizeof(StudentData), 1, fp); p p-next; } printf(保存成功共写入 %d 条记录\n, getListLength(head)); fclose(fp); }这里还有个小优化保存前可以先把文件里的旧内容删掉避免新旧数据混在一起。做法是先remove(filename)再fopen或者直接用fopen(filename, wb)wb模式本来就会覆盖旧文件所以不需要额外处理。4.3 读取恢复用数据中转结构体重新建链读取的时候就是反向操作从文件里一条条读数据每次创建一个新结点并尾插到链表里StudentNode* loadFromFile(const char* filename) { FILE* fp fopen(filename, rb); if (fp NULL) { printf(文件 %s 不存在请先录入数据\n, filename); return NULL; } StudentNode* head NULL; StudentData data; while (fread(data, sizeof(StudentData), 1, fp) 1) { StudentNode* node createNode(data.id, data.name, data.score); appendNode(head, node); } fclose(fp); printf(成功从文件加载数据\n); return head; }fread的返回值是成功读取的数据块个数。对于二进制文件当读到文件末尾时返回0这正好作为循环终止条件。用feof(fp)判断文件结束是一个很常见的错误因为feof是要先触发一次读失败后才会返回真导致循环多读一次。用fread的返回值来控制循环是更标准的做法。文件操作还有一个很实际的问题如果你在保存过程中程序突然断电或者崩溃文件可能只写了一半下次读取就会读到半条不完整的数据。要彻底解决这个问题得用“先写临时文件再改名”的策略但实验3一般不会要求到这一步把fwrite和fread配对用对了已经能拿大部分分数了。5. 内存管理三连坑野指针、断链与泄漏的排查全过程5.1 坑一删除结点之后继续访问野指针有同学给我看过这样的代码StudentNode* p findById(head, 2022001); deleteById(head, 2022001); printf(删除的学生是%s\n, p-name);看起来合情合理先找到结点再删除最后打印一下删除的学生的姓名。问题在于deleteById函数里已经对这个结点free(p)了p变成野指针再访问p-name就是访问一块已经归还给操作系统的内存。更麻烦的地方在于很多情况下这个printf可能还“碰巧”能打印出正确的姓名。原因是free只是把这块内存标记为可用操作系统并没有立刻清空里面的数据。于是你又往里写了别的数据或者这块内存被分配给其它变量再访问就会打印出一堆乱码或直接崩溃。实验演示的时候这恰恰是老师最喜欢点的一个位置“你删完了为什么还能访问你确认这个内存已经释放了吗”这个问题的标准答案是free(p)之后要立刻把p NULL让后续任何使用p的代码都能在运行时暴露出错误而不是等到数据被改写了才出莫名其妙的问题。5.2 坑二尾插忘记把next置NULL遍历直接越界createNode里面最后一行是node-next NULL。这一行看起来毫无技术含量但是漏掉它的后果非常严重。malloc出来的内存里存的可能是什么可能是之前被释放的内存残留数据也就是一个野指针地址。尾插的时候p-next newNode; // 这行没问题遍历的时候从头开始while (p ! NULL) { ... p p-next; }走到最后一个结点时p-next是一个随机地址程序就会跑到一个完全不确定的内存区域读出来的数据无意义再往p-next访问就直接段错误。虽然有些人巧合地发现malloc返回的内存恰好初始化为0有些系统会在某些情况下清零堆内存但这个行为没有任何保证。调试这类bug是最费时间的因为表现不稳定时而崩溃时而正常。所以记住一个铁律任何从malloc出来的结构体成员指针必须显式初始化为NULL。这个习惯养成了能省掉一大半的调试时间。5.3 坑三malloc出来的结点没free内存泄漏内存泄漏比野指针隐蔽得多因为症状通常不会立刻爆发而是表现为“程序越跑越卡”“总内存占用越来越大”void deleteWithLeak(StudentNode** head, int id) { // 找到p之后 if (prev NULL) { *head p-next; } else { prev-next p-next; } // 漏了 free(p) }链表结构已经正确更新了删除后遍历打印都正常看起来“没问题”但被删除的结点的内存没有被归还。如果这个程序是一个循环菜单用户反复执行删除操作每次泄漏几十个字节程序运行时间长内存占用会一路涨上去。检查方法很多最朴素有效的是在main函数退出前打印一个计数// 统计链表当前结点数和累计创建的结点数 printf(当前链表结点数%d\n, countNodes(head)); printf(累计创建的结点数%d\n, totalCreated);如果totalCreated - countNodes ! 删除失败的次数那肯定有地方漏了free。5.4 三个排查手段从快到慢排个序我先说结论出问题了不要一开始就开调试器单步走先打印。第一个手段是打印定位法在关键操作前后加printf比如删除前后打印“准备删除学号%d”、“删除成功头指针%p”。用打印把程序执行的路径画出来很快就能定位到是哪个环节出的问题。调试链表问题时最好再写一个printList(head)函数遍历打印整个链表的所有结点数据每次增删改之后都调用一次看链表状态是否符合预期。第二个手段是调试器单步跟踪。Visual Studio里F10逐过程、F11逐语句配合监视窗口查看head、p、p-next的值观察指针变化是否和你预期的一致。建议看地址值的时候在监视窗口输入(StudentNode*)类型转换VS对自定义结构体的显示有时候不友好。单步跟踪对新手特别有用走两遍很快就把链表“走”明白了。第三个手段是专门的内存检查工具。GCC环境下编译时加-fsanitizeaddress会提供内存错误检测能精确定位到是第几行对内存的非法访问。Windows下Visual Studio可以用_CrtDumpMemoryLeaks()函数检查内存泄漏但需要在调试模式下使用。这个工具手段要提前配好环境别等到演示前才手忙脚乱去查。6. 实验报告与演示答辩决定最终分数的隐藏环节6.1 实验报告的整体框架很多同学以为实验报告就是把代码粘上去实际上老师想看到的是你对题目和方案的理解过程。给你一个参考框架板块要写的内容避坑提示题目分析用你自己的话描述题目要求给出输入输出示例别抄题目原文用自己的话转述数据结构设计结构体定义、为什么选单链表而不是顺序表分析两种结构的优缺点对比说明核心算法思路建表、插入、删除、排序、文件读写的文字描述加流程图描述这个环节不用写代码写思路复杂度分析每个操作的时间复杂度和空间复杂度尾插建表是O(n²)直接写O(n)会被发现测试记录正常情况、边界情况、异常情况的运行截图截图必须能看到输入和输出对应经验总结遇到的问题和解决方法写得越具体越真实老师最反感“我学会了链表”这种空话复杂度分析这块最容易翻车。比如插入操作单链表已知位置插入是O(1)但按位置查找是O(n)所以整体是O(n)。查找是O(n)排序是O(n²)文件写入是O(n)。你把这些写清楚报告就已经超过80%的人了。6.2 演示前的边界测试清单演示的时候老师会挑几个刁钻的操作看你的程序崩不崩。你可以提前把这些测试全部跑一遍在空链表上执行删除操作程序不能崩溃要提示“链表为空”删除不存在的学号要提示“未找到”而不是崩溃查找不存在的姓名要提示“查无此人”而不是崩溃插入位置输入负数、0、超过链表长度的大数字程序行为要合理打开一个不存在的文件要给出友好提示而不是黑屏连续创建几千条数据程序不能明显卡顿或崩我见过太多演示翻车的场景老师删除一个不存在的学号程序直接段错误前面的功能全部前功尽弃。所以演示前一定把这些边界测试跑一遍。6.3 老师最经常问的几个问题演示过程中或者交报告时老师会随机问几个涉及原理的问题这里列几个高频的你提前把答案准备好“为什么这里要用链表而不是数组”答题思路链表插入删除不需要搬移元素O(1)完成指针调整数组插入删除需要移动后续所有元素O(n)。但链表的随机访问是O(n)数组是O(1)所以适合频繁增删、较少随机访问的场景。“你的删除操作时间复杂度是多少”答题思路如果已经知道目标结点的前驱O(1)但通常需要先查找所以查找O(n) 删除O(1) 整体O(n)。“为什么删除结点之后要free”答题思路防止内存泄漏让操作系统能够重新利用这部分内存。程序长期运行泄漏会导致内存耗尽。“你的排序算法最坏情况时间复杂度是多少”答案是O(n²)。如果再追问能不能优化你就说还有归并排序的链式版本可以做到O(n log n)意识到这个问题说明了你的深度。“文件里保存的是什么下次打开为什么能恢复”答题思路保存的是每个结点的数据域学号、姓名、成绩不含指针。读取时逐条重建链表。最后再分享一点个人体会。我在带学弟学妹做实验的时候发现链表这东西你说它难它真的不难无非就是画图——把一个结点画成一个盒子里面放着数据和一个箭头然后按代码走两步看箭头怎么指。你卡住的每一道链表题几乎都是因为没在纸上画图直接对着代码空想。说句实话你做实验3花掉的三五个小时里有一个小时花在画图上后面能省出两倍的时间。画着画着你就会发现所谓指针操作不过就是“把某个盒子里装的地址改成另一个地址”而已。
返回列表