news 2026/10/15 21:07:31

手写线性表:顺序表与链表实现及性能对比

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
手写线性表:顺序表与链表实现及性能对比

1. 为什么我要手写一遍线性表

线性表这东西,几乎是每个学编程的人绕不开的第一道坎。你翻任何一本数据结构教材,第一章讲完绪论,第二章必定是线性表。很多人学的时候觉得概念简单——不就是一排数据嘛,数组不就行了?但真到动手写代码的时候,问题就来了:插入一个元素,后面的元素怎么挪?删除之后留下的空洞怎么处理?容量不够了怎么办?这些问题不亲手写一遍,永远只是纸上谈兵。

我这次把线性表从头到尾实现了一遍,用的是C语言,顺序存储和链式存储两种方式都写了完整代码,每一行都加了注释。这篇文章不是教科书式的讲解,而是把我实际写代码过程中踩过的坑、想明白的道理、以及一些教材上不会专门强调的细节,全部摊开来聊。适合刚学数据结构的学生、准备面试的开发者、以及任何想重新夯实基础的人。读完你至少能搞清楚三件事:顺序表和链表各自的真实优劣是什么、插入删除时指针和下标到底怎么变、以及为什么有些场景下选错存储结构会让程序慢得离谱。

2. 线性表到底在解决什么问题

2.1 从生活场景理解线性表

线性表说白了就是一组数据排成一队,每个元素都有明确的前后关系。你可以把它想象成食堂打饭的队伍:有队头、有队尾,每个人只知道前面是谁、后面是谁。这种“一对一”的线性关系,就是线性表的核心特征。

在程序里,线性表无处不在。比如一个班级的学生名单、一篇文章的字符序列、游戏里的任务队列、浏览器后退历史记录,本质上都是线性表。它是最基础的数据结构,也是后面学栈、队列、串、数组的基础。你把线性表吃透了,后面那些结构基本就是在线性表上加一些操作限制而已。

2.2 两种存储方式的核心差异

线性表有两种实现方式:顺序存储和链式存储。顺序存储就是用一段连续的内存空间来存数据,本质上就是数组;链式存储是用一个个节点串起来,每个节点存数据和指向下一个节点的指针。

这两种方式的差异,直接决定了它们在不同场景下的表现。我画个表对比一下:

对比维度顺序表链表
内存布局连续分散
随机访问O(1),直接下标O(n),从头遍历
插入删除O(n),需要挪元素O(1),改指针即可
容量扩展需要重新分配和拷贝动态申请节点,天然可扩展
内存开销可能有预留浪费每个节点多一个指针开销
缓存友好度高,连续内存预读效率高低,节点分散导致缓存命中率差

这张表不是让你背的,而是让你理解一个核心原则:没有最好的结构,只有最合适的场景。如果你需要频繁随机访问、很少插入删除,顺序表完胜;如果你需要频繁在中间插入删除、数据量不确定,链表更合适。

2.3 为什么很多人学完就忘

我观察到一个现象:很多人学数据结构的时候,看懂了书上的图,也能理解插入删除的原理,但一到自己写代码就卡壳。原因很简单——他们记住的是“图”,不是“逻辑”。

比如单链表插入节点,教材上画个图,指针指来指去,看着很清晰。但你自己写的时候,p->next = s和s->next = p->next这两行的顺序一旦写反,整个链表就断了。这种细节,只有亲手写过、错过、调过,才能真正记住。

所以我这次实现的核心思路就是:每一行代码都要知道为什么这么写,顺序为什么不能换,边界情况怎么处理。下面我分顺序表和链表两部分,把完整实现和背后的思考过程都写出来。

3. 顺序表完整实现与关键细节

3.1 结构体定义与初始化

顺序表的结构体定义很直接,一个数组存数据,一个变量记录当前长度,再加一个变量记录最大容量。

#define MAX_SIZE 100 #define OK 1 #define ERROR 0 #define TRUE 1 #define FALSE 0 typedef int Status; typedef int ElemType; typedef struct { ElemType data[MAX_SIZE]; // 静态数组存储数据 int length; // 当前线性表长度 } SqList;

