1. 为什么说顺序表是“最朴素的线性表”
先问一个扎心的问题:让你手写一个顺序表,不查资料、不百度,你能在多长时间内写出一个像样的插入、删除、查找方法?我拿这个问题在企业面试里问过不少候选人,结果很有意思——嘴上说“顺序表很简单”的人,有一半会在扩容条件和索引边界上翻车。所以我想站在一个写过很多遍顺序表、也看过大量面试答案的开发者角度,把顺序表的定义讲透,把各种方法从设计思路到代码实现完整梳理一遍。这篇文章不只是给零基础读者讲概念,更希望让已经会写的人重新把细节抠清楚。
顺序表,通俗点说就是“用一块连续的内存空间,存储一组逻辑上相邻的元素”。数据结构的教科书里有一句经典表述:线性表的顺序存储结构,就是顺序表(sequential list)。这句话里有两个关键词,“连续”和“相邻”。逻辑上相邻,说的是元素之间有明确的前后顺序关系;物理上连续,说的是它们在内存里的地址是紧挨着的。正是因为这种“逻辑顺序 = 物理顺序”的高度一致,顺序表才成为线性表里最直接、最好理解的一种实现。
很多初学者容易把顺序表和数组当成同一个东西。严格讲,数组是编程语言提供的底层存储机制,顺序表是基于数组或其他连续存储区封装出来的抽象数据结构。区别在于:数组只管一段连续空间,顺序表在空间之上定义了容量、长度、增删改查方法和扩容策略这些“规矩”。你可以把数组类比成一块空宅基地,顺序表是在宅基地上盖好的一栋带水电、带装修的房子。直接操作数组很容易越界,而顺序表把边界检查和扩容都封装成规则,调用者用起来更安心。
这也是为什么每个学 Java 的人都应该亲手实现一遍顺序表。不是因为面试可能会考,而是因为你在实现的过程中,会真正理解“数据结构的本质就是操作规则的集合”。后面我会用 Java 来写,代码不偷懒、不省略边界处理,把顺序表内部那点事儿一次性摊开。
1.1 存储结构:连续空间上的逻辑串联
先看一个最简单的顺序表长什么样。假设我们存了 10 个整型元素,在内存中的示意图可以理解为下面这样:
text复制内存地址: 0x100 0x104 0x108 0x10C 0x110 0x114 0x118 ...
元素: 3 8 19 22 7 45 11 ...
下标: 0 1 2 3 4 5 6 ...
每个元素占用的空间大小固定,所以只要知道首地址和第 i 个元素的位置,就能用“首地址 + i × 单元素大小”直接算出第 i 个元素的地址。这个公式是顺序表“随机存取”能力的基础。数组按下标取元素为什么是 O(1),就是因为 CPU 直接通过地址计算定位,不需要从头遍历。
不过在 Java 里有个细微差别:Java 数组存的是对象引用,每个引用占 4 或 8 个字节,而对象实体不一定连续存放。也就是说,Java 数组中那段“引用空间”是连续的,但引用指向的对象实体不要求在内存里紧挨着。这并不影响我们用数组去模拟顺序表的逻辑。真要追求“所有字段完全连续”,那是 C 语言的数组和结构体更贴近,但 Java 实现的顺序表在逻辑层面上完全一致,面试和通用写法上也更清晰。
1.2 顺序表要解决的核心问题
顺序表存在的主要目的,是把一组数据组织成可以高效管理的样子。它要解决三个问题:第一,如何记录“当前有多少元素”;第二,如何做到“按下标快速取任意元素”;第三,如何在不越界的前提下安全地插入和删除。这三个问题分别对应顺序表里的三个核心要素——长度字段、数组载体、边界检查和扩容机制。
围绕这三个问题,一套完整的顺序表设计通常包含这些方法:
- size():返回当前元素个数
- isEmpty():判断表是否为空
- get(int index):按下标取元素
- set(int index, T data):按下标修改元素
- add(T data):尾部追加元素
- add(int index, T data):指定位置插入元素
- remove(int index):按下标删除元素
- indexOf(T data):查找指定元素第一次出现的位置
- contains(T data):判断是否包含某个元素
- clear():清空所有元素
这些方法听起来都很常规,但每一个都有值得抠细节的地方。接下来逐个说。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 写代码前必须想明白的三个边界问题
在敲第一行代码之前,我建议先想清楚容量、长度、扩容、泛型这几个问题。很多人写出来的顺序表运行没问题,数据一多就崩,问题基本都出在这几个地方。
2.1 容量和长度:两个常年被混淆的概念
容量,英文叫 capacity,指底层数组一共给你准备了多少个位置;长度,英文叫 size,指当前真正存了几个元素。比如你 new 了一个容量为 10 的数组,但只放了 3 个元素,此时容量是 10,长度是 3。数组的 length 属性对应容量,顺序表的 size 字段对应长度,两者千万不要画等号。
插入数据时,先判断“size + 1”是否超过容量;取数据时,先判断 index 是否小于 size。这两个判断用的是不同的量,混用了轻则浪费空间,重则数组越界,属于顺序表实现里最高频的 bug 来源。
java复制// 错误思路:用数组的 length 判断能存多少
if (size < data.length) { // 这里看似没问题,但后续取元素时用 data.length 判断就会炸
data[size++] = element;
}
// 取元素时的错误写法:没有用 size 做边界判断
return data[index]; // 如果 index 很大但 size 很小,会读到 null 或越界
正确的做法是单独维护一个 size 字段,每次增删都同步更新。这个字段就是顺序表的“灵魂计数器”。
2.2 扩容策略:一步一步扩还是翻倍扩?
当元素数量到达底层数组容量上限时,顺序表必须扩容。Java 的 ArrayList 在扩容时,新容量大约是旧容量的 1.5 倍,核心计算可以简化为:
java复制int newCapacity = oldCapacity + (oldCapacity >> 1);
有人会觉得,容量不够了,直接 new 一个“旧容量 + 1”的数组不就行了?能跑,但性能很差。假设你持续向表里添加 100 万个元素,每加一个都扩容一次,累计复制元素的次数会达到 O(n^2)。用翻倍或 1.5 倍扩容,均摊下来每个元素的复制成本是 O(1),这就是动态数组的“均摊复杂度”思想。
具体扩容流程分三步:算新容量、创建新数组、把旧元素搬过去。Java 里可以直接用 Arrays.copyOf:
java复制private void ensureCapacity(int minCapacity) {
if (minCapacity <= data.length) {
return; // 容量足够,不需要扩容
}
int newCapacity = data.length + (data.length >> 1);
if (newCapacity < minCapacity) {
newCapacity = minCapacity;
}
if (newCapacity > Integer.MAX_VALUE - 8) {
newCapacity = Integer.MAX_VALUE - 8; // 防止整数溢出
}
data = Arrays.copyOf(data, newCapacity);
}
注意最后那个 Integer.MAX_VALUE - 8,这是很多教材里不会教你的细节。某些 JVM 的数组对象头会占用一点空间,数组最大容量是 MAX_VALUE - 8,如果继续往上扩,可能出现 OutOfMemoryError。实际业务里几乎不可能用到那么大的顺序表,但写代码时保留这个边界,能体现你对 JVM 底层存储的基本认知。
2.3 泛型和数组的冲突:Java 实现躲不开的尴尬
用 Java 写顺序表,最尴尬的一点就是“泛型数组不能直接创建”。下面这行代码是会编译报错的:
java复制data = new T[DEFAULT_CAPACITY]; // 编译错误
原因是 JVM 在运行时会对泛型进行类型擦除,运行时并不知道 T 到底是什么类型,所以没法直接创建泛型数组。惯例的解决办法是创建一个 Object 数组,取出元素时做强转:
java复制private Object[] data;
private int size;
取元素时:
java复制@SuppressWarnings("unchecked")
public T get(int index) {
checkIndex(index);
return (T) data[index];
}
强转看起来不太优雅,但配合泛型在编译期的类型检查,实际使用时是安全的。因为 add 方法里已经限定了只能传入 T 类型的参数,存进去的元素必然是 T 的实例。JDK 源码里也是这个套路,ArrayList 内部就是 Object[] elementData,只是加了 @SuppressWarnings 抑制警告。
3. 方法逐个拆解:增删改查背后的索引博弈
代码骨架直接给出一份,后续方法都基于这份实现。
java复制public class SeqList<T> {
// 存放元素的底层数组,用 Object 数组规避泛型数组创建问题
private Object[] data;
// 当前元素个数,注意与容量的区别
private int size;
// 默认容量
private static final int DEFAULT_CAPACITY = 10;
public SeqList() {
data = new Object[DEFAULT_CAPACITY];
}
public SeqList(int capacity) {
if (capacity < 0) {
throw new IllegalArgumentException("容量不能为负数: " + capacity);
}
data = new Object[capacity];
}
// 其他方法在这里实现
}
这里默认容量给 10,和 ArrayList 的默认容量一致。如果你明确知道数据量会很大,最好在构造时就指定一个合适的容量,避免反复扩容。举个例子:要往表里存 100 万条数据,如果初始容量是 10,扩容几十次,每次都涉及数组复制;如果初始容量直接给 100 万,一次扩容都不需要。这是最简单的性能优化,也是很多人容易忽略的点。
3.1 add 方法:尾部追加和指定位置插入的完整逻辑
尾部追加是最常用的操作,但不要单独写一套复杂逻辑,直接调用“指定位置插入”会更省事:
java复制public boolean add(T element) {
add(size, element);
return true;
}
public void add(int index, T element) {
// index 范围为 [0, size],等于 size 表示在末尾追加
if (index < 0 || index > size) {
throw new IndexOutOfBoundsException("插入位置非法: " + index + ", size: " + size);
}
// 确保容量足够
ensureCapacity(size + 1);
// 把从 index 开始的旧元素整体往后挪一位
System.arraycopy(data, index, data, index + 1, size - index);
// 放入新元素
data[index] = element;
size++;
}
这里的核心是 System.arraycopy。很多人第一次接触时会想当然地写一个 for 循环从后往前复制,结果要么把元素覆盖了,要么数组越界。System.arraycopy 是 JDK 提供的本地方法,底层有内存复制优化,比手写循环快很多。而且当源地址和目标地址是同一个数组、并且区间有重叠时,JVM 会保证复制结果正确,相当于内部先做了临时缓冲。所以用这个方法做“插入位置后移”是最稳妥的。
注意:add(int index, T element) 允许 index == size,表示在尾部追加空位;但 remove、get 系列的 index 只能小于 size,不能等于 size。这是插入和删除在边界条件上最大的不同。
3.2 remove 方法:元素前移和置空的艺术
删除元素的核心逻辑和插入相反,把 index 后面的元素整体往前挪一位,然后把最后一个位置置为 null,让 GC 有机会回收:
java复制public T remove(int index) {
// index 必须小于 size
checkIndex(index);
T oldValue = (T) data[index];
int numMoved = size - index - 1;
if (numMoved > 0) {
System.arraycopy(data, index + 1, data, index, numMoved);
}
data[--size] = null; // 递减 size 并置空最后一个位置
return oldValue;
}
看最后一行的写法:data[--size] = null,先让 size 减 1,再把原来的最后一个位置置空。如果忘了置空,会出现“内存泄漏”隐患。想象一个场景:你向顺序表 add 了一个很大的对象,然后 remove 了它,但底层数组里那个位置还保留着引用,JVM 不会回收它,因为数组本身仍然可达。ArrayList 的源码里删除元素后有类似处理,目的就是解除引用、避免无用对象长期驻留内存。
3.3 get/set 方法:按下标访问的快感与代价
get 和 set 相对简单,但边界检查不能少:
java复制public T get(int index) {
checkIndex(index);
return (T) data[index];
}
public T set(int index, T element) {
checkIndex(index);
T oldValue = (T) data[index];
data[index] = element;
return oldValue;
}
private void checkIndex(int index) {
if (index < 0 || index >= size) {
throw new IndexOutOfBoundsException("下标越界: index=" + index + ", size=" + size);
}
}
get 之所以能 O(1) 拿到元素,靠的是数组连续存储和地址计算。这也是顺序表最大的优势。你可以把它类比成小区单元楼的房号:只要告诉我楼层和门牌号,我就能直接走到那扇门前,不用挨家挨户问过去。
3.4 indexOf 和 contains:查找方法里的相等性细节
查找方法需要区分要查的元素是否为 null,两种情况的判断条件不同。null 用 == 比较,非 null 用 equals 比较:
java复制public int indexOf(T element) {
if (element == null) {
// 查找 null 元素
for (int i = 0; i < size; i++) {
if (data[i] == null) {
return i;
}
}
} else {
for (int i = 0; i < size; i++) {
if (element.equals(data[i])) {
return i;
}
}
}
return -1;
}
public boolean contains(T element) {
return indexOf(element) >= 0;
}
这段代码里有一个容易被忽略的经验:比较对象时优先调用参数 element 的 equals,而不是 data[i].equals(element)。因为 element 可能为 null,null 调用任何方法都会抛 NullPointerException。先判 null,再按分支处理,是处理这类问题最稳妥的姿势。
另外,equals 还有一个潜在的大坑:如果元素类型的 equals 方法写得很重,比如每次比较都涉及大量字段,甚至触发了数据库查询,那么 indexOf 的时间复杂度就不仅仅是 O(n) 了,实际用时也会大得离谱。所以在分析复杂度和设计算法时,要把“比较代价”也考虑进去。
3.5 其他辅助方法:clear、size、isEmpty 的收尾
最后把辅助方法补齐:
java复制public int size() {
return size;
}
public boolean isEmpty() {
return size == 0;
}
public void clear() {
for (int i = 0; i < size; i++) {
data[i] = null;
}
size = 0;
}
clear 方法如果只把 size 置为 0,底层数组仍然持有每个元素的引用。如果这个顺序表是长期存活对象,里面又存的是大对象,那这些对象就一直不会被回收,相当于变相内存泄漏。正确做法是先把数组每个位置置空,再重置 size。这也是很多初学者写完代码后内存占用居高不下的原因之一。
写完后可以用一个简单 main 方法测一下:
java复制public static void main(String[] args) {
SeqList<String> list = new SeqList<>(4);
list.add("Java");
list.add("Python");
list.add(1, "C++");
list.set(2, "Go");
System.out.println(list.get(1)); // C++
System.out.println(list.indexOf("Go")); // 2
System.out.println(list.remove(0)); // Java
System.out.println(list.size()); // 2
list.clear();
System.out.println(list.isEmpty()); // true
}
这段测试覆盖了尾部追加、指定位置插入、修改、按值查找、按下标删除、清空等核心操作,能跑通基本说明主流程没有问题。
4. 复杂度分析:它到底快在哪里、又慢在哪里
很多人背得住“顺序表随机存取 O(1)、插入删除 O(n)”,但真被问到“为什么”时就开始含糊。我习惯把一个操作拆成“定位”和“移动”两个动作来分析。
4.1 按下标读取和按值查找:不要把两类查找混为一谈
因为底层是数组,读下标 i 的公式是“元素地址 = 基地址 + i × 元素大小”,这个计算是常数时间,和表里有多少元素无关。所以 get、set 方法的时间复杂度都是 O(1),这也是顺序表被称为“随机存取结构”的原因。
但“查找”要区分两种:按下标查找是 O(1),按值查找是 O(n)。按下标我们拿到的是“第几个位置”,但具体这个位置存了什么,我们不需要比较就能读出来。按值查找则是从头到尾遍历,拿数组里的元素和目标值逐个比较,最坏情况要找遍所有元素。面试时如果说“顺序表查找是 O(1)”,很容易被懂行的人判断为概念不清。
4.2 插入和删除为什么慢:元素搬家的开销
在顺序表中间插入元素时,第一步是定位,这个只需要 O(1);第二步是把插入位置后面的所有元素依次往后挪,这一步是 O(n)。删除同理,前移元素是 O(n)。如果从尾部插入或删除,需要移动的元素数量是 0,此时时间复杂度退化成 O(1)。
这个结论能衍生出一个实用技巧:如果某个业务特别依赖“尾部追加元素”,顺序表会很合适;如果任务经常在头部插入,顺序表的劣势就很明显。真实项目里常见的“日志追加”、“结果收集”几乎都是尾部操作,所以 ArrayList 能扛住很高的写入量。
4.3 扩容的均摊复杂度:别被单次 O(n) 吓到
动态扩容的代价很容易被高估。单次扩容确实要复制 n 个元素,是 O(n),但扩容不是每次插入都发生的。假设从容量 1 开始,每次扩容翻倍,那么插入到容量为 m 时,累计的复制次数大约是 1 + 2 + 4 + ... + m/2,这个级数和约为 m。也就是说,向空顺序表连续插入 m 个元素的总代价是 O(m),平均到每个插入就是 O(1)。
这就是“均摊 O(1)”的含义。1.5 倍扩容同理,只是常数会略大一些,但不像翻倍扩容那样让容量在三五步之内跳得非常夸张。某些场景下翻倍扩容能更快腾出空间,但可能会立刻出现大量内存空闲;1.5 倍扩容相对保守,实现了时间和空间的折中。JDK 选择 1.5 倍不是随便拍的,而是经过大量实际场景测出来的一个平衡值。
把主要操作的时间复杂度整理成一个表,方便对照记忆:
| 操作 | 时间复杂度 | 说明 |
|---|---|---|
| get(i) | O(1) | 通过地址直接计算 |
| set(i, e) | O(1) | 通过地址直接计算 |
| 尾部 add | 均摊 O(1) | 不触发扩容时 O(1),扩容时 O(n),均摊后很低 |
| 中间 add(i, e) | O(n) | 需要把 index 后面的元素整体后移 |
| 头部 add | O(n) | 所有元素整体后移 |
| 按值查找 indexOf | O(n) | 需要遍历比较 |
| 删除尾部 | O(1) | 不需要移动 |
| 删除中间或头部 | O(n) | 需要把后面的元素整体前移 |
这张表最好能自己推导出来,而不是死记。推导多了你会发现,数据结构的复杂度分析其实有一个通用套路:先看是否移动元素,再看移动多少个。
5. 顺序表和链表的正面较量:怎么选才不亏
“顺序表和链表”是数据结构面试里的高频对比题。虽然本篇主角是顺序表,但如果不和链表放在一起讲,很容易让人产生“顺序表不如链表高级”的错误印象。我的结论是:在绝大多数真实业务场景里,顺序表是对应 Java 里 ArrayList 的更常用选择,链表在很多场景里反而会拖慢性能。
5.1 存储结构与缓存友好性的差异
链表的核心优势是“逻辑相邻、物理不必相邻”,每个节点单独分配内存,节点之间用指针或引用连接。这意味着链表的插入和删除在已知节点位置的情况下,只需要改指针,不需要移动元素。顺序表则需要移动元素。
但链表有一个隐藏成本:节点分散在内存里,访问时会频繁发生指针跳转。现代 CPU 有缓存行机制,连续内存访问时缓存命中率很高;链表这种“东一个节点西一个节点”的访问方式很难利用缓存预取机制。用一句稍微夸张的话来说,顺序表是“按顺序读一排连续座位上的观众”,链表是“在不同楼层、不同房间之间来回串门”。数据量一大,顺序表的实际访问速度常常远好于链表。
5.2 内存占用与扩容比较
顺序表会在容量未满时预留空间,这会造成一定的空间浪费,但换来的是访问速度;链表每个节点除了存储数据,还要额外存一个或两个引用字段。Java 的 LinkedList 是双向链表,每个节点有两个引用,额外开销至少 16 字节。顺序表的空间浪费来自容量和长度之间的差距,但通过合理设置初始容量,通常可以把这个差距控制得很小。
扩容方面,ArrayList 会自动扩容,不需要手动管理指针;LinkedList 没有扩容的概念,只要有内存就能加节点。
