前面一节我们已经讲过了顺序表的ArrayList,如果有兴趣的同学可以看上一节( https://www.cnblogs.com/zhengxc0325/p/14056708.html),这一章我们着重讲顺序表的另外一种LinkedList。
一、前提
在剖析LinkedList的源码前,需要读者具备链表的知识,如果有不清楚什么是链表的可以看我下一节(https://www.cnblogs.com/zhengxc0325/p/14065580.html),如果已经具备了链表的知识可以继续往下看。提到LinkedList大家都或多或少第一反应都是适合插入,不适合查询。那到底是为什么呢?接下来我们就一起看看它的源码追究其一下它的本质。
二、LinkedList的数据结构
了解任何一种数据对象,首先要了解它的数据结构,LinkedList作为容器,其内部一样也是维护着自己的一套规则。根据字面意思就可以看出它内部是维护的链表,具体是什么样的呢,我们看源码:
1 transient int size = 0; 2 3 /** 4 * Pointer to first node. 5 * Invariant: (first == null && last == null) || 6 * (first.prev == null && first.item != null) 7 */ 8 transient Node<E> first; 9 10 /** 11 * Pointer to last node. 12 * Invariant: (first == null && last == null) || 13 * (last.next == null && last.item != null) 14 */ 15 transient Node<E> last;
不难看出,内部维护的是一个节点对象。
(1)、size用于维护容器的大小
(2)、first用于记录链表的第一个节点
(3)、last用于记录链表的最后一个节点。
那有同学又有疑问了,为什么会有两个节点呢?带着这个疑问我们再看节点源码的定义,LinkedList内部维护了内部类Node作为它的节点,其源码如下:
1 private static class Node<E> { 2 E item; 3 Node<E> next; 4 Node<E> prev; 5 6 Node(Node<E> prev, E element, Node<E> next) { 7 this.item = element; 8 this.next = next; 9 this.prev = prev; 10 } 11 }
了解过链表的同学都知道,这里是一个双向链表,item节点数据,next作为下一个节点,prev作为上一个节点,用有参构造形式进行初始化节点信息。那么又有同学有疑问,链表分为单链表和双向链表,为什么这里用双向链表而不用单链表呢?这里我先做个说明,当向双向链表中进行指定位置index插入节点和通过下标index获取节点时,双向链表的效率是单链表的2倍。具体是为什么呢,那我们继续一起往下看。
三、LinkedList的add()方法
这里只看使用频率最高的两种,直接添加节点add(E e)和在指定位置添加节点add(int index, E element)。
(1)add(E e)直接在链表的最后添加该节点。
1 /** 2 * Appends the specified element to the end of this list. 3 * 4 * <p>This method is equivalent to {@link #addLast}. 5 * 6 * @param e element to be appended to this list 7 * @return {@code true} (as specified by {@link Collection#add}) 8 */ 9 public boolean add(E e) { 10 linkLast(e); 11 return true; 12 }
1 /** 2 * Links e as last element. 3 */ 4 void linkLast(E e) { 5 final Node<E> l = last; 6 final Node<E> newNode = new Node<>(l, e, null); 7 last = newNode; 8 if (l == null) 9 first = newNode; 10 else 11 l.next = newNode; 12 size++; 13 modCount++; 14 }
(2)add(int index, E element)方法在指定位置添加节点。
1 /** 2 * Inserts the specified element at the specified position in this list. 3 * Shifts the element currently at that position (if any) and any 4 * subsequent elements to the right (adds one to their indices). 5 * 6 * @param index index at which the specified element is to be inserted 7 * @param element element to be inserted 8 * @throws IndexOutOfBoundsException {@inheritDoc} 9 */ 10 public void add(int index, E element) { 11 checkPositionIndex(index); 12 13 if (index == size) 14 linkLast(element); 15 else 16 linkBefore(element, node(index)); 17 } 18 19 20 /** 21 * Returns the (non-null) Node at the specified element index. 22 */ 23 Node<E> node(int index) { 24 // assert isElementIndex(index); 25 26 if (index < (size >> 1)) { 27 Node<E> x = first; 28 for (int i = 0; i < index; i++) 29 x = x.next; 30 return x; 31 } else { 32 Node<E> x = last; 33 for (int i = size - 1; i > index; i--) 34 x = x.prev; 35 return x; 36 } 37 } 38 39 /** 40 * Inserts element e before non-null Node succ. 41 */ 42 void linkBefore(E e, Node<E> succ) { 43 // assert succ != null; 44 final Node<E> pred = succ.prev; 45 final Node<E> newNode = new Node<>(pred, e, succ); 46 succ.prev = newNode; 47 if (pred == null) 48 first = newNode; 49 else 50 pred.next = newNode; 51 size++; 52 modCount++; 53 }
上面有提到LinkedList在使用双链表作为数据结构时在指定位置插入节点比使用单链表作为数据结构在指定位置插入效率高,并且是2倍关系,体现在node(int index)方法中,该方法先进行位移操作,也就是将当前size/2和index比较,看要插入的位置是在前半段还是后半段,如果是前半段,则从头节点开始遍历到index直到找到index-1的节点,如果在后半段,则从最后一节节点往前进行遍历直到找到index+1的节点为止。
三、LinkedList的remove()方法
LinkedList的remove方法只需注意根据index去删除元素的方法,举个例子:
1 List<String> list = new LinkedList<>(); 2 for(int i = 0; i < 5; i ++) 3 list.add(i + ""); 4 5 list.add("0"); 6 // 删除 "0" 7 for(int i = 0; i < list.size(); i ++){ 8 if("0".equals(list.get(i))) 9 list.remove(i); 10 }
我们向LinkedList中插入了6个元素,结果为["0","1","2","3","4","0"]。此时我们去删除元素“0”,通过for循环遍历,遍历时的界限为list.size(),满足条件时通过下标去删除,能正常删,如果遍历的界限 i < list.size(); 改成 int size = list.size(); i< size; 此时去通过下标删除则会报错。至于为什么,我们分析源码可知:
(1)、remove(int index) 删除过程是先去链表中找到下标index的结点,然后再去改变该节点的连接关系。并释放该节点。
这里注意:通过下标去找index位置,就拿上面例子来说,删除第一个”0“时,链表的size从6变成了5,而此时如果界限是int size = list.size(), i < size; 界限size依旧还是6,for循环遍历到最后一个时满足条件,i = 5,通过下标6去链表中遍历找5,链表中的下标此时最大为4,这样一来,就会发生下标越界的异常。
如果for循环的界限是i < list.size(),意味着实时和链表中的数据大小保持一致。就上面例子来说,运行虽然不会报错,但是这种运行结果不一定和预期一致。比如list = ["0","0","1","2","3","4"],依旧要删除”0“,但是运行结果却为 ["0","1","2","3","4"],这是因为虽然界限实时在获取,但是第一次满足的条件i = 0,删除之后i = 1,而此时链表中第二个"0"通过删除第一个"0"之后已经变成了第一个,i = 1 此时list.get(i) = "1",错过了第二个”0“,导致删除时漏删的情况。
(2)、remove(E element) 删除指定元素,只会删除链表中第一次出现该元素的节点。
(3)、如果分不清到底会发生异常还是会漏删的情况,则使用LinkedList为我们提供的迭代器ListIterator<E>,这里就不在讲迭代器,看过ArrayList的迭代器再去看LinkedList的迭代器应该很轻松。
四、LinkedList的set()方法
在指定下标位置进行修改节点,看源码:
1 /** 2 * Replaces the element at the specified position in this list with the 3 * specified element. 4 * 5 * @param index index of the element to replace 6 * @param element element to be stored at the specified position 7 * @return the element previously at the specified position 8 * @throws IndexOutOfBoundsException {@inheritDoc} 9 */ 10 public E set(int index, E element) { 11 checkElementIndex(index); 12 Node<E> x = node(index); 13 E oldVal = x.item; 14 x.item = element; 15 return oldVal; 16 }
可以看到它依旧通过node的方法去找到该元素,将该元素的值直接替换成设置的element。
四、总结
根据LinkedList内部数据结构特性,非常适合增加,删除操作。凡事不是绝对的,只是和ArrayList相比之下更适合,真正的使用还得放到生产环境中去决定到底是用什么合适。