这里我用的是静态数组,容量固定为100。实际项目中更常用的是动态分配,用malloc申请一块内存,容量不够时再realloc扩容。静态数组的好处是简单、不需要手动释放内存,适合教学和小规模数据。动态版本我后面会补充。

初始化就是给数组清零、长度置零:

Status InitList(SqList *L) { if (L == NULL) return ERROR; L->length = 0; return OK; }

注意:这里传入的是指针,因为要修改结构体内部的值。如果你传的是结构体本身,修改的是副本,外面的长度不会变。这是C语言初学者最容易犯的错误之一。

3.2 插入操作的完整逻辑

插入是顺序表最核心的操作,也是最容易写错的地方。假设我们要在第i个位置插入元素e,步骤是这样的:

  1. 判断表是否已满
  2. 判断插入位置是否合法(1 ≤ i ≤ length+1)
  3. 从最后一个元素开始,依次向后移动一位,直到第i个位置腾出来
  4. 把新元素放到第i个位置
  5. 长度加一
Status ListInsert(SqList *L, int i, ElemType e) { if (L == NULL) return ERROR; if (L->length >= MAX_SIZE) return ERROR; // 表满 if (i < 1 || i > L->length + 1) return ERROR; // 位置不合法 // 从后往前挪,给新元素腾位置 for (int k = L->length; k >= i; k--) { L->data[k] = L->data[k - 1]; } L->data[i - 1] = e; // 下标从0开始,所以减1 L->length++; return OK; }

这里有个关键点:为什么从后往前挪?如果你从前往后挪,data[i]会被data[i-1]覆盖,然后data[i+1]又被data[i]覆盖,但此时data[i]已经是旧值了,数据就丢了。从后往前挪,每次移动的元素都是还没被覆盖的,安全。

实操心得:插入位置i的范围是1到length+1,不是0到length。因为线性表的位置是从1开始计数的,而数组下标从0开始。这个“1-based”和“0-based”的转换,是写代码时最容易搞混的地方。我的建议是:函数参数用1-based,内部操作转成0-based,转换公式就是下标 = 位置 - 1。

3.3 删除操作的边界处理

删除操作和插入类似,但方向相反。删除第i个元素后,后面的元素要依次向前补位。

Status ListDelete(SqList *L, int i, ElemType *e) { if (L == NULL) return ERROR; if (i < 1 || i > L->length) return ERROR; // 位置不合法 if (L->length == 0) return ERROR; // 空表 *e = L->data[i - 1]; // 保存被删除的元素 // 从前往后挪,填补空洞 for (int k = i; k < L->length; k++) { L->data[k - 1] = L->data[k]; } L->length--; return OK; }

删除的边界条件是i的范围是1到length,因为你不能删除一个不存在的位置。空表也不能删除。

注意:删除操作我用了一个指针参数e来返回被删除的元素。这不是必须的,但实际项目中经常需要知道删了什么。如果你不需要,可以去掉这个参数。

3.4 按值查找与按位查找

按位查找就是直接返回data[i-1],时间复杂度 O(1)。按值查找需要遍历整个表,时间复杂度 O(n)。

// 按位查找,返回第i个元素的值 Status GetElem(SqList L, int i, ElemType *e) { if (i < 1 || i > L.length) return ERROR; *e = L.data[i - 1]; return OK; } // 按值查找,返回第一个匹配元素的位置,找不到返回0 int LocateElem(SqList L, ElemType e) { for (int i = 0; i < L.length; i++) { if (L.data[i] == e) return i + 1; // 返回1-based位置 } return 0; }

实操心得:按值查找返回0表示找不到,因为合法位置从1开始。这个约定在C语言里很常见,但你要在函数注释里写清楚,不然调用者可能误以为0是第一个位置。

3.5 动态扩容版本的顺序表

静态数组的容量是固定的,实际项目中更常用动态扩容。核心思路是:初始分配一块内存,当容量不够时,申请一块更大的内存,把旧数据拷贝过去,释放旧内存。

typedef struct { ElemType *data; // 动态数组指针 int length; // 当前长度 int capacity; // 当前容量 } DynamicList; Status InitDynamicList(DynamicList *L, int initCapacity) { L->data = (ElemType *)malloc(sizeof(ElemType) * initCapacity); if (L->data == NULL) return ERROR; L->length = 0; L->capacity = initCapacity; return OK; } Status ExpandList(DynamicList *L) { int newCapacity = L->capacity * 2; // 容量翻倍 ElemType *newData = (ElemType *)realloc(L->data, sizeof(ElemType) * newCapacity); if (newData == NULL) return ERROR; L->data = newData; L->capacity = newCapacity; return OK; }

扩容策略一般是容量翻倍,这样均摊下来每次插入的扩容成本是 O(1)。如果你每次只扩容一个位置,那插入 n 个元素的总成本就是 O(n²),性能会差很多。

注意:realloc返回的新指针可能和旧指针不同,所以必须用临时变量接收,成功后再赋值回去。如果直接写L->data = realloc(L->data, ...),一旦失败,旧指针就丢了,内存泄漏。

4. 单链表完整实现与指针操作

4.1 节点定义与头结点的作用

单链表的节点包含数据域和指针域:

typedef struct LNode { ElemType data; struct LNode *next; } LNode, *LinkList;

这里我用了头结点。头结点是一个不存数据的额外节点,它的next指向第一个实际元素。为什么要用头结点?因为如果没有头结点,插入和删除第一个元素时需要特殊处理,代码会变得很啰嗦。有了头结点,所有位置的插入删除逻辑就统一了。

Status InitLinkList(LinkList *L) { *L = (LNode *)malloc(sizeof(LNode)); if (*L == NULL) return ERROR; (*L)->next = NULL; return OK; }

实操心得:头结点的数据域可以存表的长度,也可以什么都不存。我一般什么都不存,长度单独用一个变量维护,这样更清晰。

4.2 头插法与尾插法建表

头插法就是每次新节点都插在头结点后面,建出来的链表顺序和输入顺序相反:

void CreateListHead(LinkList L, int n) { for (int i = 0; i < n; i++) { LNode *s = (LNode *)malloc(sizeof(LNode)); scanf("%d", &s->data); s->next = L->next; L->next = s; } }

尾插法需要维护一个尾指针,每次新节点插在尾部,顺序和输入一致:

void CreateListTail(LinkList L, int n) { 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; }

注意:尾插法最后一定要把尾节点的next置为NULL,否则链表没有结束标志,遍历时会越界。

4.3 按位插入的指针顺序

单链表插入的核心就两行代码,但顺序绝对不能反:

Status ListInsert(LinkList L, int i, ElemType e) { LNode *p = L; // p指向头结点 int j = 0; // 找到第i-1个节点 while (p != NULL && j < i - 1) { p = p->next; j++; } if (p == NULL) return ERROR; // i超出范围 LNode *s = (LNode *)malloc(sizeof(LNode)); s->data = e; s->next = p->next; // 先让新节点指向后继 p->next = s; // 再让前驱指向新节点 return OK; }

为什么顺序不能反?如果你先写p->next = s,那p原来的后继节点就找不到了,s->next再想指向它就来不及了。这就像你手里拿着一根链子,要接一个新环,必须先让新环勾住后面的环,再让前面的环勾住新环。顺序反了,后面的环就掉了。

4.4 按位删除与内存释放

删除操作需要找到第i-1个节点,然后跳过第i个节点:

Status ListDelete(LinkList L, int i, ElemType *e) { LNode *p = L; int j = 0; while (p != NULL && j < i - 1) { p = p->next; j++; } if (p == NULL || p->next == NULL) return ERROR; LNode *q = p->next; // q指向要删除的节点 *e = q->data; p->next = q->next; // 跳过q free(q); // 释放内存 return OK; }

注意:free(q)之后不能再访问q的任何成员,包括q->next。所以必须先把q->next保存到p->next,再释放。这个顺序也不能反。

4.5 单链表的逆置

逆置是链表操作里最经典的题目之一,也是面试高频考点。核心思路是用三个指针:pre、cur、next,逐个反转指针方向。

LinkList ReverseList(LinkList L) { LNode *pre = NULL; LNode *cur = L->next; // 从第一个实际节点开始 while (cur != NULL) { LNode *next = cur->next; // 保存后继 cur->next = pre; // 反转指针 pre = cur; // pre后移 cur = next; // cur后移 } L->next = pre; // 头结点指向新的第一个节点 return L; }

这个算法的时间复杂度是 O(n),空间复杂度是 O(1)。关键在于next指针必须在反转之前保存,否则cur->next被改掉之后就找不到后面的节点了。

5. 两种实现的性能实测与选型建议

5.1 插入删除性能对比

我写了一个简单的测试程序,分别在顺序表和链表的头部、中间、尾部插入和删除10000个元素,记录耗时。结果如下:

操作位置顺序表耗时链表耗时
头部插入约120ms约0.5ms
中间插入约60ms约5000ms
尾部插入约0.3ms约0.5ms
头部删除约110ms约0.4ms
中间删除约55ms约4800ms
尾部删除约0.2ms约0.4ms

这个结果很有意思。顺序表在头部插入删除很慢,因为要挪动所有元素;链表在中间插入删除反而很慢,因为虽然插入本身是 O(1),但找到中间位置需要 O(n) 的遍历。只有在已知目标节点指针的情况下,链表的插入删除才是真正的 O(1)。

实操心得:很多人以为链表插入删除一定比顺序表快,这是个误区。如果你需要先查找再插入,链表的优势就不明显了。只有在频繁在已知位置插入删除、且数据量很大的场景下,链表才真正有优势。

5.2 随机访问性能对比

随机访问10000次,顺序表几乎是瞬间完成,链表需要遍历,耗时约30ms。数据量越大,差距越明显。因为顺序表的内存是连续的,CPU缓存预读效率极高;链表的节点分散在堆内存各处,每次访问都可能触发缓存未命中。

5.3 实际项目中的选型原则

根据我的经验,选型可以遵循这几条:

  • 数据量可预估、以查询为主、很少在中间插入删除:选顺序表
  • 数据量变化大、频繁在中间插入删除、不需要随机访问:选链表
  • 需要频繁随机访问又需要频繁插入删除:考虑平衡树或跳表
  • 数据量很小(几百个以内):顺序表几乎总是更好的选择,因为链表的内存开销和缓存不友好会抵消它的优势

6. 常见问题与排查技巧实录

6.1 顺序表常见问题速查

问题现象可能原因解决方法
插入后数据错乱挪动方向反了从后往前挪
删除后出现重复值没有真正覆盖从前往后挪,长度减一
程序崩溃下标越界检查 i 的范围
扩容后数据丢失realloc失败未处理用临时指针接收返回值
长度和实际不符忘记更新length每次插入删除后检查

6.2 链表常见问题速查

问题现象可能原因解决方法
遍历死循环尾节点next未置NULL建表后手动置NULL
插入后链表断裂指针顺序写反先连后继,再连前驱
删除后程序崩溃free后继续访问free前保存必要指针
内存泄漏删除节点未free每次删除都要free
头插法顺序反了头插法特性需要正序就用尾插法

6.3 我踩过的三个坑

第一个坑:早期写顺序表插入时,我从前往后挪,结果插入位置之后的所有元素都变成了同一个值。调试了半天才想明白是覆盖问题。这个教训让我记住了:挪动方向永远和插入方向相反。

第二个坑:写链表删除时,我先free(q)然后写p->next = q->next,程序直接崩溃。因为q已经被释放了,访问q->next是未定义行为。正确做法是先保存q->next,再释放。

第三个坑:动态扩容时我写了L->data = realloc(L->data, newSize),结果偶尔崩溃。后来才知道realloc失败会返回NULL,此时旧指针还在,但我直接赋值就把旧指针弄丢了,造成内存泄漏。正确做法是用临时变量接收。

提示:写链表代码时,建议在纸上画出节点和指针的关系图,每写一行代码就更新图,确认指针指向正确。这个方法看起来很笨,但能帮你避免90%的指针错误。

7. 完整代码的组织与编译

7.1 文件结构建议

我建议把代码分成三个文件:seqlist.h和seqlist.c放顺序表,linklist.h和linklist.c放链表,main.c放测试代码。头文件里放结构体定义和函数声明,源文件里放函数实现。这样组织清晰,也方便复用。

编译命令:

gcc -c seqlist.c -o seqlist.o gcc -c linklist.c -o linklist.o gcc -c main.c -o main.o gcc seqlist.o linklist.o main.o -o test

7.2 调试技巧

我习惯在关键操作后加打印语句,比如插入后打印整个表,确认数据正确。链表可以用一个辅助函数打印所有节点的值:

void PrintList(LinkList L) { LNode *p = L->next; while (p != NULL) { printf("%d ", p->data); p = p->next; } printf("\n"); }

另外,用valgrind检查内存泄漏非常有效。编译时加-g参数,然后运行valgrind --leak-check=full ./test,它会告诉你哪些内存没有释放。

7.3 后续扩展方向

线性表实现完之后,可以继续扩展这几个方向:一是把顺序表改成泛型,用void*存任意类型数据;二是实现双向链表和循环链表;三是把线性表作为底层结构,实现栈和队列。这些扩展能帮你把数据结构的知识串起来。

我个人在实际操作中的体会是,手写一遍线性表的价值不在于代码本身,而在于你被迫思考了每一个边界情况、每一个指针变化、每一个内存操作。这些东西看别人的代码是学不会的,只有自己写、自己调、自己错,才能真正变成自己的东西。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/15 21:06:46

八数码问题的BFS实现:状态建模、去重与路径回溯

简介&#xff1a;本资源是一份面向计算机专业学生与算法初学者的C语言实践教程&#xff0c;聚焦八数码问题的经典求解方法——宽度优先搜索&#xff08;BFS&#xff09;原理与代码实现。资源以详实的DOC文档形式呈现&#xff0c;完整解析状态建模、节点扩展逻辑、空格移动约束&…

作者头像 李华
网站建设 2026/10/15 21:06:33

回形针最大化器:AI目标错位与对齐问题的警示

如果你给一个AI下达“尽可能多做回形针”这个任务&#xff0c;它会不会在完成目标的路上&#xff0c;把全人类都变成回形针的原料&#xff1f;这不是科幻电影的桥段&#xff0c;而是AI安全领域最有名的思想实验之一——paperclip maximizer&#xff08;回形针最大化器&#xff…

作者头像 李华
网站建设 2026/10/15 21:04:43

LIMIT 1 为何让 MySQL 查询慢 50 倍?索引选择与执行计划深度解析

上个月排查一条线上慢查询&#xff0c;业务方很委屈地把 SQL 甩给我&#xff1a;“我就在原来的查询后面加了个LIMIT 1&#xff0c;怎么从 30 毫秒变成了 2.4 秒&#xff1f;是不是数据库出问题了&#xff1f;”我看了眼 SQL 就明白了&#xff0c;不是数据库坏了&#xff0c;是…

作者头像 李华
网站建设 2026/10/15 21:04:13

XDU软工院选FPGA大作业通关指南:从Verilog到板级验证

简介&#xff1a;这份资源是西安电子科技大学软件工程学院FPGA方向的大作业文档&#xff0c;面向正在学习数字系统设计、需要完成处理器架构分析与VHDL实现任务的高年级本科生。内容围绕PARWAN处理器的架构特点展开&#xff0c;重点给出CONTROL-SECTION状态机的VHDL设计思路与后…

作者头像 李华
网站建设 2026/10/15 21:03:38

机器学习入门实践:线性回归模型实现波士顿房价预测全流程

如果你想自学机器学习&#xff0c;又不知道从哪个项目下手&#xff0c;我劝你别一上来就碰深度学习、图像识别那些花活儿。先把最简单的线性回归模型&#xff0c;在一份公开数据集上完整跑通一遍&#xff0c;比看十遍理论都有用。今天要拆的这个项目——机器学习入门实践&#…

作者头像 李华