ARTICLE DETAIL

资讯详情

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

线性表入门:顺序表与链表的原理、实现及选型对比

线性表入门:顺序表与链表的原理、实现及选型对比 1. 从排队买奶茶看线性表为什么这是数据结构的第一课很多人学数据结构第一节课就撞上“线性表”这堵墙。说实话这名字起得实在劝退——线性表听着就不像人话。但如果我换个说法你每天排队买奶茶、刷手机里的歌单、甚至翻Excel表格里的每一行数据其实都在跟线性表打交道是不是一下子就好懂多了所谓线性表本质就是一串有先后次序的元素集合。元素之间是一对一的线性关系除了第一个元素没有“前驱”最后一个元素没有“后继”中间每个元素都有且只有一个直接前驱和一个直接后继。用生活语言讲就是“一个挨一个排好队谁也不能插队谁也不能并列”——这就是线性的含义。但你有没有想过同一个“排好队”的需求实现方式却可以天差地别。你可以在固定的一块连续内存里挨个摆放元素像电影院的一排座位座位号必须连续人必须按号入座——这叫顺序存储。你也可以让每个元素各自散落在内存的各个角落用一个“指向下一个元素的指针”把大家串起来像寻宝游戏里每张线索纸条上都写着下一个线索藏在哪里——这叫链式存储。线性表的全部秘密就藏在这两种存储结构的取舍之中。而要用C语言把它们真正落地实现函数怎么设计、边界怎么处理、内存怎么管理处处都是坑。这篇我就用最“实”的方式把顺序表和链表从结构体设计到每个核心函数的实现掰开揉碎讲清楚既服务期末复习、考研408的应试需求也照顾那些想真正把代码写明白的初学者。2. 顺序表说穿了就是“更聪明的数组”2.1 结构体定义为什么要包一层“壳”顺序表最直白的理解就是数组。但你要是在考试里直接定义一个int a[100]交差多半只能拿一半分数。原因很简单裸数组只存了数据本身没有记录“当前数组里究竟有多少有效数据”。这是几乎所有初学者的第一个认知跨越——存储结构除了数据还必须包含数据的状态信息。标准做法是包一个结构体#define MaxSize 100 // 表的最大长度 typedef struct { int data[MaxSize]; // 用静态数组存放数据元素 int length; // 表当前长度 } SqList;这里length就是灵魂。有了它你才能知道data[0]到data[length-1]是有效数据data[length]及以后是空闲区。这也引出一个顺序表最重要的规则逻辑上相邻的元素物理上也相邻下标就是元素在队列中的位次。MaxSize用宏定义而不是直接写100不是为了显得专业是真的省事。后期你要把最大容量从100改成10000只改这一行就行。要是当初在代码里到处硬编码100改起来就是一场灾难——这种事情我在课程设计里干过改到怀疑人生。2.2 初始化函数一个被无数人忽略的细节顺序表的初始化网上有各种版本绝大部分都这么写void InitList(SqList *L) { L-length 0; }就这么简单对就这么简单。静态数组的data不需要清零因为length 0已经表明所有数据无效后续写入会直接覆盖。但这里有一个98%的初学者都会犯的致命错误——调用时忘记取地址。正确的调用方式是SqList L; InitList(L);很多人在刚开始学的时候写InitList(L)编译能过程序也能跑但length根本没有被改写。因为C语言函数传参默认是值传递你传进去的是L的副本函数改的是副本原变量纹丝不动。这就是为什么初始化、插入、删除这些需要修改表本身的函数参数都必须是SqList *指针类型。从设计角度讲你应该养成一个习惯看到函数要修改原数据就传指针只是读取就传值。这个判断标准在后面写链表的时候会反复用到。提示考研408的算法题里经常考“设计一个算法实现XXX”所有这些题目默认你已经在主函数里初始化好了。所以InitList虽然代码简单但它是所有后续操作的基石绝不能写错。2.3 插入操作移动元素的三个边界顺序表的核心操作里插入是最能体现“数组思维”的。要在第i个位置插入一个新元素你必须先把从i开始的所有元素往后挪一格为新人腾出位置。这个过程像极了你插队进一列整齐的队伍不建议在现实中操作后面的人全部得退一步。完整实现如下bool ListInsert(SqList *L, int i, int e) { // 1. 判断i是否合法 if (i 1 || i L-length 1) { return false; } // 2. 判断表是否已满 if (L-length MaxSize) { return false; } // 3. 从后往前依次后移元素 for (int j L-length; j i; j--) { L-data[j] L-data[j-1]; } // 4. 放入新元素并更新长度 L-data[i-1] e; L-length; return true; }这里有三处边界必须刻进脑子里第一位置i是逻辑位序从1开始而数组下标从0开始所以插入位置对应下标是i-1。第二合法范围是1 ≤ i ≤ length1——i length1意味着插入到表尾这是完全合法的。第三容量边界length MaxSize意味着表满了不能再插。很多初学者最容易搞错的就是移动方向。必须从最后一个元素开始从后往前移动。你试着从前往后挪一下就会发现前面的元素被后面要挪过来的元素覆盖了数据全乱套。我自己当年第一次写的时候就用的从前往后调试了半天才发现数组里的数据变成了“复制粘贴”而不是“平移”。从复杂度角度看插入操作在位置1表头需要移动n个元素在位置n1表尾需要移动0个元素。平均情况下要移动n/2个元素所以时间复杂度是O(n)。这个结论先记住后面选型部分我会拿它跟链表做对比。2.4 删除操作删除比插入容易踩的隐性坑删除操作的思路是插入的逆过程——删掉第i个元素然后把后面的元素全部往前挪一位。实现如下bool ListDelete(SqList *L, int i, int *e) { if (i 1 || i L-length) { return false; } *e L-data[i-1]; // 用指针参数把被删元素带出去 for (int j i; j L-length; j) { L-data[j-1] L-data[j]; // 从前往后依次前移 } L-length--; return true; }注意这里跟前插不同移动方向是从被删位置的下一个元素开始从前往后挪这没错。但真正有意思的是那个int *e参数——为什么要用指针把被删的元素带出去而不是直接在函数内部丢掉不管答案是信息完整性问题。删除操作不仅要让表少一个元素很多时候调用方还需要知道“我到底删掉了什么”。比如你在通讯录里删掉一个人界面需要显示“已删除张三”吧如果函数把元素直接丢掉调用方就再也拿不到这个信息了。所以在C语言里凡是函数需要“产出”多个结果的都会通过指针参数往外传。删除操作的平局时间复杂度和插入一样O(n)。这也是顺序表最尴尬的地方——查找一个元素只要O(1)随机访问但增删一个元素却要O(n)。它擅长“读”不擅长“写”凡是频繁增删的场景顺序表都不是最佳选择。这个结论希望你牢牢记住因为后面选型对比的时候它就是最核心的判断依据。3. 单链表让指针把散落的内存“串”起来3.1 结构体定义懂了它你就懂了指针的本质链表跟顺序表是两种完全相反的哲学。顺序表要求内存必须连续链表则完全不在乎——每个元素可以落在内存任何位置只需要通过指针告诉别人“下一个元素在哪”。这种节点结构在C语言里写出来极其直观typedef struct LNode { int data; // 数据域 struct LNode *next; // 指针域指向下一个节点 } LNode, *LinkList;这段代码值得反复品味。struct LNode *next这一行定义了一个指向“自己这个类型”的指针。很多初学者在这里卡住一个结构体里面怎么能包含指向自己类型的指针不会是无限递归吗不会。因为指针不是结构体本身它只是一个地址固定占4或8个字节。就像你家的地址可以写在纸条上纸条不包含你整栋房子但它能带别人找到你家。链表正是靠着每一个节点里藏的“下一站地址”把内存中毫无关系的节点串成一条逻辑上的链。这里还要注意LNode和LinkList其实是同一个类型的两种叫法。LNode强调的是“节点”本身LinkList强调的是“链表”头指针。考试和实际代码中两种写法都会出现你只要心里清楚它们指向的是同一个结构体类型就行。使用链表第一步一定是创建头结点LNode *L (LNode*)malloc(sizeof(LNode)); L-next NULL;头结点和第一个数据节点是两个概念。头结点的data域通常不用它的存在只是为了统一操作逻辑——比如在表头插入节点时不需要单独判断链表是否为空。这个设计看似多余实则高妙你往后才能体会到它如何避免了无数个if分支。3.2 头插法让输入顺序“翻个跟头”链表的建立有两种经典方式头插法和尾插法。先说头插法因为它的代码更短也更有迷惑性LinkList List_HeadInsert(LinkList *L) { LNode *s; int x; *L (LinkList)malloc(sizeof(LNode)); // 创建头结点 (*L)-next NULL; // 初始为空表 scanf(%d, x); while (x ! 9999) { // 约定输入9999结束 s (LNode*)malloc(sizeof(LNode)); s-data x; s-next (*L)-next; // 新节点的next指向当前第一个节点 (*L)-next s; // 头结点的next指向新节点 scanf(%d, x); } return *L; }头插法的核心逻辑只有两句话新节点先指向头结点原来的后继然后让头结点的next指向新节点。顺序绝不可以颠倒。你要是先做(*L)-next s那原来的第一个节点就找不到了后面的链全断了。这种插入方式的副产物是最终生成的链表顺序与输入顺序完全相反。因为每个新节点都被塞到了最前面最开始输入的那个元素反而跑到了链表的末尾。所以头插法常用于“链表的逆置”这类算法题——你只需用头插法把所有节点重新插一遍链表就自动反转了。3.3 尾插法游标指针的艺术头插法虽然简单但如果我想保持输入顺序就得用尾插法。尾插法的关键在于引入一个“游标指针”r始终指向当前链表的最后一个节点LinkList List_TailInsert(LinkList *L) { int x; *L (LinkList)malloc(sizeof(LNode)); LNode *s, *r *L; // r指向当前表尾 scanf(%d, x); while (x ! 9999) { s (LNode*)malloc(sizeof(LNode)); s-data x; r-next s; // 新节点挂到表尾 r s; // r后移重新指向新的表尾 scanf(%d, x); } r-next NULL; // 收尾表尾节点的next置为空 return *L; }尾插法有个新手极容易忽略的收尾动作——最后一定要把r-next置为NULL。为什么因为新节点的next在malloc时是个随机值野指针如果你不给它赋空后面遍历链表的时候就会顺着这个野指针读到一块不知道是什么的内存然后程序行为完全不可预测。这个“不可预测”比直接报错更可怕它让你根本无法定位问题在哪。你可能好奇为什么头插法不需要这个收尾动作因为头插法里头结点的next一开始就被初始化为NULL新插入的节点永远通过next (*L)-next继承了NULL天然安全。而尾插法里的新节点是直接接在尾节点后面的它的next从未被赋值。这就是两种方法在实际编码中一个重要差异。3.4 查找、插入和删除链表操作的核心三件套链表的查找分两种按序号查找和按值查找。考试和工作中按序号查找出现频率更高它的思路就是“从头开始数到第几个就停”LNode* GetElem(LinkList L, int i) { if (i 1) { return NULL; } LNode *p L-next; int j 1; while (p ! NULL j i) { p p-next; j; } return p; }注意这个函数返回的是LNode*而不是bool。找到第i个节点就直接返回它的地址找不到就返回NULL如果i0按定义返回头结点本身。你在做考研408的算法题时GetElem(L, i)就像“预制模块”可以帮你大幅节省手写循环的时间。有了查找插入和删除都能简化。在指定位置插入节点需要先找到前一个节点然后改两条指针bool InsertNextNode(LNode *p, int e) { if (p NULL) { return false; } LNode *s (LNode*)malloc(sizeof(LNode)); s-data e; s-next p-next; p-next s; return true; }删除节点稍微讲究一点。如果要删除某个节点的后继节点直接让该节点指向后继的后继然后释放掉被删节点bool DeleteNextNode(LNode *p) { if (p NULL || p-next NULL) { return false; } LNode *q p-next; // q指向待删节点 p-next q-next; // 跳过q free(q); // 释放内存 return true; }一句话总结顺序表的插入删除靠“搬家”链表的插入删除靠“改链”。正是因为链表只需要改动几个指针不需要搬动任何元素所以它插入删除的时间复杂度是O(1)——前提是你已经拿到了目标位置的前驱节点。这也是链表最大的看家本领。4. 顺序表还是链表一张表看清所有权衡4.1 复杂度对账谁快谁慢一目了然学线性表绕不开一个灵魂拷问到底用顺序表还是链表什么“数组就是好”“链表就是灵活”这种结论听一堆不如直接拉个对照表。对比维度顺序表链表随机访问查找第i个O(1)直接按下标算地址O(n)必须从头挨个找表尾插入/删除O(1)length直接操作O(1)已知尾节点时表头插入/删除O(n)需要移动全部元素O(1)只需改head指针按值查找O(n)O(n)任何位置插入/删除O(n)主要花在移动元素上O(1)主要花在查找位置上存储密度高只存数据低每个节点多了一个指针开销内存空间需要连续内存可能造成碎片不要求连续按需分配扩容麻烦需要重新分配大块内存天然自动扩容这张表里最关键的矛盾在“随机访问”和“任意位置的插入删除”这两行。顺序表随机访问是O(1)链表是O(n)差了整整一个量级反过来顺序表任意位置插入要O(n)链表拿到前驱后只要O(1)。可以说顺序表和链表的天赋点完全不同一个点在了“读”一个点在了“写”。4.2 场景决策什么时候选谁光看复杂度还不够我结合自己在课程设计、题库刷题和实际小项目里的体会给你几个可以直接套用的场景判断**优先选顺序表的场景**数据量基本固定、不频繁增删的场合。典型如静态数组实现的学生成绩管理系统、图书管理系统——录入之后就主要是查询和显示了顺序表省空间、访问快绝对是正确选择。还有需要经常随机访问第k个元素的场景比如排行榜系统顺序表data[k]一步到位链表则要循环k次。**优先选链表的场景**数据量不确定、需要频繁在头部或中间插入删除的场合。典型如实现“先进先出”的队列、操作系统的任务调度链表、文本编辑器的撤销记录。还有就是数据总量很大但内存不连续的场景——链表虽然每个节点多了指针开销但可以“见缝插针”地利用内存碎片而顺序表的连续大块内存可能根本找不出来。我说个具体的大二的时候我想用C写一个简单的“最近使用文件列表”每次打开文件就把它挪到最前面还要保持最多10条记录。用顺序表的话每次从中间把一条记录挪到头部平均要移动5个元素这倒还好。但如果扩展成100万级别任务调度每次都要搬半城的人那就彻底崩了。这个场景换成链表不管列表多长头部插入删除永远是常数时间。注意链表也有个隐性成本——CPU缓存不友好。顺序表数据在内存中是连续的CPU读取缓存行时一次能加载一大批相邻数据链表节点东一个西一个缓存命中率低实际运行速度可能比“纸面复杂度”差不少。所以现代工程里很多所谓“用链表”的地方底层其实在用动态数组比如Java的ArrayList。理解了这一层你就比只会背课本结论的人高了一截。5. 实战避坑那些年我踩过的“段错误”5.1 最常见的五种“隐形炸弹”链表和指针是C语言的两大鬼门关线性表的函数实现刚好把它们全凑齐了。这里我把新手最常踩的五个坑列出来每一个都是我或我的同学在调试器前熬过夜才换来的教训。第一丢链。修改next指针时先把旧的后继保存下来再改指针。头插法两步操作顺序反了链就断了。这个我在前面已经反复强调。第二野指针。malloc出来的节点其next值是随机的。如果不用前先赋NULL后患无穷。尾插法结尾的r-next NULL就是最典型的例子。第三忘了free。C语言没有垃圾回收malloc的每一个字节都必须由你自己用free还回去。只在堆上申请节点却从不释放程序跑一会儿内存就爆了。在循环里反复头插建链表然后只释放头结点更是把整条链都丢了变成一串永远无法回收的内存。第四传值还是传指针。在函数里修改链表头指针却用值传递结果调用完头指针还是NULL。我之前写过一个初始化函数InitList(L)主函数里链表永远为空排查了半天才发现忘了加取地址符。第五访问NULL的成员。p-next当p本身是NULL时程序会段错误。所以查找、遍历的循环条件必须是p ! NULL写循环的时候先判断一下再行动。5.2 段错误的排查与内存检测遇到段错误Segmentation Fault千万别慌它其实是最容易解决的一类错误——因为原因高度集中在“访问了不该访问的内存”。我的排查套路一般是三板斧先做代码走查。从上到下检查每条指针操作这个指针赋值了吗它指向的内存还有效吗它是否可能是NULL90%的段错误在这一步就能看出来。再用printf定位。如果代码量大了就在可疑处前后插入printf查看执行到哪里挂了。这方法虽土但效率极高。比如怀疑删除函数导致段错误就在DeleteNextNode出入口各加一个printf一眼就能看出哪一步崩了。最后用调试器。如果你在Ubuntu虚拟机环境里配好了gcc和gdb直接编译时加-g选项然后gdb ./a.out运行到出错时输入bt就能看到完整的调用栈错误发生在第几行一目了然。内存泄漏的检测用valgrindvalgrind --leak-checkfull ./a.out这个工具会告诉你哪一行malloc的内存没有被free算是C语言内存管理的神器。我在写链表练习时每隔一段就跑一次确保没有内存泄漏这习惯帮我避免了很多隐蔽的bug。5.3 期末和考研的易错点速查因为热搜词里出现了“数据结构期末复习”“考研数据结构”“数据结构408”我就把线性表这一章最容易丢分的细节一次性列清楚考前看这一节能救回不少分。位序和下标的关系。顺序表和链表的位序都从1开始数组下标从0开始。写代码时第i个元素 data[i-1]链表第i个节点要循环i次。每年考试都有大批人在这种地方栽跟头。头插法逆置的特性。给你一组输入让你画出头插法生成的链表结果必须跟输入顺序相反。这题几乎每年必考务必把它钉死在脑子里。单链表的删除必须知道前驱。考试常设“仅给被删节点的指针怎么删除它”的题目。经典解法是偷梁换柱把后继节点的数据复制到当前节点然后删除后继节点。代码我在前面写过了这思路可不能忘——它打破了“删除必须知道前驱”的惯常认识。带头结点和不带头结点的区别。408和各类教材里常见的都是带头结点的写法因为操作统一。但真题偶尔会出现不带头结点的版本这时表头插入和删除需要单独处理头指针本身可能会变。读题时一定要先搞清楚这题用的哪种结构。6. 写在最后把线性表当成所有数据结构的“基本功”从顺序表到链表表面上你只是学会了两套代码但实际上你收获的是三种能力结构体的封装意识——知道数据和元信息要绑在一起指针的操控能力——理解地址是如何把分散的数据组织成逻辑整体的复杂度权衡思维——明白没有绝对好的结构只有适不适合场景的选择。这三种能力几乎可以平移到后面所有数据结构的学习中。栈和队列不过是受限的线性表禁了某些操作而已串是元素类型为字符的线性表广义表和树某种意义上就是“多个链表交织在一起”。学透了线性表你后面再看这些内容都会觉得眼熟。我个人在实际操作中的体会是学线性表最好的方式不是捧着书背代码而是拿出编译器把顺序表和链表各建一个工程逐个函数从零手写每写完一个就测试一组边界条件空表、表头、表尾、表满再故意写几处错误体验一下段错误和野指针的滋味。这种“摔过跤”的经验比任何教程都刻骨铭心。等你写完再回头去看那些考研真题里的线性表大题会发现它们多数只是这六个核心函数的不同组合而已。地基打牢了上层建筑不过是水到渠成的事。
返回列表