LinkedList图解 源码分析

113 阅读7分钟

基于JDK1.8。

LinkedList可以插入null值的原因

LinkedList可以允许重复的原因

LinkedList插入快,查询慢的原因

我们知道ArrayList是基于动态数组的,而LinkedList是基于链表的。往下我会逐层剖析LinkedList为什么插入快,查询慢的问题。但在这之前,让我们先看看链表是什么。

链表

先看一下链表的定义:链表是一种物理存储单元上非连续、非顺序的存储结构,数据元素的逻辑顺序是通过链表中的指针链接次序实现的。链表由一系列结点(链表中每一个元素称为结点)组成,结点可以在运行时动态生成。每个结点包括两个部分:一个是存储数据元素的数据域,另一个是存储下一个结点地址的指针域。

上面描述的可能比较复杂,我们画个图理解一下:

在这里插入图片描述

在上图中,每个节点都有两个部分,即第一部分是用来保存自身的数据的,第二部分则是保存了指向下一个节点的指针。

双向链表

双向链表 双向链表和单向链表最大的不同,是每个节点即维护了下一个节点的指针,也维护了上一个节点的指针。

在这里插入图片描述

单向链表只有后一节点指针,在节点删除,移动的时候,需要暂存前一节点,删除的时候将前一节点和后一节点连接,因为比双向链表少维护一个前节点,只在删除的时候暂存,所以比单向链表节省资源,但是增加了操作的复杂性。

双向链表有前后两个节点指针,可以回溯指针,方便节点删除,移动,在做删除操作时只需要将索引节点前后两个节点连接即可,但是相比单向链表会耗费额外资源。

总结起来一句话:双向链表就是以空间换时间。我们接下来要分析的LinkedList就是基于此。

Node类

这里的Node类是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; 
    } 
}

结构非常简单,也恰恰验证了上文所说,每个节点除了维护自身的数据外,还分别维护了前一节点和后一节点的引用,用图表示就是:

在这里插入图片描述

成员变量

LinkedList的成员变量很简单,主要有以下几个:

//记录链表中节点的个数 
transient int size = 0; 
//记录链表中第一个节点 
transient Node<E> first; 
//记录链表中最后一个节点 
transient Node<E> last;

由于LinkedList继承自Deque,所以需要支持removeFirst、removeLast等一系列操作,所以需要记录链表中的首尾节点。

添加元素

假设有如下代码:

public static void main(String[] args){ 
    List<String> list = new LinkedList<String>();//1  
    list.add("hello");//2 
}

我们逐步分析以上两处代码,首先是第1处,看一下源码:

public LinkedList() { }

可以看到初始化时并没有什么特殊的操作,接着是第2处,看一下源码:

public boolean add(E e) { 
    //调用 
    linkLast(e); 
    return true; 
} 
void linkLast(E e) { 
    //首先获取最后一个节点 
    final Node<E> l = last; 
    //这里创建出一个新的节点,它的前一个节点指向l,要保存的数据即传入进来的数据,后一个节点为null 
    final Node<E> newNode = new Node<>(l, e, null); 
    //接着将这个新节点当做链表的最后一个节点保存起来 
    last = newNode; 
    //如果刚开始最后一个节点为null,说明是第一次添加 
    if (l == null) 
        first = newNode; 
    else 
        //否则将刚开始最后一个节点的下一个节点的引用指向新创建的节点 
        l.next = newNode; 
    size++; 
    modCount++; 
}

其实上面注释已经写得很明白了,这里总结一下:

1.保存当前LinkedList的第最后一个节点

2.创建出一个新的节点,并将要添加的数据赋值给新节点的e属性,那么这个时候新节点的上一个节点就应该是一开始保存的最后一个节点,即l

3.接着将LinkedList的last引用指向新创建的节点,last保存的是链表的最后一个节点,因为这个时候新的节点成为了最后一个节点,所以需要重新指向。

4.我们在第一次添加时,l肯定是null,这个时候链表中只有一个节点,那么就是新创建出来的节点,所以同时将first引用指向新节点(如果不是第一次添加,那么则l是不为空的,则需要将l的下一个节点的引用指向新节点)。

第一次添加后,链表结构:

在这里插入图片描述

第二次添加后,链表结构:

在这里插入图片描述

查看元素

这里以最简单的get(int index)方法为例:

