1. 顺序表到底是个什么东西:从数组到抽象数据类型的跳跃
很多人学数据结构的时候,把顺序表简单理解成"数组",这其实没问题,但也不完全对。数组是编程语言提供的一种基础类型,而顺序表是基于数组实现的一种抽象数据类型。什么意思呢?就是你在写业务代码的时候,不应该直接去操作数组下标、手动管理元素个数,而是通过顺序表暴露出来的接口来操作数据。这个思想上的转变,恰恰是很多入门者卡住的地方。
顺序表的定义其实很朴素:用一段地址连续的存储单元依次存储数据元素。注意"地址连续"这四个字,这是顺序表区别于链表最核心的特征。正因为地址连续,它才能做到O(1)时间内的随机访问,也就是你给我一个下标,我直接拿数组基地址加上偏移量就能取到元素。你可以把它类比成电影院连座的座位,每个座位有固定编号,观众按编号依次坐好。想找第10号座位的人,不需要从头一个个数,直接走过去就行。
但连续地址也带来了一个天然的麻烦:如果要在中间插入或者删除一个元素,你没法像链表那样只是改几个指针了事,必须把后续元素整体往后或者往前挪动。这个挪动操作的时间复杂度是O(n),顺序表的主要开销都集中在这里。
在实际工程和面试中,顺序表常用的场景包括:需要频繁按下标访问元素的集合(比如排行榜、日志缓存)、元素数量相对稳定且尾部追加为主的任务队列、以及对局部缓存友好性要求较高的数据处理流程。因为顺序表的数据是连续存放的,CPU缓存命中的概率远高于链表,这一点在大数据量迭代时差异极其明显。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. Java手写顺序表:从零搭出一个完整可用的容器
2.1 底层结构设计与字段选择
我自己在实现顺序表的时候,通常会定义两个核心字段:
java复制public class MyArrayList<E> {
private static final int DEFAULT_CAPACITY = 10;
private Object[] data;
private int size;
}
data就是那个连续存储区域的底层数组,size记录当前已存储的元素个数。这里有个特别重要的约定:size既是元素个数,也同时是"下一个元素要填入的位置下标"。比如数组容量是10,当前有3个元素,那size就是3,下次插入就直接放到data[3]。很多初学者容易在这个地方混淆,我建议从一开始就明确这个语义,后面写插入、删除方法时思路会特别清晰。
泛型类型E理论上是可以直接创建泛型数组的,但在Java中由于类型擦除机制,直接new E[10]是会报错的。我通常用Object[]作为底层存储,取值时再做强制类型转换。这属于实现细节,业务层面无感知。
2.2 扩容机制怎么实现才对
容量满时插入新元素怎么办?答案很简单:扩容。我的做法是创建一个新的更大的数组,把旧数据全部迁移过去,再让引用指向新数组。
java复制private void ensureCapacity() {
if (size == data.length) {
int newCapacity = data.length + (data.length >> 1);
data = Arrays.copyOf(data, newCapacity);
}
}
注意这里扩容倍数我选的是1.5倍,不是直接翻倍。这是我在实际项目里比较偏好的方案。如果扩容倍数太大,比如2倍,虽然均摊下来的插入效率很高,但会白白浪费很多内存;如果太小,比如每次只加1,那频繁扩容带来的数组复制开销会非常恐怖。1.5倍是一个在时间和空间之间比较均衡的取值。Java官方ArrayList在JDK 8之后的默认扩容策略就是1.5倍,这也印证了这个选择的合理性。
关于扩容还有个小细节:ensureCapacity这个检查,应该是每次插入之前做,还是每次插入之后做?我在初学阶段一度写成了插入之后检查,结果逻辑稍不留神就容易出现数组越界。我建议养成习惯:在插入方法里,第一步就是做容量检查,保证调用插入时底层数组一定有空位。
3. 核心方法逐个击破:增删改查的全景拆解
3.1 插入方法:头部、尾部和中间位置的做法
顺序表的插入分三种典型位置:头部插入、尾部插入和任意位置插入。尾部插入是最简单的,不需要移动任何元素:
java复制public boolean add(E element) {
ensureCapacity();
data[size++] = element;
return true;
}
但头部插入和中间插入就麻烦多了。假设现在数组里有 [A, B, C, D],你想在索引1的位置插入X,那C和D要往后挪,把位置1空出来给X。关键在于:要从最后一个元素开始,从后往前依次往后挪,绝不能从前往后。如
