1. 基本用法
LinkedList实现了List、Deque、Queue接口,可以按照队列、栈和双端队列的方式进行操作。LinkedList有两个构造方法,一个是默认构造,另一个接受Collection:
public LinkedList() public LinkedList(Collection<? extends E> c)
可以按照List操作:
List<Integer> list = new LinkedList<>(); List<Integer> list1 = new LinkedList<>(Arrays.asList(2, 3, 4, 5));
LinkedList还实现了队列接口Queue,队列的特点是先进先出,在尾部添加数据,在头部删除数据,其接口定义为:
public interface Queue<E> extends Collection<E> { // 在尾部添加元素 boolean add(E e); // 在尾部添加元素 boolean offer(E e); // 返回头部元素,并且从队列中删除 E remove(); // 返回头部元素,并且从队列中删除 E poll(); // 返回头部元素,但不改变队列 E element(); // 返回头部元素,但不改变队列 E peek(); }
Queue接口扩展了Collection,主要有三种操作,在尾部添加数据(add、offer)、查看头部元素(element、peek)和删除头部元素(remove、poll)。每种操作都有两种形式,区别在于特殊情况的处理不同。特殊情况是指当队列为空或者为满时,为空就是没有元素数据,为满是指队列有长度大小限制,而且已经占满了。LinkedList的实现中,队列长度没有限制,但是其他的Queue的实现可能有。在队列为空时,remove和element会抛出异常NoSuchElementException,而poll和peek返回null;在队列为满时,add会抛出IllegalStateException,而offer只是返回false。
把LinkedList当做Queue使用:
Queue<String> queue = new LinkedList<>(); queue.offer("a"); queue.offer("b"); queue.offer("c"); while (queue.peek() != null) { System.out.println(queue.poll()); }
栈是一种和队列特点相反的数据结构,它的特点是先进后出,后进先出。Java中没有单独的栈接口,栈的相关方法包括在了表示双端队列的接口Deque中,主要有三个方法:
// 入栈 void push(E e); // 出栈 E pop(); // 查看 E peek();
push表示入栈,在头部添加元素,栈的空间可能是有限的,如果栈满了,push会抛出IllegalStateException;pop表示出栈,返回头部元素,并且从栈中删除,如果栈为空会抛出NoSuchElementException;peek查看栈头部元素,不修改栈,如果栈为空,返回特殊值null。使用方法如下:
Deque<Integer> stack = new LinkedList<>(); stack.push(1); stack.push(2); stack.push(3); while (stack.peek() != null) { System.out.println(stack.pop()); } /** * output: * 3 * 2 * 1 */
Java中还有一个Stack类,就是栈的意思,它也实现了栈的一些方法,如push、pop、peek等,但它没有实现Deque接口,他是Vector的子类它增加的这些方法也通过synchronized实现了线程安全。由于Vector和Stack内部使用了大量的syncronized做同步操作,效率比较低,已经过时了,具体就不学习了。
栈和队列都是在两端进行操作,栈只操作头部,队列两端都操作,但只在尾部添加、头部只查看和删除元素。有一个更为通用的操作两端的接口Deque。接口定义如下:
public interface Deque<E> extends Queue<E> { void addFirst(E e); void addLast(E e); boolean offerFirst(E e); boolean offerLast(E e); E removeFirst(); E removeLast(); E pollFirst(); E pollLast(); E getFirst(); E getLast(); E peekFirst(); E peekLast(); //删除第一次出现的指定元素(从头到尾遍历) boolean removeFirstOccurrence(Object o); //删除最后次出现的指定元素(从头到尾遍历) boolean removeLastOccurrence(Object o); boolean add(E e); boolean offer(E e); E remove(); E poll(); E element(); E peek(); void push(E e); E pop(); boolean remove(Object o); // 队列是否包含指定元素 boolean contains(Object o); public int size(); Iterator<E> iterator(); // 从后往前遍历的迭代器 Iterator<E> descendingIterator(); }
根据方法名很容易知道作用,稍微不太清晰的做了注释,descendingIterator()方法示例如下:
Deque<String> deque = new LinkedList<>(Arrays.asList("a", "b", "c", "d")); Iterator<String> it = deque.descendingIterator(); while (it.hasNext()) { System.out.print(it.next() + " "); } /** * output: * d c b a */
下面看下实现原理。
2. 原理
先来看下LinkedList的内部组成,再分析一些主要方法的实现,代码基于JDK8。
2.1内部组成
我们知道,ArrayList内部是数组,元素在内存中是连续存放的,基于索引的访问效率非常高,但LinkedList不是。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类表示节点,item指向实际的元素,next后一个节点,prev指向前一个节点。LinkedList内部组成就是如下三个实例变量:
transient int size = 0; transient Node<E> first; transient Node<E> last;
size表示链表长度,默认为0,first指向头节点,last指向尾节点,初始值都是null。LinkedList的所有public方法内部操作的就是这三个实例变量,来看下具体方法:
2.2 add方法
public boolean add(E e) { linkLast(e); return true; } void linkLast(E e) { // 将尾节点赋给l变量 final Node<E> l = last; // 新建节点,将l赋给新建节点的pre前驱节点,e为当前节点的元素值,新建节点没有后继节点,所以为null final Node<E> newNode = new Node<>(l, e, null); // 将新建节点赋给尾节点 last = newNode; // 如果尾节点不存在,就将新建节点作为头结点赋给first实例变量 if (l == null) first = newNode; // 如若尾节点存在,就将新建节点作为尾节点的后继节点赋给l.next else l.next = newNode; // 链表长度加1 size++; // 修改次数加1 modCount++; }
代码的基本步骤见代码中注释,modCount变量用来记录修改次数,便于在迭代中检测结构性变化。我们根据图示来理解下。比如如下代码:
List<String> list = new LinkedList<String>(); list.add("a"); list.add("b");
当新建list对象后内部结构如图一,头结点和尾节点都是null;当添加“a”后内部结构如图二,size加1,头结点和尾节点都指向同一个Node节点;当添加完“b”后内部结构如图三所示。
2.3 根据索引访问元素
来看下get方法:
public E get(int index) { // 检查索引位置的有效性,若无效,抛出异常 checkElementIndex(index); // 索引有效,执行node方法,查找指定索引位置的元素并返回 return node(index).item; } private void checkElementIndex(int index) { if (!isElementIndex(index)) // 抛出未受检异常,索引越界异常 throw new IndexOutOfBoundsException(outOfBoundsMsg(index)); } private boolean isElementIndex(int index) { return index >= 0 && index < size; } Node<E> node(int index) { // 若索引位置在前半部分,则从头结点开始查找(右移一位相当于除以2),若找到返回节点 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; } }
与ArrayList不同,ArrayList中数组元素连续存放,可以根据索引直接定位,而在LinkedList中,则必须从头到尾顺着连接查找,效率比较低。
2.4 按内容查找元素
看下indexOf的代码:
public int indexOf(Object o) { int index = 0; // 查找元素为null时 if (o == null) { for (Node<E> x = first; x != null; x = x.next) { if (x.item == null) return index; index++; } // 查找元素不为null时 } else { for (Node<E> x = first; x != null; x = x.next) { if (o.equals(x.item)) return index; index++; } } // 买找到指定元素返回-1 return -1; }
代码比较简单,有两种情况,都是从头节点开始找,见代码注释。
2.5 插入元素
add是在尾部添加元素,如果在头部或者中间插入元素可以使用如下重载方法:
public void add(int index, E element) { checkPositionIndex(index); // 这就是在尾部添加元素 if (index == size) linkLast(element); // 主要看这个 else linkBefore(element, node(index)); } void linkBefore(E e, Node<E> succ) { // succ不为空,就把succ的前驱节点赋给pred final Node<E> pred = succ.prev; // 新建节点,将pred指定为新建节点的前驱节点,succ为后继节点 final Node<E> newNode = new Node<>(pred, e, succ); // 将后的前驱指向新建节点 succ.prev = newNode; // 将前驱的后继指向新建节点,若前驱为空,修改头结点指向新节点 if (pred == null) first = newNode; else pred.next = newNode; // 增加长度 size++; modCount++; }
下面通过图示来加深理解,比如添加一个元素
list.add(1, "c");
可以看出,在中间插入元素,LinkedList只需按需分配内存,修改前驱和后继节点的链接,而ArrayList则可能需要分配很多的额外空间,且移动、复制所有后继元素。
2.6 删除元素
再来看看删除元素的代码:
public E remove(int index) { // 同上检查索引是否有效 checkElementIndex(index); // node方法先查找节点,再执行unlink删除指定节点 return unlink(node(index)); } E unlink(Node<E> x) { // x节点不为空,取得节点元素值 final E element = x.item; // 取得前驱节点 final Node<E> next = x.next; // 取得后继节点 final Node<E> prev = x.prev; // 指定前驱的后继为x的后继(不在指向x),若前驱为空,修改头节点指向x的后继 if (prev == null) { first = next; } else { prev.next = next; x.prev = null; } // 指定后继的前驱为x的前驱(不再指向x),若后继为空,修改尾节点指向x的前驱 if (next == null) { last = prev; } else { next.prev = prev; x.next = null; } // x节点元素值设为null,便于垃圾回收 x.item = null; // 链表长度减1 size--; // 修改次数加1 modCount++; // 返回删除的节点值 return element; }
分析逻辑见代码注释,基本思路就是让x的前驱和后继直接链接起来,再把x的前驱、后继节点、item都设置为null,便于垃圾回收。下面通过图示加深理解,比如删除一个元素:
list.remove(1);
3. LinkedList特点总结
用法上LinkedList是一个List,有序有重复元素,也实现了Deque接口,可以作为队列、栈和双端队列使用。实现原理上,LinkedList内部是一个双向链表,并维护了长度、头结点和尾节点。有如下特点:
1. 按需分配空间,不需要预先分配很多空间。
2. 不可以随机访问,按照索引位置访问效率比较低,必须从头或尾顺着链接找,效率为O(N/2)。
3. 不管列表是否已排序,只要按照内容查找元素,效率都比较低,必须逐个比较,效率为O(N)。
---------- I love three things in this world. Sun, moon and you. Sun for morning, moon for night , and you forever .