在开发中,我们经常要处理“一组有序数据”的存储问题,比如学生成绩表、商品列表、任务队列。最容易想到的方式就是使用数组,但数组的长度固定、增删元素麻烦,用起来并不顺手。数据结构中的“顺序表”正是在数组基础上封装出来的一种线性表,它既保留了数组随机访问快的优势,又解决了容量固定、插入删除需要手动搬移数据的痛点。本文将以 Java 语言为例,从顺序表的定义出发,完整实现初始化、插入、删除、查找、扩容等核心操作,并给出可直接运行的完整代码和测试用例。无论你是正在学习数据结构课程,还是准备面试、复习算法基础,这篇文章都能帮你把顺序表这一章彻底吃透。
1. 背景与核心概念
1.1 线性表是什么
在系统学习顺序表之前,首先要理解“线性表”这个概念。线性表是具有相同数据类型的 n 个数据元素的有限序列,比如一个班级的学生名单、一串电话号码记录,都可以抽象成线性表。线性表中每个元素都有唯一的前驱和后继,除了第一个元素没有前驱、最后一个元素没有后继之外,其他元素都“手拉手”排成一条直线,这种逻辑结构非常直观。
线性表的存储方式主要有两种。一种是顺序存储,用一组地址连续的内存单元依次存放数据元素,这种实现就是顺序表;另一种是链式存储,通过指针把散落在内存各处的节点串起来,这种实现就是链表。在真实项目中,两种方式都有大量应用,但对于“按位置访问”特别频繁、很少在中间插入删除的场景,顺序表的效率远高于链表。这也是为什么我们要先把顺序表吃透。
1.2 顺序表解决什么问题
顺序表本质上是一段连续的物理存储空间,通常用“数组 + 当前长度”两个属性来描述。它解决的问题可以归纳成三个:
第一,容量的动态管理。静态数组在使用前必须确定大小,如果估计少了,数据存不下;估计多了,又浪费内存。顺序表通过扩容机制解决了这个问题,在空间不足时自动申请更大的数组,并把原数据搬移过去。
第二,数据的有序组织。顺序表让元素按照逻辑顺序紧密排列,通过下标可以直接访问任意位置的元素。比如要访问第 3 个元素,数组下标就是 2,一步到位。
第三,常用操作的标准化。初始化、插入、删除、查找、遍历、清空,这些操作被封装成方法或函数,使用时不需要关心内部细节。这样写业务代码时,可以像使用工具一样操作数据集合,而不用手动管理底层数组。
1.3 顺序表和数组、链表的区别
初学者最容易把数组和顺序表混为一谈。数组是编程语言提供的一种基本语法结构,长度固定,只能由程序员手动处理增删逻辑。顺序表是建立在数组之上的抽象数据结构,它把数组的固定长度和增删逻辑封装起来,形成一个具备“自动扩容、边界检查、标准接口”的数据容器。在学习时可以把顺序表想象成一个“升级版数组”。
和链表相比,顺序表的特点是“内存连续、随机访问快、插入删除慢”。链表正好相反,它不要求内存连续,插入删除只需要修改指针,但随机访问必须从头遍历。实际项目中选型时要根据业务特点决定。如果读多写少,优先考虑顺序表;如果频繁在中间插入删除,链表可能更合适。
2. 环境准备与代码结构
2.1 运行环境说明
本文所有代码使用 Java 语言实现,在本地开发时建议准备以下环境:
- JDK 8 及以上版本,本文代码使用了泛型和增强 for 循环等基础特性,JDK 8 完全可以运行。
- 任意 Java IDE,例如 IntelliJ IDEA、Eclipse,或者直接用记事本配合命令行编译运行。
- 使用命令行时,请确认
java -version和javac -version能正常输出版本信息。
版本需要根据你的项目实际情况调整,本文示例以常见环境为例,重点演示配置思路和代码实现。
2.2 项目文件结构
示例工程采用最简单的单类结构,目的是让读者把注意力集中到数据结构本身,而不是被 Maven 或 Gradle 的工程结构干扰。实际项目中使用时,可以把顺序表类放到独立的datastructure包中,方便统一管理。
顺序表演示项目/ ├── src/ │ └── SqList.java (顺序表完整实现) │ └── SqListTest.java (测试入口类)SqList是顺序表的核心类,包含成员变量和所有操作方法;SqListTest是测试类,用于验证功能。
2.3 使用到的 Java 技术点
在阅读代码前,先了解几个关键的 Java 技术点:
- 泛型。通过泛型可以让顺序表支持任意数据类型,而不只是
int。本文为了降低上手难度,先用int类型演示核心逻辑,最后补充泛型化的改造思路。 - 可变参数。部分初始化方法可以使用可变参数,便于一次性添加多个元素。
- 数组拷贝。扩容时需要把原数组的数据复制到新数组,可以使用循环逐位复制,也可以使用
System.arraycopy提高效率。
代码中会尽量避免使用过于高级的语法,保证基础薄弱的读者也能看懂。
3. 顺序表的核心设计原理
3.1 顺序表的存储结构
顺序表的逻辑结构是“一对一”的线性关系,物理结构是连续内存空间。它的定义可以概括为:用一组地址连续的存储单元依次存放线性表中的数据元素,元素之间的逻辑关系通过它们在内存中的物理位置自然表达。
在 Java 中,我们使用数组作为底层存储结构,再加上一个size变量记录当前元素个数。这里有个容易混淆的地方:数组的容量capacity和顺序表的长度size是不同的概念。容量是数组最多能放多少个元素,长度是当前实际存放了多少个元素。判断顺序表是否为空,看的是size是否为 0,而不是容量是否为 0。
private int[] data; // 底层数组 private int size; // 当前元素个数data用来存放元素,size指向下一个要写入的位置,同时也是当前元素个数。初始化时分配一个默认容量的数组,size置为 0。
3.2 为什么需要扩容机制
数组一旦创建,长度就不可变。如果顺序表在元素插入时发现数组已满,继续写入就会导致数组下标越界。扩容机制的思路是:在插入前判断是否还有剩余空间,如果没有,申请一个更大的新数组,把原数组元素全部搬移过去,然后让data指向新数组。
扩容的容量策略在真实项目中很有讲究。最简单的是“每次增加一个固定大小”,比如每次多分配 10 个空间;更通用的是“原容量翻倍”或者“增加原容量的一半”。容量翻倍的好处是均摊下来每次插入的时间复杂度为 O(1),不会出现多次扩容的抖动问题。Java 的ArrayList在扩容时,默认策略是oldCapacity + (oldCapacity >> 1),也就是增加原来容量的一半。本文会采用这个策略作为演示。
3.3 插入操作的核心思想
插入操作比较容易出错的点在于元素搬移。如果要在第index个位置插入元素,那么从index到size-1的所有元素都必须往后挪动一位,空出第index个位置,再把新元素写入。
这里必须强调一个关键细节:搬移元素时,必须从最后一个元素开始,从后向前依次移动。如果从前往后移动,后面的元素还没有被搬走,就直接被前一个元素覆盖,导致数据丢失。这种“从后往前搬”的规则是顺序表插入操作最常见的考点。
// 从后往前搬移元素 for (int i = size - 1; i >= index; i--) { data[i + 1] = data[i]; }插入前还需要判断下标是否合法。合法范围是0到size。如果index等于size,相当于在表尾追加元素;如果超出这个范围,应该抛出下标越界异常。
3.4 删除操作的核心思想
删除操作和插入操作方向相反。删除第index个元素后,从index+1到size-1的元素都必须往前挪动一位,填补被删除元素留下的空位。
搬移元素时,必须从前往后移动。这正好和插入操作相反:
for (int i = index; i < size - 1; i++) { data[i] = data[i + 1]; }删除完成后,size减 1。这里还有一个性能优化点:末尾的废弃数据可以置为默认值,帮助垃圾回收。对于基本数据类型int,可以置为 0;对于对象类型,可以置为null。
删除操作的时间主要消耗在元素搬移上,平均要移动n/2次,所以时间复杂度是 O(n)。
3.5 查找操作的设计
查找可以分成两种。第一种是按位置查找,给定下标返回元素,顺序表支持随机访问,时间复杂度是 O(1)。第二种是按值查找,需要遍历整个顺序表,找到第一个与目标值相等的元素并返回其下标,时间复杂度是 O(n)。
按值查找在真实项目中非常常见。比如在成绩表里找到 90 分以上的第一个学生,或者在订单表里定位某个订单号。实现时要注意两个点:第一,如果找不到,要有一个统一的返回值约定,比如返回-1表示不存在;第二,在泛型场景下比较元素时要使用equals而不是==,否则比较的是引用地址而不是内容。
4. 顺序表完整 Java 实现
4.1 类的成员与构造方法
先来定义顺序表类的基本结构:
// 文件路径:src/SqList.java public class SqList { private int[] data; // 底层数组 private int size; // 当前元素个数 // 默认构造方法:初始化容量为 10 public SqList() { data = new int[10]; size = 0; } // 指定初始容量构造方法 public SqList(int capacity) { if (capacity < 0) { throw new IllegalArgumentException("容量不能为负数"); } data = new int[capacity]; size = 0; } }默认容量设置为 10,这样既不会在刚创建时就占用大量内存,又能满足一般的小规模数据需求。第二个构造方法允许调用者按照预估的数据量分配初始空间,减少后续扩容次数。
4.2 基本成员方法
包括获取长度、判断是否为空、按位置访问元素、修改元素:
// 获取当前元素个数 public int size() { return size; } // 判断顺序表是否为空 public boolean isEmpty() { return size == 0; } // 按位置获取元素 public int get(int index) { checkIndexForGet(index); return data[index]; } // 修改指定位置的元素 public void set(int index, int value) { checkIndexForGet(index); data[index] = value; } // 检查按位置访问时下标是否合法 private void checkIndexForGet(int index) { if (index < 0 || index >= size) { throw new IndexOutOfBoundsException("下标越界: " + index); } }get和set都是 O(1) 的随机访问操作,这也是顺序表相对链表最核心的优势。检查下标的代码被抽取成私有方法,避免重复编写,同时让外部调用时能立即得到清晰的异常信息。
4.3 扩容方法
扩容是顺序表内部的基础能力,被插入操作调用。虽然写成public也不会报错,但合理的设计应该把扩容声明为private,因为扩容属于内部机制,不应该暴露给调用者。
// 确保数组有足够的容量 private void ensureCapacity() { if (size < data.length) { return; } // 扩容策略:原容量 + 原容量的一半 int newCapacity = data.length + (data.length >> 1); if (newCapacity == data.length) { newCapacity = 10; } int[] newData = new int[newCapacity]; // 搬移原数组数据 for (int i = 0; i < size; i++) { newData[i] = data[i]; } data = newData; }这里的data.length >> 1相当于data.length / 2,用移位运算比除法运算效率更高。扩容后原来的数组失去引用,由 JVM 垃圾回收机制自动回收。工程实践中,如果知道数据量会持续增长,也可以在调用插入时手动触发一次较大容量的扩容,减少多次扩容带来的性能开销。
4.4 插入操作实现
插入操作支持在任意合法位置插入新元素。插入位置可以是表头、表中或者表尾。
// 在指定位置插入元素 public void insert(int index, int value) { if (index < 0 || index > size) { throw new IndexOutOfBoundsException("插入位置非法: " + index); } ensureCapacity(); // 从后往前搬移元素,腾出位置 for (int i = size - 1; i >= index; i--) { data[i + 1] = data[i]; } data[index] = value; size++; } // 在表尾追加元素 public void add(int value) { ensureCapacity(); data[size] = value; size++; }在表尾追加元素是插入的一种特殊情况,单独提供add方法会让调用更加简洁。每次插入完都要记得size++,漏掉这一步会导致后续读写错位,也是最常见的低级错误。
为了验证插入逻辑,可以看一个简单例子。假设数组中已有元素[1, 2, 3, 4],要在下标 1 的位置插入9。首先从后往前搬移:下标 3 的4移到下标 4,下标 2 的3移到下标 3,下标 1 的2移到下标 2,然后下标 1 写入9。最终数组变为[1, 9, 2, 3, 4],完全符合预期。
4.5 删除操作实现
删除操作接收一个下标,返回被删除的元素值。返回被删除的元素在某些业务场景中很有用,比如需要记录日志或做撤销操作。
// 删除指定位置的元素,并返回被删除的元素 public int remove(int index) { if (index < 0 || index >= size) { throw new IndexOutOfBoundsException("删除位置非法: " + index); } int oldValue = data[index]; // 从前往后搬移元素,覆盖被删除位置 for (int i = index; i < size - 1; i++) { data[i] = data[i + 1]; } size--; // 将末尾废弃位置置为 0,帮助释放引用(对基本类型是可选操作) data[size] = 0; return oldValue; }从前往后搬移是删除操作的正确姿势。如果从后往前搬移,前一个元素会被后一个元素覆盖,导致数据混乱。
4.6 查找与遍历
按值查找返回第一个匹配元素的下标,如果找不到返回-1。判断容器中是否包含某个元素,可以基于查找方法实现:
// 按值查找,返回第一个匹配的下标,找不到返回 -1 public int indexOf(int value) { for (int i = 0; i < size; i++) { if (data[i] == value) { return i; } } return -1; } // 判断是否包含目标值 public boolean contains(int value) { return indexOf(value) != -1; } // 遍历打印所有元素 public void print() { System.out.print("["); for (int i = 0; i < size; i++) { System.out.print(data[i]); if (i != size - 1) { System.out.print(", "); } } System.out.println("]"); }print方法方便在测试时观察顺序表的内部状态。注意打印遍历时,循环条件是i < size而不是i < data.length。否则会把未使用的扩容空间也打印出来。
4.7 清空与判空
清空操作只重置size,不要求立即释放数组内存。这样即使扩容后的数组被保留,再次添加元素时也能避免立刻扩容,空间可以复用。
// 清空顺序表 public void clear() { size = 0; }对于对象类型的顺序表,清空时最好把数组中每个引用都置为null,否则会出现“对象无法被垃圾回收”的内存隐患。基本类型数组则没有这个问题。
4.8 完整代码汇总
把上面的方法组合到一起,形成完整的SqList.java类。
// 文件路径:src/SqList.java public class SqList { private int[] data; private int size; public SqList() { data = new int[10]; size = 0; } public SqList(int capacity) { if (capacity < 0) { throw new IllegalArgumentException("容量不能为负数"); } data = new int[capacity]; size = 0; } public int size() { return size; } public boolean isEmpty() { return size == 0; } public int get(int index) { if (index < 0 || index >= size) { throw new IndexOutOfBoundsException("下标越界: " + index); } return data[index]; } public void set(int index, int value) { if (index < 0 || index >= size) { throw new IndexOutOfBoundsException("下标越界: " + index); } data[index] = value; } private void ensureCapacity() { if (size < data.length) { return; } int newCapacity = data.length + (data.length >> 1); int[] newData = new int[newCapacity]; for (int i = 0; i < size; i++) { newData[i] = data[i]; } data = newData; } public void insert(int index, int value) { if (index < 0 || index > size) { throw new IndexOutOfBoundsException("插入位置非法: " + index); } ensureCapacity(); for (int i = size - 1; i >= index; i--) { data[i + 1] = data[i]; } data[index] = value; size++; } public void add(int value) { ensureCapacity(); data[size] = value; size++; } public int remove(int index) { if (index < 0 || index >= size) { throw new IndexOutOfBoundsException("删除位置非法: " + index); } int oldValue = data[index]; for (int i = index; i < size - 1; i++) { data[i] = data[i + 1]; } size--; data[size] = 0; return oldValue; } public int indexOf(int value) { for (int i = 0; i < size; i++) { if (data[i] == value) { return i; } } return -1; } public boolean contains(int value) { return indexOf(value) != -1; } public void print() { System.out.print("["); for (int i = 0; i < size; i++) { System.out.print(data[i]); if (i != size - 1) { System.out.print(", "); } } System.out.println("]"); } public void clear() { size = 0; } }5. 测试与运行验证
5.1 编写测试类
顺序表类写完以后,需要编写测试类验证功能是否正常。测试类覆盖了插入、删除、查找、修改、遍历等核心操作,每个操作之间通过打印结果来检验正确性。
// 文件路径:src/SqListTest.java public class SqListTest { public static void main(String[] args) { // 1. 创建一个顺序表 SqList list = new SqList(); System.out.println("初始是否为空:" + list.isEmpty()); // 2. 尾部追加元素 list.add(10); list.add(20); list.add(30); list.add(40); System.out.print("尾部追加 10,20,30,40 后:"); list.print(); // 3. 指定位置插入 list.insert(1, 15); System.out.print("在下标 1 插入 15 后:"); list.print(); // 4. 按位置查找和修改 System.out.println("下标 2 的元素是:" + list.get(2)); list.set(0, 100); System.out.print("将下标 0 修改为 100 后:"); list.print(); // 5. 按值查找 System.out.println("元素 30 的下标是:" + list.indexOf(30)); System.out.println("是否包含 99:" + list.contains(99)); // 6. 删除操作 int removed = list.remove(1); System.out.println("删除的元素是:" + removed); System.out.print("删除后:"); list.print(); // 7. 清空操作 list.clear(); System.out.println("清空后是否为空:" + list.isEmpty()); } }5.2 编译运行
在命令行进入src目录,依次执行编译和运行命令:
javac SqList.java SqListTest.java java SqListTest使用 IDE 开发时,直接运行SqListTest类中的main方法即可。
5.3 预期输出结果
运行程序后,控制台输出如下:
初始是否为空:true 尾部追加 10,20,30,40 后:[10, 20, 30, 40] 在下标 1 插入 15 后:[10, 15, 20, 30, 40] 下标 2 的元素是:20 将下标 0 修改为 100 后:[100, 15, 20, 30, 40] 元素 30 的下标是:3 是否包含 99:false 删除的元素是:15 删除后:[100, 20, 30, 40] 清空后是否为空:true输出结果和预期完全一致。这里需要特别说明set(0, 100)之后,indexOf(30)的结果仍然是 3。因为删除操作发生在查找之后,此时顺序表为[100, 15, 20, 30, 40],元素 30 位于下标 3。
5.4 扩容能力测试
为了验证扩容机制是否正常工作,可以专门写一个循环添加大量数据的测试:
// 扩容测试:连续添加 100 个元素 SqList bigList = new SqList(5); for (int i = 0; i < 100; i++) { bigList.add(i); } System.out.println("扩容后元素个数:" + bigList.size()); System.out.println("扩容后下标 99 的元素:" + bigList.get(99));初始容量只有 5,但需要存储 100 个元素,因此会触发多次扩容。只要最后能输出元素个数 100,并且get(99)返回 99,就证明扩容机制正常工作。
6. 复杂度分析与性能边界
6.1 时间复杂度分析
顺序表各操作的时间复杂度如下表所示:
| 操作 | 平均时间复杂度 | 说明 |
|---|---|---|
按下标访问get | O(1) | 直接通过数组下标定位 |
修改元素set | O(1) | 直接通过数组下标定位 |
尾部插入add | O(1) | 均摊分析下,扩容次数少 |
任意位置插入insert | O(n) | 需要移动 n/2 个元素 |
删除remove | O(n) | 需要移动 n/2 个元素 |
按值查找indexOf | O(n) | 需要遍历数组 |
判断包含contains | O(n) | 依赖 indexOf |
从表中可以看到,顺序表的强项是随机访问和尾部的增删,弱项是中间位置的插入删除和按值查找。
6.2 空间复杂度分析
顺序表的空间复杂度为 O(n),因为底层数组需要为所有元素分配连续内存。在 real-time 场景中,扩容会瞬间申请一份更大的内存,如果顺序表存储的是大量对象引用,扩容时的内存开销会比较明显。为了避免频繁扩容,创建顺序表时可以根据业务大小给出合理的初始容量,例如预估 1000 条数据就初始化new SqList(1000)。
6.3 顺序表适合的场景
从复杂度分析可以看出,顺序表最擅长处理以下场景:
- 需要频繁按下标读取元素的场景,比如排行榜、缓存队列。
- 数据量相对稳定、增长不剧烈的场景。
- 主要在尾部添加和删除数据的场景,比如程序运行日志的暂存,或者栈的实现。
如果业务中需要频繁在头部插入或者中间删除,例如待办事项列表、消息队列等,那么建议使用链表或者其他更合适的数据结构,否则会产生大量元素搬移的性能损耗。
7. 常见问题与排查思路
7.1 插入后数据错乱
问题现象:插入元素后,后面的数据出现重复或丢失。
这个问题的根本原因通常是搬移元素的方向搞反了。插入操作必须从后向前搬移元素,如果从前向后移动,前一个元素会覆盖后一个元素,导致后半部分数据重复。例如数组[1, 2, 3, 4],在下标 1 插入9,如果从前往后搬移,下标 0 的1会覆盖到下标 1,原下标 1 的2被覆盖,后续依次处理会得到[1, 1, 1, 1, 1],数据完全错乱。
排查思路:检查循环起点。插入时循环变量从size-1开始,递减到index结束;删除时循环变量从index开始,递增到size-2结束。这是最容易出错的地方。
7.2 数组下标越界异常
问题现象:调用insert、remove等操作时报IndexOutOfBoundsException。
常见原因有三个:一是传入的index是负数;二是index大于当前顺序表的长度;三是在遍历时错误地使用了data.length作为循环边界,访问到了未使用的空间。
排查思路:所有公共操作入口都要先做下标检查。遍历顺序表时循环条件必须是i < size。如果底层使用data.length,一旦发生扩容,很多未使用的数组空间也会被遍历到,引发逻辑错误。
| 问题现象 | 常见原因 | 解决思路 |
|---|---|---|
| 插入后数据重复 | 搬移元素方向错误,从前向后移动 | 改为从后向前搬移元素 |
| 下标越界 | 未校验传入下标或遍历越界 | 操作前检查下标,遍历使用 size 作为边界 |
| 数据丢失 | 删除后元素未正确前移 | 从前往后搬移元素 |
| 扩容后原数据丢失 | 扩容循环边界错误 | 使用 size 作为循环上限,而不是 data.length |
| 尾部追加失败 | 忘记调用扩容方法 | 追加前调用 ensureCapacity |
7.3 扩容后原数据丢失
问题现象:插入大量元素后,前面的数据变成 0 或者丢失了。
常见原因是扩容时复制数据的循环边界写错,把i < size写成了i < data.length。在扩容方法被调用时,data还指向旧数组,如果循环上限使用data.length,会访问到新数组未初始化的位置,同时漏掉了旧数组的部分数据。
排查思路:在扩容方法中打印旧数组的长度和size值,确认循环边界。扩容复制的目标是把旧数组中范围内有效的元素全部搬到新数组,循环边界必须使用size。
7.4 删除元素后末尾残留 0
问题现象:删除元素后,打印顺序表发现末尾出现了一个多余的 0。
这个现象其实不是错误。删除元素后,size--已经让末尾的“残留值”不再属于逻辑范围,打印遍历时只打印到size-1,所以不会看到这个 0。如果使用的是 IDE 的调试功能直接查看data数组,才会发现末尾残留值。对于基本类型数组,残留值没有影响;对于对象类型数组,为了帮助垃圾回收,最好把data[size]置为null。
8. 最佳实践与工程建议
8.1 合理设置初始容量
顺序表的扩容操作涉及数组复制,数据量越大,复制开销越高。如果能预估数据规模,创建顺序表时应该指定初始容量,尽量减少扩容次数。例如在处理一个固定数量学生的成绩时,可以这样写:
SqList scores = new SqList(50); // 预估最多 50 个学生如果业务中数据是持续增长的,初始容量可以适当设置得偏大一些,用少量内存换取更好的性能,这在嵌入式和高性能计算环境中尤其重要。
8.2 封装边界检查逻辑
每次插入、删除、访问前都要做下标校验,如果每个方法里都写一遍,代码会显得很凌乱。建议把边界检查抽取成私有方法,例如:
private void checkIndex(int index, int upperBound, String message) { if (index < 0 || index >= upperBound) { throw new IndexOutOfBoundsException(message + ": " + index); } }这样既减少了重复代码,也让公共方法的逻辑更清晰。
8.3 使用位运算提高性能
在扩容计算中,使用移位运算代替除法和乘法是常见优化。例如:
int newCapacity = oldCapacity + (oldCapacity >> 1);这里的>> 1表示右移一位,相当于除以 2。对于计算机来说,移位运算比乘除法更快。不过这属于微优化,在实际业务中影响不大,更重要的是代码可读性。如果团队其他人不熟悉位运算,也可以写成乘 1.5 的方式,配合注释说明。
8.4 接口设计优于直接暴露数组
初学者喜欢直接操作底层数组,虽然代码看起来简单,但是很容易破坏顺序表的不变量。比如直接设置data[10] = 5,却没有同步修改size,导致顺序表内部状态错误。更合理的做法是把顺序表封装成对外提供标准方法的类,内部实现细节完全隐藏,外部使用者只能通过add、insert、remove等方法操作数据。
在大型项目中,可以进一步抽象一个List接口,将顺序表定义为接口的实现类。这样在使用时面向接口编程,后续如果需要换成链表实现,业务代码不需要大改。
8.5 泛型化改造
本文示例使用的是int类型数组。实际项目中,顺序表通常需要存储任意类型的数据,因此应该泛型化。泛型化改造的核心点有两个:
第一,创建泛型数组时不能直接new T[capacity],因为 Java 无法创建泛型数组,需要借助(T[]) new Object[capacity]进行强制转换。
第二,按值查找时不能使用==比较对象,要使用equals方法。
public class SqList<T> { private Object[] data; private int size; public SqList(int capacity) { data = new Object[capacity]; size = 0; } @SuppressWarnings("unchecked") public T get(int index) { if (index < 0 || index >= size) { throw new IndexOutOfBoundsException("下标越界: " + index); } return (T) data[index]; } public boolean contains(T value) { for (int i = 0; i < size; i++) { if (data[i].equals(value)) { return true; } } return false; } }泛型化之后,SqList<String>、SqList<Student>都能使用同一个类,大大提升了代码复用率。
8.6 与 Java 集合框架的关系
看到这里,很多读者会想到 Java 自带的ArrayList。实际上,ArrayList就是标准库中的顺序表实现,它的底层就是Object[]数组,同样支持动态扩容、随机访问、按值查找。理解了本文的顺序表后,再去看ArrayList的源码会轻松很多。
实际生产项目中,不需要自己造轮子,直接使用ArrayList是更稳妥的选择。自己实现顺序表的价值更多体现在面试、考试和理解数据结构原理上。如果往深处学习,可以阅读ArrayList的源码,看看 Java 官方是如何处理扩容、迭代器和快速失败机制的。
9. 总结与下一步学习方向
本文从线性表和顺序表的概念出发,解释了顺序表“数组 + 长度”的存储结构,并完整实现了初始化、扩容、插入、删除、按位访问、按值查找、清空等核心操作。插入和删除的元素搬移方向是关键,扩容的触发时机和容量策略也直接影响性能。通过测试用例和预期输出,我们验证了整个实现是正确可靠的。
学完顺序表之后,下一步建议学习链表。顺序表和链表是线性表的两种典型实现,它们在内存布局、操作效率、适用场景上形成鲜明对比,只有把两者放在一起对比学习,才能真正理解“数据结构的选择取决于业务需求”。可以继续尝试用 Java 实现单链表,并对比两者在头部插入、尾部插入、随机访问上的效率差异。
在面试中,顺序表常见的进阶问题包括:怎么实现动态扩容才能避免性能抖动、如何在顺序表中实现去重、如何用两个顺序表实现集合的交集和并集、如何基于顺序表实现栈和队列。这些都是在本文基础上的延伸练习。
如果本文对你有帮助,可以收藏备用,动手把代码敲一遍。数据结构没有捷径,自己实现的每个类都会成为后面学习算法和框架源码的坚实基础。