我第一次认真研究“顺序表”,其实是吃了面试的亏。对方问“ArrayList 的 add(int index, E element) 底层到底做了什么”,我张口就想说“就移动一下元素嘛”,结果追问到扩容系数、System.arraycopy 为什么快、头插尾插复杂度,直接露馅。这些年带过不少实习生,发现大家普遍卡在同一个地方:数组用得很熟,但一旦涉及“顺序表”这个概念、各种方法的底层实现,就又开始发虚。这篇就好好聊透顺序表的定义和常用方法,顺便把索引表的顺序查找、顺序表和链表的选型这些高频考点一并讲清楚,适合刚学数据结构的人,也适合要面 Java 后端岗位的同学拿来当复习索引。
1. 顺序表定义:本质还是一块连续内存,关键看你怎么用
1.1 线性表、顺序存储两个词拆开看
数据结构教材给的定义很简洁:顺序表(Sequential List)是线性表的一种存储实现方式,用一组地址连续的存储单元依次存放线性表中的数据元素。这句话里真正要紧的是两个限定词——线性表、顺序存储。
线性表强调元素之间是一对一的前后关系,每个元素(除了头尾)有且只有一个直接前驱和一个直接后继。这个逻辑关系必须被完整保留下来。顺序存储则决定了物理实现:元素在内存里一个挨着一个排,第 i 个元素的存储位置,可以由起始地址直接算出来:Loc(ai) = Loc(a0) + i * sizeof(E)。这个公式看着简单,但它就是顺序表一切特性的根源——随机访问是 O(1),插入删除是 O(n),全由它决定。
我当年听老师用储物柜打比方,到现在还觉得特别贴切:顺序表就是一条标好编号的连续储物柜,你想取第 5 个格子里的东西,直接走过去就行,不用从头数;但如果你要在第 3 格和第 4 格之间硬塞一件东西,对不起,后面所有东西都得挨个挪一挪。挪一次无所谓,要是数据量到了百万级还频繁中间插入,那性能就是灾难。
1.2 顺序表和普通数组到底差在哪
很多人会脱口而出:顺序表不就是数组吗?这句话只对了一半。数组是语言层面给你的连续内存语法,而顺序表是基于数组这种存储结构实现出来的一种抽象数据类型。换句话说,数组是“原材料”,顺序表是“加工后的产品”。
普通数组的硬伤有三点:第一,长度固定,装满了只能自己 new 一个更大的数组,然后手动拷贝旧数据,这个逻辑每用一次就要重写一遍;第二,插入和删除时元素搬移的循环,散落在业务代码里,又丑又容易越界;第三,没有任何边界保护,arr[len] 一越界,程序直接崩给你看。
顺序表把这些脏活全部封装进 add、remove、get 这些方法内部,外面的人只关心“往哪里插、删哪个、取哪个”,不需要操心底层空间怎么腾。Java 里的 ArrayList 就是这个思路最成熟的产品级实现。所以面试如果只回答“顺序表就是数组”,基本会被判定为理解停留在表面;准确的说法是:顺序表是用连续存储的方式实现线性表,数组只是它的地基。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 手写顺序表:把各种方法逐行拆开讲
2.1 类框架:泛型、数组、size 三者缺一不可
先上一份完整的简化版实现,后面所有细节都围绕它展开:
java复制public class SeqList<E> {
private Object[] data; // 底层存储数组
private int size; // 当前元素个数
private static final int DEFAULT_CAPACITY = 10;
public SeqList() {
this.data = new Object[DEFAULT_CAPACITY];
this.size = 0;
}
public int size() {
return size;
}
public boolean isEmpty() {
return size == 0;
}
private void checkIndex(int index) {
if (index < 0 || index >= size) {
throw new IndexOutOfBoundsException("index: " + index + ", size: " + size);
}
}
private void grow(int minCapacity) {
int oldCapacity = data.length;
int newCapacity = oldCapacity + (oldCapacity >> 1); // 1.5 倍
if (newCapacity < minCapacity) {
newCapacity = minCapacity;
}
data = Arrays.copyOf(data, newCapacity);
}
// ... 其余方法见下文
}
三个关键点说一下。第一,底层数组声明成 Object[] 而不是 E[],是因为 Java 泛型在运行时会被擦除,直接 new E[10] 编译过不了。取元素时做一次强转即可,这是 JDK 源码里 ArrayList 的经典做法。第二,size 是当前实际元素个数,它和 data.length(容量)是两个概念,所有边界检查都以 size 为准。第三,留一个 DEFAULT_CAPACITY 为 10,是为了避免频繁扩容——每 new 一个顺序表就分配一次大数组,对小数据量场景是浪费。
2.2 add 方法:尾插和中间插入是两套逻辑
尾部添加是最简单的场景:
java复制public void add(E element) {
if (size == data.length) {
grow(size + 1);
}
data[size++] = element;
}
先判断容量是否满了,满了就扩容,然后直接往 size 位置放元素,最后 size 加一。这里有个容易被忽略的坑:必须先把元素放进 data[size],再 size++,顺序不能反。如果先 size++,再用 data[size - 1] = element,也可以,但写完代码要立刻意识到这里是两步,不能贪图合并成 data[size++] 之外的操作顺序。
再看中间插入,这是顺序表最核心的方法:
java复制public void add(int index, E element) {
if (index < 0 || index > size) {
throw new IndexOutOfBoundsException("index: " + index + ", size: " + size);
}
if (size == data.length) {
grow(size + 1);
}
System.arraycopy(data, index, data, index + 1, size - index);
data[index] = element;
size++;
}
注意这里的边界范围和之前的 checkIndex 不一样:允许 index == size,表示插到末尾。搬移的方向必须是从后往前——先把 [index, size-1] 区间整体往后挪一位,腾出空位,再写入新元素。用 System.arraycopy 而不是自己写 for 循环,是因为它是 native 方法,底层可能走内存拷贝指令,性能比逐元素赋值高一个量级,ArrayList 官方实现也是这么干的。
2.3 remove 方法:搬移方向、置空回收一个不能少
java复制public E remove(int index) {
checkIndex(index);
E oldValue = (E) data[index];
int numMoved = size - index - 1;
if (numMoved > 0) {
System.arraycopy(data, index + 1, data, index, numMoved);
}
data[--size] = null; // 让 GC 可以回收
return oldValue;
}
删除是插入的逆操作:把 [index+1, size-1] 的元素整体往前挪一位,覆盖掉被删元素的位置。这里有两个细节值得说。第一个是搬移方向,插入从后往前挪、删除从前往后挪,方向反了就会覆盖掉还没搬的数据,这是手写时最容易踩的坑。第二个是末尾置空:删掉最后一个元素后,data[size] 里还残留着旧引用,如果不置 null,对象就永远无法被 GC 回收,在频繁 add/remove 的场景会造成内存泄漏。JDK 源码里特意写了 elementData[--size] = null,不是画蛇添足。
2.4 get / set / indexOf:查找与修改的分工
访问和修改是顺序表最爽的操作,因为支持随机访问:
java复制public E get(int index) {
checkIndex(index);
return (E) data[index];
}
public E set(int index, E element) {
checkIndex(index);
E oldValue = (E) data[index];
data[index] = element;
return oldValue;
}
public int indexOf(Object o) {
if (o == null) {
for (int i = 0; i < size; i++) {
if (data[i] == null) return i;
}
} else {
for (int i = 0; i < size; i++) {
if (o.equals(data[i])) return i;
}
}
return -1;
}
get 和 set 都是 O(1),这是顺序表对比链表最大的优势,没有之一。indexOf 则是顺序查找,平均 O(n)。写的时候要注意,equals 调用要放在 o.equals(data[i]) 的方向,不能反过来写 data[i].equals(o),否则当 data[i] 为 null 时会抛空指针。这也是 JDK 源码里对 null 单独分支处理的原因——顺序表是允许存 null 的。
2.5 扩容机制:为什么是 1.5 倍而不是 2 倍
扩容这块,很多人只知道“满了就扩容”,但不知道系数设计背后的讲究。ArrayList 的默认扩容是 oldCapacity + (oldCapacity >> 1),也就是 1.5 倍。为什么不用 2 倍?因为扩容的代价是数组拷贝,一次性扩得太大,浪费内存;扩得太小,频繁触发拷贝,摊还成本高。1.5 倍是一个在空间和时间上比较中庸的取值。
还有一个更隐蔽的点:申请新数组的时机。最优策略是“用到满的时候再去扩”,而不是“每次 add 都判断一下要不要扩”这么简单。比如已知要插入 1000 个元素,你可以提前 ensureCapacity(1000),一次性扩容到位,避免中途多次扩容产生的多次数组拷贝。ArrayList 提供了 ensureCapacity 方法,自己实现时也可以加一个:
java复制public void ensureCapacity(int minCapacity) {
if (minCapacity > data.length) {
grow(minCapacity);
}
}
注意:扩容后旧数组会被垃圾回收,但如果你有某个局部变量一直握着旧数组的引用,那部分内存还是回收不掉。写代码时尽量让旧数组引用尽快出作用域。
3. 顺序表和链表怎么选:别被“增删快慢”四个字带偏
3.1 内存布局决定访问方式
链表(LinkedList)和顺序表的根本差异,还是在物理存储结构上。链表每个节点单独分配内存,节点之间用引用串起来,逻辑上连续、物理上不连续;顺序表物理连续,逻辑也连续。这个差异直接决定了两者的访问方式。
顺序表可以通过下标直接算出地址,所以 get(5) 是 O(1)。链表不行,你只能从 head 开始 next 五次才能到第 5 个节点,即使双向链表,也只能从两头开始折半逼近,最坏还是 O(n)。这就是为什么说“顺序表随机访问强,链表随机访问弱”。
3.2 增删性能要看“位置”和“代价”两个维度
网上流行一句话:链表适合增删频繁的场景。这个说法过于笼统,至少误导了一半人。来算一笔账:
- 顺序表在尾部插入:判断容量 + 一次赋值,摊还下来 O(1)。
- 顺序表在头部插入:要搬移 n 个元素,O(n)。
- 链表在头部插入:new 一个节点,改两个引用,O(1)。
- 链表在尾部插入:如果只有头指针,需要遍历到尾部,O(n);如果有尾指针,O(1)。
看出来了吗?链表只有在已知插入位置的场景下增删才是 O(1),比如“在某个节点后面插一个新节点”。现实业务里,你拿到的往往不是“某个节点引用”,而是“第 5 个位置”,那链表要先找位置 O(n),再改引用 O(1),整体还是 O(n)。顺序表虽然搬移也要 O(n),但数组拷贝在底层是连续内存复制,常数很小,实际跑起来往往比链表的一次次 next 跳转快得多。所以单纯说“增删多就选链表”,是不负责任的说法。
3.3 实际场景下我的选型习惯
我自己的经验是三条:
- 读多写少、按下标访问多的场景,比如缓存排行榜、配置列表,无脑顺序表。
- 只在两端操作、几乎不按下标访问的场景,比如队列、栈、LRU 的双向链表部分,用链表。
- 中间插入删除频繁、且你能直接持有节点引用的场景,比如 LRU 缓存、文本编辑器的行管理,用链表。
还有一个容易被忽略的维度是内存占用。顺序表每个元素只存数据,几乎无额外开销;链表每个节点要额外的 next(和 prev)引用,Java 里一个节点对象还有对象头开销,小对象场景下内存可能是顺序表的 2-3 倍。数据量大时,这个差距会很明显。
4. 索引表的顺序查找:顺序表上的分块查找玩法
4.1 分块查找:索引表 + 块内顺序查找
既然聊到顺序表,就绕不开“索引表的顺序查找”这个概念,它对应的正是数据结构里的分块查找(Block Search)。分块查找解决的是这样一个问题:数据量很大,又不想做全量顺序扫描,也不想花 O(n log n) 建平衡树,那能不能折中一下?
思路是:把长度为 n 的表分成若干块,块与块之间有序(第 1 块的所有元素都小于第 2 块的所有元素,依此类推),块内元素可以无序。额外建立一个索引表,每个索引项记录“该块的最大关键字”和“该块在顺序表中的起始下标”。查找时,先顺序查索引表,确定目标值可能在哪个块;然后再到块内做顺序查找。
这有点像查字典的“边查拼音索引边翻页”:先翻到拼音对应的那几页,再在页内逐字找,而不是从第一页翻到最后一页。
4.2 分块查找的代码实现与复杂度
直接上一个可运行的实现:
java复制public class BlockSearch {
static class IndexItem {
int maxVal; // 块内最大值
int start; // 块起始下标
IndexItem(int maxVal, int start) {
this.maxVal = maxVal;
this.start = start;
}
}
private final int[] data;
private final int blockSize;
private final IndexItem[] index;
public BlockSearch(int[] data, int blockSize) {
this.data = data;
this.blockSize = blockSize;
this.index = buildIndex();
}
private IndexItem[] buildIndex() {
int n = data.length;
int blockCount = (n + blockSize - 1) / blockSize;
IndexItem[] idx = new IndexItem[blockCount];
for (int i = 0; i < blockCount; i++) {
int start = i * blockSize;
int end = Math.min(start + blockSize, n);
int maxVal = data[start];
for (int j = start + 1; j < end; j++) {
if (data[j] > maxVal) {
maxVal = data[j];
}
}
idx[i] = new IndexItem(maxVal, start);
}
return idx;
}
public int search(int key) {
// 第一步:顺序查索引表,确定块
int i;
for (i = 0; i < index.length; i++) {
if (key <= index[i].maxVal) {
break;
}
}
if (i == index.length) {
return -1; // 比所有块的最大值都大
}
// 第二步:块内顺序查找
int start = index[i].start;
int end = Math.min(start + blockSize, data.length);
for (int j = start; j < end; j++) {
if (data[j] == key) {
return j;
}
}
return -1;
}
}
分块查找的平均查找长度 = 索引表查找长度 + 块内查找长度。假设 n 个元素分成 b 块、每块 s 个元素,顺序查找索引表时平均长度为 (b+1)/2,块内平均长度为 (s+1)/2,总和 ASL = (b+1)/2 + (s+1)/2。当 b ≈ s ≈ √n 时,ASL 大约等于 √n + 1,比纯顺序查找的 n/2 好不少,又不像折半查找那样要求全表严格有序。
这种结构很适合数据库索引的早期设计思路,也适合一些“外部半有序、内部无规则”的业务数据。实际工作中,如果你有一批分段数据,每段内部没有规律但段间天然有序,分块查找是非常实用的折中方案。当然,如果数据支持全量排序,折半查找仍然是顺序表上更优的选择。
5. 常见问题与排查技巧实录
5.1 下标越界的两种边界,别搞混
顺序表的下标检查最容易在 add 和 remove 上翻车。add(int index, E element) 的合法范围是 0 <= index <= size,index 等于 size 表示尾插;remove 和 get 的范围是 0 <= index < size,等于 size 就属于“删了一个不存在的位置”。很多手写实现只写一个 checkIndex 方法给所有方法用,结果 add 在尾部插入时报错,这种问题调试起来特别恶心。建议 add 单独用一个 checkIndexForAdd,语义清晰,报错信息也友好。
5.2 扩容耗时与恰好触发一次扩容的边界
还有一次,我在中间插入的方法里先判断了容量、再检查下标,结果传了一个非法下标,容量反而先被扩容了——虽然最后抛异常,但数组白扩了一次。正确顺序是先做下标检查,再做容量检查。扩容是很贵的操作,一次数组拷贝耗时随容量线性增长,绝不能因为异常路径白白触发。
5.3 删除元素后为什么要把末尾置空
前面代码里有一行 data[--size] = null,很多人会问我“是不是多余”。它真不多余。假设顺序表里存了一条很大的对象,你 remove(0) 把第一个元素删了,但数组最后一个位置的引用还指向它。size 已经变小,你永远访问不到它了,但 GC 可达性分析时,这个对象还活在数组里,这就成了隐性内存泄漏。在长生命周期的大对象场景下,日积月累能把堆撑爆。所以不管是自己实现还是看 JDK 源码,这个置空操作一定要保留。
5.4 遍历时修改集合:并发修改检查了解一下
顺序表还有一个高频坑:foreach 遍历的时候调 remove。Java 里 foreach 实际用的是迭代器,迭代器内部有 expectedModCount 和 modCount 的比较,一旦发现集合被结构性修改(add 或 remove),就会抛 ConcurrentModificationException。这个机制不是为了防多线程,连单线程里边遍历边删都会炸。
正确的删除姿势是用迭代器的 remove 方法:
java复制Iterator<String> it = list.iterator();
while (it.hasNext()) {
String s = it.next();
if ("x".equals(s)) {
it.remove(); // 通过迭代器删,会同步 modCount
}
}
或者用 Java 8 的 removeIf,一个方法搞定:list.removeIf(s -> "x".equals(s));。这既简洁又安全,推荐直接用它。
5.5 遍历性能优化:把循环里的方法调用收一下
顺序表遍历不该每次循环里都调 list.size() 吗?其实可以,因为 size() 不是 O(n) 操作,它就是返回一个 int 字段,开销极小。真正的问题是不要在循环里反复调 list.get(i) 去遍历顺序表——虽然 get 是 O(1),但每次都有边界检查和类型强转,不如直接用 Arrays 或 for-each 底层走一次批量访问。对顺序表做批量操作时,尽量使用 System.arraycopy、Arrays.copyOf 这类 native 方法,性能差距在百万级数据下非常明显。
写到这里,说点个人习惯:我在公司里 review 代码时,只要看到别人在循环里频繁手动搬移数组元素,都会顺手建议他换成 System.arraycopy;只要看到有人 new 了数组又不关心扩容,就会提醒他用 ArrayList。顺序表这个知识点看起来简单,但就像地基——懂它的人写出的代码,在数据量上来的时候,稳定性真的会不一样。如果你正在准备面试,建议照着上面这份代码,把 add、remove、grow 三个方法闭着眼睛默写一遍,再想想每个方法里的判断顺序和边界条件,基本就能把这部分吃透了。