public E get(int index) { 
    //参数合法检验 
    checkElementIndex(index); 
    //node方法返回节点,获取该节点保存的值并返回 
    return node(index).item;
} 

Node<E> node(int index) { 
    //size >> 1 相当于size / 2 位运算比较高效,不需要转换为10进制计算 
    //index如果小于链表大小的一半,则从头遍历 
    if (index < (size >> 1)) { 
        Node<E> x = first; 
        for (int i = 0; i < index; i++) 
            x = x.next; 
            return x; 
        } else { 
            //index如果大于等于链表大小的一半,则从尾部遍历 
            Node<E> x = last; 
            for (int i = size - 1; i > index; i--) 
                x = x.prev; return x; 
            } 
}

这段代码就体现出了双向链表的好处了。双向链表增加了一点点的空间消耗(每个Node里面还要维护它的前置Node的引用,相对于单链表来说空间消耗增加),同时也增加了一定的编程复杂度,却大大提升了效率。

举例:假设LinkedList中有10000个元素,如果我要找到第10000的元素,则直接从尾部开始遍历,只需要一次就能找到想要的元素。但最坏情况下如果查询第5000个元素,那么效率大打折扣。

删除元素

看完查看元素后,我们看一下如何删除一个元素,这里以按下标删除举个例子好了,下面先用图示解释一下如何删除元素。 假设现在链表中存在三个节点:

在这里插入图片描述

现在需要删除中间的节点,即将第一个节点的next引用指向第三个节点,再将最后一个节点的pre引用指向第一个节点:

在这里插入图片描述

最终结果:

在这里插入图片描述

那么接下来看看应用到LinkedList具体是怎么实现的:

public E remove(int index) { 
    checkElementIndex(index); 
    //node方法和查看元素时相同 
    return unlink(node(index)); 
} 

E unlink(Node<E> x) { 
    //分别获取当前要删除节点的值、前置节点、后置节点
    final E element = x.item; 
    final Node<E> next = x.next; 
    final Node<E> prev = x.prev; 
    
    //前置节点为null,说明当前节点为首节点 
    if (prev == null) { 
        first = next; 
    } else {
        //前置节点不为null,将前置节点的next指向后置节点 
        prev.next = next; 
        //1 
        x.prev = null; 
    } 
    
//后置节点为null,说明当前节点为尾节点 
if (next == null) { 
    last = prev; 
} else { 
    //后置节点不为null,将后置节点的prev指向前置节点 
    next.prev = prev; 
    //2 
    x.next = null; 
} 
    //3 
    x.item = null; 
    size--; 
    modCount++; 
    return element; 
}

这里注意一点:上面源码中的1、2、3步骤都设置为了null,目的是为了GC。

插入元素

插入元素其实和上面讲的几种是一个道理,如果读者理解了上面的逻辑,插入元素也就能想通怎么回事了。

LinkedList和ArrayList的区别

这个问题不管是在平时面向搜索编程还是在基础面试过程中都算是老生常谈了,在这里我们逐个分析一下这两个的优缺点:

1.插入速度比较。网上大部分说LinkedList插入比ArrayLst快。这种说法是不准确的。LinkedList做插入、删除的时候,慢在寻址,快在只需要改变前后Node的引用地址,而ArrayList做插入、删除的时候,慢在数组元素的批量copy,快在寻址。 所以如果待插入的元素位置在数据结构的前半段尤其是非常靠前时,ArrayList需要拷贝大量的元素,势必LinkedList会更快;如果带插入元素位置在数据结构后半段尤其是非常靠后,ArrayList需要拷贝的元素个数会越来越少,所以速度也会提升,甚至超过LinkedList。

2.ArrayLst基于动态数组,所以内存上是连续的,而LinkedList基于链表,内存上不需要保持连续。

3.一般遍历LinkedList时最好不要使用普通for循环,而是使用迭代器代替。

我们在实际工作中,还是要根据实际情况来确定选用哪种数据结构存储数据,最好是根据需求,经过理论支撑和实际测试,最终选择合适的数据结构。

总结

本篇文章介绍了LinkedList增加元素、查看元素、移除元素、插入元素等,以图示和源码结合的方式掌握了LinkedList实现原理,其内部实现就是一个双向链表,通过以空间换时间的方式提高查询的效率。