在之前的博客中,我们深入探讨了ArrayList的实现原理和使用场景。但正如文档中指出的,ArrayList在任意位置插入和删除元素时存在效率问题,因为需要搬移后续元素,时间复杂度为O(n)。这正是Java集合框架引入LinkedList的原因。今天,让我们深入理解链表结构及其在Java中的实现------LinkedList。
一、ArrayList的缺陷回顾
让我们先回顾一下ArrayList的核心问题。ArrayList的底层实现是数组:
// ArrayList底层使用数组来存储元素
transient Object[] elementData; // 存储元素的数组
private int size; // 有效元素个数
连续存储带来的问题:
-
插入/删除效率低:在任意位置(非尾部)插入或删除元素时,需要将后续元素整体搬移
-
空间浪费:1.5倍扩容策略可能导致空间利用率不高
-
固定容量:虽然能动态扩容,但每次扩容都有性能开销
时间复杂度分析:
-
随机访问:O(1) - 优势
-
任意位置插入/删除:O(n) - 劣势
-
尾部插入:O(1) - 平均情况
正因为这些限制,Java提供了另一种选择------LinkedList。
二、链表:不连续存储的数据结构
2.1 链表的基本概念
链表 是一种物理存储结构上非连续的存储结构,数据元素的逻辑顺序是通过链表中的引用链接次序实现的。
链表的特性:
注意:
1. 链式结构在逻辑上是连续的,但在物理上不一定连续
2. 现实中的结点一般都是从堆上申请出来的
3. 从堆上申请的空间,是按照一定的策略来分配的,两次申请的空间可能连续,也可能不连续
2.2 链表的多样化结构
链表的结构非常多样,主要有以下几种组合:
| 维度 | 选项 | 描述 |
|---|---|---|
| 方向 | 单向/双向 | 节点只有后继指针/有前驱和后继指针 |
| 头节点 | 带头/不带头 | 是否有不存储数据的头节点 |
| 循环 | 循环/非循环 | 尾节点是否指向头节点形成环 |
这些组合起来共有8种链表结构,但文档指出我们重点掌握两种:
1. 无头单向非循环链表
-
结构最简单
-
一般不会单独用来存数据
-
实际中更多作为其他数据结构的子结构,如哈希桶、图的邻接表
-
笔试面试中常见
2. 无头双向链表
-
LinkedList底层实现就是无头双向链表
-
Java集合框架库中使用
-
实际开发中最常用
三、LinkedList的实现原理
3.1 LinkedList的底层结构
LinkedList的官方文档说明:
LinkedList的底层是双向链表结构,由于链表没有将元素存储在连续的空间中,
元素存储在单独的节点中,然后通过引用将节点连接起来了,
因此在任意位置插入或者删除元素时,不需要搬移元素,效率比较高。
LinkedList的核心特性:
-
实现了List接口
-
底层使用双向链表
-
没有实现RandomAccess接口,因此不支持随机访问
-
任意位置插入和删除元素时效率高,时间复杂度为O(1)
-
适合任意位置插入的场景
3.2 节点的内部结构
LinkedList的节点通常包含:
class Node<E> {
E item; // 存储的数据
Node<E> prev; // 前驱节点引用
Node<E> next; // 后继节点引用
}
这种双向链接的结构使得LinkedList可以在两个方向上遍历,并且插入和删除操作更加高效。
四、LinkedList的使用方法
4.1 LinkedList的构造方法
文档中列出了LinkedList的构造方法:
| 方法 | 解释 |
|---|---|
LinkedList() |
无参构造 |
public LinkedList(Collection<? extends E> c) |
使用其他集合中的元素构造List |
使用示例:
// 构造一个空的LinkedList
List<Integer> list1 = new LinkedList<>();
// 使用ArrayList构造LinkedList
List<String> list2 = new ArrayList<>();
list2.add("JavaSE");
list2.add("JavaWeb");
list2.add("JavaEE");
List<String> list3 = new LinkedList<>(list2);
4.2 LinkedList的常用方法
文档提供了详尽的LinkedList方法列表:
| 方法 | 解释 | 时间复杂度 |
|---|---|---|
boolean add(E e) |
尾插e | O(1) |
void add(int index, E element) |
在index位置插入元素 | O(n) - 需要找到位置 |
boolean addAll(Collection<? extends E> c) |
尾插c中的元素 | O(m) - m为c的大小 |
E remove(int index) |
删除index位置元素 | O(n) - 需要找到位置 |
boolean remove(Object o) |
删除遇到的第一个o | O(n) |
E get(int index) |
获取index位置元素 | O(n) |
E set(int index, E element) |
设置index位置元素 | O(n) |
void clear() |
清空 | O(1) |
boolean contains(Object o) |
判断o是否在线性表中 | O(n) |
int indexOf(Object o) |
返回第一个o所在下标 | O(n) |
int lastIndexOf(Object o) |
返回最后一个o的下标 | O(n) |
List<E> subList(int fromIndex, int toIndex) |
截取部分list | 返回视图 |
LinkedList特有的方法:
LinkedList<Integer> list = new LinkedList<>();
list.add(1); // 尾插
list.add(2);
list.add(3);
// LinkedList独有的方法
list.addFirst(0); // 在头部添加
list.addLast(4); // 在尾部添加
list.removeFirst(); // 删除头部元素
list.removeLast(); // 删除尾部元素
list.getFirst(); // 获取头部元素
list.getLast(); // 获取尾部元素
这些特有方法的时间复杂度都是O(1),这是LinkedList的优势所在。
4.3 LinkedList的遍历方式
文档中展示了三种遍历LinkedList的方式:
LinkedList<Integer> list = new LinkedList<>();
// ... 添加元素
// 1. for循环 + 下标(效率最低,不推荐)
for (int i = 0; i < list.size(); i++) {
System.out.print(list.get(i) + " "); // 每次get都是O(n)
}
// 2. foreach循环(推荐)
for (Integer num : list) {
System.out.print(num + " ");
}
// 3. 迭代器(推荐)
Iterator<Integer> it = list.iterator();
while (it.hasNext()) {
System.out.print(it.next() + " ");
}
重要提示 :由于LinkedList的get(int index)是O(n)操作,使用for循环+下标的方式遍历LinkedList是效率最低的,应该避免在需要频繁随机访问的场景下使用。
五、ArrayList vs LinkedList:如何选择?
文档中提供了详细的对比表格:
| 不同点 | ArrayList | LinkedList |
|---|---|---|
| 存储空间 | 物理上一定连续 | 逻辑上连续,物理上不一定连续 |
| 随机访问 | 支持O(1) | 不支持,需要O(N) |
| 头插 | 需要搬移元素,O(N) | 只需修改引用,O(1) |
| 扩容 | 空间不够时需要扩容 | 没有容量的概念,按需申请 |
| 应用场景 | 元素高效存储 + 频繁访问 | 任意位置插入和删除频繁 |
5.1 性能对比分析
访问元素:
-
ArrayList :
list.get(index)是O(1),因为可以直接计算内存地址 -
LinkedList :
list.get(index)是O(n),需要从头或从尾遍历
插入元素:
-
ArrayList尾部插入:平均O(1),最坏O(n)(需要扩容)
-
ArrayList任意位置插入:O(n),需要搬移元素
-
LinkedList任意位置插入:查找位置O(n) + 插入O(1)
-
LinkedList头尾插入:O(1)
删除元素:
-
ArrayList尾部删除:O(1)
-
ArrayList任意位置删除:O(n),需要搬移元素
-
LinkedList任意位置删除:查找位置O(n) + 删除O(1)
-
LinkedList头尾删除:O(1)
5.2 内存使用对比
-
ArrayList:只存储数据,内存利用率高,但有扩容开销
-
LinkedList:每个节点需要额外存储前驱和后继引用,内存开销大约为ArrayList的2-4倍
六、链表面试题解析
这里重点分析其中几个经典问题:
6.1 判断链表是否有环(第9题)
问题:给定一个链表,判断链表中是否有环。
思路:快慢指针法
public boolean hasCycle(ListNode head) {
if (head == null || head.next == null) return false;
ListNode slow = head;
ListNode fast = head;
while (fast != null && fast.next != null) {
slow = slow.next; // 慢指针走一步
fast = fast.next.next; // 快指针走两步
if (slow == fast) { // 相遇说明有环
return true;
}
}
return false; // 快指针走到末尾,说明无环
}
为什么快指针走两步,慢指针走一步可以?
详细解释:
-
假设链表带环,两个指针最后都会进入环
-
快指针先进环,慢指针后进环
-
当慢指针进环时,快指针已经在环中
-
每次移动,快慢指针之间的距离缩短1
-
在慢指针走完一圈前,快指针肯定能追上慢指针
扩展问题:快指针一次走3步、4步可以吗?
不一定可以。比如快指针走3步,慢指针走1步,可能会出现快指针刚好跳过慢指针的情况,导致无法相遇。
6.2 找到环的入口点(第10题)
问题:给定一个链表,返回链表开始入环的第一个节点。如果链表无环,则返回null。
结论:
让一个指针从链表头开始,另一个指针从相遇点开始,每次都走一步,最终会在环的入口点相遇。
证明概要:
设:H到E(入口点)距离为L,E到M(相遇点)距离为X,环长度为R
则:M到E距离为R-X
由快慢指针速度关系:2(L+X) = L+X+nR
推导得:L = nR - X
当n=1时:L = R - X
这意味着:从链表头到入口点的距离 = 从相遇点到入口点的距离
七、模拟实现LinkedList
LinkedList模拟实现的方法框架:
public class MyLinkedList {
// 头插法
public void addFirst(int data) {}
// 尾插法
public void addLast(int data) {}
// 任意位置插入
public void addIndex(int index, int data) {}
// 查找是否包含关键字key
public boolean contains(int key) {}
// 删除第一次出现的关键字key
public void remove(int key) {}
// 删除所有值为key的节点
public void removeAllKey(int key) {}
// 得到链表的长度
public int size() {}
public void display() {}
public void clear() {}
}
通过自己实现这些方法,可以深入理解链表的操作原理。
八、实战建议与总结
8.1 如何选择ArrayList和LinkedList?
选择ArrayList的情况:
-
需要频繁随机访问元素
-
数据量相对稳定,或可预估
-
主要在尾部进行插入删除操作
-
内存空间有限
选择LinkedList的情况:
-
需要频繁在任意位置插入删除元素
-
不需要频繁随机访问
-
数据量变化较大,无法预估
-
需要实现队列、双端队列等数据结构
8.2 实际应用场景
ArrayList适用场景:
-
查询操作远多于插入删除
-
需要快速访问任意位置元素
-
实现堆栈、数组相关的算法
LinkedList适用场景:
-
实现队列、双端队列
-
需要频繁在列表中间插入删除
-
实现LRU缓存淘汰算法
-
浏览器历史记录(前进后退功能)
8.3 最佳实践
-
接口编程 :使用
List<Integer> list = new LinkedList<>()而不是具体类 -
避免随机访问 :不要用
for(int i=0; i<list.size(); i++)遍历LinkedList -
利用特有方法 :充分利用
addFirst(),removeLast()等O(1)操作 -
注意内存开销:LinkedList的节点开销较大,大数据量时需注意
-
线程安全 :两者都不是线程安全的,多线程环境需要同步或使用
CopyOnWriteArrayList
九、总结
LinkedList作为Java集合框架中与ArrayList互补的数据结构,通过链式存储解决了ArrayList在频繁插入删除时的性能问题。理解其双向链表的实现原理,掌握其适用场景,能够帮助我们在实际开发中做出更合适的选择。
核心要点回顾:
-
物理结构:ArrayList连续存储 vs LinkedList链式存储
-
时间复杂度:ArrayList随机访问O(1),LinkedList插入删除O(1)
-
使用选择:根据具体场景选择合适的数据结构
-
遍历方式:LinkedList避免使用索引遍历
-
内存考量:ArrayList内存紧凑,LinkedList节点开销大
无论是准备面试还是实际开发,深入理解ArrayList和LinkedList的差异,都能帮助我们编写出更高效、更合适的代码。下次当你在选择使用哪个List实现时,不妨先思考一下:我的主要操作是什么?是查询多还是修改多?这将成为你做出正确选择的关键。