动手整理数据结构笔记,翻来覆去还是觉得顺序表值得单独写一整篇。很多初学者把顺序表当成“加了几个函数的数组”,代码能跑就过,结果一到考试、面试题里问“为什么插入是O(n)”“动态扩容为什么翻倍”,直接卡壳。实际上顺序表是整个数据结构地基中的地基,后面的栈、队列、字符串、图、排序算法,只要涉及存储容器,十有八九都能绕回顺序表。这篇文章我就按自己当年从零手写、再考场反复训练的经验,把这章从头到尾拆开讲清楚,顺便把408、期末题和面试里常见的考点一并串起来。
1. 顺序表是什么:从排队场景理解线性表的顺序存储
1.1 线性表的两种生存方式:顺序vs链式
线性表是很朴素的概念:一串数据排成一行,像超市排队结账的顾客,有先后次序,每个元素最多有一个直接前驱和一个直接后继。排队队形怎么表达?最常见的就是大家挨个站在一起,位置固定连续;另一种就是每个人手里拿张纸条,上面写着下一个人的电话,你顺着电话找下一个人,队伍不必站在一起,但依然有序。
计算机里这两条路对应线性表的两种存储结构:顺序存储和链式存储。顺序存储就是我们说的顺序表,它把所有元素按顺序放在内存里的一块连续区域,用“物理上的相邻”来表达“逻辑上的相邻”。链式存储则是链表,用指针把不连续的内存节点“串”起来。
一个最容易忽略的点是:顺序表的物理存储特点,决定了它天生支持随机访问。因为内存连续,只要知道首地址和元素大小,就能用“首地址 + i × 元素大小”直接算出第i个元素的位置,一步到位。这是链表做不到的——链表需要从头节点一个个跳过去。
1.2 顺序表和数组只差一个“动态”
C语言的静态数组int a[100]算不算顺序表?严格来说,它的确是用顺序存储表达线性结构,但它有个硬伤:长度固定,100个位置用完了就没了,只能存100个元素;你也不知道现在实际用了多少个,还得自己维护一个计数器。顺序表就是在这个基础上加一层封装,让它变“聪明”:
- 有一个数组区域用来放元素(可以是静态数组,也可以是malloc出来的动态空间)。
- 有一个变量length记录当前已存了多少个元素。
- 有一个变量capacity记录当前这片区域最多能存多少个元素(动态分配时尤其重要)。
这样就有了两个“容量”的概念:物理容量capacity和实际长度length。当length == capacity时就说明满了,要想再插入,必须先扩容。
很多教材会区分静态顺序表和动态顺序表:静态的是struct{ int data[MAXSIZE]; int length; },动态的是struct{ ElemType *data; int length; int capacity; }。C语言课程里更推荐动态版本,因为它能在运行时扩容,更贴近真实项目里的内存管理思路。
1.3 一个顺序表结构体的诞生
拿C语言写一个最通用的动态顺序表结构体,通常是这样的:
c复制#include <stdio.h>
#include <stdlib.h>
typedef int ElemType; // 用typedef把元素类型抽象出来,方便以后改成double、struct等
typedef struct {
ElemType *data; // 指向堆上分配的连续内存
int length; // 当前元素个数
int capacity; // 当前容量(最多能容纳多少元素)
} SeqList;
为什么要把ElemType定义成int而不是直接写int?这是数据结构写代码的一个好习惯。顺序表是“容器”,业务里可能是图书信息、学生成绩、坐标点……把元素类型typedef出来,将来换类型只需要改一行,其他代码逻辑完全不用动。同理,以后你会看到对栈、队列、图的操作也都是围绕这个“元素类型”打转,这个习惯越早养成越好。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 手写顺序表:初始化、增删查改的完整实现与时间复杂度
2.1 初始化和判空判满:微小的操作决定生死
顺序表第一步是初始化。很多人觉得初始化不就“分配一块内存,把length设成0”嘛,平淡无奇。但这里有一个特别容易被忽略的坑:动态内存分配可能失败,malloc返回NULL。如果不对返回值做检查,后面一访问data[0]直接段错误崩掉。
c复制#define INIT_CAPACITY 10
int InitList(SeqList *L) {
L->data = (ElemType *)malloc(sizeof(ElemType) * INIT_CAPACITY);
if (L->data == NULL) {
return 0; // 分配失败
}
L->length = 0;
L->capacity = INIT_CAPACITY;
return 1;
}
这里的参数为什么要传指针?因为函数内部要修改L本身(让L->data指向新内存、设置length和capacity),C语言没有引用,只能传地址。如果传成SeqList L而不是SeqList *L,那函数内改的是实参的拷贝,调用结束后原结构体毫无变化——这是我见初学者犯过最多的一类错误。
判空判满也很简单:
c复制int IsEmpty(SeqList *L) { return L->length == 0; }
int IsFull(SeqList *L) { return L->length == L->capacity; }
函数名要不要写成ListEmpty这种?看你习惯。不过考试时如果要求写算法,函数命名尽量贴近教材,比如ListInsert、ListDelete,不然阅卷老师可能看得一头雾水。
2.2 插入:从第i个位置到内存移动
插入是顺序表第一个真正需要动脑的操作。假设现在表中存储的是[a,b,c,d,e],我想把x插到下标2的位置(也就是第三个位置,因为线性表从1开始编号,第3个位置),需要先把c、d、e都向后挪一个位置,腾出下标2这个坑,再把x放进去。
这里最容易混淆的是“位序”和“下标”。教材和考试里,线性表插入通常说“在第i个位置插入元素”,i是从1开始的位序;而数组下标是从0开始。所以第i个位置对应的数组下标是i-1。写代码时一定要在脑子里把这个转换做清楚,否则移动的起止点会全错。
c复制int ListInsert(SeqList *L, int i, ElemType e) {
if (i < 1 || i > L->length + 1) {
return 0; // 位置非法
}
if (L->length >= L->capacity) {
return 0; // 这里演示简单版,经典实现是自动扩容
}
for (int j = L->length; j >= i; j--) { // 注意从尾部开始挪
L->data[j] = L->data[j - 1];
}
L->data[i - 1] = e;
L->length++;
return 1;
}
移动元素的循环为什么从后往前?因为从前向后挪会把后面的元素覆盖掉。只有先动最后一个元素,才能保证前面的元素在腾位前不被破坏。这是插入算法的核心细节,笔试里也经常让你把这个循环写出来。
时间复杂度方面:最坏情况在头部插入,需要移动n个元素,O(n);最好情况在尾部插入,不需要移动,O(1);平均情况假设每个位置插入概率相等E(i)=1/(n+1),移动次数约为n/2,所以平均时间复杂度是O(n)。线性表越长,插入越贵,这是顺序表的固有缺陷。
2.3 删除和按值查找:顺序表的痛点暴露
删除第i个位置的元素,就是把第i+1个到最后一个元素都往前挪一位,把空出的位置补上。跟插入相反,这次要从前往后挪。
c复制int ListDelete(SeqList *L, int i, ElemType *e) {
if (i < 1 || i > L->length) {
return 0;
}
*e = L->data[i - 1]; // 用指针把被删元素带回给调用者
for (int j = i - 1; j < L->length - 1; j++) {
L->data[j] = L->data[j + 1];
}
L->length--;
return 1;
}
为什么要把被删元素带出来?因为调用者可能需要知道删除的到底是谁。C语言函数只有一个返回值,通常会用来表示操作成功与否,所以删除的元素用指针参数传出去。有些新手会问直接返回元素不就行了?那成功怎么办?所以养成“返回值放状态,数据经指针带回”的习惯很重要。
按值查找:给定一个元素e,找它在表中的位序。思路是遍历,第一个相等就返回位序i (i从1开始),找不到返回0(因为线性表位置没有0)。代码很简单,时间复杂度也是O(n)。这里要注意,如果表里存的是结构体,判等要用比较函数或逐个字段比较,不能用简单的==。
2.4 修改和访问:为什么O(1)才是顺序表的灵魂
顺序表最大的红利在于随机访问:给定位置i,直接通过下标L->data[i-1]取值或修改,时间复杂度O(1)。链表做不到,链表访问第i个元素必须从头开始走i步。也正是因为这一点,很多需要频繁按下标存取数据的场景——二分查找、堆排序、图邻接矩阵、双端队列——底层都会优先考虑顺序表。
c复制ElemType GetElem(SeqList *L, int i) { // 取第i位元素
if (i < 1 || i > L->length) {
printf("位置越界\n");
exit(0);
}
return L->data[i - 1];
}
void SetElem(SeqList *L, int i, ElemType e) {
if (i < 1 || i > L->length) return;
L->data[i - 1] = e;
}
位置越界检查不能省。数组是“信任”语言的,但顺序表作为数据结构要更严谨,学会自己防御边界条件,是写工程代码的第一步。
3. 顺序表为什么在408和面试题里这么“香”:底层逻辑与经典例题
3.1 复杂度分析的套路:最好、最坏、平均
在408统考和期末复习里,顺序表的考点几乎总伴随着时间复杂度的分析。我总结了一个套路:先说“访问和修改是O(1)”,然后重点讲插入删除是O(n),最后用平均复杂度做收尾。这三个结论背清楚,大多数选择题就过去了。
但题目最容易挖坑的地方在“平均移动次数”。比如:一个表长度为n,在第i个位置插入元素的概率是p_i,那么平均移动次数怎么算?教材通常默认在每个位置(含尾部)插入概率相等,平均次数是n/2。可如果考试给出非均匀概率,你就得老老实实算Σ(p_i×(n-i+1))。这种题每年都有人因套固定结论丢分,所以理解推导比背数字重要。
删除时,最后一个元素删除不用移动,第一个元素删除要移动n-1个,概率相等时平均移动(n-1)/2,大致也是O(n)。
3.2 经典面试/考研题:合并有序表、去重、逆置
顺序表相关的算法题,在期末、考研复试和算法岗笔试题里出现频率非常高。挑三道最能体现“顺序表操作核心思想”的题来讲。
第一道:逆置顺序表(原地)。 思路是双指针,一头一尾,交换元素后向中间靠拢。重点:空间复杂度O(1),时间复杂度O(n)。这道题衍生出的变种是“将前k个元素移到末尾”或“把两个子区间互换”,本质上都是用双指针做原地倒转。
c复制void ReverseList(SeqList *L) {
int left = 0, right = L->length - 1;
while (left < right) {
ElemType temp = L->data[left];
L->data[left] = L->data[right];
L->data[right] = temp;
left++;
right--;
}
}
第二道:删除所有值等于x的元素(不新建表)。 最直观的做法是“边查边删”,但那样每删一个就要移动大量元素,最坏能退化到O(n²)。常用优化是“双下标覆盖法”:用一个i指向当前要写入的位置,用一个j扫描所有元素,只把不等于x的元素往前覆盖。整个算法一趟完成,时间复杂度直接降到O(n),空间复杂度O(1)。
c复制int DeleteAllX(SeqList *L, ElemType x) {
int k = 0;
for (int j = 0; j < L->length; j++) {
if (L->data[j] != x) {
L->data[k++] = L->data[j];
}
}
L->length = k;
return 1;
}
这个技巧的思维模型叫“读写指针不同步”:读指针负责走完全程,写指针只负责保存“需要保留”的元素。掌握了这一个思想,后面“数组去重”“移除元素”“奇偶分离”等同类型LeetCode题全都一通百通。
第三道:合并两个有序顺序表。 类似归并排序的merge过程:两个表各设一个下标,比较当前元素,小的先放进新表。如果其中一个表到头了,就把剩下部分整体拷贝过去。这道题同时考察了顺序表的连续存储和循环控制能力。注意新表容量要预先设为两个表长度之和,否则中途扩容会拖慢算法并让代码更复杂。
c复制int MergeList(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;
}
3.3 从热搜词看应用边界:暴力枚举、并集问题、双端队列
很多人问,除了考试,顺序表到底在真实算法里扮演什么角色?看看大家常搜索的几个关联词就明白。
“暴力枚举算法”是算法初学者最早接触的套路,它的核心是两层循环遍历所有可能的组合。这种场景要求对每个候选元素都能快速定位和读取,顺序表自然是容器首选。比如两数之和的暴力解法,本质就是基于数组随机访问。
“求解一般集合的并集问题用顺序表实现”——这是很多数据结构实验课的经典作业。思路也不难:建一个结果表,先把A表全放进去,再遍历B表,遇到一个元素就查一遍结果表;如果结果表里没有,就追加进去。这里的“查”就是顺序查找,时间复杂度O(n²),但正因为顺序表随机访问方便,这个代码写起来几乎零门槛。虽然效率不高,但作为第一次作业完全达标。
“双端队列”严格来说是一种抽象数据类型,底层实现既可以基于链表,也可以基于顺序表。如果基于顺序表实现,需要在数组两端都可插入删除:尾部插入直接放入data[length],头部插入则需要把原数组整体后移。这其实用到的是顺序表最核心的“移动元素”能力。很多同学学完顺序表再学队列,会觉得轻松得多,原因就在这里。
4. 顺序表vs链表:除了“快”和“慢”,你还得知道缓存
4.1 三种操作的对比表
数据结构课程永远逃不开灵魂拷问:顺序表和链表,哪个好?正确答案永远不是非黑即白的,得看操作习惯。先把两者的主要指标摆出来对比一下:
| 对比维度 | 顺序表(数组) | 链表 |
|---|---|---|
| 按位置随机访问 | O(1) | O(n) |
| 在已知节点的前/后插入 | O(n)(要移动) | O(1)(改指针) |
| 删除已知位置的节点 | O(n)(要移动) | O(1)(改指针) |
| 按值查找(无序) | O(n) | O(n) |
| 空间利用率 | 高,无额外指针 | 低,每个节点存指针 |
| 缓存友好性 | 非常好 | 差 |
| 扩容 | 需要整体搬迁 | 天然支持 |
看到这个表,新人最容易得出“链表插入删除快所以链表更好”的结论,这是片面的。注意链表O(1)的前提是“已知节点的指针/位置”,但现实中往往要先花O(n)去找到这个节点。而顺序表虽然是O(n)的移动,但移动元素的操作在现代CPU上非常高效——因为连续内存可以很好地利用缓存预读。所以很多时候顺序表实际跑起来反而比链表快,这是单看大O复杂度看不出来的。
4.2 缓存局部性:为什么同样是O(n),顺序表更快
很多教材不讲缓存,导致这个点成了“会做题但不会做事”的分水岭。现代CPU访问内存时不会一个字节一个字节读,而是成块读入缓存;如果你顺序遍历连续内存,第一次加载第0号元素时,整个缓存行(比如64字节)都已经把后面十几二十个元素也带进来了,后面的访问几乎就是“如果命中缓存”的零开销。链表就惨了,每个节点散落在内存的不同角落,指针指向哪里,CPU就只能重新去那片地址取数据,大概率缓存不命中,每次访问都要到真正的主存甚至更慢的地方去取。所以在大量遍历场景下,顺序表往往比链表快出一个数量级。
这也印证了一个工程经验:能用数组(顺序表)表达的,尽量不要为了炫技而用链表。真正的生产中,内存池、对象池、序列化容器等,底层大多是连续内存。
4.3 选型决断:什么场景必须用顺序表
判断到底该用顺序表还是链表,我给一个粗糙但好用的原则:
- 读多写少、数据量相对固定:顺序表。
- 需要频繁按下标访问元素,比如二分查找、堆操作:必须顺序表。
- 元素数量经常变化,但主要在尾部增删:顺序表。
- 需要在任意位置频繁插入删除,且数据量巨大:链表。
- 数据总数不确定且动态规模变化剧烈:两者都可以,但顺序表要做好扩容设计。
真实工程项目里,双端队列、优先队列、环形缓冲区的实现,基础都首选顺序表;而哈希拉链法、邻接表这类结构才更依赖链表。把顺序表当成基础工具,而不是“低级版本”,这个认知到位了,后面很多设计就不会跑偏。
5. 写顺序表最容易踩的坑与优化建议:从能用到好用
5.1 一页代码里的五个坑:越界、判满、传参、内存泄漏、混淆位序
顺序表的代码不长,但初学者写出来能一次通过的人极少。我把这些年批作业和看群里提问的“高频事故”整理一下,也当给自己提个醒。
第一个坑:忘了判满就插入。静态数组版本特别容易出这个问题,数组越界在C语言里不一定立刻崩溃,但会悄悄破坏相邻内存,最后报错找半天都定位不到原因。所以每个插入操作前都必须检查满不满,不能靠“感觉”。
第二个坑:传参错误。前面提过,C语言里要修改结构体内容,必须传结构体指针。初学者写成void insert(SeqList L, ...)的实在太多,函数内部改了L.length,main里打印还是旧的。调试这个问题时很多人还会怀疑编译器bug,其实是没理解值传递。
第三个坑:内存泄漏。动态顺序表用完后要记得free(L.data)并置NULL。考试卷面上不会扣分,但工程里这是质检重点。一个进程反复创建销毁顺序表而不释放,内存占用会像漏水的桶一样不断增长。
第四个坑:位序和下标混淆。题目里说“第3个位置”,代码里直接写data[3],结果插到了第4个位置。我建议写代码时统一注释约定:本题接口全部使用从1开始的位序,内部实现用0基下标,每行代码都要清楚自己操作的是哪个。
第五个坑:C语言里结构体比较。有人想判断两个顺序表是否相等,直接写if (A == B),编译器直接报错或行为未知。结构体不能整体比较,必须逐个元素比较。这跟“数组名是常量指针”一样,是C语言的老考点。
5.2 动态扩容的扩容因子选择:为什么每次翻倍
简单版插入函数一旦判满就直接返回失败,而生产级顺序表插入时应该自动扩容。扩容最常用的策略是“容量翻倍”,即新容量 = 旧容量 * 2(也可以用1.5倍)。为什么翻倍是有讲究的。
假设初始容量为1,每次只增加1个容量,那么插入第n个元素时,前n-1次都各复制了一次旧数组,总共复制的次数大约是1+2+...+(n-1)≈n²/2,均摊到每次插入是O(n)。但如果容量翻倍,第1次扩容到2复制1次,第2次扩容到4复制2次,第3次扩容到8复制4次……整个过程中累计复制的元素总数为n的常数倍(大概2n),均摊到每次插入就是O(1)。这就是为什么真正的动态数组(比如C++的vector)扩容时基本都是倍增或者乘一个大于1的因子,而不是加固定长度。
具体扩容代码如下:
c复制int ExpandCapacity(SeqList *L) {
int newCapacity = L->capacity * 2;
ElemType *newData = (ElemType *)malloc(sizeof(ElemType) * newCapacity);
if (newData == NULL) return 0;
for (int i = 0; i < L->length; i++) {
newData[i] = L->data[i];
}
free(L->data);
L->data = newData;
L->capacity = newCapacity;
return 1;
}
这里有个值得注意的隐蔽问题:用realloc替代“malloc+copy+free”行不行?行,但realloc在内存不足时会返回NULL,而原来的内存块还在,如果直接把返回值赋给L->data,就相当于“丢了手里唯一一把钥匙”,原来的内存就泄漏且访问不到了。稳妥做法是先用一个新指针接收realloc的返回值,判断非NULL后再把它赋给L->data。这也是面试官喜欢问的“realloc隐藏陷阱”。
5.3 从顺序表到线性表抽象:面向接口编程的思想
最后想聊一个超出课本,但看着代码质量从“能跑”变为“好用”的关键:把顺序表的所有操作都封装成函数,并只暴露稳定的接口,比如InitList、ListInsert、ListDelete、GetElem、ListLength。这样调用者不用关心data数组是怎么分配的、length是怎么维护的,你也不用担心别人直接改乱了核心数据。
这种设计就是最朴素的“抽象数据类型”思想。C语言里虽然没有面向对象语法,但我们可以用结构体+一组函数去模拟。等你学完顺序表再学栈,会发现栈本身就是“把线性表的部分操作禁掉”得到的:只允许在一端插入删除。用这个思维去学数据结构的任何容器,你都不会再觉得那是背代码,而是一套约定好的行为规范。
我自己的体会是,顺序表这章真正的分水岭不在会不会写那十几个函数,而在能不能画清楚内存布局、能不能把均摊复杂度和内存移动过程推到别人能听懂。把这些想透了,后面栈、队列、KMP、图的邻接矩阵,其实都是在吃这一章的“老本”。写代码遇到问题时,我还会把顺序表打印成[1,2,3]这样的形式,逐步观察每次插入、删除后元素位置的变化,这对理解移动过程极其有用,建议你也试试。
