数据结构作业1:顺序表与单链表基本操作详解与调试技巧
发布时间:2026/9/7 21:35:12 作者:尧图编辑部 阅读量:1,286

作业年年有线性表这题几乎是每个学数据结构的人都要过的一道坎。这次分享的是【数据结构-作业1线性表的基本操作】的完整实现过程覆盖顺序表和单链表两种存储结构从结构体定义、初始化、插入删除到查找打印每一步都有详细代码和心得。不管你是刚开始学数据结构的小白还是正在赶实验报告、准备期末复习的选手这篇都能直接参考。写这个作业的时候很多人会陷入一个误区代码能跑就行甚至直接从网上复制一段改改交上去。但数据结构这门课真正的价值不在“跑通”而在“为什么这么写”以及“边界条件怎么处理”。这篇博文我打算从作业题本身出发把两种线性表实现的每一个细节都拆开讲清楚包括我踩过的坑和调试时总结出的一些土办法。1. 线性表作业到底在考什么1.1 顺序表和链表不是二选一而是两种思维线性表是最基础的数据结构它的核心逻辑很简单一组数据元素排成一条线每个元素有唯一的前驱和后继。但同样是“一条线”在计算机里存法却有两种一种是物理上挨在一起像电影院连排座位这叫顺序表另一种是物理上分散通过指针串成一条链像一个寻宝游戏每张纸条写着下一张纸条藏在哪里这叫链表。作业里要求同时实现这两种存储结构不是故意折腾人而是让你对比着理解。顺序表的优势是随机访问list[3]想取就取时间复杂度 O(1)缺点是插入和删除要搬移大量元素。链表的优势是插入删除只需要改指针时间复杂度 O(1)前提是你已经定位到了那个位置缺点是不支持随机访问要找到第 i 个元素必须从头遍历。用大白话说顺序表是“搬家麻烦但找东西快”链表是“搬家方便但找东西慢”。这两种结构没有绝对的谁好谁坏只看场景。比如后面学到的栈用顺序存储更常见因为栈只在栈顶操作不存在中间插入删除而 LRU 缓存淘汰算法用链表就很自然因为要频繁删头插尾。1.2 作业做题的正确姿势先画图再写码我见过不少同学拿到作业先打开编译器噼里啪啦敲代码结果调了半天 bug 一堆。我自己写过两次线性表作业一次本科一次辅导别人最大的体会是写之前先画图尤其是链表不画图你根本搞不清楚指针指来指去到底指到哪了。画图有两种层面。第一种是画存储结构图顺序表就是画一个方框数组链表就是画若干个节点方块每个方块分 data 域和 next 域再用箭头把 next 指出去。第二种是画流程图插入操作分几步、删除操作分几步每一步指针怎么变画清楚再写代码正确率会高一大截。比如单链表在第 i 个位置插入新节点 s核心操作就两句s-next p-next; p-next s;这两句话的顺序是死的如果先执行p-next s那么原来 p 后面的节点就找不到了链表就断了。画图就能一眼看出来不画图纯靠脑子想很容易写反。这也就是为什么后面我会反复强调链表操作中指针的赋值顺序是灵魂。1.3 作业验收的常见标准以我了解到的情况大部分学校这道作业的验收标准集中在以下几个方面能正确实现顺序表和链表的基本操作初始化、插入、删除、查找、打印。代码风格良好有头文件、函数注释、变量命名规范不是一坨 main 函数写到底。能正确处理边界条件空表插入、尾部删除、位置越界、内存分配失败。能口头回答算法的时间复杂度。所以这篇博文的代码组织方式我按“头文件 源文件 主函数测试”的标准工程结构来写一方面便于交作业另一方面也符合真实工作里的代码组织习惯。2. 环境准备与整体设计2.1 语言选型与编译环境实现线性表主流用 C 语言原因很朴素C 语言指针操作直观、能看到内存管理的细节数据结构本身就是讲内存里怎么组织数据用 C 学最接近本质。有些学校用 C 或 Java但 C 依然是考研和期末复习的主流语言严蔚敏老师的教材也是以类 C 的伪代码为主。编译环境这块我自己用的是 Visual Studio Code GCC平时调试就在 VS Code 里直接跑。对于初学者如果你不熟悉命令行用 Dev-C 或者 Code::Blocks 最省事打开就能写能编译。在线编译器比如各种 OJ 刷题网站自带的 IDE也可以但调试功能弱一些遇到指针问题不好查。需要说明的是这只是我个人的使用习惯工具选型本身不影响代码逻辑选你顺手的就行。2.2 工程文件怎么组织我的建议是把代码拆成三个文件SeqList.h和SeqList.c顺序表相关操作。LinkList.h和LinkList.c单链表相关操作。main.c测试入口写各种用例验证功能。这样拆分的好处是逻辑清晰交实验报告的时候也方便贴代码。更重要的是这对应了真实项目里的模块化思想——每个模块暴露头文件给外部用内部实现细节自己管互不干扰。顺序表和链表共用一个 main.c 测试入口测试顺序表就调用 SeqList 的函数测试链表就调用 LinkList 的函数两个模块互不干扰。2.3 整体层次结构一览先把整体结构放在这心里有个数后面每一块我都会展开说。模块核心结构体核心操作顺序表SeqList { int* data; int length; int capacity; }初始化、插入、删除、查找、打印单链表LNode { int data; LNode* next; }初始化、头插、尾插、删除、按值/按位查找、打印后面我会按“先顺序表、再链表、最后问题排查”的顺序来讲这样符合从易到难的学习曲线。3. 顺序表的实现把“搬数据”这件事做明白3.1 结构体设计与动态扩容顺序表本质上就是一个数组但作业里通常不会让你直接开一个巨大的定长数组而是要求用动态数组实现否则体现不出“表长可扩展”这个特性。所以我定义了这样的结构体#define INIT_CAPACITY 10 typedef struct { int* data; // 指向动态分配数组的指针 int length; // 当前有效元素个数 int capacity; // 当前分配的容量 } SeqList;这里多了一个capacity初学者容易忽略。它的作用是当length capacity时再插入就要扩容。如果只有data和length你怎么知道数组还有没有位置放新元素你可以硬规定一个最大值MAXSIZE但那又回到了定长数组的老路。所以capacity是动态扩容的关键也是顺序表区别于普通数组的关键。初始化函数很直接void SeqList_Init(SeqList* list) { list-data (int*)malloc(INIT_CAPACITY * sizeof(int)); if (list-data NULL) { printf(内存分配失败\n); exit(1); } list-length 0; list-capacity INIT_CAPACITY; }注意检查malloc返回值虽然作业里内存分配失败的几率不大但这是一个好习惯。后面我还会在扩容函数里同样检查。扩容逻辑用realloc实现注意不要原地扩容失败导致原数据丢失稳妥的做法是先用新指针接收判断成功后再赋给原来的指针。我见过不少同学直接用list-data realloc(list-data, new_capacity * sizeof(int))一旦 realloc 失败原指针就丢了这属于初学阶段比较隐蔽的坑。void SeqList_Resize(SeqList* list) { int new_capacity list-capacity * 2; int* new_data (int*)realloc(list-data, new_capacity * sizeof(int)); if (new_data NULL) { printf(扩容失败\n); exit(1); } list-data new_data; list-capacity new_capacity; }3.2 插入操作为什么必须从后往前搬顺序表的插入逻辑是要在下标pos处插一个值val先把pos到length-1的元素全部往后移一位空出位置再把val放进去最后length。这里的关键是从后往前搬也就是先移动最后一个元素再移动倒数第二个一直到下标pos的元素。想象排队打饭你要在第五个位置插队站在你后面的人都得往后退一步。谁先退肯定是队伍最后的人先退否则最后一个人先不退、倒数第二个人先退就会踩到后面的人。代码里也是一个道理用循环从length开始把data[length]赋值为data[length-1]一直循环到data[pos1] data[pos]为止。int SeqList_Insert(SeqList* list, int pos, int val) { if (pos 0 || pos list-length) { printf(插入位置非法\n); return 0; } if (list-length list-capacity) { SeqList_Resize(list); } for (int i list-length; i pos; i--) { list-data[i] list-data[i - 1]; } list-data[pos] val; list-length; return 1; }为什么能插到pos length的位置这就是尾部追加。循环条件i pos当 pos 等于 length 时循环一次都不执行直接把值放到data[length]这是合法的因为此时 length 正好是当前最后一个元素的下一个位置而且已经保证了 capacity 够用。时间复杂度方面插入操作平均要搬动 n/2 个元素所以时间复杂度是 O(n)。这也是顺序表最重要的性能特征随机访问快中间插入慢。3.3 删除操作为什么必须从前往后搬删除下标pos的元素逻辑上和插入对称把pos1到length-1的元素全部往前挪一位覆盖掉pos位置的值最后length--。这里必须从前往后搬也就是先移动 pos1 位置的元素覆盖 pos再移动 pos2 覆盖 pos1依此类推直到最后一个元素被覆盖。照例用排队来类比队伍中间有人走了后面的人依次往前补位最前面的人先动。如果最后的人先动往前一步踩到别人逻辑就乱了。int SeqList_Delete(SeqList* list, int pos) { if (pos 0 || pos list-length) { printf(删除位置非法\n); return 0; } for (int i pos; i list-length - 1; i) { list-data[i] list-data[i 1]; } list-length--; return 1; }这里有个细节删除后最后一个位置data[length-1]其实还残留着旧值但因为length减一了这个值永远不会被访问到所以不需要清理。忽略这个残留值不影响正确性。3.4 按值和按位查找的套路顺序表的查找有两种按位置取元素和按值找下标。按位置取直接return list-data[pos]O(1)。按值查找则是遍历一遍找到第一个等于目标值的下标返回找不到返回 -1O(n)。int SeqList_FindByValue(SeqList* list, int val) { for (int i 0; i list-length; i) { if (list-data[i] val) { return i; } } return -1; }这里有个容易踩的坑如果表里存的是结构体比如学生信息判断相等就不是了而要用成员逐个比较或自定义比较函数。作业里用的是 int 类型所以没问题。但写代码的时候保持习惯凡是判断相等先想一想这个类型的比较方式是什么。打印函数也顺带提一下遍历输出每个元素我习惯在末尾加一个换行方便看输出效果void SeqList_Print(SeqList* list) { printf(SeqList: ); for (int i 0; i list-length; i) { printf(%d , list-data[i]); } printf(\n); }4. 单链表的实现指针操作才是真正的灵魂4.1 带头节点和不带头节点的差别单链表的实现第一个分歧点就是到底带不带头节点。头节点是一个辅助节点它本身不存有效数据只是为了让“在第一个位置插入”和“删除第一个节点”这种操作和其他位置统一不需要特殊处理头指针。我强烈推荐带头节点。原因很现实如果你不带头节点那么插入到第一个位置时要改的是头指针本身删除时也要判断“删的是不是头节点”。这些判断不难但容易出错一出错就是链表断裂或段错误。带头节点后无论插入删除哪个位置处理的都是某个节点的 next 指针逻辑完全一致代码简洁性提升不少。结构体定义typedef struct LNode { int data; struct LNode* next; } LNode;注意struct LNode* next这行typedef 还没有生效所以必须写struct LNode*这是初学者很容易搞混的地方。在结构体内部引用自身类型时要使用完整的struct LNode*。初始化带头节点的链表LNode* LinkList_Create() { LNode* head (LNode*)malloc(sizeof(LNode)); if (head NULL) { printf(内存分配失败\n); exit(1); } head-next NULL; return head; }这里的 head 就是头节点不存数据head-next才指向第一个有效节点。4.2 尾插法建立链表作业里经常要求从键盘输入一组数建立链表。尾插法的逻辑是用一个尾指针始终指向链表的最后一个节点新节点每次都连到它后面然后更新尾指针。void LinkList_Append(LNode* head, int val) { LNode* p head; while (p-next ! NULL) { p p-next; } LNode* s (LNode*)malloc(sizeof(LNode)); s-data val; s-next NULL; p-next s; }每次 append 都从 head 开始遍历到尾部效率是 O(n)。如果你要连续插入 n 个元素总复杂度是 O(n^2)这在数据量小的时候无所谓但如果是刷题或性能要求高的场景就应该用一个tail指针记住尾部位置每次在尾部 O(1) 插入。作业里简单处理可以接受心里要知道这个优化空间。头插法也提一句新节点永远插到 head 之后链表的顺序和输入顺序相反。某些场景比如反转链表头插法特别好用。4.3 插入操作指针赋值顺序千万不能反在第 i 个位置插入节点思路是先找到第 i-1 个节点记为 p然后执行两个关键赋值s-next p-next; p-next s;我前面说过这两句的顺序是死的。原理很简单第一句是把 p 原来的后继“交给”新节点 s 作为它的后继第二句才是真正把 s 挂到 p 后面。如果先执行第二句p-next 就指向 s 了原来 p 后面那一段链表就和 p 断了联系后面的节点全丢了。画图一眼就能看出来。完整的指定位置插入函数int LinkList_Insert(LNode* head, int pos, int val) { LNode* p head; int i 0; while (p ! NULL i pos) { p p-next; i; } if (p NULL) { printf(插入位置非法\n); return 0; } LNode* s (LNode*)malloc(sizeof(LNode)); if (s NULL) { printf(内存分配失败\n); return 0; } s-data val; s-next p-next; p-next s; return 1; }注意循环结束的判断条件p ! NULL i pos。如果 pos 太大p 会走到 NULL说明这个位置不存在插入失败。如果p-next NULL且 pos 恰好是 length说明是在尾部插入循环正常结束p 指向最后一个节点把新节点挂上去即可。4.4 删除操作和内存释放删除指定位置节点的逻辑找到第 i-1 个节点 p然后让p-next p-next-next这样就把目标节点绕过去了。但要小心如果直接这样写目标节点还在内存里成为游离的孤儿节点必须先用一个临时变量保存它再改指针最后 free。int LinkList_Delete(LNode* head, int pos) { LNode* p head; int i 0; while (p-next ! NULL i pos) { p p-next; i; } if (p-next NULL) { printf(删除位置非法\n); return 0; } LNode* q p-next; // q 就是要删除的节点 p-next q-next; free(q); return 1; }这里循环结束条件是p-next ! NULL为什么不是p ! NULL因为我们要判断的是“p 后面有没有节点可删”。如果 p 本身不是 NULL 但 p-next 是 NULL说明 p 是最后一个节点没有节点可删那就是删除位置非法。这个细节很细但面试和考试里就喜欢问这种边界情况。内存释放是链表特有的问题。顺序表只用一次性 free(data) 就行链表则必须从头到尾逐个释放每个节点的内存。如果只 free(head)那其他节点全部泄漏了。void LinkList_Free(LNode* head) { LNode* p head; while (p ! NULL) { LNode* q p-next; free(p); p q; } }很多同学的链表销毁函数写成free(head); head NULL;就完事了这在作业里可能验收不出问题但如果你写一个长跑程序反复创建销毁链表内存就会越占越多。养成逐个释放的习惯很重要。4.5 查找链表必须从头遍历按值查找在顺序表里已经是 O(n)在链表里同样要 O(n)。代码很简单LNode* LinkList_FindByValue(LNode* head, int val) { LNode* p head-next; while (p ! NULL) { if (p-data val) { return p; } p p-next; } return NULL; }这里返回的是节点的指针而不是下标。因为链表根本没有下标的概念你最多告诉用户“这是第几个节点”而要数它是第几个又得从头遍历一遍。这也是链表查找的一个痛点知道值能找到节点但要报告它的位置得多一次遍历。试卷上有时候会考“查找和定位的区别”这就是答案所在。5. 常见问题与调试技巧实录5.1 编译期报错类型定义和头文件问题我帮同学看过最多的问题是结构体定义里用了 typedef 之后函数里又是struct LNode*又是LNode*混着写导致类型不匹配编译报错。其实混着写没问题只要类型本质一致就行但更统一的做法是全部用LNode*别在代码里混搭看着乱。另一个常见问题是忘记#include stdlib.h或#include stdio.h。malloc和free都在 stdlib.h 里如果你没包含编译会报 warning 或者 implicit declaration 错误。这类问题最好把常用的头文件集中放在一个头文件里或者每个 .c 文件开头都检查一遍。5.2 运行时崩溃段错误和野指针段错误基本是野指针或非法内存访问造成的。初学链表最经典的一幕LNode* p; p-data 5; // 悲剧了p 没有指向任何合法内存代码里最常见的野指针来源有两个。第一个是局部指针没有初始化。C 语言中未初始化的局部指针值是不确定的直接解引用就是未定义行为。第二个是访问已经 free 的内存。比如删除了节点 q后面又用 q 取数据这就是 use-after-free结果不可预测。调试这类问题我的土办法是加打印。每个函数入口打印一句“进入 XX 函数”关键操作后打印当前指针的值和节点的值。这种办法虽然原始但定位问题非常快。比如删除函数里删完一个节点后发现段错误就把p-next、q-next这些都打出来看看哪里变成了 NULL 或非法地址。5.3 逻辑错误死循环、输不出正确结果死循环通常出现在遍历链表时循环条件写错。比如用while (p ! NULL)遍历但循环体里p p-next因为某种原因没执行到或者链表构造时形成了一个环那就会无限循环下去。这里分享一个排查死循环的经验在循环体里加一个计数器比如循环次数超过 1000 就强制退出并打印“疑似死循环”这样至少不会卡死。等你确定链表结构没问题了再把计数器删掉。输不出正确结果特别是顺序表插入后打印出来顺序不对大概率是搬移方向错了。插入要用从后往前搬删除用从前往后搬。如果你搬反了插入到中间位置时后面的数据就会互相覆盖输出结果一团糟。这个只能靠对“数组搬移方向”的深刻理解来避免画图最有效。5.4 顺序表和链表调试的一点经验总结我整理的常见问题速查表看完基本能覆盖作业里 90% 的报错症状可能原因排查方法编译报错缺少头文件、类型不统一检查 include统一用 typedef 后的类型名插入后顺序错乱数组搬移方向反了插入从后往前删除从前往后链表插入后部分数据丢失指针赋值顺序写反先s-next p-next再p-next s删除节点后崩溃用 free 后没置 NULL又访问了free 后不要再解引用或置 NULL程序卡死链表成环、循环条件错误加计数器检查 while 条件内存泄漏链表销毁时只 free(head)逐个节点释放输出乱码/多余值顺序表未销毁残留值被打印检查 length 是否正确维护关于 last codeFree之后再 Node 置 NULL 这件事我多说一句。free(p)释放的是 p 指向的内存块但 p 本身仍然存着原来的地址这个地址现在已经不可用了这叫“悬空指针”。如果你后面不小心又访问了p-data就会踩到未定义行为。所以用完就顺手把 p 置为 NULL代价极小收益极大。6. 作业做完之后还能怎么延伸6.1 把这个作业改成双向链表和循环链表线性表作业做完后一个很自然的练习方向是把它改造成双向链表。带prev指针的双向链表在删除节点时不需要找到前驱节点因为节点里直接存了前驱指针时间复杂度从 O(n) 降到 O(1)。代码上其实就是多维护一个字段插入和删除时多改一条指针画图理解起来比单链表还容易。循环链表则是把尾节点的 next 指向头节点形成一个环。约瑟夫环问题就是循环链表最经典的应用场景有兴趣可以自己实现一下难度不大但思路很有代表性。6.2 线性表在后续内容里的位置完成这个作业你就拥有了整个数据结构课程里最基础的两个“积木”。栈可以看作只能在表尾插入删除的顺序表或链表队列可以看作一端进另一端出的线性表字符串的很多操作本质上也是顺序表操作的变体。后面学树、图的时候邻接表本质上就是一堆链表的组合。所以线性表作业看起来简单但它是后面所有高级数据结构的基础。把这次作业里的函数写熟练了后面课设和刷题都受益。6.3 由这个作业延伸出的几个经典算法题如果你时间充裕我推荐几个基于线性表的经典算法题对巩固知识特别有帮助很多企业面试也会考单链表反转三指针遍历法也可以递归实现理解了链表指针操作后就不再困难。合并两个有序链表双指针遍历依次取较小的接到结果链上。判断链表是否有环快慢指针法一个走一步一个走两步相遇则有环。寻找链表倒数第 k 个节点双指针快指针先走 k 步再同步走。就地逆置顺序表双指针头尾交换O(n) 完成。这些题目网上都有非常多题解和代码模板在实践题库里就能找到相同的题型。练完这些你对线性表的理解会再上一个台阶。我在实际写这个作业的过程中最大的体会是数据结构这东西你写一遍代码比看十遍课本都管用而一旦你画清楚了图再动手写代码思维会清晰很多。线性表的基本操作代码量不大但涉及了指针、内存管理、边界条件、复杂度分析这些核心概念把这些打扎实了后面学什么都顺。如果做作业的时候遇到报错先静下来画个图再看看指针指到了哪里你会发现很多问题其实是自己在“脑内运行代码”的时候偷懒了。希望这篇拆解能帮到你也欢迎在评论区交流你调试时遇到的那些奇奇怪怪的瞬间。