资讯动态

顺序表从零实现:结构体设计、插入删除与扩容机制全解

发布时间:2026/10/20 1:52:17 来源:尧图企业网站定制
1. 项目概述顺序存储的线性表到底在解决什么问题线性表的顺序表示和实现是数据结构课程里绕不开的第一道门槛。很多同学在学习《数据结构》时第一次接触“抽象数据类型”这个概念就是从顺序表(List)开始的。它表面上只是把一个数组封装进结构体但背后涉及内存布局、元素搬迁、容量管理、边界判断等一系列基础功。把这部分学扎实后面学链表、栈、队列、串和树都会轻松很多。这个项目适合谁不管你是刚入门数据结构的在校生、准备考研笔试的人还是已经工作但需要补内功的开发者顺序表的实现都是一块试金石。它能帮你搞清楚一个问题当我们需要在内存中保存一串前后关系固定的数据时最简单的做法是什么答案就是用一段连续的存储空间把元素按照逻辑次序依次存放。顺序表就是“数组 管理信息”的组合体也就是从底层数组抽象出的一张“桌子”上面摆着数据同时记录这张桌子能放多少东西、现在已经放了多少。从应用场景看顺序表并不是摆设。比如学生成绩管理系统中成绩记录按学号排序人数固定且经常需要按下标快速访问顺序表就很合适。又比如操作系统中的任务列表如果任务数量变化不大、重点是快速拿到第n个任务顺序表能给你 O(1) 的随机访问能力。它最大的价值是简单、直观、缓存友好。CPU 读取数据时是按缓存行拉的连续内存可以让相邻元素大概率落在同一块缓存区域遍历速度明显优于跳跃节点的链表结构。当然简单也有代价。顺序表的致命弱点是插入和删除需要移动大量元素最坏情况下要搬动整个表。为了更好地理解这个代价我们需要从存储结构设计的角度把顺序表和链表做一个对比看看顺序存储为什么是“有舍有得”的典型代表。2. 核心设计思路为什么顺序存储能成为首选2.1 顺序表与链表的核心区别数据结构教材通常会把线性表分成两种存储方式顺序存储和链式存储。顺序存储的逻辑结构直接映射到物理结构第 i 个元素就是第 i 个元素前后关系由内存地址的递增体现。链式存储则不同它靠指针把散落在各处的节点串起来逻辑上是线性的物理上则是零散的。这两种方式在操作性能上差异明显。以查找为例顺序表按下标访问是 O(1) 的链表则需要从头节点开始逐个跳转最坏是 O(n)但插入和删除时链表如果已经定位到目标节点只需要调整指针的指向而顺序表必须通过元素搬迁来腾出或填补位置。我在实际开发中见过不少因为选错存储结构而吃亏的例子。某同学写一个高频插入、低频遍历的日志缓冲模块起初用了顺序表结果插入性能成了瓶颈。后来换成链表结构插入操作只需要维护指针。反过来如果数据规模固定、读操作远多于写操作顺序表通常是更优解。因此在动手写代码之前先问自己三个问题数据量是否基本已知、是否常按下标访问、插入删除是否频繁。这三个问题的答案基本决定了你该选哪种结构。2.2 顺序表结构体里的三个关键成员顺序表的实现方式有很多种教材里最常见的 C 语言描述是定义如下结构体typedef struct { int *elem; // 存储空间的基地址 int length; // 当前长度也就是表中元素个数 int listsize; // 当前分配的存储容量 } SqList;为什么要用指针而不是定长数组呢这是很多初学者第一个困惑的点。定长数组相当于把容量写死扩容时无法优雅处理。用指针指向动态分配的内存意味着我们可以在容量不够时重新申请一块更大的空间然后把原数据拷贝过去实现真正的动态扩展。length 和 listsize 的区别也要拎清楚。length 表示“现在有多少元素”listsize 表示“最多还能装多少元素”。判断表是否为满看的是length listsize判断插入位置是否合法看的是位置是否落在[1, length1]的区间。这两个值如果混淆极易产生越界写入和数组下标访问错误。我记得某次给一个模拟项目调试数据表反复崩溃最后定位到问题根源就是扩容时只更新了 elem 指针却忘了同步 listsize导致下一次插入时系统误判容量充足直接越界写把这片内存之后的栈数据都破坏了。所以结构体三个成员是互相绑定的更新一个往往需要同步另外两个。2.3 为什么用一个“达成共识”的抽象接口对于一个真正可用的数据结构操作接口必须标准化。教材里通常定义初始化、销毁、清空、判空、求长度、按位查找、按值查找、插入、删除、遍历等基本操作。这些接口统一后上层业务代码可以完全不关心底层是顺序表还是链表只要调用同一个函数名就能获得一样的语义。这就是“抽象数据类型”的意义。我在项目里见过最典型的例子某服务端模块最初用顺序表保存在线用户列表后来用户规模变大、频繁上下线导致插入删除性能吃紧团队准备换成链表。因为所有业务代码都只调用统一的接口函数底层替换时只需要重写相应的函数实现业务层改动几乎为零。如果你在初学阶段就养成了“把操作封装成接口”的习惯后面重构会省下大量精力。3. 完整实现过程从抽象定义到每一行代码落地3.1 初始化与内存分配顺序表的第一步是初始化。我们需要动态分配一块初始容量并设置 length 和 listsize。教材里的常见写法是#define LIST_INIT_SIZE 100 #define LISTINCREMENT 10 int InitList(SqList *L) { L-elem (int *)malloc(LIST_INIT_SIZE * sizeof(int)); if (!L-elem) { return -1; // 内存分配失败 } L-length 0; L-listsize LIST_INIT_SIZE; return 0; }这里的关键在于malloc 的结果一定要判空。很多入门代码习惯性地忽略返回值这在教学环境里可能没事但在真正的生产环境下内存耗尽时如果不判空后续对空指针写入就直接造成段错误。我在某实验室的项目调试中遇到过一种隐蔽问题初始化函数分配失败后仍然返回正常上层误以为表可用第一次插入就崩溃排查起来非常麻烦。好的做法是让函数返回状态码调用方根据返回值决定是否继续。初始化顺便是一种“基建”操作地基打不好后面全白搭。3.2 插入操作的三个细节插入操作是顺序表实现的难点也是面试高频考点。其核心逻辑是把从插入位置开始的元素统一向后移动一位腾出插入点再写入新元素。如果表已满需要先扩容。我用最常用的写法演示int ListInsert(SqList *L, int pos, int e) { int *newbase; int *q, *p; // 1. 判断插入位置是否合法 if (pos 1 || pos L-length 1) { return -1; } // 2. 如果容量已满扩容 if (L-length L-listsize) { newbase (int *)realloc(L-elem, (L-listsize LISTINCREMENT) * sizeof(int)); if (!newbase) { return -1; } L-elem newbase; L-listsize LISTINCREMENT; } // 3. 元素后移从最后一个元素开始依次向后挪 q (L-elem[pos - 1]); // 插入位置的门牌号 for (p (L-elem[L-length - 1]); p q; p--) { *(p 1) *p; } // 4. 写入新元素 *q e; L-length; return 0; }这段代码里最需要留意的是位置换算。用户视角的位置从 1 开始即“第一个元素是位置1”而数组下标从 0 开始所以内部访问位置 pos 时要用pos - 1。移动元素时为什么是从后往前而不是从前往后这是为了防止数据被覆盖。如果从前往后移动必然会把后面的元素覆盖掉导致原始数据丢失。这个道理可以用搬家来类比一排人右移一个座位必须从最右边的人开始挪否则左边的人一动右边座位的新人又会被下一个人覆盖。代码里我用指针操作而非数组下标一是更贴近教材经典实现二是能帮助理解指针与数组名在底层的一致性。当然用 for 下标循环写起来更直观效果是一样的。3.3 删除操作与元素前移删除操作和插入互为反过程把目标位置后面的所有元素统一向前移动一位覆盖被删元素然后把 length 减 1。代码如下int ListDelete(SqList *L, int pos, int *e) { int *p, *q; if (pos 1 || pos L-length) { return -1; } p (L-elem[pos - 1]); // 被删除元素的位置 *e *p; // 用 e 返回被删除的值方便调用方处理 q (L-elem[L-length - 1]); // 最后一个元素的位置 for (p; p q; p) { *(p - 1) *p; } L-length--; return 0; }这里有个容易忽略的地方为什么删除函数要带一个int *e指针参数很多新手会觉得没必要直接在函数内部丢掉被删元素就行。但真正的业务场景里删除往往不是“扔掉”而是“取出”。比如一个待处理队列从队首取出任务时需要知道取出的任务是什么然后去做后续处理。用指针参数把删除结果带回给调用方是一种常见且高效的做法。虽然也可以返回值但能同时返回状态码和带出数据指针参数方案更灵活C 语言的数组删除操作、栈的 pop 操作都采用这种思路。移动方向也与插入相反。删除时需要从前往后移动用已删除位置后面的第一个元素覆盖待删除元素然后依次前移。此时不能从后往前因为那样会先把后面的数据搬到前面覆盖掉还没处理的中间元素。插入时从后往前删除时从前往后这两个方向千万别记反。我见过很多初学者在第一次手写顺序表时把删除循环的移动方向写反结果删除后整个表的数据一片混乱。3.4 查找与定位顺序表的查找分为按位查找和按值查找。按位查找最简单只要位置合法直接返回L-elem[pos-1]时间复杂度 O(1)。这部分没什么好说的随机访问是顺序表最大的优势。按值查找则要遍历整个表int LocateElem(SqList *L, int e) { int i; for (i 0; i L-length; i) { if (L-elem[i] e) { return i 1; // 返回元素在线性表中的位置从1开始 } } return 0; }有几点要提醒。第一如果表允许重复元素按值查找返回的是第一个匹配元素的位置而不是所有匹配位置。如果业务需要返回全部就得额外提供“查找下一个”的接口并用起始位置参数控制查询范围。第二C 语言的结构体比较不能直接用上面的写法只适用于基本数据类型。如果想要泛化到结构体需要传入一个“比较函数”参数或者把查找逻辑抽象成针对特定字段的比较操作。第三时间复杂度 O(n) 是无法避免的这也是后续会引入查找表、索引结构的原因。3.5 遍历、清空与销毁遍历操作看起来简单但有一个设计层面的问题需要考虑遍历时是否需要修改元素教材里通常单独提供ListTraverse函数内部循环访问每个元素。清空操作与销毁操作的本质区别也要区分清楚。清空ClearList只把 length 置为 0逻辑上表为空但底层分配的存储空间保留可以继续复用。销毁DestroyList则是free(L-elem); L-elem NULL; L-length 0; L-listsize 0;彻底释放内存。很多粗心代码只调用了 free 却不把指针置空导致出现“悬空指针”——指针还指向一块已经被归还系统的内存后续如果误用了这块地址大概率读到垃圾数据甚至造成崩溃。我在实际排查过一个上线模块模块卸载后偶尔在下一轮加载时崩溃定位很久才发现是销毁函数漏掉了置空操作二次初始化时又使用了一个残留旧地址的指针最终分配和释放混在一起出了问题。所以销毁操作一定要三步走释放内存、指针置空、容量归零。还有一个很实际的经验分享清空操作里把 length 置为 0 是不够的如果元素本身是动态分配的指针比如每个元素都是一个 malloc 出来的字符串指针那么清空前还需要先逐个释放这些指针指向的内存否则就造成了内存泄漏。这块需要根据具体的数据类型做处理我在后面“常见问题”章节会展开讲。4. 实操中的坑与性能细节排查4.1 高频错误与对应排查方向顺序表代码量不大但出错率不低。根据我给多个初学者答疑和调试模拟项目的经验把常见的错误整理成一张速查表如果你发现程序崩溃或者数据错乱优先对照这些方向排查。常见表现最可能的原因排查与建议插入后数据被覆盖移动元素方向搞反插入必须从最后一个元素开始向后移动删除后数据错乱移动元素方向搞反删除必须从待删除位置的下一个元素开始往前移动越界写入、程序崩溃未判断插入位置合法性插入前检查pos 1 || pos length1数据丢失、表内出现乱值扩容后未更新 listsize扩容必须同步更新 elem、listsizefree 后仍访问报错悬空指针释放后将指针置空警惕二次 free局部变量表初始化无效结构体只传值不传址需要修改结构体内容时必须传结构体指针按位置查找返回错误值位置与下标混淆用户位置从 1 开始数组下标从 0 开始需转换表满时插入仍成功漏掉了扩容逻辑插入前必须检查length listsize其中最隐蔽的是“结构体只传值不传址”这个错误。举个例子void InitList(SqList L) { // 错误示范 L.elem (int *)malloc(sizeof(int)); L.length 0; L.listsize 1; }这个函数内部虽然正确分配了内存并更新了字段但 C 语言函数传参默认是值传递。在函数内部修改的只是 L 的副本调用方的原结构体成员实际上完全没变初始化等于白做。尤其容易迷惑的是调用后程序可能并不会马上崩溃因为在主函数里你又创建了一块新的零散变量只有当后续插入时才发现 elem 仍是一个乱值。正确的做法是把函数签名改成InitList(SqList *L)或者用返回值返回初始化后的结构体。这个经验值得反复强调在 C 语言里结构体相当于一个“包裹”需要修改包裹内的内容就要把包裹的地址传入函数。4.2 扩容策略选型与性能分析顺序表的扩容策略看起来只是一个 realloc 的问题其实也有讲究。上面代码里使用的是“固定增量”策略也就是每次扩展固定大小的容量LISTINCREMENT。这种方式实现简单但如果频繁插入触发多次扩容会发生多次 realloc 和数据搬迁时间复杂度会较高。另一种常用策略是“倍增扩容”即每次容量不足时翻倍扩容比如从 100 直接扩到 200、400、800。这种策略的好处在于虽然单次扩容代价高但均摊到每次插入操作后时间复杂度只有 O(1)。这个思想在很多语言的标准库里都能看到比如一些动态数组容器就是按倍增策略来扩容的尽管它们还可能更细致地调节增长因子例如 1.5 倍目的都是为了平衡内存占用和搬运成本。那么项目里到底该选哪种如果数据量增长速度平稳且不大固定增量就够用如果数据量可能突然暴涨最好提前用倍增策略避免反复搬迁造成卡顿。举个例子某日志系统初始表容量为 100实际数据量在百万级别如果使用固定增加 10 的扩容方案每插入 10 条就要扩容一次总搬迁成本是巨大的。相反用倍增扩容虽然偶尔会有一次大搬迁但是整体插入性能平滑很多。还有一个性能细节是插入和删除的综合代价。顺序表在中间位置插入一个元素需要平均移动 n/2 个元素所以按平均情况看插入、删除的时间复杂度是 O(n)。这是顺序表最核心的局限。如果要把频繁在中间位置插入删除的数据结构那就不该再强行用顺序表了。我之前接手过一个内部任务调度组件任务列表主要操作是“新增任务”和“取出最早任务”最初用顺序表做后插入导致大量搬迁。后来改成一个环形数组存储队列首尾操作都能做到 O(1)效果立刻不一样。这个例子一方面说明顺序表的适用范围另一方面也提醒数据结构选型要根据真实操作频率来做而不是因为“教材里这么写”就机械套用。4.3 通用化改造与工程扩展建议上面所有代码都用 int 作为元素类型这是为了教学清晰。但工程上显然不能每换一种数据就重写一个 List。常见做法有两种一是用 void* 指针存储元素地址数据本身单独分配二是用结构体封装元素大小给每个操作传入 elemSize 和比较回调函数。前者使用广泛缺点是内存管理复杂后者更贴近 C 库的风格很多通用容器都这么实现。如果手边的编译器支持泛型也可以用更现代的泛型语法来实现一个类型安全的顺序表原理是一样的底层保留一块内存字节数组插入时按元素大小拷贝字节查找时用回调比较字节。另外也有一些同学会问顺序表和数组到底有什么区别这个问题从代码层面看数组是一种语言内置语法一旦定长就不能变顺序表是在数组基础上增加了容量、长度、操作接口同时允许动态扩容。顺序表本质上抽离出“变长数组”的语义。数组你可以直接a[i] 1然后不管越界顺序表则通过操作接口帮你拦截非法位置。你可以理解成数组是“裸砖头”顺序表是“盖好的房间”。作为初学者阶段目标就是亲手把“裸砖头”砌成“房间”从而理解数据结构的抽象过程。我自己的体会是顺序表这个项目看上去不难但最值得认真对待的地方不是把代码写出来跑通而是把每一处移动、每一次边界判断都解释清楚。等到你能不带思考地说出“插入从后往前移、删除从前往后移”“位置从1开始、数组下标从0开始”“扩容要同时更新 elem 和 listsize”这三句话并且知道背后的原因时这个项目才算真正拿下。5. 一个完整的可运行示例为了让前面的函数串成一个可运行的程序我整理了一个最小完整示例集成了初始化、插入、删除、查找、遍历、销毁这几个核心操作。你可以直接把这段代码拷贝进本地环境运行后观察每一步的输出变化。#include stdio.h #include stdlib.h #define LIST_INIT_SIZE 5 #define LISTINCREMENT 5 typedef struct { int *elem; int length; int listsize; } SqList; int InitList(SqList *L) { L-elem (int *)malloc(LIST_INIT_SIZE * sizeof(int)); if (!L-elem) { return -1; } L-length 0; L-listsize LIST_INIT_SIZE; return 0; } int ListInsert(SqList *L, int pos, int e) { int *newbase, *q, *p; if (pos 1 || pos L-length 1) { return -1; } if (L-length L-listsize) { newbase (int *)realloc(L-elem, (L-listsize LISTINCREMENT) * sizeof(int)); if (!newbase) { return -1; } L-elem newbase; L-listsize LISTINCREMENT; } q (L-elem[pos - 1]); for (p (L-elem[L-length - 1]); p q; p--) { *(p 1) *p; } *q e; L-length; return 0; } int ListDelete(SqList *L, int pos, int *e) { int *p, *q; if (pos 1 || pos L-length) { return -1; } p (L-elem[pos - 1]); *e *p; q (L-elem[L-length - 1]); for (p; p q; p) { *(p - 1) *p; } L-length--; return 0; } int LocateElem(SqList *L, int e) { int i; for (i 0; i L-length; i) { if (L-elem[i] e) { return i 1; } } return 0; } void ListTraverse(SqList *L) { int i; for (i 0; i L-length; i) { printf(%d , L-elem[i]); } printf(\n); } void DestroyList(SqList *L) { free(L-elem); L-elem NULL; L-length 0; L-listsize 0; } int main() { SqList list; int delElem; InitList(list); ListInsert(list, 1, 10); ListInsert(list, 2, 20); ListInsert(list, 3, 30); ListInsert(list, 2, 99); ListInsert(list, 5, 55); ListInsert(list, 6, 66); printf(After insert: ); ListTraverse(list); // 10 99 20 30 55 66 printf(99 is at position %d\n, LocateElem(list, 99)); ListDelete(list, 2, delElem); printf(Deleted: %d\n, delElem); printf(After delete: ); ListTraverse(list); // 10 20 30 55 66 DestroyList(list); return 0; }运行这段代码你会看到插入、扩容、删除、查找的完整效果。尤其注意初始容量设置成 5在连续插入 6 个元素时第 6 次插入会触发扩容逻辑你可以打印 listsize 来观察容量的变化。这个细节我故意保留着就是想让你在跑通之后把断点打在 realloc 那一行亲眼看看数据搬迁是如何发生的这比只看文字理解要深刻得多。关于这段代码我自己实际跑过很多次也建议你在本地多做两个小实验第一把容量初始值改成 1反复插入 8 个元素观察扩容发生多少次第二把删除循环的方向改成从后往前再看看删除后输出结果变成什么样。只有亲手制造一次错误才能真正体会“方向错了全盘皆输”这句话的分量。这两个实验是我带新人时最喜欢留的作业做得越细你对顺序表的理解就越接近底层本质。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