1 序
api文档给出的解释还是可以仔细读,从中可以得到综述信息。
- 双端队列
- 实现了list接口的所有操作
- 允许添加null
- JDK版本为1.8
与ArrayList一样,linkedList本身也不是线程安全的。api解释到了,如果在多线程环境下使用list并且没有添加必要的同步代码,那么更推荐使用Collections.synchronizedList。
LinkedList对象获取的遍历器满足fail-fast策略,多线程环境下的使用就要注意了。但是请不要依赖于fail-fast策略来在多线程环境下使用这个容器。这并不能保证避免一些少见、晦涩的异常。到时候就很难排查了。
基于链表的数据结构,add和remove操作linkedList的表现比ArrayList好,其add(E)与remove()操作的时间复杂度是O(1),get(int)与remove()为O(n)。ArrayList基于动态数组的数据结构与更适合随机访问的场景。
2 代码
2.1 类的继承结构
public class LinkedList<E>
extends AbstractSequentialList<E>
implements List<E>, Deque<E>, Cloneable, java.io.Serializable
除了期望中的list、cloneable和序列化之外,还有一个deque队列接口和AbstractSequentialList。
实现了队列接口意味着linkedList实现了队列数据结构的一系列方法,比如pop/peek/push等等。
另一个AbstractSequentialList是一个之前从没有去注意过的另一个抽象类。一个list接口的最小化实现。其通过迭代器实现了与随机访问不同的get/set/add等一系列方法。这一系列方法可以被linkedList复用到。
API中的介绍说明LinkedList的一部分特质是和ArrayList类似的(这些特质也是List接口本身决定的)
- 允许插入null
- 允许重复元素
2.2 链表基础操作
和在以前数据结构的教材上看到的类似,类中包含了一个头结点和一个尾节点。
/**
* Pointer to first node.
* Invariant: (first == null && last == null) ||
* (first.prev == null && first.item != null)
*/
transient Node<E> first;
/**
* Pointer to last node.
* Invariant: (first == null && last == null) ||
* (last.next == null && last.item != null)
*/
transient Node<E> last;
/* LinkedList的内部类,类结构本身比较简单,一个前驱节点一个后继节点和一个用来容纳节点内容的泛型对象*/
private static class Node<E> {
E item;
Node<E> next;
Node<E> prev;
Node(Node<E> prev, E element, Node<E> next) {
this.item = element;
this.next = next;
this.prev = prev;
}
}
链表的插入操作和删除操作都是修改对应前驱和后继节点的指向。这个Node对象是LinkedList的一个内部类,包含一个前驱引用和一个后继引用,这样的数据结构可以帮助实现双端队列。
2.2.1 构造函数
总计有两个构造函数,一个无参构造和一个接纳已有集合中元素的构造函数。
/**
* Constructs an empty list.
*/
public LinkedList() {
}
/**
* Constructs a list containing the elements of the specified
* collection, in the order they are returned by the collection's
* iterator.
*
* @param c the collection whose elements are to be placed into this list
* @throws NullPointerException if the specified collection is null
*/
public LinkedList(Collection<? extends E> c) {
this();
addAll(c);
}
无参构造没有做任何多余的操作。另一个构造函数则会执行addAll方法。这个方法是可以直接调用的,向链表的指定位置插入集合列表。
public boolean addAll(int index, Collection<? extends E> c) {
checkPositionIndex(index);
// 集合对象转换为Object对象数组
Object[] a = c.toArray();
int numNew = a.length;
// 判断数组长度,验证有效性
if (numNew == 0)
return false;
// 用于保存原有链表指定位置前后的两个Node节点
Node<E> pred, succ;
if (index == size) {
succ = null;
pred = last;
} else {
succ = node(index);
pred = succ.prev;
}
// for循环遍历对象数组,注释中说明了toArray方法生成的数组排列顺序取决于collection对象遍历器的实现逻辑
for (Object o : a) {
@SuppressWarnings("unchecked") E e = (E) o;
Node<E> newNode = new Node<>(pred, e, null);
if (pred == null)
first = newNode;
else
pred.next = newNode;
pred = newNode;
}
// 将新生成的链表段与之前保存的前驱后缀节点链接起来
if (succ == null) {
last = pred;
} else {
pred.next = succ;
succ.prev = pred;
}
// 更新链表size字段,该字段表明链表长度
size += numNew;
// modCount字段累加
modCount++;
return true;
}
2.2.2 增删改查
boolean add(E e)
向链表添加元素,是在其表尾新增一个节点(尾插法)。如果加入的元素是链表中的第一个元素,那么首节点和尾节点会同时指向这个节点元素。
public boolean add(E e) {
linkLast(e);
return true;
}
/**
* Links e as last element.
*/
void linkLast(E e) {
final Node<E> l = last;
final Node<E> newNode = new Node<>(l, e, null);
// 新的元素,赋值给当前链表的尾节点
last = newNode;
// 如果之前的尾节点为空即当前add方法加入的是这个链表中唯一的一个元素,那么头结点的指向也会指向新加入的节点newNode
if (l == null)
first = newNode;
else
l.next = newNode;
// 更新链表的长度size字段
size++;
// 累加modCount统计字段
modCount++;
}
示例为向链表尾加入一个新元素的方法linkLast
注意Node节点是LinkedList里的一个内部类。结构简洁,包含一个prev,一个next和一个泛型化的元素容器类成员item。
同时add操作也对modCount计数做了累加操作。
public boolean addAll(Collection<? extends E> c)
这个方法的内部调用非常简洁,直接复用前面已经分析过的方法==addAll(int index, Collection<? extends E> c)==。位置参数直接指定为当前链表的表尾。注意这两个方法都是由public修饰,所以都可以直接使用。
boolean remove(Object o)
删除指定元素,那么需要先遍历链表检查是否确实包含了这个目标元素。然后修改该位置元素的指向,包括检查对应的prev和next指向。
// 注意区分删除的是节点内容为null的节点,不是删除null节点。这个方法的删除遍历顺序是从头节点开始遍历,找到并删除第一个匹配的元素。
public boolean remove(Object o) {
if (o == null) {
for (Node<E> x = first; x != null; x = x.next) {
if (x.item == null) {
unlink(x);
return true;
}
}
} else {
for (Node<E> x = first; x != null; x = x.next) {
if (o.equals(x.item)) {
unlink(x);
return true;
}
}
}
return false;
}
链表的删除操作-解除这个节点在链表中的前驱、后继的关联关系。
E unlink(Node<E> x) {
// assert x != null;
final E element = x.item;
final Node<E> next = x.next;
final Node<E> prev = x.prev;
if (prev == null) {
// 如果前驱节点为null即当前要删除的节点就是链表的头结点,则将被删除节点的后继节点赋给头结点
first = next;
} else {
// 如果前驱节点不为null则将要删除节点的后继指向删除节点的前驱节点的后继。然后释放被删除节点的前驱引用
prev.next = next;
x.prev = null;
}
if (next == null) {
// 如果后继节点为null即当前要删除的节点就是链表的尾结点,则将被删除节点的后继节点赋给尾结点
last = prev;
} else {
// 如果后继节点不为null则将要删除节点的前驱指向删除节点的前驱节点的前驱。然后释放被删除节点的后继引用。
next.prev = prev;
x.next = null;
}
// 将被删除元素的内容item释放
x.item = null;
// 更新链表长度的size字段
size--;
// modCount统计字段累加
modCount++;
// 返回这个被删除的节点的内含元素内容
return element;
}
要想在链表中访问到指定位置的元素,需要从头节点开始遍历直到指定下标的位置。这里就和随机访问存在区别和性能差异了。如果链表size很大,那么访问一个元素的操作就会耗时较多。
链表的删除操作都是将指定位置的元素的头结点指向这个元素的next节点。注意为了方便GC回收释放空间,修改了引用指向后,被删除元素的prev与next指向都一并置空,包括置空元素的item指向。
E remove(int index)
public E remove(int index) {
checkElementIndex(index);
return unlink(node(index));
}
除了删除指定元素,还有一个删除指定位置的元素方法。这个方法需要首先检查是否有数组越界的潜在危险。找到指定位置上的元素然后执行unlink方法来解除已经存在的引用关系。
/**
* Returns the (non-null) Node at the specified element index.
*/
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;
}
}
这个方法在增删查的操作中会被经常用到。获取指定下标位置的非空节点对象。
E get(int index)
/**
* Returns the element at the specified position in this list.
*
* @param index index of the element to return
* @return the element at the specified position in this list
* @throws IndexOutOfBoundsException {@inheritDoc}
*/
public E get(int index) {
checkElementIndex(index);
return node(index).item;
}
和上一个方法一样,获取指定位置的元素内容,首先检查数组下标是否是在正确的范围内,然后用node(index)遍历获取到目标位置的元素item内容。注意区分linkedList与ArrayList的获取指定下标元素方法的实现,这里可以区别随机访问与顺序访问。
E set(int index, E element)
public E set(int index, E element) {
// 检查下标参数合法性
checkElementIndex(index);
// 获取index下标的Node节点
Node<E> x = node(index);
// 获取节点原有元素
E oldVal = x.item;
// 将新的元素值赋给这个节点
x.item = element;
// 返回被替换的节点的值 oldValue
return oldVal;
}
设置指定位置的元素。
跟指定元素位置有关的操作都需要先检查入参下标是否是可用的。下标检查不通过的都会抛出数组越界异常IndexOutOfBoundsException。然后node(index)方法获取指定下标位置的元素,修改该元素的item引用并返回被替换掉的原有的item值。set方法操作成功会返回原有的旧的值。
void add(int index, E element)
public void add(int index, E element) {
checkPositionIndex(index);
if (index == size)
linkLast(element);
else
linkBefore(element, node(index));
}
向指定位置插入元素
- 检查入参下标
- 如果下标是当前list的尾巴上,直接addLast,与add方法的实现一致(尾插法)
- 如果是在list中嵌入一个元素。那个要对应修改前后节点元素的prev与next的指向。
顺序表(如ArrayList)插入一个元素后,就要将其后的元素一一进行后移。但是链表只需要修改共计三个节点的前后指向关系。所以这里的操作成本比较,链表是更好的。所以这里就可以看到链表的插入和删除操作的优越性。
2.3 队列系列操作
因为Node的实现里面包含了前节点的引用后后续节点的引用,再加上实现了Deque接口,因此LinkedList是一个双端队列的实现。其中的一系列操作方法(在队列首、尾的插入与删除操作)都是基于前面我们提到的增删查改的操作完成的。
peek()
public E peek() {
final Node<E> f = first;
return (f == null) ? null : f.item;
}
返回队首元素,不过这个方法不会删除原有队首元素。
element()
public E element() {
return getFirst();
}
public E getFirst() {
final Node<E> f = first;
if (f == null)
throw new NoSuchElementException();
return f.item;
}
返回队首元素,与前一个方法不同的地方在于,如果当前list列表是一个空列表的话,会抛出异常
与上面这一组方法类似的还有一组方法,这里放在一起比较。
E poll()
E remove()
这两个方法都是删除队首元素,poll在遇到队列为空的情况下返回null而remove会抛出异常。
boolean offer(E e)
向队尾加入元素
2.4 双端队列系列操作
包含一系列的双端队列队首队尾插入、获取双端队列队首队尾元素、队首队尾删除等等。这一些列方法都是LinkedList类实现的Deque接口所要求的方法。
2.5 值得注意的地方
LinkedList实现了cloneable接口,但是其实现的克隆操作是一个浅拷贝。注意ArrayList也仅仅是实现了一个浅拷贝方法。
boolean removeFirstOccurrence(Object o)
删除list列表中遇到的第一个符合要求的object元素的对象。其实这个方法就是很直接的包装了remove(Object)方法。
boolean removeLastOccurrence(Object o)
删除list列表中遇到的最后一个符合要求的object元素的对象。这个方法是前一个方法的另一端。反过来从队列的尾端开始寻找。
3 总结
LinkedList的使用,主要可以与ArrayList作对比,二者有各自的适用场景和特点。用一句很简洁的话说:ArrayList适合快速随机访问操作而其插入删除的操作效率不如LinkedList。当然这几句话几乎是背下来的,至于为什么是这个结论,需要结合类的数据结构和思想来总结。
可以简要对比一下几种主要操作二者的时间复杂度:
- add(E e) ArrayList是O(n),LinkedList是O(1)
- remove(int n) ArrayList是O(n),LinkedList是O(1)
- get(int n) ArrayList是O(1),LinkedList是O(n)