
1. 线性表为什么是数据结构的“第一块砖”我每次带新人或者给考研的同学讲数据结构第一课永远是线性表。不是因为它简单而是因为后面所有东西——栈、队列、串、数组、广义表甚至树和图里那些复杂操作的局部逻辑本质上都在反复使用线性表的那套“增删查改”思路。你如果能把这套东西在C语言层面上吃透后面的路会顺很多。线性表是什么一句话由n个数据元素组成的有限序列。注意是“序列”说明元素之间有先后次序。比如一个班的学生名单、一批订单记录、一部手机里的联系人列表都是典型的线性表。它强调的是一对一的逻辑关系除了第一个元素没有前驱最后一个元素没有后继中间任何一个元素都有一个直接前驱和一个直接后继。这个逻辑结构本身很简单但真正让初学者头疼的是“存”和“取”的方式。同样是线性表你可以用一块连续的内存去存它这叫顺序存储结构也可以用一组任意的、可能分散的内存单元通过指针串起来这叫链式存储结构。两种结构各有各的脾气对应的C语言函数实现也完全是两套打法。我在给学生上课时常打一个比方顺序表和链表的关系就像火车和货运卡车车队。火车车厢是连死的座位编号固定你知道第8节车厢在哪就能一步走过去——这就是顺序表的随机访问卡车车队每辆车可以停在不同地方车与车之间靠司机联系要找到第8辆车你得从第1辆挨个问过去——这就是链表的顺序访问。但反过来如果要在两节火车车厢中间插一节新车厢你得把整台火车拆开重连成本极高而卡车车队只需要通知前面那辆车的司机换条路走就行。2. 顺序表用数组思维实现的线性表以及那几个关键函数2.1 顺序表的底层定义为什么能随机访问顺序表的存储结构其实就是在C语言里用一个结构体包住数组同时记录当前有几个元素。这是最常见的定义方式#define MAX_SIZE 100 // 约定最大容量 typedef struct { int data[MAX_SIZE]; // 用静态数组做存储区 int length; // 当前表长 } SeqList;有的教材会用int *data配合动态内存分配那就是顺序表的动态版本。不过不管是静态数组还是动态堆内存核心思想一样元素在物理上连续存储。数组下标就是元素的位置要访问第i个元素直接用data[i-1]就能取到。这就是它最大的优势——时间复杂度O(1)的随机存取。像a[i]这样一个下标操作C语言编译器在底层把你做的事翻译成“基地址 i × 元素大小”的偏移计算。2.2 插入操作从后往前挪数据方向别搞反顺序表的插入算法是所有初学者的第一道坎。逻辑很简单在第i个位置插入新元素e需要把第i个位置及其之后的元素全部往后移一位再把e放进去表长加1。但这里有三个细节必须注意int SeqInsert(SeqList *L, int i, int e) { // 1. 表满检查 if (L-length MAX_SIZE) return -1; // 2. 位置合法性检查 if (i 1 || i L-length 1) return -1; // 3. 从最后一个元素开始逐个后移 for (int j L-length - 1; j i - 1; j--) { L-data[j 1] L-data[j]; } L-data[i - 1] e; L-length; return 1; }为什么循环要从length - 1往i - 1走而不是从i - 1往length - 1走我见过不少同学第一次都会写反。你想只要你正着挪前面的元素先把值覆盖到后面后面的元素还没动下一步会把已经挪过来的值再次覆盖到下一个位置——结果整个数组变成一片重复数据。从后往前挪每一步都是先从还没被覆盖的位置取值再放到空出来的位置才能保证不丢数据。还有一个容易忽略的边界插入的合法位置是1到length1。也就是说在表尾追加元素也是合法的插入操作这时候循环一次都不会执行因为i - 1 length循环条件是length - 1 length不成立直接放到最后一位就行。2.3 删除操作从前往后覆盖同样别搞反删除第i个位置的元素逻辑正好相反从i后面一位开始把每个元素往前覆盖一位覆盖到最后一个元素为止然后表长减1。int SeqDelete(SeqList *L, int i) { if (i 1 || i L-length) return -1; for (int j i - 1; j L-length - 1; j) { L-data[j] L-data[j 1]; } L-length--; return 1; }这里要注意删除操作不需要把最后一个位置“清空”。因为我们永远只认length以内的元素length - 1位置之后即使残留旧数据逻辑上也不属于这个线性表了。有些强迫症同学喜欢每次删除后把data[length] 0这没有错但没必要而且如果数组元素是结构体之类的大对象白白浪费了清零的开销。2.4 顺序表的瓶颈插入和删除为什么这么贵这是必须让你刻在脑子里的结论顺序表在表尾操作是O(1)但在表头或中间位置操作是O(n)。也就是说在一个10000个元素的表头插入一个元素你得挪9999个元素。我在讲这个知识点时总会让学生算一笔账如果有一个5000人的学生名单要频繁增删平均每次操作涉及一半也就是2500人的移动如果一秒钟做1000次操作就意味着有一两百万次数据搬移。这就是顺序表在频繁增删场景下干不过链表的地方。顺序表适合“查得多、长得少”的场景比如通讯录这种写好后基本不变、只做精确查找的应用。顺序表的扩容也是一个常被忽略的话题。如果你的顺序表用的是malloc动态空间表满时需要realloc扩一倍。问题在于realloc不一定是在原地址后面追加空间它可能会找一片更大的新内存把旧数据整体拷过去。这个拷贝本身就是O(n)的所以“动态扩容”并不像听起来那么廉价。如果提前知道数据量会涨得很凶不如一开始就把容量给足或者用倍数扩容每次扩一倍均摊下来插入的代价才会接近O(1)。3. 单链表用指针串起来的动态结构函数实现里的门道3.1 节点定义和头结点为什么头结点能让代码简单一半链表的节点结构在C语言里是一个自引用结构体typedef struct Node { int data; struct Node *next; } LNode, *LinkList;每个节点存一个数据元素外加一个指向后继节点的指针next。最后一个节点的next置为NULL表示链表到此为止。初学者最容易困惑的点是为什么几乎每本教材都要搞一个“头结点”直接让头指针指向第一个数据节点行不行行但代价是很多函数都要写特殊判断。举个例子在不带头结点的链表里删除第一个节点和删除中间节点处理方式完全不同——因为没有前驱可以帮忙连接你得直接改头指针。这意味着删除函数内部要写if (删除的是第一个节点) { 头指针 第一个节点的next; } else { 常规删除逻辑; }。带头结点之后头结点的next就是链表的入口任何位置上的删除和插入都可以统一成同样的逻辑找到前驱节点改前驱节点的next指向。头结点本身不存数据但它让“空表”和“非空表”的代码逻辑完全一致不会再出现“空表时头指针为NULL导致各种判断分支”的情况。所以我的建议很直接只要写链表一律带头结点省下来的分支判断远比你想象的多。3.2 初始化与遍历最容易忘的边界判断带头结点的初始化是这样的int InitList(LinkList *L) { *L (LNode *)malloc(sizeof(LNode)); if (*L NULL) return -1; (*L)-next NULL; return 1; }注意这里为什么是LinkList *L而不是LinkList L。因为malloc出来的头结点地址必须传回调用方如果只传LinkList L函数内部修改的是指针形参的副本调用方的头指针依然是NULL这就是C语言里典型的“值传递陷阱”。凡是“修改指针本身”的操作——初始化、头插法、整个链表删除——都必须要二级指针。链表的遍历就很简单了void PrintList(LinkList L) { LNode *p L-next; // 跳过带头结点 while (p ! NULL) { printf(%d , p-data); p p-next; } printf(\n); }这里要多说一句遍历时用while (p ! NULL)而不是while (p-next ! NULL)。前一种写法让p依次指向每个数据节点打印的正是每个节点的数据后一种写法在单链表里会导致最后一个节点打印不到因为最后一个节点的next是NULL循环提前结束了。我在检查学生作业时看到这个错误特别频繁而且debug半天很难看出来因为前几个元素都正常就少输出一个尾巴。3.3 插入操作“先接后断”原则为什么顺序不能反在第i个位置插入节点s核心代码就是三行s-next p-next; p-next s;其中p是第i-1个位置的节点。这两行语句的顺序是固定的先让新节点指向后继再让前驱指向新节点。如果反过来写成先p-next s再s-next p-next就出大问题了——因为p-next已经被改成s你再取p-next拿到的已经是s自己s-next就等于s链表当场断掉并且形成自环。我习惯給学生一个口诀“先接后断先建路再改路”。新节点先跟后面的节点建立起联系然后再把前驱的指针掰过来指向新节点。想象一下你要把一辆新车插进一列车队里肯定得先让新车和后面的车对接好再让前面的车松手并挂上新车反过来操作的话车队早就断开了。完整插入函数int ListInsert(LinkList L, int i, int e) { if (i 1) return -1; LNode *p L; // p从带头结点开始 int j 0; while (p ! NULL j i - 1) { // 找到第i-1个节点 p p-next; j; } if (p NULL) return -1; // 位置不合法 LNode *s (LNode *)malloc(sizeof(LNode)); if (s NULL) return -1; s-data e; s-next p-next; p-next s; return 1; }这个默认按位置插入的写法是基于“前驱定位”的。但在实际开发中还有一种更常用的场景我已经拿到了某个节点的指针p想直接在它后面插入一个节点这种情况不需要遍历找前驱时间复杂度是O(1)。操作就三句s-next p-next; p-next s;这也是后面学栈和队列的链式实现时反复用到的技巧。3.4 删除操作借助前驱特别注意中间节点的释放删除第i个位置的节点同样要找到它的前驱p然后把p-next指向被删节点的后继int ListDelete(LinkList L, int i, int e) { if (i 1) return -1; LNode *p L; int j 0; while (p-next ! NULL j i - 1) { p p-next; j; } if (p-next NULL) return -1; // 第i个节点不存在 LNode *q p-next; e q-data; p-next q-next; free(q); return 1; }我要强调的是那句free(q)很多人学链表时容易忽略它。链表节点的内存是malloc出来的属于堆内存不释放就会泄漏。这个程序可能跑一次两次看不出问题但一个长期运行的服务比如嵌入式设备上的任务调度链表每天增删几千次内存碎片和泄漏积累下来程序迟早崩溃。另外free之后最好把q置为NULL避免成为野指针。我这里用e带出被删节点的值算是“按值删除”和“按位删除”的组合方便使用者拿到删掉的数据做后续处理。3.5 头插法和尾插法一个建链表一个造顺序有了插入操作建链表就有两种方法。头插法每次把新节点插到头结点后面输入的顺序和链表的顺序相反尾插法每次把新节点挂在链表末尾保持输入顺序。// 头插法建表 LinkList CreateListHead(int n) { LinkList L (LNode *)malloc(sizeof(LNode)); L-next NULL; for (int i 0; i n; i) { LNode *s (LNode *)malloc(sizeof(LNode)); scanf(%d, s-data); s-next L-next; L-next s; } return L; }头插法代码短不需要遍历找尾节点但结果是逆序的。尾插法需要用一个尾指针r始终指向链表的最后一个节点每插一个更新r的位置// 尾插法建表 LinkList CreateListTail(int n) { LinkList L (LNode *)malloc(sizeof(LNode)); LNode *r L; // r始终指向尾节点 for (int i 0; i n; i) { LNode *s (LNode *)malloc(sizeof(LNode)); scanf(%d, s-data); r-next s; r s; // 更新尾指针 } r-next NULL; return L; }不要小看尾插法这个r s;的更新。有些同学忘了更新尾指针结果每插入一个新节点总是挂到原来的最后一个节点后面覆盖掉上一次的插入结果链表永远只有两个节点还丢了一堆内存。4. 顺序表与链表的核心差异选型才是真正的考验学到这里很多同学的下一步是纠结“到底哪种结构更好”。答案是没有绝对好坏只有适不适合场景。我把关键差异整理成一个表比一堆空话直观得多对比维度顺序表链表存储空间连续需预分配分散按需分配随机访问O(1)直接下标定位O(n)必须从头遍历插入删除已知位置O(n)大量移动元素O(1)只需改指针空间利用率可能有预分配浪费每个节点有指针域额外开销内存碎片较少节点频繁malloc/free会产生碎片适用场景查找频繁、数据量较稳定增删频繁、数据量不可预知我举个真实的例子供你体会。假设你要做一个图书管理系统书的信息基本固定读者常做的是“按书号查书”——这时候顺序表就很合适因为可以用书号直接映射到数组下标O(1)查到。反过来如果你要维护一个操作系统里的“就绪进程队列”进程随时创建、随时被调度出去频繁插入和删除那链表就是不二之选因为每次都只改两个指针。还有一个容易被忽视的差异是缓存友好性。顺序表的元素在内存里紧挨着遍历时CPU缓存命中率极高现代CPU加载一次缓存行能连续喂给你好几个元素链表节点在内存里东一个西一个每跳一个节点都可能触发一次缓存缺失。所以即使是同样的O(n)遍历顺序表的实际速度往往比链表快不少。这就是为什么很多高性能算法库会“用顺序表模拟链表”来实现所谓“静态链表”其中一个重要动机就是利用内存连续性的优势。5. 那些年我们一起踩过的C语言实现坑写了这么多年C又看了大量学生代码我发现线性表这个章节的bug高度集中。下面几条是我总结出的高频坑每一个都值得你写代码时留个心眼。5.1 指针悬空和内存泄漏一对“孪生坑”指针悬空最常见的产生方式就是——节点被free了但还有别的指针指向它。比如LNode *p L-next; LNode *q p-next; free(p); // p已经被释放 p q; // 安全写法先移动指针再释放正确做法是先把要用的后继节点保存下来再释放当前节点。很多同学的链表删除函数写完后运行没问题但用Valgrind一检测全是“Invalid read”和“definitely lost bytes”大概率就是这个原因。另一种典型泄漏链表删除函数只做了p-next q-next忘了free(q)。表面上看链表结构正常了但被删的节点还占据着内存。如果这个函数被循环调用一万次就泄漏一万个节点。5.2 二级指针C语言函数传参的灵魂考验我前面已经强调过初始化函数要传二级指针这里再展开一点。凡是函数内部要对“头指针本身”赋值的地方都必须用二级指针或返回头指针的方式。常见的有三种场景InitList(L)分配头结点头插法建表每次可能动静不大但初始化时涉及头结点DestroyList(L)释放整个链表后要置L NULL如果你用int DestroyList(LinkList L)这种一级指针写法最后在函数里写L NULL这行代码对调用方毫无意义——你只是把局部变量置空了调用方的指针还是那块已经释放的地址。这就是教科书上“值传递”的概念在指针上的一次深刻教训。还有一点要特别提醒判断函数参数应该用几级指针看的不是“我要操作几个节点”而是“我要修改调用方持有的那个指针变量本身吗”。修改节点的内容一级就够修改头指针的值必须二级。5.3 边界条件测试1和n是最容易漏的线性表的边界就是“空表”“只有一个元素”“表满”“位置1”“位置length”。很多函数用常规情况测是对的一到边界就翻车。比如插入时漏了i length 1表尾插入的测试删除时忘记位置为1的情况是否会导致头指针变化不带头结点时必翻车遍历时表为空是否会越界或崩溃我给学生的建议是写完每个操作函数至少跑六组测试——空表、单元素表、位置1、最后一个位置、越界位置0和length2、以及连续插入删除的混合操作。能把这几组全跑通基本就不会在考试或者实际项目里被边界条件打脸。5.4 不带头结点的单链表能不用就不用我知道总有些教材为了体现“先难后易”非要先把不带头结点版本的代码讲上一遍。但以我个人的经验正式写代码时请直接带头结点。我见过太多项目里的链表bug最后排查下来都是“第一个节点被删后头指针该改没改”“空表插入时头指针为NULL不知道怎么处理”。头结点那一个节点的空间开销买来的是代码逻辑的极大简化这笔交易非常划算。6. 学完线性表之后栈、队列和递归都与它血脉相连线性表是数据结构的起点但绝不是一个孤立的知识点。很多人学到栈的时候觉得又在学新东西其实栈就是“只允许在表尾插入和删除”的线性表队列就是“只能在表尾插入、在表头删除”的线性表。如果你把顺序表和单链表这两个基础打牢了栈和队列的实现就是在它们上面套一层“操作限制”而已。我曾经让学生做一个实验把前面写好的顺序表函数复制一份删掉“中间插入”“中间删除”的函数只保留表尾插入、表尾删除、访问最后一个元素这三个操作剩下的代码就已经是一个能用的顺序栈了。链栈就更明显把头插法建的那个链表拿来只允许在头结点后插入和删除就是链栈。这个实验做完大部分人会觉得功力提升了一大截。递归那一块也和线性表有深刻联系。链表本身就适合用递归处理——“打印链表”可以写成“打印第一个节点 递归打印剩余链表”“反转链表”也可以按递归的思路去拆解。你如果线性表学得扎实对“抽象数据结构”的感觉会建立得更早。后面学树的遍历其实就是在处理“多个链表的组合体”。7. 我自己的一点点实操建议最后说点题外话。我一直觉得学数据结构C语言版本是最“见血”的——没有面向对象帮你封装没有标准库替你管理内存所有东西都得自己动手。但反过来正因为如此你对“内存布局”和“指针本质”的理解会比用高级语言的人深得多。如果你正在刷这门课或者准备考研我特别建议你做一件事合上书在纯文本编辑器里把顺序表的插入删除和单链表的头插法建表、按位插入、按位删除这5个函数默写三遍。第一遍允许你错错的地方就是你理解不到位的地方第二遍要比照着教材检查边界条件第三遍你需要做到在完全脱离参考的情况下一次通过边界测试。这个方法看起来笨但效果出奇地好。我自己当年学的时候就这么练的后来给上千名学生讲这门课也一直推荐这个练法。另外写完链表代码建议打开Valgrind跑一下哪怕是简单的main函数测试。它能帮你找出哪些malloc没有对应free哪些指针操作读到了已释放的内存。很多“考试写得出来、上机就崩溃”的同学缺的就是这种内存层面的体检意识。数据结构的路很长但线性表的这些函数几乎是你往后每一步都要用到的“基本功”。把现在这篇的每个函数吃透后面学栈学队列你会觉得像做填空题一样轻松。