一、线性表概述
在Java集合框架中,List是一个核心接口。从数据结构角度看,List本质是线性表------由n个具有相同类型元素组成的有限序列,支持增删改查、遍历等基础操作。
常见线性表实现:顺序表(对应ArrayList)、链表(对应LinkedList)、栈、队列等。
线性表的核心特征:
-
逻辑结构:呈线性关系,即元素之间是"连续的一条直线",每个元素(除首尾外)有且仅有一个前驱元素和一个后继元素;若存在多个前驱或后继,则不属于线性结构。
-
物理结构:不一定连续。线性表在物理存储时,主要以两种形式存在------数组(连续空间)和链式结构(非连续空间)。
注意:List是接口,无法直接实例化使用,必须通过其实现类(如ArrayList、LinkedList)实例化。
核心实现类对应关系:
-
ArrayList:基于顺序表实现
-
LinkedList:基于链表实现
二、ArrayList与顺序表
2.1 顺序表的概念
顺序表是用一段物理地址连续的存储单元依次存储数据元素的线性结构,底层通常基于数组实现,通过数组的索引完成数据的增删改查操作。
2.2 ArrayList简介
ArrayList是Java集合框架中List接口的普通实现类,其核心特点如下:
-
泛型实现:使用时需指定元素类型(如ArrayList),避免类型转换错误。
-
动态扩容:底层是连续的数组空间,当存储的元素数量超过当前数组容量时,会自动申请更大的空间,实现"动态增长",是典型的动态顺序表。
2.3 ArrayList的构造方法
ArrayList提供三种核心构造方法,具体说明如下:
| 构造方法 | 功能解释 |
|---|---|
| ArrayList() | 无参构造,创建一个初始容量为0的空表,首次添加元素时会自动扩容至默认容量10。 |
| ArrayList(int initialCapacity) | 指定初始容量的构造方法,创建一个初始容量为initialCapacity的空表;若initialCapacity≤0,会抛出IllegalArgumentException异常。 |
| ArrayList(Collection<? extends E> c) | 基于已有集合构造,创建一个包含指定集合中所有元素的ArrayList,元素顺序与集合的迭代顺序一致。 |
构造方法使用示例(修正拼写错误后):
java
public static void main(String[] args) {
// 1. 无参构造(推荐写法)
List<Integer> l1 = new ArrayList<>();
// 2. 指定初始容量为10的构造
List<Integer> l2 = new ArrayList<>(10);
// 3. 基于已有集合构造,l3包含l2中所有元素
List<Integer> l3 = new ArrayList<>(l2);
}
2.4 ArrayList常见操作
ArrayList提供丰富的操作方法,核心常用方法如下(E为泛型类型,代表元素类型):
| 方法签名 | 功能解释 | 注意事项 |
|---|---|---|
| boolean add(E e) | 尾插元素e,添加成功返回true。 | 若容量不足,会触发自动扩容。 |
| void add(int index, E e) | 在索引index位置插入元素e。 | index需满足0≤index≤size(),否则抛出IndexOutOfBoundsException;插入后index及后续元素需向后搬移。 |
| E remove(int index) | 删除索引index位置的元素,返回被删除的元素。 | index需满足0≤index<size(),否则抛出异常;删除后index后续元素需向前搬移。 |
| boolean remove(Object o) | 删除首次出现的元素o,删除成功返回true,若不存在返回false。 | 需遍历数组查找元素,找到后触发后续元素搬移;若元素为null,会匹配null值。 |
| E get(int index) | 获取索引index位置的元素并返回。 | index需合法,否则抛出异常;直接通过数组索引访问,效率高。 |
| E set(int index, E e) | 将索引index位置的元素替换为e,返回被替换的旧元素。 | index需合法,否则抛出异常;仅修改元素值,不改变数组结构。 |
| void clear() | 清空ArrayList中的所有元素。 | 仅将数组元素置为null,不释放数组空间(容量不变)。 |
| boolean contains(Object o) | 判断元素o是否存在于ArrayList中,存在返回true,否则返回false。 | 底层通过indexOf(o)实现,需遍历数组。 |
| int indexOf(Object o) | 返回首次出现元素o的索引,若不存在返回-1。 | 从数组头部开始遍历查找。 |
| int lastIndexOf(Object o) | 返回最后一次出现元素o的索引,若不存在返回-1。 | 从数组尾部开始遍历查找。 |
| int size() | 返回当前ArrayList中元素的个数(注意:不是数组容量)。 | size() ≤ 数组容量。 |
| boolean isEmpty() | 判断ArrayList是否为空(元素个数为0),空返回true,否则返回false。 | 等价于size() == 0。 |
特别提醒:使用ArrayList的核心是理解方法底层原理(如扩容、元素搬移),这是优化代码性能的关键。
2.5 ArrayList的遍历方式
ArrayList支持三种常用遍历方式,适用于不同场景:
2.5.1 普通for循环(基于索引)
利用数组索引直接访问元素,效率最高,适用于需要获取元素索引的场景。
java
public static void main(String[] args) {
List<Integer> l1 = new ArrayList<>();
l1.add(1);
l1.add(2);
l1.add(3);
l1.add(4);
l1.add(5);
l1.add(6);
// 普通for循环遍历
for (int i = 0; i < l1.size(); i++) {
System.out.println("索引" + i + ":" + l1.get(i));
}
}
2.5.2 增强for循环(for-each)
语法简洁,无需关注索引,适用于仅需遍历元素、无需索引的场景。
java
public static void main(String[] args) {
List<String> l2 = new ArrayList<>();
l2.add("Java");
l2.add("ArrayList");
l2.add("遍历");
l2.add("for-each");
// 增强for循环遍历
for (String elem : l2) {
System.out.println(elem);
}
}
注意:for-each依赖Iterable接口,只有实现了Iterable接口的类才能使用for-each遍历;ArrayList实现了List接口,而List继承自Iterable,因此支持该遍历方式。
2.5.3 迭代器(Iterator)
最灵活的遍历方式,支持遍历过程中删除元素(通过iterator.remove()),适用于需要在遍历中修改集合的场景。
java
public static void main(String[] args) {
List<String> l3 = new ArrayList<>();
l3.add("a");
l3.add("b");
l3.add("c");
l3.add("d");
// 迭代器遍历
Iterator<String> iterator = l3.iterator();
while (iterator.hasNext()) { // 判断是否还有下一个元素
String elem = iterator.next(); // 获取下一个元素
System.out.println(elem);
// 遍历中删除元素(推荐用迭代器的remove方法,避免ConcurrentModificationException)
if ("b".equals(elem)) {
iterator.remove();
}
}
System.out.println("删除后的集合:" + l3); // 输出:[a, c, d]
}
总结:ArrayList最常用的遍历方式是普通for循环 (需索引)和增强for循环(无需索引);遍历中需删除元素时,优先使用迭代器。
2.6 ArrayList的自动扩容机制
ArrayList底层依赖数组(命名为elementData)存储元素,数组容量固定,因此需要通过"扩容"实现动态存储。扩容的核心逻辑是:当添加元素时发现容量不足,创建新的更大数组,拷贝旧数组元素到新数组,释放旧数组空间。
2.6.1 扩容触发条件
调用add()/add(int index, E e)等添加元素的方法时,会先检查当前元素个数(size)是否大于等于数组容量(elementData.length);若满足,则触发扩容。
2.6.2 扩容核心步骤
-
计算新容量:默认扩容策略为"新容量 = 旧容量 + 旧容量 >> 1"(即旧容量的1.5倍);若指定了初始容量或通过集合构造,首次扩容会直接扩至默认容量10(无参构造)或所需最小容量。
-
校验新容量:若计算出的新容量小于所需最小容量(如添加大量元素时),则新容量 = 所需最小容量。
-
创建新数组:通过Arrays.copyOf()方法创建容量为新容量的新数组,并将旧数组(elementData)中的元素拷贝到新数组。
-
更新引用:将elementData指向新数组,旧数组因失去引用会被垃圾回收机制自动释放。
2.6.3 扩容示例
-
无参构造ArrayList:初始容量0 → 首次add()时扩容至10 → 当元素个数达10时,再次扩容至15(10的1.5倍) → 元素达15时扩容至22(15的1.5倍,向下取整),以此类推。
-
指定初始容量10的ArrayList:元素达10时扩容至15 → 达15时扩容至22, etc。
2.7 ArrayList核心方法底层实现(重点)
ArrayList的核心方法底层均基于数组操作,以下是关键方法的简化实现(基于JDK 8),帮助理解原理:
java
public class ArrayList<E> implements List<E> {
// 底层存储元素的数组(默认初始容量0,首次添加扩容至10)
transient Object[] elementData;
// 当前元素个数
private int size;
// 默认初始容量
private static final int DEFAULT_CAPACITY = 10;
// 空数组(无参构造时使用)
private static final Object[] DEFAULTCAPACITY_EMPTY_ELEMENTDATA = {};
// 1. 无参构造
public ArrayList() {
this.elementData = DEFAULTCAPACITY_EMPTY_ELEMENTDATA;
}
// 2. 尾插方法add(E e)
public boolean add(E e) {
ensureCapacityInternal(size + 1); // 检查容量,不足则扩容
elementData[size++] = e; // 元素存入数组,size自增
return true;
}
// 3. 检查容量的核心方法
private void ensureCapacityInternal(int minCapacity) {
if (elementData == DEFAULTCAPACITY_EMPTY_ELEMENTDATA) {
// 无参构造的首次扩容,最小容量设为默认容量10
minCapacity = Math.max(DEFAULT_CAPACITY, minCapacity);
}
ensureExplicitCapacity(minCapacity);
}
private void ensureExplicitCapacity(int minCapacity) {
// 记录集合修改次数(用于快速失败机制)
modCount++;
if (minCapacity - elementData.length > 0) {
// 容量不足,触发扩容
grow(minCapacity);
}
}
// 4. 扩容核心方法grow()
private void grow(int minCapacity) {
int oldCapacity = elementData.length;
// 计算新容量:旧容量的1.5倍(oldCapacity + oldCapacity >> 1)
int newCapacity = oldCapacity + (oldCapacity >> 1);
// 若新容量小于最小需求,直接用最小需求作为新容量
if (newCapacity - minCapacity < 0) {
newCapacity = minCapacity;
}
// 拷贝旧数组元素到新数组
elementData = Arrays.copyOf(elementData, newCapacity);
}
// 5. 获取元素方法get(int index)
public E get(int index) {
rangeCheck(index); // 检查索引合法性
return elementData(index); // 直接返回数组对应位置元素(需强转)
}
// 6. 删除方法remove(int index)
public E remove(int index) {
rangeCheck(index);
modCount++;
E oldValue = elementData(index); // 记录被删除元素
// 计算需要搬移的元素个数
int numMoved = size - index - 1;
if (numMoved > 0) {
// 从index+1位置开始,将后续元素向前搬移1位
System.arraycopy(elementData, index + 1, elementData, index, numMoved);
}
elementData[--size] = null; // 最后一位元素置为null,帮助GC
return oldValue;
}
// 7. 检查索引合法性
private void rangeCheck(int index) {
if (index >= size) {
throw new IndexOutOfBoundsException("Index: " + index + ", Size: " + size);
}
}
// 其他方法(set、clear、contains等)底层逻辑类似,均基于数组操作...
}
2.8 ArrayList的局限性
ArrayList基于连续数组实现,存在以下固有缺陷:
-
增删效率低:任意位置插入/删除元素时,需搬移后续元素(时间复杂度O(N)),元素越多,搬移成本越高。
-
扩容消耗大:扩容时需申请新空间、拷贝旧数组元素、释放旧空间,频繁扩容会显著影响性能。
-
空间浪费:扩容采用1.5倍增长策略,容易产生空闲空间(如容量15仅存6个元素,浪费9个空间)。
为解决上述问题,Java提供了基于链表实现的LinkedList。但链表是否能完美解决这些问题?又会带来哪些新问题?下文将详细说明。
三、LinkedList与链表
链表是与顺序表互补的线性表实现------物理存储上不连续,元素存储在独立的"节点"中,通过节点间的引用(指针)维护逻辑顺序。
3.1 链表的概念与分类
链表的结构多样,可通过三个维度组合出8种类型:
-
方向:单向(节点仅存下一个节点引用)/ 双向(节点存前、后两个节点引用)
-
头节点:带头(有一个虚拟头节点,不存储数据)/ 不带头(无虚拟头节点,第一个节点即数据节点)
-
循环性:循环(首尾节点相连)/ 非循环(尾节点引用为null)
实际开发和学习中,重点掌握两种核心结构:
3.1.1 无头单向非循环链表
结构最简单,节点仅包含数据和下一个节点的引用(next)。特点:
-
不适合单独存储数据(增删查效率低,需遍历查找节点)。
-
常用作其他数据结构的子结构(如哈希桶、图的邻接表)。
-
笔试/面试高频考点(如反转链表、判断环、求中间节点等)。
3.1.2 无头双向循环链表
节点包含数据、前驱引用(prev)和后继引用(next),尾节点的next指向头节点,头节点的prev指向尾节点。特点:
-
增删查效率高(双向遍历、无需搬移元素)。
-
Java中LinkedList的底层实现就是此类链表。
3.2 LinkedList简介
LinkedList是List接口的另一个核心实现类,同时实现了Deque接口(双端队列),因此兼具线性表和双端队列的特性。其核心特点如下:
-
底层结构:无头双向循环链表,元素存储在独立节点中,节点间通过prev和next引用连接。
-
增删效率:任意位置插入/删除元素时,无需搬移元素,仅需修改节点引用(时间复杂度O(1)),效率高于ArrayList。
-
查询效率:无索引支持,查询元素需从表头/表尾开始遍历(时间复杂度O(N)),效率低于ArrayList。
-
无容量限制:链表节点可动态创建,无需扩容(只要内存足够,可无限添加元素)。
3.3 LinkedList的构造方法
LinkedList提供两种核心构造方法:
| 构造方法 | 功能解释 |
|---|---|
| LinkedList() | 无参构造,创建一个空的双向循环链表。 |
| LinkedList(Collection<? extends E> c) | 基于已有集合构造,创建一个包含指定集合中所有元素的LinkedList,元素顺序与集合的迭代顺序一致。 |
构造方法使用示例:
java
public static void main(String[] args) {
// 1. 无参构造
List<String> list1 = new LinkedList<>();
// 2. 基于已有集合构造
List<Integer> srcList = new ArrayList<>(Arrays.asList(1,2,3));
List<Integer> list2 = new LinkedList<>(srcList);
System.out.println(list2); // 输出:[1, 2, 3]
}
3.4 LinkedList常见操作
LinkedList实现了List和Deque接口,因此拥有线性表和双端队列的双重操作方法。核心常用方法如下:
| 方法分类 | 方法签名 | 功能解释 |
|---|---|---|
| 线性表核心操作(实现List接口) | boolean add(E e) | 尾插元素e,成功返回true(底层调用addLast(e))。 |
| void add(int index, E e) | 在索引index位置插入元素e(需先遍历找到index位置节点,再修改引用)。 | |
| E remove(int index) | 删除index位置的元素,返回被删除元素。 | |
| E get(int index) | 获取index位置的元素(需遍历查找)。 | |
| E set(int index, E e) | 将index位置元素替换为e,返回旧元素。 | |
| 双端队列操作(实现Deque接口) | void addFirst(E e) | 头插元素e(直接修改头节点引用,效率O(1))。 |
| void addLast(E e) | 尾插元素e(直接修改尾节点引用,效率O(1))。 | |
| E removeFirst() | 删除并返回头节点元素;若集合为空,抛出NoSuchElementException。 | |
| E removeLast() | 删除并返回尾节点元素;若集合为空,抛出NoSuchElementException。 | |
| E getFirst() | 获取头节点元素;若集合为空,抛出NoSuchElementException。 | |
| E getLast() | 获取尾节点元素;若集合为空,抛出NoSuchElementException。 | |
| 其他常用操作 | void clear() | 清空链表(遍历所有节点,将prev、next、data置为null,帮助GC)。 |
| boolean contains(Object o) | 判断元素o是否存在(需遍历链表查找)。 | |
| int size() | 返回链表中元素个数(LinkedList维护了size变量,直接返回,无需遍历)。 |
3.5 LinkedList的遍历方式
LinkedList支持三种遍历方式,注意避开性能陷阱:
3.5.1 增强for循环(for-each)
语法简洁,效率较高,适用于仅需遍历元素的场景(底层基于迭代器实现)。
java
public static void main(String[] args) {
List<Integer> list = new LinkedList<>(Arrays.asList(1,2,3,4));
for (int num : list) {
System.out.println(num);
}
}
3.5.2 迭代器(Iterator)
支持遍历中删除元素,是LinkedList的推荐遍历方式(效率高于普通for循环)。
java
public static void main(String[] args) {
List<String> list = new LinkedList<>(Arrays.asList("a","b","c"));
Iterator<String> iterator = list.iterator();
while (iterator.hasNext()) {
String elem = iterator.next();
System.out.println(elem);
if ("b".equals(elem)) {
iterator.remove(); // 遍历中安全删除
}
}
System.out.println(list); // 输出:[a, c]
}
3.5.3 普通for循环(不推荐)
通过get(index)遍历,效率极低(每次get(index)都需从表头/表尾重新遍历到index位置,时间复杂度O(N²))。
警告:避免使用普通for循环遍历LinkedList!若需遍历并获取索引,可通过迭代器配合计数器实现。
3.6 LinkedList核心底层实现(重点)
LinkedList的核心是"节点"和"双向循环链表"的维护,以下是简化实现(基于JDK 8):
java
public class LinkedList<E> implements List<E>, Deque<E> {
// 元素个数
transient int size = 0;
// 首节点(无头节点,first直接指向第一个数据节点)
transient Node<E> first;
// 尾节点
transient Node<E> last;
// 链表节点类(双向节点)
private static class Node<E> {
E item; // 节点数据
Node<E> next; // 后继节点引用
Node<E> prev; // 前驱节点引用
Node(Node<E> prev, E element, Node<E> next) {
this.prev = prev;
this.item = element;
this.next = next;
}
}
// 1. 无参构造
public LinkedList() {}
// 2. 尾插方法addLast(E e)(add(E e)底层调用此方法)
public void addLast(E e) {
final Node<E> l = last;
// 创建新节点:前驱为原尾节点,后继为null
final Node<E> newNode = new Node<>(l, e, null);
last = newNode; // 更新尾节点为新节点
if (l == null) {
// 原链表为空,首节点也指向新节点(形成循环)
first = newNode;
} else {
// 原尾节点的后继指向新节点
l.next = newNode;
}
size++; // 元素个数自增
}
// 3. 头插方法addFirst(E e)
public void addFirst(E e) {
final Node<E> f = first;
final Node<E> newNode = new Node<>(null, e, f);
first = newNode;
if (f == null) {
last = newNode;
} else {
f.prev = newNode;
}
size++;
}
// 4. 获取指定索引节点(get(int index)底层调用)
Node<E> node(int index) {
// 优化:判断index靠近表头还是表尾,选择遍历方向(提升效率)
if (index < (size >> 1)) {
// 靠近表头,从表头开始遍历
Node<E> x = first;
for (int i = 0; i < index; i++) {
x = x.next;
}
return x;
} else {
// 靠近表尾,从表尾开始遍历
Node<E> x = last;
for (int i = size - 1; i > index; i--) {
x = x.prev;
}
return x;
}
}
// 5. 删除指定索引节点remove(int index)
public E remove(int index) {
checkElementIndex(index); // 检查索引合法性
return unlink(node(index)); // 找到节点,解除引用(unlink方法实现)
}
// 解除节点引用(核心删除逻辑)
E unlink(Node<E> x) {
final E element = x.item;
final Node<E> next = x.next; // 节点x的后继
final Node<E> prev = x.prev; // 节点x的前驱
// 处理前驱节点
if (prev == null) {
// x是首节点,更新首节点为next
first = next;
} else {
// 前驱节点的next指向x的后继
prev.next = next;
x.prev = null; // 帮助GC
}
// 处理后继节点
if (next == null) {
// x是尾节点,更新尾节点为prev
last = prev;
} else {
// 后继节点的prev指向x的前驱
next.prev = prev;
x.next = null; // 帮助GC
}
x.item = null; // 帮助GC
size--;
return element;
}
// 其他方法(get、set、clear等)底层均基于节点的遍历和引用修改...
}
四、ArrayList与LinkedList的核心区别
ArrayList和LinkedList作为List接口的两大实现类,核心差异源于底层数据结构(数组vs链表),具体区别如下表所示:
| 对比维度 | ArrayList | LinkedList |
|---|---|---|
| 底层结构 | 动态数组(连续空间) | 无头双向循环链表(非连续空间) |
| 索引支持 | 支持随机访问(通过索引直接访问,效率高) | 不支持随机访问(需遍历查找节点) |
| 增删效率 | 尾插/尾删效率高(O(1));中间插入/删除效率低(O(N),需搬移元素) | 任意位置增删效率高(O(1),仅修改节点引用);需先查找节点时,效率为O(N) |
| 查询效率 | 高(O(1),直接索引访问) | 低(O(N),需遍历链表) |
| 容量特性 | 有容量限制,需自动扩容(1.5倍增长),可能浪费空间 | 无容量限制,节点动态创建,无空间浪费 |
| 内存占用 | 内存占用少(仅存储元素,数组占用连续空间) | 内存占用多(每个节点需额外存储prev和next引用) |
| 遍历方式推荐 | 普通for循环(索引)、增强for循环 | 增强for循环、迭代器(避免普通for循环) |
| 适用场景 | 频繁查询、少量增删(如数据展示、查询系统) | 频繁增删、少量查询(如队列实现、任务调度) |
总结:选择ArrayList还是LinkedList,核心看"查询"和"增删"的频率------查询多选ArrayList,增删多选LinkedList;若不确定,优先选ArrayList(大多数场景下查询更频繁)。