从ArrayList到LinkedList:理解链表,掌握Java集合的另一种选择

在之前的博客中,我们深入探讨了ArrayList的实现原理和使用场景。但正如文档中指出的,ArrayList在任意位置插入和删除元素时存在效率问题,因为需要搬移后续元素,时间复杂度为O(n)。这正是Java集合框架引入LinkedList的原因。今天,让我们深入理解链表结构及其在Java中的实现------LinkedList。

一、ArrayList的缺陷回顾

让我们先回顾一下ArrayList的核心问题。ArrayList的底层实现是数组:

复制代码
// ArrayList底层使用数组来存储元素
transient Object[] elementData; // 存储元素的数组
private int size;              // 有效元素个数

连续存储带来的问题

  1. 插入/删除效率低:在任意位置(非尾部)插入或删除元素时,需要将后续元素整体搬移

  2. 空间浪费:1.5倍扩容策略可能导致空间利用率不高

  3. 固定容量:虽然能动态扩容,但每次扩容都有性能开销

时间复杂度分析

  • 随机访问: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的核心特性

  1. 实现了List接口

  2. 底层使用双向链表

  3. 没有实现RandomAccess接口,因此不支持随机访问

  4. 任意位置插入和删除元素时效率高,时间复杂度为O(1)

  5. 适合任意位置插入的场景

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 性能对比分析

访问元素

  • ArrayListlist.get(index)是O(1),因为可以直接计算内存地址

  • LinkedListlist.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. 假设链表带环,两个指针最后都会进入环

  2. 快指针先进环,慢指针后进环

  3. 当慢指针进环时,快指针已经在环中

  4. 每次移动,快慢指针之间的距离缩短1

  5. 在慢指针走完一圈前,快指针肯定能追上慢指针

扩展问题:快指针一次走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的情况

  1. 需要频繁随机访问元素

  2. 数据量相对稳定,或可预估

  3. 主要在尾部进行插入删除操作

  4. 内存空间有限

选择LinkedList的情况

  1. 需要频繁在任意位置插入删除元素

  2. 不需要频繁随机访问

  3. 数据量变化较大,无法预估

  4. 需要实现队列、双端队列等数据结构

8.2 实际应用场景

ArrayList适用场景

  • 查询操作远多于插入删除

  • 需要快速访问任意位置元素

  • 实现堆栈、数组相关的算法

LinkedList适用场景

  • 实现队列、双端队列

  • 需要频繁在列表中间插入删除

  • 实现LRU缓存淘汰算法

  • 浏览器历史记录(前进后退功能)

8.3 最佳实践

  1. 接口编程 :使用List<Integer> list = new LinkedList<>()而不是具体类

  2. 避免随机访问 :不要用for(int i=0; i<list.size(); i++)遍历LinkedList

  3. 利用特有方法 :充分利用addFirst(), removeLast()等O(1)操作

  4. 注意内存开销:LinkedList的节点开销较大,大数据量时需注意

  5. 线程安全 :两者都不是线程安全的,多线程环境需要同步或使用CopyOnWriteArrayList

九、总结

LinkedList作为Java集合框架中与ArrayList互补的数据结构,通过链式存储解决了ArrayList在频繁插入删除时的性能问题。理解其双向链表的实现原理,掌握其适用场景,能够帮助我们在实际开发中做出更合适的选择。

核心要点回顾

  1. 物理结构:ArrayList连续存储 vs LinkedList链式存储

  2. 时间复杂度:ArrayList随机访问O(1),LinkedList插入删除O(1)

  3. 使用选择:根据具体场景选择合适的数据结构

  4. 遍历方式:LinkedList避免使用索引遍历

  5. 内存考量:ArrayList内存紧凑,LinkedList节点开销大

无论是准备面试还是实际开发,深入理解ArrayList和LinkedList的差异,都能帮助我们编写出更高效、更合适的代码。下次当你在选择使用哪个List实现时,不妨先思考一下:我的主要操作是什么?是查询多还是修改多?这将成为你做出正确选择的关键。

相关推荐
错把套路当深情2 小时前
Java 全方向开发技术栈指南
java·开发语言
han_hanker2 小时前
springboot 一个请求的顺序解释
java·spring boot·后端
MaCa .BaKa2 小时前
44-校园二手交易系统(小程序)
java·spring boot·mysql·小程序·maven·intellij-idea·mybatis
希望永不加班2 小时前
SpringBoot 静态资源访问(图片/JS/CSS)配置详解
java·javascript·css·spring boot·后端
oh LAN3 小时前
RuoYi-Vue-master:Spring Boot 4.x (JDK 17+) (环境搭建)
java·vue.js·spring boot
ch.ju3 小时前
Java程序设计(第3版)第二章——java的数据类型:小数
java
Advancer-3 小时前
RedisTemplate 两种序列化实践方案
java·开发语言·redis
java1234_小锋3 小时前
Java高频面试题:MyBatis如何实现动态数据源切换?
java·开发语言·mybatis
墨神谕3 小时前
Java中,为什么要将.java文件编译成,class文件,而不是直接将.java编译成机器码
java·开发语言