☰
线性表、顺序表与链表:C语言实现核心要点与避坑指南
2026/10/7 17:00:08 网站建设 项目流程

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到length+1。也就是说,在表尾追加元素也是合法的插入操作,这时候循环一次都不会执行,因为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和length+2)、以及连续插入删除的混合操作。能把这几组全跑通,基本就不会在考试或者实际项目里被边界条件打脸。

5.4 不带头结点的单链表:能不用就不用

我知道总有些教材为了体现“先难后易”,非要先把不带头结点版本的代码讲上一遍。但以我个人的经验,正式写代码时请直接带头结点。我见过太多项目里的链表bug,最后排查下来都是“第一个节点被删后头指针该改没改”“空表插入时头指针为NULL不知道怎么处理”。头结点那一个节点的空间开销,买来的是代码逻辑的极大简化,这笔交易非常划算。

6. 学完线性表之后:栈、队列和递归都与它血脉相连

线性表是数据结构的起点,但绝不是一个孤立的知识点。很多人学到栈的时候觉得又在学新东西,其实栈就是“只允许在表尾插入和删除”的线性表,队列就是“只能在表尾插入、在表头删除”的线性表。如果你把顺序表和单链表这两个基础打牢了,栈和队列的实现就是在它们上面套一层“操作限制”而已。

我曾经让学生做一个实验:把前面写好的顺序表函数复制一份,删掉“中间插入”“中间删除”的函数,只保留表尾插入、表尾删除、访问最后一个元素这三个操作,剩下的代码就已经是一个能用的顺序栈了。链栈就更明显,把头插法建的那个链表拿来,只允许在头结点后插入和删除,就是链栈。这个实验做完,大部分人会觉得功力提升了一大截。

递归那一块也和线性表有深刻联系。链表本身就适合用递归处理——“打印链表”可以写成“打印第一个节点 + 递归打印剩余链表”,“反转链表”也可以按递归的思路去拆解。你如果线性表学得扎实,对“抽象数据结构”的感觉会建立得更早。后面学树的遍历,其实就是在处理“多个链表的组合体”。

7. 我自己的一点点实操建议

最后说点题外话。我一直觉得,学数据结构,C语言版本是最“见血”的——没有面向对象帮你封装,没有标准库替你管理内存,所有东西都得自己动手。但反过来,正因为如此,你对“内存布局”和“指针本质”的理解会比用高级语言的人深得多。

如果你正在刷这门课或者准备考研,我特别建议你做一件事:合上书,在纯文本编辑器里把顺序表的插入删除和单链表的头插法建表、按位插入、按位删除这5个函数默写三遍。第一遍允许你错,错的地方就是你理解不到位的地方;第二遍要比照着教材检查边界条件;第三遍,你需要做到在完全脱离参考的情况下,一次通过边界测试。这个方法看起来笨,但效果出奇地好。我自己当年学的时候就这么练的,后来给上千名学生讲这门课也一直推荐这个练法。

另外,写完链表代码,建议打开Valgrind跑一下,哪怕是简单的main函数测试。它能帮你找出哪些malloc没有对应free,哪些指针操作读到了已释放的内存。很多“考试写得出来、上机就崩溃”的同学,缺的就是这种内存层面的体检意识。

数据结构的路很长,但线性表的这些函数,几乎是你往后每一步都要用到的“基本功”。把现在这篇的每个函数吃透,后面学栈学队列,你会觉得像做填空题一样轻松。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询