
很多人学数据结构学到顺序表第一反应基本都一样这不就是数组吗怎么还值得单独开一章笔记来写我见过不少初学者就卡在这个认知上结果一到自己动手写代码、做课程实验、备战考研的时候才发现顺序表的应用远不是“开个数组”那么简单。下标到底从0还是1开始、插入元素要搬动几个位置、扩容时旧数据怎么无痛迁移、两个有序表怎么合并才不重复不遗漏这些坑我几乎每年都能在答疑群里看一遍。这篇笔记归纳核心就抓一个词应用。我会把顺序表从“怎么封装”讲到“怎么用”再讲到“什么时候不该用它”最后把面试和考研里最常见的变形题和易错点一起盘掉。1. 先搞清楚顺序表的“应用”到底指什么1.1 顺序表在现实代码里的常见栖身之所很多教材讲顺序表开篇就是定义、结构体、插入删除的代码学完以后你会背了但不知道什么时候真正用它。顺序表的应用往大了说就是“一块连续内存 随机访问下标”这一模型能覆盖的所有场景往具体了说日常代码里最常见的几类包括学生花名册、通讯录、商品列表这类“总量基本固定中间不拼命插入删除”的数据集合需要频繁按位置取数据的场景比如排行榜、游戏里的单位编队、GUI组件列表作为更复杂结构的底层存储比如哈希表里的桶、堆完全二叉树的存储、并查集的父数组教材和算法题里更经典的有序表合并、多项式表示、矩阵压缩存储。你发现没有这些场景有个共同特征数据是“平铺”的访问靠坐标很少在中间动刀。顺序表最舒服的姿势就是把“连续”和“随机访问”这两个特性用到极致而不是用它去模拟链表的插入删除。1.2 选顺序表还是链表先看数据怎么被访问说到顺序表应用绕不开和链表的对比。很多新手选数据结构是凭感觉链表听起来高级指针很酷所以一律用链表。这是个大误区。我自己的选择标准很朴素如果这个集合的“读”远远多于“改”尤其是按位置读果断选顺序表如果“改”的位置飘忽不定、频繁在中间插和删再考虑链表。顺序表按下标访问是O(1)链表要走到第k个节点是O(k)。反过来顺序表在头部插入要搬动整表链表只需要改两个指针。所以“应用”的第一步不是写代码而是判断该不该用它。判断错了后面写再多代码都是在给性能挖坑。2. 从零封装一个可靠可用的顺序表C语言接口设计与踩坑先把最有价值的实操部分放在前面。我建议初学阶段不要上来就用STL的vector做“黑盒”先用C语言手写一个顺序表你才能真正理解动态扩容、元素搬移和内存释放这些底层动作。下面的实现不追求花哨追求的是能跑、能查错、能应付实验和考研手写代码。2.1 结构体与初始化别把容量和长度混为一谈顺序表结构体看起来就几行但“容量”和“长度”这两个概念是第一个分水岭。容量capacity是底层数组最多能装多少长度length是当前实际存了多少。很多人只开一个size变量结果插入到数组末尾时不知道数组满了直接越界。一个常见的基础定义#define DEFAULT_CAPACITY 8 typedef struct { int *data; // 底层数组基址 int length; // 当前元素个数 int capacity; // 当前容量 } SeqList; void initSeqList(SeqList *list) { list-data (int *)malloc(DEFAULT_CAPACITY * sizeof(int)); list-length 0; list-capacity DEFAULT_CAPACITY; }注意两点第一data用malloc分配而不是定死一个int arr[100]否则扩容无从谈起第二初始化后length一定是0capacity一定是分配出来的真实大小这两个值在调试时打印出来能帮你避开一堆莫名奇妙的“越界”和“读取不到数据”的问题。2.2 插入、删除、查找三个核心操作的正确写法插入和删除是顺序表应用的灵魂也是初学阶段最容易写错的代码。插入的核心逻辑是“从后往前搬”删除的核心逻辑是“从前往后盖”顺序反了就会把数据覆盖掉。下面是一种经过反复检验的写法int insertElem(SeqList *list, int pos, int value) { if (pos 0 || pos list-length) return 0; // 位置非法 if (list-length list-capacity) { if (!expandSeqList(list)) return 0; // 扩容失败 } for (int i list-length; i pos; --i) { list-data[i] list-data[i - 1]; // 从尾部开始后移 } list-data[pos] value; list-length; return 1; } int deleteElem(SeqList *list, int pos) { if (pos 0 || pos list-length) return 0; for (int i pos; i list-length - 1; i) { list-data[i] list-data[i 1]; // 从删除点开始前盖 } list-length--; return 1; }这个写法的精妙之处在于边界条件极其可读插入允许pos等于length因为尾部追加是合法的删除只允许到length-1因为删最后一个元素是原地缩一位。很多教材的写法是if (pos length - 1)之类的怪判断虽然也能跑但不如上面这种清晰考试手写代码的时候清晰就是得分。查找就简单了按值找首个出现的下标找不到返回-1这也是后面很多变形题的基础int findElem(SeqList *list, int value) { for (int i 0; i list-length; i) { if (list-data[i] value) return i; } return -1; }2.3 扩容策略与内存释放顺序表最容易翻车的两个地方动态扩容是顺序表应用里最容易被忽略的环节。我见过不少实验报告capacity写死100然后插入第101个元素时静默越界程序不崩溃但结果全是垃圾值。正确的扩容思路是“翻倍”不是“加固定值”因为翻倍才能保证插入的时间复杂度均摊下来是O(1)。int expandSeqList(SeqList *list) { int newCapacity list-capacity * 2; int *newData (int *)realloc(list-data, newCapacity * sizeof(int)); if (newData NULL) return 0; list-data newData; list-capacity newCapacity; return 1; }这里用realloc而不是malloc手动拷贝是因为realloc可能原地扩大省一次数据搬移就算搬移也只是在底层做一次memcpy级别的工作比手写for循环搬元素高效得多。不过realloc也有坑如果它返回NULL你原来的指针并没有被释放所以一定不要写成list-data (int *)realloc(...)而是用临时指针接住返回值。再有就是内存释放。写完整应用时链表大家都会记得free节点顺序表反而容易忘。删除整个顺序表只需要两步void destroySeqList(SeqList *list) { free(list-data); list-data NULL; list-length 0; list-capacity 0; }顺序表的内存是“一块连续区域”释放干净就是free一次的事这也是它比链表好管理的地方但也正因为是好管理很多人在项目里用栈上数组替代最后程序崩溃在堆区写越界都是这一课没补上。3. 三个拿来即用的真实案例点名系统、有序表合并、稀疏多项式代码封装得再漂亮不落到场景里都是白搭。这一节我挑三个最典型、覆盖“查、改、合并、语义映射”多个方向的应用案例全部按照可直接复现的思路写。3.1 案例一学生花名册的点名与修改这个案例来自很多数据结构的课程实验需求很简单一个班50名学生前期录入中期支持按学号点名标记出勤支持修改成绩最后统计。这个场景最合适的数据结构就是顺序表原因有两条学生人数在学期内基本不变中途不会频繁插删学生点名和查成绩本质上都是“按学号定位”只需要一个按学号查找的映射。我习惯把学号设计成数组下标用“学号 下标 偏移”完成O(1)定位。比如学号从2025001开始那我分配一个容量大于等于人数范围的数组下标i存学号2025001i的信息。这就是顺序表应用里的“直接定址法”思想比每次都遍历一遍找学号快得多。typedef struct { int studentId; int attendance; // 出勤次数 int score; // 成绩 } Student; SeqList students; // 容量按班级人数上限开点名时直接按下标定位修改成绩也一样根本不需要写查找循环。这个案例想说明的道理是顺序表的下标本身就是一种“语义”不要浪费它能用下标表达的业务逻辑就不要用线性查找去表达。3.2 案例二合并两个有序顺序表考研高频算法这是一个每个考计算机的人都躲不过的算法题两个非递减有序顺序表合并成一个新的非递减有序顺序表。别小看它这是归并排序的思想雏形也是后面学“归并”系列的起点。标准解法是双指针int mergeSeqList(SeqList *a, SeqList *b, SeqList *c) { if (a-length b-length c-capacity) return 0; int i 0, j 0, k 0; while (i a-length j b-length) { if (a-data[i] b-data[j]) c-data[k] a-data[i]; else c-data[k] b-data[j]; } while (i a-length) c-data[k] a-data[i]; while (j b-length) c-data[k] b-data[j]; c-length k; return 1; }为什么用双指针而不是先把a放进去再把b插进去因为双指针每次只比较两个“当前最小”利用了两个表各自有序的性质把复杂度控制在O(nm)。如果硬插每插一个元素还要搬动后续数据复杂度直接退化到O(n*m)。这个案例的真正考点不是“你会不会写循环”而是你能不能写出“每次只取两个序列当前最小值”的那个判断。还有一个面试喜欢追问的点如果要求结果中不含重复元素怎么办很简单在while里加一个“相等时只放一个”的判断放完a的再跳过b里所有相等的值。这个变形一定要会它和“合并两个有序数组并去重”是同一类题。3.3 案例三用顺序表存稀疏多项式系数指数的妙用多项式是顺序表应用中非常经典的一个例子因为它的存储方式体现了“用下标隐含信息”的高级用法。以一元多项式P(x) 3 2x 5x^2为例最直观的存法是顺序表下标就是指数数组里存系数下标0存常数项系数3下标1存x的系数2下标2存x^2的系数5。这样两个多项式相加只需要把对应下标的系数加起来就行一次循环O(n)搞定。#define MAX_DEGREE 100 double poly1[MAX_DEGREE] {0}; double poly2[MAX_DEGREE] {0}; double result[MAX_DEGREE] {0}; for (int i 0; i MAX_DEGREE; i) { result[i] poly1[i] poly2[i]; }这段代码简单到有点不像算法但它的思想很值钱用下标代替显式存储指数省掉了每一对“指数相等才相加”的判断。不过它有个明显的短板——如果多项式是稀疏的比如只有x^0和x^99两项用这种方案要开100个格子才存2个有效系数空间浪费率极高。这也是顺序表和稀疏结构之间的典型博弈后面学到稀疏矩阵、十字链表时你还会碰到同样的抉择。真正做题时如果多项式的次数很密就用下标映射方案如果指数跨度巨大但项数不多就得换成结构体数组去存(系数, 指数)二元组。4. 性能账本顺序表为什么“读快写慢”以及什么时候该放弃应用做多了你就会发现选型不是玄学本质是算账。这一节把这个账本摊开讲透。4.1 按位置访问是O(1)按值查找是O(n)别被“数组很快”骗了顺序表底层是连续内存所以按下标访问data[i]时CPU只需要做一次“基地址 i × 元素大小”的地址计算就能直接取到数据这就是O(1)随机访问。这个特性让顺序表在榜单、队列、堆这种“按序取数”的场景里快得飞起。但“按值查找”是另一笔账。如果你不知道元素在哪个位置就只能从下标0开始一个个比对最坏情况要扫完整个表复杂度O(n)。很多初学者以为顺序表“有下标所以一定是O(1)”这个误解会让你的算法分析整个废掉。考试里描述顺序表性能必须分情况按位置取是O(1)按值查是O(n)这是两个完全不同的操作不能混为一谈。4.2 插入和删除的代价平均移动n/2个元素意味着什么插入删除要移动元素这一点大家都懂但“平均移动多少个”才是关键。在长度为n的顺序表中在位置i插入一个新元素需要把从i到n-1的元素全部后移移动次数是n-i在位置i删除需要把从i1到n-1的元素全部前移移动次数是n-1-i。位置可以是0到n之间的任意合法位置各位置等概率时平均移动次数就是n/2左右。n/2这个数字单看不大但放到应用里就很恐怖如果你频繁在头部插入每插入一个就要整体后移10000个元素的表往头部插一条数据要移10000次插1000条就是1000万次搬移。实际工程里我见过有人用顺序表做“最近浏览记录”每次把新浏览的条目插到最前面然后列表越跑越慢最后几乎卡死。这就是典型的“用错了地方”——浏览记录的“新数据往头部塞”操作应该用链表或者循环数组来解决。所以你需要一条很实用的判断规则你的应用里“头部和中间”的操作多不多多就别用顺序表。顺序表最舒服的更新场景是“只在尾部追加和弹出”也就是栈的那个动作模式其他更新都要在心里加一笔移动开销。4.3 扩容摊销分析与实际建议预留容量还是动态扩容动态扩容又是一个账。假设每次容量不够就翻倍那么初始化后连续插入n个元素的过程中扩容发生的次数是log2(n)级别每次扩容的搬移代价和当时的容量成正比。把所有搬移加起来你会发现总和是一个关于n的常数倍所以均摊下来每次插入的“搬移成本”是O(1)。这就是“均摊复杂度”的直观解释也是STL vector敢放心自动扩容的底气。不过均摊O(1)不代表没有瞬时卡顿。扩到一倍时刚好差一个元素不知道的人会以为这是“刚刚好”实际上如果你的应用对延迟极其敏感比如实时渲染里保存一帧的点位扩容瞬间的整块拷贝会导致那一帧卡一下。省事的做法是预先根据业务上限一次性开足容量代价是内存浪费一点稳妥的做法是扩容时预留缓冲而不是每次触顶才扩。我个人在写实验和考试手写代码时习惯“预留一半容量”理由是既省去频繁realloc又不会让capacity等于length的精确状态造成误判。5. 面试、考研、期末卷上的顺序表常见变形题与易错点清单学完前面的实现和案例最后一定要落到“题”上。顺序表初阶水平能见到的题目翻来覆去就那么几类掌握了套路考场和面试都不慌。5.1 五道必刷变形题逆置、删值、去重、划分类、找中位数第一类是逆置。把顺序表前k个元素与后n-k个元素整体互换位置这种题就地操作双指针从两头往中间swap时间复杂度O(n)空间O(1)。别去看那种“新建一个数组再拷回去”的写法考试和面试都要求原地。第二类是删除所有等于某个值的元素。要求不新建数组只扫描一趟。正确思路是双指针慢指针指向“保留区”末尾快指针从头扫描发现不等于目标值就把值写到慢指针位置慢指针前进等于目标值就跳过。这样一趟下来所有等于目标值的元素都被“覆盖”掉了时间复杂度O(n)空间O(1)。int removeAllValue(SeqList *list, int target) { int k 0; for (int i 0; i list-length; i) { if (list-data[i] ! target) { list-data[k] list-data[i]; } } list-length k; return 1; }第三类是去重把有序顺序表中重复的元素删掉只留一个。思路和删特定值高度相似区别在于判断条件从“不等于target”变成“与保留区最后一个元素不相等”因为你面对的是有序表重复元素必然相邻。这个题是考研408特别喜欢出的本质上考的就是“原地覆盖有序性利用”。第四类是划分类比如把负数放在正数前面把奇数放在偶数前面。这类题的思想是快速排序中的partition一个指针从左往右找一个指针从右往左找不符合条件的就交换一趟扫完。它不需要真的排序只需要“按某个性质分边”这也是顺序表应用题里最需要理解“性质优先级”的一道题。第五类是找中位数两个等长有序顺序表合并后找中位数。最简单的做法是先归并再取中间O(n)如果为了高分可以利用“比较两个序列中位数”的二分思路做到O(log n)。初阶阶段先把归并做法写对这一步理解透了进阶的二分做法会顺很多。5.2 易错点盘点从下标越界到循环边界每年都有人栽跟头下面这张表是我在答疑过程里整理出来的高频易错点代码和试卷上都反复出现易错类型错误表现正确思路容量与长度混淆用capacity当length遍历读到一堆空位和垃圾值遍历和业务逻辑一律以length为准插入位置边界pos length还允许插入数组出现空洞非尾部插入必须满足pos length删除位置边界删除最后一个元素时循环越界循环条件用i length - 1不是i length扩容返回处理realloc返回NULL时原指针丢失用临时指针接收失败时保留原数据循环方向搞反插入时从前往后移把后一个元素盖掉了插入必须从最后一个元素开始倒着挪静态数组装动态数据开了arr[100]却不知道什么时候会满要么预留足够容量要么实现扩容机制忽略length更新插入后没加、删除后没减每个成功操作必须同步更新length这些错误有一个共同根源初学顺序表时“脑中有数组心中没有数据结构”。数组的物理结构是连续内存数据结构的语义结构是“length个有效元素”。只要你在心里时刻记住“有效元素是前length个下标0到length-1才是合法区间尾插合法位是length”90%的边界错误都能提前避免。再分享一个我自己调代码的习惯调试顺序表时第一步永远是打印capacity和length这两个值。很多人一看到“越界”“乱码”就往元素搬运方向猜其实很多时候只是length忘记更新或者扩容失败打印出来一眼就能定位。把这两个值打出来比盯着代码看半小时有效得多。顺序表初阶应用的笔记归纳写到这里我脑子里大概浮现出这些年带过的初学者踩过的坑有用静态数组装动态数据的有插入删除方向反了覆盖数据的有在头部疯狂插入然后把程序跑成蜗牛的。这些坑并不高级但每一届都在重演。把这篇笔记里的接口设计、应用场景和性能账本真正吃透你的“初阶”这一页就算翻过去了。下一步可以拿着同样的思路去啃链表你会发现判断“该用什么结构”的能力远比背诵某个结构本身的代码更有后劲。