前言
LinkedList是Java中常用的集合类之一,其核心价值有如下3点:
高效的增删操作。充分利用内存空间。用作其他数据结构(如栈、队列、双端队列)。
操千曲而后晓声,观千剑而后识器。虐它千百遍方能通晓其真意。
一、基本概念
1.1、定义
链表是一种物理存储单元上非连续、非顺序的存储结构,数据元素的逻辑顺序是通过链表中的指针链接次序实现的。
1.2、结点
链表由一系列结点(链表中每一个元素称为结点)组成,结点可以在运行时动态生成。
每个结点包括两个部分:
- 数据域:
存储数据元素。 - 指针域:
存储下一个结点的地址。
1.3、链表的类型
1.4、LinkedList的概述
LinkedList 是 java.util 包中的一个类,它实现了 List 接口。基于双向链表实现,具备高效的增删操作的能力和充分利用内存空间,允许在运行时动态生成结点。
二、单向链表及其手写实现
2.1、特点:
- 每个元素
只知道其下一个元素,意味着访问链表中的元素必须从头指针开始。 - 尾结点(
tail)指向null,是结束遍历的标识。
2.2、实现步骤:
- 1、定义一个单向链表类
SingleLinkedList。 - 2、定义一个结点类
Node。 - 3、链表是由一系列结点组成,它们之间是
组合关系,这种组合关系可使用内部类方式实现。 - 4、根据链表的特点还需要定义一个
头指针。 - 5、分别实现链表的
遍历、增、删、查等方法。
完整代码实现及详细注释如下:
import java.util.Iterator;
import java.util.function.Consumer;
/**
* 单向链表
*/
public class SingleLinkedList implements Iterable<Integer> {
/**
* 头指针: 代表链表的起始位置
* 根据头指针才能访问到链表中的其他元素
* 初始时, 头指针指向null, 意味着链表中无结点
*/
private Node head = null;
public void addFirst(int value) {
/**
* 1.链表为空:
* 创建新结点, 赋值给头指针
* 意味着头指针指向该结点, 然后将结点链入链表中, 此时新节点就是第一个结点
*/
// head = new Node(value, null);
//2.链表非空: 创建新结点, 其next指向head 然后再让head指向新结点后链入链表
//
/**
* 2.链表非空:
* 创建新结点, 其next指向head
* 然后再让head指向新结点后链入链表中
* 代码包含1场景, 保留如下代码即可, 但分析步骤不可少, 可细细体会下该过程
*/
head = new Node(value, head);
}
/**
* 查找最后一个结点
*/
private Node findLast() {
//空链表处理
if (head == null) {
return null;
}
Node p = head;
while (p.next != null) {
p = p.next;
}
return p;
}
/**
* 链表尾部添加数据, 需要先知晓最后一个结点
*/
public void addLast(int value) {
//1.查找到最后一个结点
Node last = findLast();
//2.处理链表为空的场景
if (last == null) {
addFirst(value);
return;
}
//3.创建新结点, 新结点指针指向null, 最后一个结点指向新结点
last.next = new Node(value, null);
}
/**
* 根据索引插入数据
* 1.需要先找到该索引的上一个结点 视为前结点
* 2.创建新结点:
* a.新结点的next指向前结点的next
* b.前结点的next指向新结点
*/
public void add(int index, int value) {
if (index == 0) {
addFirst(value);
return;
}
Node prev = findNode(index - 1);
if (prev == null) {
throw new IndexOutOfBoundsException();
}
prev.next = new Node(value, prev.next);
}
/**
* 1.根据索引获取结点的值 索引是在遍历的过程才知晓的
* 2.若在Node中加index属性, 增删时维护起来比较困难
*/
private Node findNode(int index) {
int i = 0;
for (Node p = head; p != null; p = p.next, i++) {
if (i == index) {
return p;
}
}
return null;
}
public int get(int index) {
Node node = findNode(index);
if (node == null) {
throw new IndexOutOfBoundsException();
}
return node.value;
}
/**
* 删除第一个节点, 通过改变头指针的指向就能实现
*/
public void removeFirst() {
if (head == null) {
throw new IndexOutOfBoundsException();
}
head = head.next;
}
/**
* 根据索引删除元素, 关键也是找到上一个结点
*/
public void remove(int index) {
if (index == 0) {
removeFirst();
return;
}
//查找索引对应的上一个结点
Node prev = findNode(index - 1);
if (prev == null) {
throw new IndexOutOfBoundsException();
}
//获取删除的节点
Node removed = prev.next;
if (removed == null) {
throw new IndexOutOfBoundsException();
}
//处理结点直接的指向
prev.next = removed.next;
}
/**
* while 循环遍历
*/
public void loop(Consumer<Integer> consumer) {
Node p = head;
while (p != null) {
// 循环中对链表的操作, 最好不要写到循环中, 而是把它当做参数传递进来
// System.out.println(p.value);
consumer.accept(p.value);
//更新数据
p = p.next;
}
}
/**
* for 循环遍历
*/
public void loop1(Consumer<Integer> consumer) {
for (Node p = head; p != null; p = p.next) {
consumer.accept(p.value);
}
}
/**
* 迭代器 遍历
*/
@Override
public Iterator<Integer> iterator() {
//此处也可使用匿名内部类
return new NodeIterator();
}
/**
* 1.结点类: 对结点描述
* 2.静态内部类: 未使用到外部类的属性时使用
* 3.内部类: 使用到外部类的属性时使用
* 4.为啥使用内部类?
* 1.与外部类是组合关系(链表是有多个结点组成), 实际应用中,
* 若遇到类似场景时, 需要考虑是否使用外部类和内部类组合实现
* 2.封装思想: 避免外部知晓更多信息,
* 对外暴露的信息越少越好, 外部不需要知道内部更底层的实现
*/
private static class Node {
/**
* 值
*/
int value;
/**
* 指向下一个结点的指针
*/
Node next;
public Node(int value, Node next) {
this.value = value;
this.next = next;
}
}
/**
* 内部类 使用到了外部类的head属性, 不能使用static
*/
private class NodeIterator implements Iterator<Integer> {
//指针初始值
Node p = head;
@Override
public boolean hasNext() {
//询问是否有下一个元素
return p != null;
}
@Override
public Integer next() {
// 1.返回当前值
int value = p.value;
// 2.并指向下一个元素
p = p.next;
return value;
}
}
}
三、单向链表(带哨兵)及其手写实现
单向链表中还有一种特殊的结点称为哨兵结点,它不存储数据,通常用作头尾,用来简化边界的判断,可对比一下不带哨兵的实现。
完整代码实现及详细注释如下:
/**
* 单向链表(带哨兵): 主要简化单链表的边界判断
*/
public class SentinelLinkedList implements Iterable<Integer> {
private Node head = new Node(666, null);
public void addFirst(int value) {
add(0, value);
}
public void addLast(int value) {
//带哨兵的最后一个结点不可能为null
Node last = findLast();
last.next = new Node(value, null);
}
public void add(int index, int value) {
Node prev = findNode(index - 1);
if (prev == null) {
throw new IndexOutOfBoundsException();
}
prev.next = new Node(value, prev.next);
}
public void removeFirst() {
remove(0);
}
public void remove(int index) {
//查找索引对应的上一个结点
Node prev = findNode(index - 1);
if (prev == null) {
throw new IndexOutOfBoundsException();
}
//获取删除的节点
Node removed = prev.next;
if (removed == null) {
throw new IndexOutOfBoundsException();
}
//处理结点直接的指向
prev.next = removed.next;
}
private Node findNode(int index) {
int i = -1;
for (Node p = head; p != null; p = p.next, i++) {
if (i == index) {
return p;
}
}
return null;
}
public int get(int index) {
Node node = findNode(index);
if (node == null) {
throw new IndexOutOfBoundsException();
}
return node.value;
}
private Node findLast() {
Node p = head;
while (p.next != null) {
p = p.next;
}
return p;
}
@Override
public Iterator<Integer> iterator() {
//此处也可使用匿名内部类
return new NodeIterator();
}
private static class Node {
int value;
Node next;
public Node(int value, Node next) {
this.value = value;
this.next = next;
}
}
private class NodeIterator implements Iterator<Integer> {
//指针初始值 从哨兵的下一个值开始遍历
Node p = head.next;
@Override
public boolean hasNext() {
return p != null;
}
@Override
public Integer next() {
int value = p.value;
p = p.next;
return value;
}
}
}
四、双向链表(带哨兵)及其手写实现
4.1、特点:
- 每个元素
知道其上一个元素和下一个元素。 - 头结点(
head)指向null。 - 尾结点(
tail)指向null。
/**
* 双向链表(带哨兵): 主要简化单链表的边界判断
*/
public class DoubleLinkedList implements Iterable<Integer> {
private final Node head;//头哨兵
private final Node tail;//尾哨兵
public DoubleLinkedList() {
head = new Node(null, 666, null);
tail = new Node(null, 888, null);
head.next = tail;
tail.prev = head;
}
/**
* 根据索引位置查找其对应的节点
*/
private Node findNode(int index) {
int i = -1;
for (Node p = head; p != tail; p = p.next, i++) {
if (i == index) {
return p;
}
}
return null;
}
public void addFirst(int value) {
add(0, value);
}
public void add(int index, int value) {
//查找上一个结点
Node prev = findNode(index - 1);
if (prev == null) {
throw new IndexOutOfBoundsException();
}
//查找下一个节点
Node next = prev.next;
Node inserted = new Node(prev, value, next);
prev.next = inserted;
next.prev = inserted;
}
public void addLast(int value) {
//带哨兵的最后一个结点不可能为null
Node last = tail.prev;
Node added = new Node(last, value, tail);
last.prev = added;
tail.next = added;
}
public void removeFirst() {
remove(0);
}
public void remove(int index) {
//查找索引对应的上一个结点
Node prev = findNode(index - 1);
if (prev == null) {
throw new IndexOutOfBoundsException();
}
//获取待删除的节点
Node removed = prev.next;
if (removed == tail) {
throw new IndexOutOfBoundsException();
}
//获取待删除的节点的下一个结点
Node next = removed.next;
prev.next = next;
next.prev = prev;
}
public void removeLast() {
Node removed = tail.prev;
if (removed == head) {
throw new IndexOutOfBoundsException();
}
Node prev = removed.prev;
prev.next = tail;
tail.prev = prev;
}
@Override
public Iterator<Integer> iterator() {
//此处也可使用匿名内部类
return new NodeIterator();
}
private static class Node {
Node prev;
int value;
Node next;
public Node(Node prev, int value, Node next) {
this.prev = prev;
this.value = value;
this.next = next;
}
}
private class NodeIterator implements Iterator<Integer> {
//指针初始值 从哨兵的下一个值开始遍历
Node p = head.next;
@Override
public boolean hasNext() {
return p != tail;
}
@Override
public Integer next() {
int value = p.value;
p = p.next;
return value;
}
}
}
五、循环链表
5.1、特点:
- 每个元素
只知道其下一个元素。 - 尾结点(
tail)指向头结点(head)。 - 实际开发中
不常用,做简单了解就好。
六、LinkedList的源码详解(基于JDK1.8)
通过上述手动实现,我们对LinkedList有了一个初步的认知,接下来深入探索JDK中LinkedList源码的实现,继续加深理解程度。关于如何阅读源码,可从如下3个方面入手:
- 类的继承关系及接口实现。
- 成员变量和构造函数。
- 核心方法。
图像表示:
6.1、类的继承关系及接口实现
public class LinkedList<E>
extends AbstractSequentialList<E>
implements List<E>, Deque<E>, Cloneable, java.io.Serializable {
...
}
知识以图片的形式进入大脑,记忆更为深刻。
其他接口在上一章节已详细描述,本章节主要介绍Deque
Deque是双端队列(Double-Ended Queue)的缩写,它允许在队列的两端进行插入和删除操作。LinkedList实现了 Deque接口,提供了双端队列的功能。
以下是LinkedList作为Deque的主要作用:
-
1、两端插入和删除
addFirst(E e):在队列头部添加一个元素。addLast(E e):在队列尾部添加一个元素。removeFirst():删除并返回队列头部的元素。removeLast():删除并返回队列尾部的元素。
-
2、两端访问
getFirst():返回队列头部的元素,但不删除。getLast():返回队列尾部的元素,但不删除。
-
3、迭代器
iterator():返回一个从队列头部开始的迭代器。descendingIterator():返回一个从队列尾部开始的迭代器。
6.2、成员变量和构造函数
/** 链表中元素的个数 */
transient int size = 0;
/** 头结点 */
transient Node<E> first;
//** 尾结点 */
transient Node<E> last;
//** 无参数构造 */
public LinkedList() {
}
/** 集合构造 */
public LinkedList(Collection<? extends E> c) {
this();
addAll(c);
}
6.3、核心方法(add(index,value)和node(index))
/**
* 添加指定元素到列表的末尾
*/
public boolean add(E e) {
linkLast(e);
return true;
}
/**
* 链入末尾的元素 做简单的指针变更操作
* 1.创建新结点 前驱指向链表中的尾结点 后继指向null
* 2.变更尾结点的指向
* 3.若原始尾结点为null,将新结点即为头结点
* 4.若原始尾节点不为null,将尾结点的后继指向新结点
*/
void linkLast(E e) {
final Node<E> l = last;
final Node<E> newNode = new Node<>(l, e, null);
last = newNode;
if (l == null)
first = newNode;
else
l.next = newNode;
size++;
modCount++;
}
/**
* 向指定的位置插入数据
* 1.下标越界检测
* 2.判断位置是否最后
* 3.查找node的位置
* 4.创建新结点插入指定位置
*/
public void add(int index, E element) {
checkPositionIndex(index);
if (index == size)
linkLast(element);
else
linkBefore(element, node(index));
}
/**
* 通过折半查找法
* 1. 小于size一半的从前往后遍历获取指定的节点
* 2. 大于size一半的从后往前遍历获取指定的节点
*/
Node<E> node(int index) {
// assert isElementIndex(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;
}
}
/**
* 对插入的元素做指针变换操作
*/
void linkBefore(E e, Node<E> succ) {
// assert succ != null;
final Node<E> pred = succ.prev;
final Node<E> newNode = new Node<>(pred, e, succ);
succ.prev = newNode;
if (pred == null)
first = newNode;
else
pred.next = newNode;
size++;
modCount++;
}
熟练掌握上述的核心方法,删除、修改、查询等操作也类似。就不一一讲述了。LinkedList相关面试攻略可查看ArrayList章节。
七、LinkedList的关键特性
7.1、双向链表:
- 基于
双向链表实现,每个结点有前驱、数据域、后继,能高效地从两端进行操作(核心价值)。
7.2、动态大小:
- 其
大小是动态的,可根据需要增长或缩小。
7.3、较慢的随机访问:
- 随机访问:时间复制度为
O(n),因为需要从头或尾开始逐个遍历结点。 - 顺序访问:虽然
随机访问较慢,但顺序访问(如使用迭代器)仍然非常高效。
7.4、高效的增删操作:
- 中间增删:在查找到增删位置的前提下,增删操作复杂度为
O(1)。 - 两端操作: 其实现了
Deque接口,能进行高效的两端操作。
7.5、内存占用:
- 内存开销大:其内部
结点需要额外的前驱及后继指针。
7.6、泛型支持:
支持泛型,可以指定存储的元素类型。
7.7、非线程安全:
非线程安全的,如需在多线程环境中使用,可以使用Collections.synchronizedList方法将其包装成线程安全的列表。
八、总结
8.1、优点:
高效的增删操作。充分利用内存空间。运行时动态生成结点。
8.2、缺点:
较慢的随机访问。内存开销较大。非线程安全,多线程环境下需要外部同步。
8.3、适用场景:
频繁的任意位置的增删操作。用作其他数据结构(如栈、队列和双端队列)。
码字不易,记得 关注 + 点赞 + 收藏 + 评论