很多人在学数据结构的时候,第一站就是线性表,而线性表最经典的两种实现,一个是顺序表,一个是链表。顺序表这个名字听起来挺唬人,其实拆开就俩字——顺序存储的线性表,说白了就是用一段连续的内存空间,把数据一个挨一个地存起来。但我发现一个很有意思的现象:很多人代码能写出来,却说不清顺序表和数组的区别,更搞不清为什么插入要“从后往前”,删除要“从前往后”,一旦面试官追问扩容策略或者边界条件,立刻露馅。
这篇就以“顺序表的实现”为切口,从底层结构定义到插入删除、动态扩容、边界测试,再到和链表的选型对比,完整走一遍。我会用Java为主语言,关键地方对比C语言的差异,最后手把手梳理一套可以直接抄作业的实现代码和测试用例。不管你是刚学数据结构的新手,还是想回头补基础、准备面试的开发者,这篇都能给你一些平时教程里看不到的细节。
1. 顺序表到底是什么,为什么数据结构第一课总是它
1.1 数组和顺序表:看起来一样,其实是两回事
很多人第一次听到“顺序表”三个字,第一反应是:这不就是数组吗?我刚学的时候也这么想过。直到面试官问我“数组和顺序表有什么区别”,我支支吾吾答不上来,才意识到问题没那么简单。
数组是编程语言提供的一种基础类型,它负责在内存中划分一段连续空间,让你能按下标存取数据。但数组本身不关心“你有多少个元素”,它只关心“你分配了多少空间”。而顺序表是在数组之上封装出来的一种数据结构,它管理着“当前存了多少数据”“还有没有空间”“插入删除时元素怎么挪”这一整套逻辑。换句话说,数组是砖头,顺序表是用砖头砌好的房子。你住进房子不需要关心水电管线怎么铺,但如果你想改造房子,就得知道墙哪堵能拆、哪堵不能拆。
顺便说一个面试里高频率追问:数组能随机访问,顺序表也能,那为什么还要顺序表?核心答案在于“封装”。数组的下标越界、容量不足、插入挪移,全都暴露给调用方,一个不留神就是越界异常;顺序表把这些脏活累活收进内部,对外只暴露 add、remove、get 这些语义清晰的操作。你写业务代码时不需要每次手动搬数组,出错的概率自然就降下来了。
1.2 线性表的顺序存储为什么“自然”
顺序表本质上是线性表的一种存储方案。线性表的特点是数据元素之间有“一对一”的逻辑关系:除了首尾,每个元素都有唯一的前驱和后继,就像排队买奶茶,你前面固定是谁,后面固定是谁。这个逻辑关系是抽象的,它并不关心数据在内存里怎么放。
顺序存储的核心思想特别朴素:既然逻辑上是一个挨一个,那我就让它们在物理上也一个挨一个。逻辑上相邻的元素,在物理内存地址上也相邻,这样你只要知道第一个元素的地址,就能通过下标计算出任意元素的地址。这就是它“随机访问”能力的来源。
用生活里的场景来类比:顺序表就像电影院里的一排固定连座,座位号从1到N,人按顺序坐下,你想找第8个座位的人,直接看座位号走过去就行,一步到位。而链表是另一种极端,大家随便坐,但每人都捏着一张纸条,上面写着下一位同学坐在哪,你想找第8个人,只能顺着纸条一个一个找过去。这个类比后面讲链表的时候还会用到。先记住这个感觉:顺序表靠“算”找到元素,链表靠“找”找到元素。
理解了这一层,后面初始化、插入、删除、扩容这些操作就都有了抓手——你本质上做的所有事情,都是在维护那块连续内存里的元素顺序和有效数量。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 从零搭建顺序表:结构定义与初始化
2.1 底层存储结构的设计
我用Java来实现,因为Java的ArrayList本身就是最经典的工业级顺序表,代码更好读,也方便对照源码理解。先定义字段:
java复制public class MyArrayList<E> {
private static final int DEFAULT_CAPACITY = 10;
private Object[] data; // 存储元素的底层数组
private int size; // 当前元素个数
public MyArrayList() {
data = new Object[DEFAULT_CAPACITY];
size = 0;
}
public MyArrayList(int capacity) {
if (capacity < 0) {
throw new IllegalArgumentException("capacity must >= 0, but was " + capacity);
}
data = new Object[capacity];
size = 0;
}
}
这里有个Java特有的细节:泛型存在类型擦除,我们没法直接 new E[DEFAULT_CAPACITY],所以底层数组必须是 Object[],在取值的时候做强转:
java复制@SuppressWarnings("unchecked")
public E get(int index) {
checkIndex(index);
return (E) data[index];
}
如果你用的是C语言,结构体定义就直白得多:
c复制typedef struct {
int* data; // 指向堆区连续内存的指针
int size; // 当前元素个数
int capacity; // 当前容量
} SeqList;
两种语言本质上是同一件事:一个连续内存块加上两个“管理数字”。很多初学者会问,为什么不能用定长数组直接实现?能,但定长意味着你事先必须知道最多存多少数据,这在真实项目里几乎不可能。所以顺序表的设计一定是“可扩容的数组”,这就引出了capacity和size的分工。
2.2 容量和长度:两个最容易混的概念
我见过太多初学写插入时写出 data[size++] = x 结果越界的代码,根子就是把capacity和size搞混了。
capacity是底层数组总共能装多少个元素,它是“内存层面”的概念。size是当前实际存了多少个元素,它是“逻辑层面”的概念。两者之间是 size <= capacity 的关系,当size追上capacity,说明数组满了,需要扩容。
为什么要多维护一个capacity?因为扩容是有代价的,每次扩容都要申请新内存并搬移全部元素。预先把容量多留一些,就是用空间换时间。这个思想跟你以后学HashMap扩容因子是相通的,本质都是在“省空间”和“省时间”之间找平衡。
面试里经常问“ArrayList默认容量为什么是10”,其实没有一个数学上的唯一答案,但你要明白:容量太小会导致频繁扩容,太大浪费内存,10是JDK早期版本反复权衡后的经验值。理解了这一点,就算面试官换个数字问你,你也能答出背后的考量逻辑。
2.3 初始化函数的边界处理
初始化代码本身很简单,但有两个边界必须处理。第一个是负数容量:数组长度不可能是负数,虽然Java会在运行时抛 NegativeArraySizeException,但那个错误信息对调用者不够友好。在构造器里主动拦截并抛出 IllegalArgumentException,属于防御式编程,能让人第一时间定位问题。
第二个边界是容量为0的情况。new Object[0] 是合法的,但接下来第一次插入就会触发扩容。这不是bug,只是你要清楚这个行为,别在文档里写成“传0就代表默认容量”之类的错误注释。
还有一个常被忽略的点:无参构造里 data = new Object[DEFAULT_CAPACITY] 之后,不要顺手把size设成和capacity一样大。size初始一定是0,因为数组里还没有真正放入元素。这句话看起来是废话,但我review代码时真的见过有人把逻辑写反,导致一上来就以为表是满的,插入直接触发扩容,白白浪费一次内存分配。
3. 插入和删除:数组搬移的艺术
3.1 插入操作的完整实现
插入是顺序表里最体现“搬移”思想的操作。假设数组里已经有 [a, b, c, d],要在下标1的位置插入 x,得到 [a, x, b, c, d]。你必须先把 d 挪到下标4,c 挪到下标3,b 挪到下标2,给下标1腾出空位。这就是“从后往前”移动。
直接从前往后挪行不行?不行。如果先把 b 挪到下标2,b 就把后面还没搬的 c 覆盖了,数据直接丢失。唯一的正确顺序是先搬最后一个,再依次向前,代码如下:
java复制public void add(int index, E element) {
rangeCheckForAdd(index); // index ∈ [0, size]
ensureCapacity(size + 1); // 确保容量足够
for (int i = size; i > index; i--) {
data[i] = data[i - 1]; // 从后往前搬
}
data[index] = element;
size++;
}
注意 rangeCheckForAdd 的合法范围是 0 <= index <= size,也就是说可以插到末尾(下标为size),但不能插到size之外。这是插入操作最容易被写错的边界:写成 index > size 会漏掉“末尾插入”,写成 index >= size 更离谱,直接把末尾插入也ban了。
这里我再补一个工程细节:当 index 正好等于size时,for循环的搬移步数n = 0,也就是说只需要把新元素写到 data[size] 再执行 size++,这其实就是 add(E element) 的默认行为。很多源码会把这种“追加”单独做优化,目的就是省掉一次 rangeCheck 和函数调用开销,数据量大时积少成多。
3.2 删除操作与内存回收
删除比插入稍微轻松一点,但要特别注意Java的内存回收。删除下标1的元素,需要把后面的元素往前挪:b 挪到下标1,c 挪到下标2,d 挪到下标3,然后把下标4处置空。这就是“从前往后”移动:
java复制public E remove(int index) {
rangeCheck(index); // index ∈ [0, size - 1]
E oldValue = elementData(index);
int numMoved = size - index - 1;
if (numMoved > 0) {
System.arraycopy(data, index + 1, data, index, numMoved);
}
data[--size] = null; // 关键:最后一个位置置null
return oldValue;
}
这里的 data[--size] = null 是很多人会漏掉的一行。数组里还存着那个“已经被删除”的对象的引用,如果不置null,这个对象永远不会被GC回收,长此以往就是内存泄漏。对基本类型数组这行倒是无所谓,但Java里存的是对象引用,必须手动断开。
我在生产环境里排查过一个内存占用持续上涨的问题,最后定位到自定义数组容器删除元素后没有置null,导致大量废对象被底层数组引用着。这个坑,教科书里一般不写,但真实项目里很容易踩。如果你用C语言实现,对应的教训就是删除后不用管,但扩容缩容时要记得 free 旧内存,否则就是另一种资源泄漏。
3.3 位置参数的设计哲学:从0开始还是从1开始
如果你看过严蔚敏的《数据结构》C语言版,会发现教材里的顺序表位置是从1开始的,第一个元素是 L.elem[1]。但绝大多数编程语言的数组下标是从0开始的,这个差异害人不浅。
教材用1开始是因为“逻辑位序”更符合人类的直觉,考试写伪代码也方便。但工程实现必须跟语言走,你用Java就老老实实从0开始。问题是很多初学者写代码时,脑子里还停留在教材的“第i个位置”,导致插入下标偏移一位,删错元素。
我的建议是:底层数据结构一律用语言原生的下标语义,0表示第一个元素。如果业务层确实需要“第1个位置”这种语义,就在上层做换算,底层不要迁就。否则你会在每个函数的边界判断里陷入 index - 1 和 index + 1 的泥潭,改一个bug引出三个新bug。
4. 动态扩容与缩容:让顺序表真正“活”起来
4.1 扩容时机与扩容因子的选择
固定容量的顺序表只能算“半成品”,真正能用的顺序表必须支持动态扩容。扩容的触发时机非常明确:插入前发现 size == data.length,数组满了。
扩容的核心问题是“一次扩多少”。最简单的方案是固定加N个位置,比如每次加10个。这种策略的均摊性能很糟糕:假设容量从100扩到1000,中间要发生90次扩容,每次都要复制整个数组,总复制次数接近O(n^2),数据量一大就卡成PPT。
更好的方案是倍增扩容,容量不够就乘以2。为什么倍增好?因为扩容次数变少了,而且每次复制的元素总量是“旧容量×2”,均摊下来每个元素平均只被复制两三次。具体推导:从容量1开始连续插入n个元素,扩容时复制的总元素个数是 1 + 2 + 4 + ... + n/2 = n - 1,均摊到n次插入,每次插入的额外成本是O(1)。这就是“均摊常数复杂度”——最坏情况下单次插入确实要复制n个元素,但把时间拉长看,绝大多数插入都没有复制。
java复制private void ensureCapacity(int minCapacity) {
if (minCapacity > data.length) {
int newCapacity = data.length + (data.length >> 1); // 1.5倍
if (newCapacity < minCapacity) {
newCapacity = minCapacity;
}
data = Arrays.copyOf(data, newCapacity);
}
}
4.2 缩容的陷阱与策略
扩容讲完,缩容是另一半问题。删除大量元素后,底层数组的容量仍然很大,内存里可能空着一大半,这时候要不要缩容?
最极端的场景:你往表里插了100万个元素,然后删到只剩1个。如果不缩容,这100万个元素的存储空间就白白占着。Java的ArrayList选择不自动缩容,而是提供 trimToSize() 让开发者手动触发。C++的vector在 erase 时也不强制缩容,但是可以用 swap 手法强制释放多余容量。
缩容最大的陷阱是“抖动”:如果缩容阈值和扩容阈值太接近,程序可能反复扩容、缩容、再扩容、再缩容,性能断崖式下跌。比如size降到7就缩容到10,size涨到10又扩容到20,这种反复横跳比不缩容还可怕。
一个合理的缩容策略是“延迟缩容”:只有当size降到容量的1/4以下才缩容,而且只缩到1/2。这样缩容后还剩一半余量,不会很快再次触发扩容。跟HashMap加载因子设0.75是同一个道理——给未来留缓冲,而不是把资源卡在临界点上。
4.3 工业级实现:JDK ArrayList的扩容源码
自己实现之后,强烈建议你打开JDK源码看一遍 ArrayList.grow 的实现。它没有用2倍扩容,而是 int newCapacity = oldCapacity + (oldCapacity >> 1);,即1.5倍。
为什么是1.5倍而不是2倍?一个重要的考量是内存碎片和空间浪费。2倍扩容时,新容量恰好是旧容量的两倍,如果你反复扩容,旧的数组空间在内存里会留下很多“刚好差一点”的碎片,分配器不好利用。1.5倍让每次扩容增长得更平滑,同时也能保证均摊复杂度和2倍同级别。
还有一个细节经常被忽略:当 oldCapacity 很大时,oldCapacity + (oldCapacity >> 1) 可能溢出为负数。JDK里专门做了兜底,如果 newCapacity 超过最大数组大小,就调用 hugeCapacity 处理。这就是生产级代码和练习代码的区别——逻辑写通只是及格线,极端情况不崩才是优秀。
5. 查找、遍历与边界用例:正确性藏在细节里
5.1 按值查找与二分查找的适用条件
顺序表按下标访问是O(1),这是它对比链表的王牌。但按值查找是另一个故事:如果数据无序,你只能从头到尾遍历,时间复杂度O(n)。只有当所有元素有序时,才能用二分查找把复杂度降到O(logn)。
这里有一个很多人忽略的“为什么”:二分查找为什么必须以顺序表为基础?因为二分查找需要按下标快速跳到中间位置。数组里这次跳跃是O(1),链表里你需要从头走过去,时间复杂度直接退化成O(n),二分就失去了意义。所以你能在顺序表上用二分,本质上是“随机访问”能力给的。
实现二分查找时还有一个经典坑:mid = (low + high) / 2 在low和high都很大时会溢出成负数。正确写法是 mid = low + (high - low) / 2。这个问题在LeetCode的二分题里反复出现,但很多人不知道它最早的数据结构场景就是顺序表。
5.2 边界测试用例清单
写完顺序表,别急着提交,先拿下面这组用例过一遍。这个清单也是我日常review数据结构代码时的检查表:
| 用例 | 期望行为 | 容易踩的坑 |
|---|---|---|
| 空表调用get(0) | 抛IndexOutOfBoundsException | 返回null或越界读取 |
| 空表调用remove(0) | 抛IndexOutOfBoundsException | 数组越界 |
| 在下标size处插入 | 插入成功,追加到尾 | 误判为非法位置 |
| 在下标0处插入 | 全部元素后移一位 | 覆盖了原data[0] |
| 删除最后一个元素 | 只移动0个元素 | 误把data[size]也处理 |
| 扩容后立即插入 | 旧数据完整迁移 | 复制时用了capacity而不是size |
| 连续插入超过2倍容量 | 多次扩容后数据不乱 | 扩容因子写死导致死循环 |
我特别提醒“扩容后插入”这条。很多人写 grow 方法时,把复制范围写成 for (int i = 0; i < data.length; i++),这在扩容前的语境里没问题,但如果中途删过元素,size和capacity有差距,把空位也复制过去就会把垃圾数据带进新数组。正确做法是复制 size 个元素,而不是 capacity 个。
5.3 遍历时删除元素的大坑
一个非常经典的生产场景:在for循环里按条件删除元素。
java复制for (int i = 0; i < size; i++) {
if (条件) {
remove(i); // 错误示范
}
}
问题在于:删除后所有后面的元素往前挪了一位,但循环变量i还是照常+1,于是紧跟在被删元素后面的那个元素被跳过了。比如数组 [a, b, c],删除下标0的a后变成 [b, c],下一次循环i=1,直接指向c,b被漏掉了。
三个正确的解法:
- 倒序遍历删除:
for (int i = size - 1; i >= 0; i--)。因为删除下标i只会影响它之后的元素,而你已经遍历过那些位置了,所以倒序天然安全。 - 使用Iterator,调用
iterator.remove()。Java的ArrayList迭代器内部维护了cursor和lastRet,删除时会同步调整游标,不会让元素跳过。 - 先收集要删除的下标,统一从大到小删除。
这里多说一句为什么直接调 remove(i) 和迭代器 remove() 行为不一样:迭代器会同步修改内部游标位置,而裸循环里手动维护的i不会自动感知数组变化。理解了机制,你就明白为什么所有Java规范都建议“在foreach里不要直接remove”。
6. 顺序表和链表的取舍:别被“增删快”带偏了
6.1 时间复杂度对比与真实场景
学习顺序表时,链表总被拿来对比。有个流传很广的说法“链表增删快,顺序表查询快”,这句话只对了一半,而且很容易误导新手。
| 操作 | 顺序表 | 链表 |
|---|---|---|
| 按下标访问 | O(1) | O(n) |
| 按值查找 | O(n) | O(n) |
| 已知位置插入/删除 | O(n) 需要搬移 | O(1) 改指针 |
| 先查找再插入/删除 | O(n) | O(n) |
注意表格第三行有个隐藏条件:链表的O(1)指的是你手里已经握着前驱节点的指针。真实业务里,绝大多数时候你只有“要找的元素的值”,根本不知道它的前驱在哪,你得先花O(n)把它找出来。这时候链表的增删优势就消失了。
所以更准确的说法是:如果程序核心操作是“按下标访问”,顺序表完胜;如果核心操作是“频繁在头部插入且只需要顺序遍历”,链表有结构性优势;如果是按值查找后再删除,两者都是O(n),这时候要考虑的是别的因素,比如内存占用、缓存表现、编码复杂度。
6.2 缓存友好性:一个常被忽略的维度
这个维度在教科书的时间复杂度分析里看不出来,却对真实性能影响巨大——CPU缓存。
数组是连续内存,遍历时CPU会自动预取后续数据到高速缓存,元素挨个读,几乎全是缓存命中。链表节点分散在堆里,每个节点在内存地址上毫无关系,CPU每次访问大概率缓存未命中,需要去主存取数据,延迟比缓存命中高一个数量级。
我之前做过一个测试:同样存储10万个整数,按顺序遍历,顺序表比链表快了几十倍。这不是理论推导,而是缓存命中率带来的现实差异。所以哪怕时间复杂度上两者都是O(n),实际跑起来顺序表的优势比纸面大得多。这也是为什么Java的LinkedList在绝大多数场景下都打不过ArrayList,很多老程序员干脆说“LinkedList能不用就不用”。
6.3 实际项目中的选择经验
最后聊一点真实干活时的取舍经验。我给自己的决策思路大致是:
- 数据量小(几百以内),无脑用顺序表,操作简单不折腾。
- 需要随机访问,比如按索引取第N个任务,顺序表是唯一合理选择。
- 业务场景天然是队列或栈,只用头部/尾部操作,顺序表也能做得很好,不一定非要链表。
- 大量插入删除且集中在链表头部,同时不需要随机访问,链表才有明确优势。
- 内存极其敏感,元素又大又少,链表的节点引用开销和内存碎片非常不值得。
真实项目里的案例也印证了这一点。我之前做一个LRU缓存模块时,反而选了 LinkedHashMap,因为它既需要快速查找又需要维护访问顺序,这是“有序哈希表”的活,跟顺序表/链表二选一完全不是一回事。认清数据结构的适用边界,比背熟时间复杂度表重要得多。
回到顺序表本身,这一篇把它从结构定义到扩容缩容、边界测试完整过了一遍,核心操作都有可直接复制的Java实现。趁热打铁,把底层的 ensureCapacity、rangeCheck 这些工具方法补齐,跑通我上面列的边界用例,你会发现后面学链表、栈、队列时轻松一大截。下一期我会把链表自己也实现一遍,再拿这两个“活体”做一轮真实性能对比,到时候很多直觉和误区都会被数据重新校正。
