1. 顺序表到底是个什么玩意
很多同学一看到"顺序表"三个字,第一反应是:这不就是数组吗?还有啥好学的?其实这个理解对了一半。顺序表确实是用数组来实现的,但它的本质是一套"用连续内存存储同类型数据"的逻辑结构,数组只是它的物理载体。换句话说,数组是"房子",顺序表是"住在房子里的生活方式"——你规定了怎么住、怎么搬东西、怎么腾房间,这套规则才是顺序表的核心。
拿考研、期末考和面试的场景来说,顺序表几乎是必考的基础。为什么?因为它是所有线性结构的地基。你后面学的链表、栈、队列、哈希表,本质上都是在解决"数据怎么组织、怎么操作"的问题,而顺序表提供的就是最朴素的一种答案:挨个放、挨个找、挨个挪。理解了顺序表的机制,你再看ArrayList、Python的list、Go的slice,会发现它们底层全是同一个套路——动态扩容的数组。
顺序表解决的痛点非常明确:在已知数据规模、或者数据规模不会剧烈变化的前提下,用一块连续的内存快速存储和访问数据。它最大的优势是随机访问,也就是你给我一个下标,我直接通过"起始地址 + 下标 × 元素大小"算出内存位置,一步到位。这是链表永远做不到的。但反过来,它也有一个致命的短板——插入和删除需要搬动大量元素,而且一旦数组满了,扩容的成本很高。后面我会详细展开。
这篇内容我会按照"上篇"的定位,先把顺序表的底层原理、存储结构、基本操作的实现还有时间复杂度的推导讲透。链表、双向链表那些,留到下篇再聊。如果你正在复习数据结构准备期末考试或者考研408,这一篇可以当作你的精读材料;如果你是刚接触编程的小白,跟着代码敲一遍,你也会对"内存和数据结构的关系"有全新的感觉。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 顺序表的存储结构:连续、随机、长度可变
2.1 逻辑结构和物理结构的关系
数据结构这门课,第一件事就是分清楚逻辑结构和物理结构。顺序表在逻辑上是一对一的线性关系,也就是说,除了第一个元素,每个元素都有且只有一个直接前驱;除了最后一个元素,每个元素都有且只有一个直接后继。这是"线性表"的定义,顺序表只是线性表的一种存储实现。
物理上,顺序表要求所有元素连续存放在一段内存中。这里的"连续"是一个硬性要求。你可以把它类比成电影院的连排座位:你必须买一排连续的座位,座位号就是下标,观众就是数据元素。如果你想在第3个位置插入一个新观众,那么第3个位置之后的所有人都得往右挪一个座位;如果有人要离场,后面所有人又得往左挪。这个类比能帮你理解为什么顺序表插入删除慢。
实际存储时,顺序表的底层就是一个一维数组。数组名保存的是首地址,通过下标计算地址的公式是:
code复制地址(i) = 起始地址 + i × sizeof(元素类型)
这个公式就是顺序表"随机存取"的底气。它意味着,不管表有多长,访问第1个和第第10000个元素,耗时完全一样。这是顺序表最值得炫耀的优点,后面做时间复杂度分析的时候你会再次看到它。
2.2 静态分配和动态分配的区别
按实现方式,顺序表可以分成静态和动态两种。静态版本用定长数组,比如 int data[100],长度定了就不能变。适合数据量很确定的场景,写法简单,但一旦估算失误,要么空间浪费,要么直接溢出。动态版本则使用指针,配合 malloc/realloc(C语言)或者 new(C++),在运行期按需申请和扩容。Java里的ArrayList本质上就是动态顺序表。
我在教初学者的时候,发现很多人不理解为啥要用指针。你想一下,如果数组长度是固定的,你还能在函数里通过传参来"扩容"吗?不行。你只能换一片更大的内存,然后让原来的指针指向新内存,再把旧数据搬过去。这就是动态分配的核心思路:用指针指向堆内存,长度不够了就换一块更大的地皮。
静态分配适合的场景是题目里明确给了最大值,比如学籍管理系统里一个班最多50人。动态分配适合你在写通用库,比如实现一个通用的ArrayList,你不知道调用者会塞多少数据。实际开发中,动态分配几乎是标配。但考试中,静态分配更常出现,因为它更简单,能让你把所有注意力放在"操作逻辑"上而不是内存管理上。
2.3 顺序表的结构体定义(C语言版)
我用C语言来写核心实现,因为C语言能让你真正看到内存是怎么工作的。Java、Python封装得太好,反而不利于理解原理。下面这个结构体是动态顺序表最经典的定义方式:
c复制#define INIT_SIZE 10
#define EXPAND_SIZE 5
typedef struct {
int* data; // 指向堆内存中数组的指针
int length; // 当前表长,即实际存储的元素个数
int capacity; // 当前容量,即数组最多能存多少个元素
} SeqList;
data 指向一块连续内存,length 是你真正存了多少个数据,capacity 是这块内存能装下多少个。很多新手搞混 length 和 capacity,我告诉你一个判断方法:length 是"已有货架上的商品数量",capacity 是"货架总共能放多少件"。货架可能有一半是空的,所以 length <= capacity 永远成立。
初始化的时候,需要手动申请内存:
c复制void initList(SeqList* list) {
list->data = (int*)malloc(INIT_SIZE * sizeof(int));
if (list->data == NULL) {
printf("内存分配失败\n");
exit(1);
}
list->length = 0;
list->capacity = INIT_SIZE;
}
一定要检查malloc的返回值,这在考试代码里可能不是重点,但在真实工程里是必须的。申请不到内存就继续往下跑,后续就是访问空指针,直接崩溃。
如果你用Java,对应结构大致是这样的:
java复制public class SeqList {
private int[] data;
private int length;
private int capacity;
public SeqList() {
this.capacity = 10;
this.data = new int[capacity];
this.length = 0;
}
}
Java的数组自带length属性,但那只是数组容量,并不能代表"实际元素个数",所以你还是需要一个单独的 length 字段来记录当前表长。很多初学者会直接把 data.length 当表长用,这会导致你永远不知道表到底存了多少有效数据。这个细节值得记下来,属于面试官很喜欢挖的坑。
3. 顺序表的核心操作详解:增删查改
3.1 插入操作:从后往前挪
插入操作是顺序表最核心的操作之一,逻辑思路其实不难,但边界条件多,一不留神就翻车。核心步骤如下:
- 判断表是否已满。如果
length == capacity,需要先扩容(动态表)或者报错(静态表)。 - 判断插入位置是否合法。通常要求
1 <= pos <= length + 1,这里我用的是逻辑位置,从1开始计数。如果你习惯从0开始的下标,那范围就是0 <= index <= length。 - 从最后一个元素开始,依次往后移动一位,直到把目标位置腾出来。
- 把新元素放进去,
length++。
这里有一个关键点:搬运必须从后往前。为什么?你想想,如果从前往后搬,第一个元素往后移的时候会把第二个元素的位置覆盖掉,第二个元素还没来得及搬家就已经被覆盖了。所以必须从最后一个开始,一个一个往后挪,才能保证每个元素都安全抵达新位置。
代码实现如下:
c复制int insertList(SeqList* list, int pos, int val) {
if (list->length == list->capacity) {
printf("表已满,无法插入\n");
return 0;
}
if (pos < 1 || pos > list->length + 1) {
printf("插入位置不合法\n");
return 0;
}
for (int i = list->length; i >= pos; i--) {
list->data[i] = list->data[i - 1];
}
list->data[pos - 1] = val;
list->length++;
return 1;
}
注意循环里初始 i = length,因为下标比逻辑位置小1,最后一个有效元素的下标是 length - 1,而它要搬到 length 的位置。所以循环从 length 开始,每次把 data[i-1] 的值赋给 data[i],一直到 i == pos 时,原来 pos-1 位置的值被搬到 pos,腾出了 pos-1 这个空位。最后把新值放进去。
如果你用的是链表,插入只需要修改两个指针的指向,根本不需要搬动元素。但在顺序表里,这就是宿命,你得陪着元素一起搬家。这也是顺序表在"频繁插入"场景下不好用的根本原因。
3.2 删除操作:从前往后挪
删除操作和插入是镜像的。按位置删除时,目标元素后面的所有元素都要往前移动一个位置,直接覆盖掉被删的元素。这里要注意的是:移动的方向和插入正好相反,必须从前往后搬。
删除的完整逻辑:
- 判断表是否为空,空表不能删。
- 判断删除位置是否合法,
1 <= pos <= length。 - 从
pos的后面一个元素开始,依次往前覆盖。 length--。
很多人问,删除之后需不需要把最后一个位置的值清空?在我们这个场景下不需要。因为 length 已经减了,最后一个位置已经不算有效数据了,下次插入的时候直接覆盖就行。但如果你是存放指针的数组,出于内存管理的考虑,最好手动置空,避免"野指针"残留导致后续误用。
C语言实现:
c复制int deleteList(SeqList* list, int pos) {
if (list->length == 0) {
printf("表为空,无法删除\n");
return 0;
}
if (pos < 1 || pos > list->length) {
printf("删除位置不合法\n");
return 0;
}
for (int i = pos - 1; i < list->length - 1; i++) {
list->data[i] = list->data[i + 1];
}
list->length--;
return 1;
}
这里循环的起始下标是 pos - 1,也就是被删元素的下标。它被后面一个元素覆盖,然后后面的每一个都往前挪一位。最后一次循环是 i = length - 2 时,把 data[length-1] 赋给 data[length-2],此时最后一个有效元素也被处理完了。
3.3 按值查找:遍历的代价
顺序表支持按位置随机访问,但按值查找就得老老实实遍历。逻辑很简单:从第一个元素开始,一个个比较,相等就返回位置,到末尾还没找到就返回0(或 -1,看你的约定)。
c复制int locateElem(SeqList* list, int target) {
for (int i = 0; i < list->length; i++) {
if (list->data[i] == target) {
return i + 1; // 返回逻辑位置
}
}
return 0;
}
这里的时间复杂度是O(n),最好情况第一个就是,O(1);最坏情况最后一个才是,O(n);平均情况是 (n+1)/2 次比较,所以平均也是O(n)。这也是顺序表在"按值查找频繁"的场景下比较吃亏的地方。如果你想快,就得引入有序性(二分查找,O(log n))或者哈希索引(O(1)),但那已经超出"基础顺序表"的讨论范围了。
3.4 按位置取值:O(1) 的秘密
按位置取值的代码非常简单:
c复制int getElem(SeqList* list, int pos, int* result) {
if (pos < 1 || pos > list->length) {
return 0;
}
*result = list->data[pos - 1];
return 1;
}
评价一个操作快不快,看的是时间复杂度和内存访问次数。按位置取值不需要循环,直接根据下标计算内存地址,一次访问就能拿到数据。这就是随机存取的威力,是顺序表最值得炫耀的资本。
很多算法题里,快排、堆排序、二分查找,全部依赖于顺序表这个"O(1)随机访问"的特性。你换成一个链表试试,二分查找直接没戏,因为你要跳来跳去,链表的随机访问是O(n)。数据结构没有绝对的好坏,只有适不适合当前场景,这句话在顺序表和链表的对比里体会最深。
4. 动态扩容:顺序表从"固定"到"自动"
4.1 为什么需要扩容
静态顺序表有先天缺陷:容量固定。你初始开了100个int的空间,结果业务数据膨胀,塞了101个,直接溢出。更麻烦的是,这种错误往往不是立刻暴露的,而是程序运行到某个犄角旮旯才崩溃,排查起来非常恶心。
动态扩容就是为了解决这个问题:数组快满了,就在堆上重新申请一块更大的内存,把数据搬过去,释放旧内存。这个操作让你的顺序表变成"自动适应"的容器,Java的ArrayList、C++的vector、Python的list,底层全是这个思路。
4.2 扩容的时机和策略
扩容的时机很简单:当 capacity == length 时,说明货架满了,要扩容了。问题在于扩多少。常见的策略有两种:
- 固定增量:比如每次增加5个位置。实现简单,但频繁扩容时性能不太好。
- 倍率扩容:比如每次扩大到原来的2倍。均摊下来,每次插入的代价接近O(1),这是ArrayList和vector的标准做法。
为什么倍率扩容更好?我给你算一笔账。如果容量是n,你每次扩容增加固定n个位置,那么你插入大约n个元素就要扩容一次,每次扩容都要搬n个数据,总共搬了O(n²)次。但如果每次容量翻倍,总共只需要扩容O(log n)次,总搬移次数只有O(n)。这是一个非常经典的均摊分析,面试常考,考试也喜欢出选择题。
具体实现:
c复制int expandList(SeqList* list) {
int newCapacity = list->capacity * 2;
int* newData = (int*)realloc(list->data, newCapacity * sizeof(int));
if (newData == NULL) {
printf("扩容失败\n");
return 0;
}
list->data = newData;
list->capacity = newCapacity;
return 1;
}
注意,realloc 在C语言里是"原地扩容优先,不行就搬家"的函数。它可能在原地址直接扩大,也可能搬到新的内存区域。所以必须把返回值赋值回 list->data,否则如果搬家了,你的指针还指着旧地址,就出大问题了。
如果你用Java,可以这样手动模拟:
java复制public void expand() {
capacity = capacity * 2;
int[] newData = new int[capacity];
System.arraycopy(data, 0, newData, 0, length);
data = newData;
}
Java里有现成的 Arrays.copyOf,但我建议你手动写一次 arraycopy,体会一下搬家的过程。
4.3 缩容:另一个容易被忽略的问题
很多教材只讲扩容,不讲缩容。但在实际工程里,缩容同样重要。如果你的ArrayList存了100万个元素,然后删到只剩100个,底层的数组还是100万的容量,内存就白白浪费了。
不过缩容要谨慎,核心原则是"别频繁触发"。你删一个元素就缩容,再插一个又扩容,这会导致性能抖动。通常的做法是:当 length < capacity / 4 时,才把容量缩为原来的一半。这样留出缓冲区,避免"扩容-缩容"反复横跳。
4.4 均摊复杂度:为什么 ArrayList 的 add 是 O(1)
这个知识点考研和面试都经常考。单独看一次扩容,代价是O(n),因为要搬n个元素。但如果把N次插入放在一起看,你会发现扩容的总代价并不是O(n²),而是O(n)。
分析思路:假设初始容量为1,扩容因子为2。第1次扩容搬1个元素,第2次搬2个,第3次搬4个,累加起来是 1 + 2 + 4 + ... + n = 2n - 1。也就是说,N次插入的总搬移量不超过2n次。均摊到每次插入,是常数级别的O(1)。这就是"均摊常数时间"的来源,也是Java的ArrayList add 方法能标称O(1)的底气。
5. 顺序表的常见问题与避坑经验
5.1 位置表示法:从0还是从1
这是碰到的最容易混淆的问题。数据结构教材(严蔚敏版)喜欢用逻辑位序,从1开始;C语言和Java的数组下标从0开始。写代码的时候,你必须在两者之间来回切换:逻辑位置是 pos,对应的数组下标是 pos-1。
我给你的建议是:统一一套体系。写代码时,所有参数都用"逻辑位置",内部转换在下标那一步完成。这样对外接口更容易理解,考研做题也不容易错。
5.2 为什么不检查边界就崩
看完上面的代码,你会发现每个操作都在检查边界。这不是形式主义。数组越界在C/C++里是未定义行为,编译器不报警,运行时不一定立刻崩,可能悄悄改坏了相邻内存的数据,等真正爆发时已经追查不到源头了。这种bug最难查,比报错还麻烦。
我的经验是:凡是涉及下标的地方,先问自己三个问题:
- 这个位置会不会超过当前表长?
- 这个位置会不会导致数组越界?
- 表空/表满的时候,这个操作还有意义吗?
把这三个问题养成肌肉记忆,你写的数据结构代码会稳很多。
5.3 常见错误速查表
| 错误类型 | 典型表现 | 原因分析 | 解决办法 |
|---|---|---|---|
| length和capacity混淆 | 遍历时越界 | 用capacity代替length | 遍历永远用length |
| 插入位置判断错误 | 允许插入到远离表尾的位置 | 误把pos上限设为capacity | 最大允许位置是length+1 |
| 删除位置判断错误 | 删除空位 | 误把pos上限设为capacity | 最大允许位置是length |
| 忘记扩容 | 插入时越界崩溃 | 插入前没检查length==capacity | 插入前先检查并扩容 |
| 扩容后指针未更新 | 数据丢失/崩溃 | realloc的返回值没赋回 | 必须 list->data = newData |
| 从前往后搬运 | 数据被覆盖 | 插入时移动方向搞反 | 插入从后往前,删除从前往后 |
5.4 为什么建议你手写一遍而不是直接用库
我知道很多人会想:Java有ArrayList,C++有vector,Python有list,我干嘛还要自己写?我的回答是:会用工具和懂工具的原理是两码事。你直接查API,永远不知道扩容背后的性能代价,不知道为什么频繁在中间插入会慢,不知道为什么用 ArrayList 在头部插入的性能能差到离谱。
手写一遍顺序表,你会建立很扎实的"数据结构感"。以后再看源码,再调优性能,再设计自己的容器,心里都有底。这种底子,是刷一百道LeetCode也换不来的。
6. 顺序表的优缺点与典型应用场景
6.1 优点速览
顺序表的优点,我给它总结成三个"快":
第一,随机访问快。按下标访问任意元素,O(1)时间。这是所有顺序存储结构的天然优势。
第二,存储密度高。数组里存的就是纯数据,不存指针。每个元素只占用数据类型本身的空间,不像链表,每个节点都要额外存储前驱/后继指针。空间利用效率更高。
第三,实现简单。创建、遍历、修改,逻辑都非常直接,代码量小,不容易出错。对入门新手极其友好。
6.2 缺点同样明显
第一,插入删除的平均代价高。因为要搬动元素,平均情况需要移动一半的元素,O(n)时间。如果你的业务是"高频插入+高频删除",顺序表大概率不是好选择。
第二,扩容代价重。动态扩容需要申请新内存+搬移全部数据。虽然均摊下来可以接受,但单次扩容的延迟可能很高。在实时性要求极高的场景里,这个"偶发卡顿"可能会成为问题。
第三,内存碎片。扩容时,如果旧内存没有及时释放,频繁的申请/释放会在堆上产生碎片。C语言手写动态数组时尤其要注意。
6.3 适合用顺序表的场景
我的经验是,以下场景选顺序表基本不会错:
- 数据规模稳定,最大长度可预估。比如学生信息表、比赛成绩表。
- 主要操作是按下标访问和尾部插入/删除。比如快速排序、二分查找、堆操作。
- 需要频繁遍历全部元素。比如打印报表、求平均值。
- 实现栈和队列(循环队列)。这两个结构天然适合用顺序表。
如果你发现自己要在中间位置频繁增删,或者数据规模完全不可控,那真的可以考虑链表或者别的结构了。工具没有高低之分,只有合适不合适,这句话在心里默念三遍。
6.4 顺序表与链表第一次正面PK
我直接做一个小对比,方便你复习时一眼看清差异:
| 对比项 | 顺序表 | 链表 |
|---|---|---|
| 存储方式 | 连续内存 | 分散节点+指针连接 |
| 随机访问 | O(1) | O(n) |
| 按值查找 | O(n) | O(n) |
| 在已知位置插入/删除 | O(n),需搬元素 | O(1),改指针 |
| 扩容 | 需搬移数据 | 无此概念 |
| 存储密度 | 高 | 低(存指针) |
| 适用场景 | 读多写少、下标访问多 | 写多读少、插入删除频繁 |
这个表格不是让你死记,而是要让你在看到具体问题时能自己推。比如题目说"频繁在表尾插入",线性表、顺序表、链表都行,但尾部插入顺序表O(1)且缓存友好,所以顺序表更优。如果题目说"经常在头部插入",顺序表每次都要搬全部元素,O(n),这时链表 O(1) 的优势就体现出来了。
7. 手写一套完整可运行的最小顺序表
我直接给出一份完整的C语言代码,你可以在本地直接编译运行。里面包含初始化、插入、删除、查找、打印、销毁六个功能,配合 main 函数做一轮完整的冒烟测试。
c复制#include <stdio.h>
#include <stdlib.h>
#define INIT_SIZE 4
typedef struct {
int* data;
int length;
int capacity;
} SeqList;
void initList(SeqList* list) {
list->data = (int*)malloc(INIT_SIZE * sizeof(int));
if (list->data == NULL) exit(1);
list->length = 0;
list->capacity = INIT_SIZE;
}
int expandList(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;
}
int insertList(SeqList* list, int pos, int val) {
if (pos < 1 || pos > list->length + 1) return 0;
if (list->length == list->capacity) {
if (!expandList(list)) return 0;
}
for (int i = list->length; i >= pos; i--) {
list->data[i] = list->data[i - 1];
}
list->data[pos - 1] = val;
list->length++;
return 1;
}
int deleteList(SeqList* list, int pos) {
if (pos < 1 || pos > list->length) return 0;
for (int i = pos - 1; i < list->length - 1; i++) {
list->data[i] = list->data[i + 1];
}
list->length--;
return 1;
}
int locateElem(SeqList* list, int target) {
for (int i = 0; i < list->length; i++) {
if (list->data[i] == target) return i + 1;
}
return 0;
}
void printList(SeqList* list) {
printf("length=%d, capacity=%d, data:", list->length, list->capacity);
for (int i = 0; i < list->length; i++) {
printf(" %d", list->data[i]);
}
printf("\n");
}
void destroyList(SeqList* list) {
free(list->data);
list->data = NULL;
list->length = 0;
list->capacity = 0;
}
int main() {
SeqList list;
initList(&list);
insertList(&list, 1, 10);
insertList(&list, 2, 20);
insertList(&list, 3, 30);
insertList(&list, 4, 40);
insertList(&list, 5, 50);
printList(&list);
deleteList(&list, 2);
printList(&list);
int pos = locateElem(&list, 40);
printf("40 的位置是: %d\n", pos);
destroyList(&list);
return 0;
}
建议你亲手把这份代码敲一遍,然后自己加上"按位置修改""求表长""清空"这几个操作。等你能不看任何参考代码把这些功能都写对,顺序表的基本功就算打牢了。
8. 写在后面:我的经验与下一步建议
顺序表这一篇,我没有急着把它和链表放在一起做长篇对比,因为我觉得基础没打牢之前,对比再多都是空谈。我的建议是:先把顺序表的定义、结构体和增删查改代码反复看到"肌肉记忆"的程度,再去看链表。否则你很容易陷入"顺序表和链表区别我知道,但代码写不出来"的尴尬。
最后分享一个我自己带学生时反复强调的小技巧:画图。学数据结构最大的误区就是只看代码不画图。拿起一张草稿纸,把数组画成格子,把容量和表长标出来,然后手动模拟一次插入和删除的过程,用箭头表示元素移动的方向。这个过程看起来笨,但它能让你的大脑真正建立起"内存布局"的直觉。等你能不用纸笔,纯粹在脑子里"看见"元素在数组中移动的时候,你的数据结构就算真正入门了。
