1. 线性表这块内容到底在学什么
得先聊一个很容易被忽略的问题:为什么数据结构的教材和课程,几乎都是从线性表开始讲?不管是严蔚敏老师的经典教材,还是各校的期末复习提纲,线性表都是第一个正式登场的抽象数据类型。这个安排不是随便定的——线性表是数据结构里最基础、最贴近直觉的一种逻辑结构,它把“一组数据元素排成一条线”这个朴素概念抽象成了规范的操作集合,你后面学栈、队列、串、数组、广义表,其实都是在线性表的基础上加了访问限制或者做了扩展。甚至树和图的某些遍历顺序,最终也会落到线性序列上。所以第2章线性表没啃扎实,后面会一路吃力。
这一章通常分成两大部分:顺序存储结构和链式存储结构。这次先聚焦前半部分——顺序存储结构,也就是大家常说的顺序表。简单说,顺序表就是用一段地址连续的内存空间,依次存放线性表中的数据元素,在C语言里最直观的实现载体就是数组。它的特点是逻辑上相邻的元素,在物理存储位置上也是相邻的,所以“随机存取”是它的天生优势——你按下标直接就能访问到第i个元素,时间复杂度O(1)。但相应的代价是插入和删除操作往往要移动大量元素,因为要维持物理位置的连续性。
这篇文章适合正在学数据结构的新手、准备期末复习的大学生、考研备考的同学,以及那些C语言基础尚可但想搞懂“结构体+数组到底怎么组合成顺序表”的读者。我会把存储结构设计、核心操作的代码实现、时间复杂度的由来、常见坑点一次讲透,尽量让每个细节都能直接落在你的代码里。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 顺序表的存储结构设计:为什么是“结构体包数组”
2.1 三种存储方案的取舍
很多人第一反应是:顺序表不就是数组吗?直接定义个 int a[100] 然后用一个变量记录长度不就行了?理论上思路是对的,但工程上不够用。直接裸用数组,你会遇到这几个麻烦:
- 数组长度是固定的,如果元素数量超过容量,程序直接越界崩溃。
- 长度和容量混在一起,代码里到处都要记“当前用到了第几个”,很容易写乱。
- 如果你想把一个顺序表作为参数传给函数,裸数组传参时会退化成指针,长度信息丢失,只能再额外传一个长度参数,接口很别扭。
所以教材和实际工程里,普遍采用“结构体包装数组”的方案,也就是用一个结构体同时保存三样东西:数据存储区(用指针或定长数组)、当前长度、容量。这样顺序表就变成了一个自治的抽象数据类型,长度和容量跟着数据走,操作函数只需要接收一个指向该结构体的指针,就能完成所有管理任务。
2.2 定长数组 vs 动态数组
定长实现是严蔚敏教材里的经典写法,结构体里直接放一个足够大的数组,例如 #define MAXSIZE 100,然后 ElemType data[MAXSIZE]。这种实现简单可靠,适合解决“已知规模上限”的问题,比如一个班的成绩表、一个固定容量的缓冲区。
动态实现的思路是结构体里放一个指针 ElemType *data,在初始化时用 malloc 申请堆内存,容量不够时用 realloc 扩容。这就是现代工程里更倾向的做法,因为内存是按需申请的,不会一上来就霸占一大块连续空间。后面我会重点讲动态方案,它的完整度和实用性更高,也更能体现指针、内存管理这些C语言核心功力的综合运用。
两种方案的表结构定义如下:
c复制// 定长顺序表
#define MAXSIZE 100
typedef struct {
int data[MAXSIZE];
int length;
} SeqListFixed;
// 动态顺序表
typedef struct {
int *data; // 指向动态分配数组的指针
int capacity; // 当前最大容量
int length; // 当前有效元素个数
} SeqListDynamic;
结构体里 capacity 和 length 是两回事,千万不能混淆。capacity 代表这块内存最多能放下多少个元素,length 代表当前实际存了多少个元素。判断“表满”的条件是 length == capacity,判断“表空”的条件是 length == 0。这个区分看起来简单,但很多新手写扩容错误、写遍历越界,根子都是没把这两个概念分清楚。
2.3 存储地址的连续性问题
顺序表之所以能实现O(1)随机访问,背后依赖的是地址计算公式。假设每个元素占 sizeof(ElemType) 个字节,首元素地址是 LOC(a1),那么第i个元素的地址就是:
code复制LOC(ai) = LOC(a1) + (i - 1) * sizeof(ElemType)
这个公式很多教材一笔带过,但它是理解顺序表“随机存取”特性的钥匙。只要给你一个下标i,直接套公式就能算出元素所在的内存地址,不需要从头遍历。这也是为什么数组的下标从0开始更自然——如果下标从0开始,第i个元素 的地址就是 LOC(a1) + i * sizeof(ElemType),还少一次减法运算。所以C语言数组下标从0开始不是故意难为你,而是符合指针运算的习惯。
3. 顺序表的核心操作逐一拆解
3.1 初始化操作:空表是怎么建立的
动态顺序表的初始化,核心就三件事:给 data 申请一块初始内存、把 capacity 设成初始容量、把 length 清0。这里有一个重要的设计细节——传参方式。如果你在函数内部直接给结构体变量赋值,赋值操作只作用于函数内部的副本,外面的结构体一点变化都没有。所以必须传指针:
c复制#include <stdio.h>
#include <stdlib.h>
#define INIT_CAPACITY 10
typedef struct {
int *data;
int capacity;
int length;
} SeqList;
// 初始化动态顺序表
void InitList(SeqList *L) {
L->data = (int *)malloc(INIT_CAPACITY * sizeof(int));
if (L->data == NULL) {
printf("内存分配失败\n");
exit(1);
}
L->capacity = INIT_CAPACITY;
L->length = 0;
}
malloc 之后一定要检查返回值是否为NULL,这是C语言里“内存可能分配失败”这一客观事实的兜底。虽然考试代码里经常忽略,但真实项目里这是基本功。如果你用 int arr[10] 这种定长数组,就不需要初始化内存,直接把 length 置0即可。
3.2 插入操作:为什么循环必须从后往前
插入是顺序表里最容易写错的操作。假设你要在位置 pos(从0开始计数的下标)插入一个新元素 e,逻辑分三步:判断位置合法性、判断表是否已满、从最后一个元素开始依次后移、在目标位置赋值并 length++。
后移这一步非常关键,很多初学者习惯从前往后移动,结果后面的元素把前面的元素覆盖了,数据全乱掉。正确的循环方向必须是从最后一个元素开始,逐个向后挪一位,给待插入位置腾出空间。位置合法性的判断条件同样值得注意,插入位置 pos 的合法区间是 0 <= pos <= length。为什么可以等于 length?因为顺序表允许在表尾追加元素,此时不需要移动任何元素,直接放到末尾就行。
c复制int ListInsert(SeqList *L, int pos, int e) {
// 1. 位置合法性判断
if (pos < 0 || pos > L->length) {
printf("插入位置不合法\n");
return 0;
}
// 2. 检查容量是否已满
if (L->length == L->capacity) {
printf("顺序表已满,无法插入\n");
return 0;
}
// 3. 从最后一个元素开始后移
for (int i = L->length - 1; i >= pos; i--) {
L->data[i + 1] = L->data[i];
}
// 4. 插入新元素并更新长度
L->data[pos] = e;
L->length++;
return 1;
}
这里循环里的 i >= pos 这个边界条件也是高频出错点。如果 pos = 0,循环要一直走到 i = 0 才停,这样 data[0] 会被复制到 data[1],腾出位置给新元素。如果写成了 i > pos,data[pos] 本身就不会后移,后面赋值时就会把原来的元素直接覆盖丢掉。
移动的次数取决于插入位置:插在第0个位置需要移动n个元素,插在最后一个位置需要移动0个。平均下来,每个位置被插入的概率均等,期望移动次数是 n/2,所以插入操作的时间复杂度是O(n)。
3.3 删除操作:移动方向正好相反
删除操作是把 pos 位置的元素移除,让后面的元素依次前移填补空缺。循环方向从前向后,因为要让后一个元素覆盖前一个元素。
c复制int ListDelete(SeqList *L, int pos) {
if (pos < 0 || pos >= L->length) {
printf("删除位置不合法\n");
return 0;
}
// 从 pos 后面的元素开始,逐个前移
for (int i = pos; i < L->length - 1; i++) {
L->data[i] = L->data[i + 1];
}
L->length--;
return 1;
}
删除操作的位置合法性判断和插入略有不同:删除位置 pos 的合法区间是 0 <= pos <= length - 1,也就是 pos < length。如果 pos == length,说明要删除的是“最后一个元素后面”的位置,那是不存在的。很多同学在插入和删除的边界条件上栽跟头,就是因为没注意这两个约束的微妙差别。
删除头元素需要移动n-1个元素,删除末元素不需要移动,平均移动次数 (n-1)/2,时间复杂度同样是O(n)。
3.4 按值查找:最简单的线性扫描
按值查找就是在一个无序的顺序表里找第一个等于给定值的元素,返回它的下标。最直接的办法是线性扫描,从下标0开始逐个比较。
c复制int LocateElem(SeqList *L, int e) {
for (int i = 0; i < L->length; i++) {
if (L->data[i] == e) {
return i; // 返回下标
}
}
return -1; // 查找失败
}
这段代码的返回值设计有一个约定俗成的规范:返回下标用非负整数,查找失败返回-1。当然如果你要严格区分“找到元素0”和“找不到”,用-1作为失败标识就没问题。如果是有序表,你可以优化成二分查找,时间复杂度从O(n)降到O(logn),但前提是数据有序,这属于算法设计的范畴了,本章先不展开。
3.5 按位访问:数组下标的红利
按位访问是顺序表最值得炫耀的操作。如果你想获取第 i 个元素,直接 L.data[i] 就完事了,绝对不复杂。这也是“随机存取”结构的核心优势——任何位置都能在常数时间内直接访问。
c复制int GetElem(SeqList *L, int i, int *e) {
if (i < 0 || i >= L->length) {
return 0; // 下标越界
}
*e = L->data[i];
return 1;
}
这里用指针参数 e 来“带出”找到的元素值,这种做法在C语言里很常见。因为函数只能有一个返回值,而这里返回值要用来表示操作是否成功,所以结果就通过指针参数回传。这种“返回值表示状态,指针参数带出结果”的模式,在很多标准库API里都能看到,是一个非常实用的C语言设计习惯。
4. 扩容策略与内存管理:动态顺序表的进阶玩法
4.1 为什么需要动态扩容
前面讲的插入操作有一个前提:容量够用。如果容量不够还强行插入,就是数组越界,程序会在运行时崩溃或者出现难以排查的内存踩踏问题。真实场景下,我们很难提前预知数据规模,比如你读取一个文件的行数、接收网络客户端发来的消息,事先都不知道会有多少条。所以动态扩容不是可选项,而是工程场景下的必选项。
4.2 realloc扩容的正确姿势
扩容的思路很直接:开辟一块更大的内存、把旧数据搬过去、释放旧内存、更新 data 指针和 capacity。在C语言里,我们可以用 realloc 一行搞定“重新分配+复制+释放旧内存”的流程。
c复制int ExpandList(SeqList *L) {
int newCapacity = L->capacity * 2;
int *temp = (int *)realloc(L->data, newCapacity * sizeof(int));
if (temp == NULL) {
printf("扩容失败\n");
return 0;
}
L->data = temp;
L->capacity = newCapacity;
return 1;
}
扩容是一次性申请一整块连续内存,然后把旧数据整体搬过去。所以顺序表的动态扩容代价很大:每触发一次扩容就要重新分配内存并复制所有元素。为了降低频繁扩容带来的性能损耗,通常不会“缺一个扩一个”,而是成倍扩容——常见策略是扩容到原来的2倍。这样做的道理其实挺简单:如果每次只增加一个位置,插入n个元素就要扩容n次,每次都要把已有数据全部复制一遍,总代价是O(n²);而按2倍扩容,总扩容次数大约只有logn次,均摊下来每次插入的代价接近O(1)。这个思路叫“均摊分析”,听起来有点唬人,实际就是你一次性多买点内存,减少来回搬家的次数,省下来的整体开销远比一次性多花的内存成本划算。
4.3 扩容之后插入操作也要跟着改
有了扩容函数,插入操作里“检查容量”这一步要改成“容量不足就先扩容”:
c复制int ListInsert(SeqList *L, int pos, int e) {
if (pos < 0 || pos > L->length) {
printf("插入位置不合法\n");
return 0;
}
if (L->length == L->capacity) {
if (!ExpandList(L)) {
return 0; // 扩容失败,返回插入失败
}
}
// 后移、插入、length++,和之前一样
}
这里有一个魔鬼细节:realloc 返回的指针可能和原来的 data 指针不同,也可能相同。当原有内存块后面有足够的连续空闲空间时,realloc 会在原地扩展,返回原指针;如果没有足够空间,它会另找一块更大的内存,把旧数据复制过去,释放旧内存,返回新指针。所以千万不要想当然地继续用旧的 data 指针去访问数据,必须用 realloc 返回的新指针去更新 L->data。很多内存踩踏、悬垂指针问题就是这么来的。
4.4 销毁顺序表:别让内存泄漏找上门
动态扩容的另一面是动态内存管理,用完要记得释放。销毁顺序表很简单,但要先释放 data 指向的堆内存,再把指针置空,防止悬垂指针。
c复制void DestroyList(SeqList *L) {
if (L->data != NULL) {
free(L->data);
L->data = NULL;
}
L->capacity = 0;
L->length = 0;
}
顺序是“先free、再置NULL、再改其他字段”,顺序本身没有强制的规矩,但习惯上先释放内存再置空指针,逻辑更清楚。如果你只是把 L->data 置空却不 free,就是典型的内存泄漏——内存一直占着但你已经没有任何指针能访问到它了。C语言不像Java和Go有垃圾回收机制,内存管理全靠自觉,顺序表这种动态结构尤其要养成配对习惯:谁分配,谁释放;malloc 和 free 必须成对出现。
5. 顺序表的复杂度分析:O(1)和O(n)分别是哪来的
5.1 插入删除的平均移动次数推导
先说插入。长度为n的顺序表,共有n+1个可插入位置(下标0到n)。假设每个位置被插入的概率相等,都是 1/(n+1)。在第i个位置插入需要移动n-i个元素,则平均移动次数为:
code复制E_insert = Σ (i=0 to n) (n-i)/(n+1) = n/2
所以插入的平均时间复杂度是O(n)。删除比插入少一个可删除的位置,共有n个位置(下标0到n-1),删除第i个位置需要移动n-1-i个元素,平均移动次数为:
code复制E_delete = Σ (i=0 to n-1) (n-1-i)/n = (n-1)/2
所以删除的平均时间复杂度也是O(n)。这个结论直接决定了顺序表的适用场景:适合“存多改少”的静态数据,不适合频繁插入删除的动态数据。
5.2 各操作复杂度速查表
我整理了一张顺序表核心操作的复杂度对照表,复习的时候可以直接拿来用:
| 操作 | 最好情况 | 最坏情况 | 平均情况 | 说明 |
|---|---|---|---|---|
| 按位访问 GetElem | O(1) | O(1) | O(1) | 随机存取,数组下标直接定位 |
| 按值查找 LocateElem | O(1) | O(n) | O(n) | 从前往后线性扫描 |
| 插入 ListInsert | O(1) | O(n) | O(n) | 平均移动n/2个元素 |
| 删除 ListDelete | O(1) | O(n) | O(n) | 平均移动(n-1)/2个元素 |
| 表尾插入 | O(1) | O(1) | O(1) | 追加在末尾不需移动元素 |
| 表尾删除 | O(1) | O(1) | O(1) | 直接length--即可 |
表尾插入和表尾删除是最舒服的操作,因为不需要移动任何元素。这也是后面你会学到栈(线性表的受限版本)时,“栈顶插入删除”操作效率高的底层原因。如果你能用“移动次数”这个维度把这些复杂度串起来理解,顺序表的优劣就一目了然了。
5.3 顺序存储 vs 链式存储:一道永恒的选择题
顺序表最核心的缺陷是插入和删除需要移动大量元素,这在元素体积大、数量多的场景下代价很高。链式存储就是针对这个痛点诞生的——它不需要连续内存,每个节点保存数据和下一个节点的地址,插入删除只需要修改指针指向,不需要移动元素。但链式存储的代价是不能随机访问,你想找第i个节点必须从头遍历,时间复杂度O(n)。
所以顺序表和链表之间没有绝对的优劣,只有适不适合。频繁查找、数据规模相对稳定、很少插入删除的场景,顺序表完胜;频繁插入删除、数据规模不确定、对查找性能要求不高的场景,链表更合适。考研题和期末题里反复让你“比较顺序表和链表的优缺点”,本质上是考验你能否根据场景选型,而不是单纯背结论。
6. 实操中的5个高频坑点与排查技巧
6.1 坑点一:插入删除的边界条件写错
这是新手出错频率最高的地方。插入位置的合法区间是 0 <= pos <= length,注意右边界是闭的;删除位置的合法区间是 0 <= pos < length,右边界是开的。我见过太多人把插入的边界写成 pos < length,结果想往表尾追加元素时直接被判不合法;也有人把删除的边界写成 pos <= length,结果删除了一个不存在的位置,循环里出现下标越界。排查这类问题,最简单的办法就是在函数入口打印一下 pos 和 length 的值,肉眼对照区间判断,基本一眼就能看出问题。
6.2 坑点二:后移或前移的方向搞反
插入时元素后移必须从后往前循环,删除时元素前移必须从前往后循环。如果方向反了,就会出现数据覆盖。假想一个数组 [1,2,3,4,5],要在下标1处插入99,如果你从前往后移动,data[1] 先被赋成 data[0],然后 data[2] 被赋成已经被覆盖的 data[1],结果整个数组被1全部刷掉。这种错误很难用肉眼直接发现,建议小数据量画图模拟一遍循环过程,或者用调试器单步跟踪几次,感受一下数据是怎么被一步步覆盖的,之后就不会再犯。
6.3 坑点三:结构体传参时误用值传递
顺序表结构体里面有一个指针和两个整数,如果你在Init之类的函数里直接传结构体而不是结构体指针,函数内部修改的只是实参的副本。初始化完成后外部的结构体依然是个未初始化的野变量,data指针没有分配内存,后面一访问就是段错误。
c复制// 错误示范:值传递,外部 L 不会被修改
void InitListWrong(SeqList L) {
L.data = (int *)malloc(...);
L.capacity = INIT_CAPACITY;
L.length = 0;
}
// 正确示范:指针传递
void InitListRight(SeqList *L) {
L->data = (int *)malloc(...);
L->capacity = INIT_CAPACITY;
L->length = 0;
}
检查方法很简单:看函数里有没有对结构体字段的修改。如果有,就一定要传指针。很多人刚学结构体时都会在这里栽一跤,记住这一个原则能帮你避掉很多段错误。
6.4 坑点四:忘记检查malloc/realloc的返回值
严格来说,malloc 和 realloc 在内存分配失败时会返回NULL。初学者写代码基本都不检查,直接往下用,一旦内存申请失败,对NULL指针解引用就是程序直接崩溃。考试的时候可能不会出现内存不足,但真实项目里内存不足的情况时有发生。而且检查返回值这件事本身成本极低,就是多写两行if判断,却能带来巨大的健壮性提升,养成这个习惯是C语言学习过程中很重要的一课。
6.5 坑点五:释放内存后没有将指针置空
free(L->data) 只会释放指针指向的堆内存,但 L->data 这个指针变量本身还残留着那个已经失效的内存地址。如果不把 L->data 置为NULL,后续代码如果误操作了 L->data[i],它会尝试访问一块已经被系统回收的内存,这种问题的表现非常隐秘——有时候能跑,有时候崩溃,时好时坏,极难排查。因此释放内存后立即将指针置空,是好习惯,也是帮你省时间的坏毛病克星。
7. 一个完整可运行的代码示例
把上面所有细节整合成一份完整的代码,可以直接在VS Code或任何C语言环境里跑起来测试。我建议你不要直接复制粘贴,而是自己敲一遍,敲的过程中会注意到很多看代码时注意不到的细节。
c复制#include <stdio.h>
#include <stdlib.h>
#define INIT_CAPACITY 5
typedef struct {
int *data;
int capacity;
int length;
} SeqList;
// 初始化
void InitList(SeqList *L) {
L->data = (int *)malloc(INIT_CAPACITY * sizeof(int));
if (L->data == NULL) {
printf("内存分配失败\n");
exit(1);
}
L->capacity = INIT_CAPACITY;
L->length = 0;
}
// 扩容
int ExpandList(SeqList *L) {
int newCapacity = L->capacity * 2;
int *temp = (int *)realloc(L->data, newCapacity * sizeof(int));
if (temp == NULL) {
printf("扩容失败\n");
return 0;
}
L->data = temp;
L->capacity = newCapacity;
printf("已扩容到 %d\n", L->capacity);
return 1;
}
// 插入
int ListInsert(SeqList *L, int pos, int e) {
if (pos < 0 || pos > L->length) {
printf("插入位置不合法\n");
return 0;
}
if (L->length == L->capacity) {
if (!ExpandList(L)) {
return 0;
}
}
for (int i = L->length - 1; i >= pos; i--) {
L->data[i + 1] = L->data[i];
}
L->data[pos] = e;
L->length++;
return 1;
}
// 删除
int ListDelete(SeqList *L, int pos) {
if (pos < 0 || pos >= L->length) {
printf("删除位置不合法\n");
return 0;
}
for (int i = pos; i < L->length - 1; i++) {
L->data[i] = L->data[i + 1];
}
L->length--;
return 1;
}
// 按值查找
int LocateElem(SeqList *L, int e) {
for (int i = 0; i < L->length; i++) {
if (L->data[i] == e) {
return i;
}
}
return -1;
}
// 打印所有元素
void PrintList(SeqList *L) {
printf("当前容量: %d, 当前长度: %d\n", L->capacity, L->length);
printf("元素: ");
for (int i = 0; i < L->length; i++) {
printf("%d ", L->data[i]);
}
printf("\n");
}
// 销毁
void DestroyList(SeqList *L) {
if (L->data != NULL) {
free(L->data);
L->data = NULL;
}
L->capacity = 0;
L->length = 0;
}
int main() {
SeqList L;
InitList(&L);
// 插入5个元素,测试自动扩容
for (int i = 1; i <= 7; i++) {
ListInsert(&L, L.length, i * 10);
}
PrintList(&L);
// 在头部插入
ListInsert(&L, 0, 99);
PrintList(&L);
// 删除下标2的元素
ListDelete(&L, 2);
PrintList(&L);
// 查找元素40
int idx = LocateElem(&L, 40);
if (idx != -1) {
printf("元素40所在下标: %d\n", idx);
} else {
printf("未找到元素40\n");
}
DestroyList(&L);
return 0;
}
运行这段代码,你会看到自动扩容被触发的日志。初始容量只给了5,但插入了7个元素,所以第6次插入时扩容到10,所有元素完好保留。这正好印证了前面讲的realloc行为——它把旧数据完整搬到了新内存块里,之前插入的数据一条都不会少。
8. 关于顺序表学习的几点私人体会
我见过太多人学数据结构时陷入一个误区:光看书不动手,感觉懂了就翻下一页。但顺序表这章恰恰是最需要动手的一章,因为它的知识点高度依赖细节——边界条件、移动方向、容量判断、内存管理,任何一个细节写错,代码就会出问题,而这些问题只有在你亲手敲代码、亲手调试时才会真正暴露出来。
我的建议是,学完顺序表后,尝试在不看教材的情况下,自己把初始化、插入、删除、查找、按位访问、扩容、销毁这七个函数从头到尾写一遍。第一次写不出来很正常,卡住了就翻书重新看一遍原理,然后再合上书继续写。这个过程本身就是在帮你建立“数据结构操作实现”的肌肉记忆,后面学链表、学栈、学队列的时候,你会发现自己进入状态快得多。
另外特别想提醒准备考研和期末复习的同学:不要只背“插入平均移动n/2个元素”这种结论,要会推导。面试和考试时,老师可能随便改一个条件,比如“插入位置i的概率分布不均匀,越靠前概率越大”,问平均移动次数怎么变,这时候只背结论就会卡壳。顺序表的推导链条很短,从移动次数公式到求和再到复杂度,每一步都有清晰的数学逻辑,理解之后你就不需要死记硬背了。
后续还可以这样扩展:把顺序表里的 int 类型换成结构体类型,做一个学生信息管理系统;或者把顺序表改造成定长循环队列,体会一下线性表在“先进先出”场景下的变化。等第2章链表部分学完,你再回来对比一下这两种存储结构各自处理同一个需求时的代码量和性能表现,那才是线性表整章真正融会贯通的时刻。
