揭秘双向链表LinkedList源码

一、LinkedList链表的基本结构

        链表,可以简单的理解为一个链子。链子的特点就是一环套一环。当我们需要某一环的时候,只要我们拥有链子的任意一环,都能够找到我们想要的那一环。LinkedList可以看成是一个双向的链表。我们知道ArrayList内部用的是数组来存储数据。而LinkedList用的是“对象”来存储数据通过源码可以知道,此对象来自于一个内部类Node。

        内部类Node的代码十分简单。

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类是一个嵌套类(内部类知识参考)。它的构造函数接收了要被存储的数据"element"。并且Node构造函数还接收两个其它Node对象的引用。这是用来记录前一个Node节点对象和后一个Node节点对象的内存地址。

          Node类,如图,Node类构造一览无余。图片来源:点击打开链接

        一个简单的链表如图所示,颜色含义参考上一个图片。图片来源:点击打开链接

       

二、LinkedList链表的特点

        还是使用我的终极口诀,“序重步+数据结构”。

        序:有序。LinkedList是List接口的实现类,List接口的最大特点就是有角标,因此是有序的。

        重复:元素可以重复。内部使用“Node对象”存储数据,不同的Node类对象可以封装相同的数据,因为这两个Node对象并不是同一个对象。另外,从Node类的构造函数中,并没有对存放的元素element进行“非空”的限制,因此LinkedList可以存放“null”,即LinkedList支持存放"null"值

        同步:不同步。LinkedList是java.util包下的类,是快速失败的,因此不同步。(快速失败可以参考上一篇ArrayList源码分析)

        数据结构的影响:增删速度快,查询速度慢。其实增删之前还要定位到删除位置,比ArrayList也快不到哪里去。

三、源代码分析

        size表示LinkedList对象中存放的实际元素的个数。

transient int size = 0;

       first代表的是当前LinkedList的头节点,last代表的是末尾节点,这两个引用可以看成是指针。它们用于指向头和尾,可以看到它们并没有被初始化,例如 first = new Node<T>(); 其实它们只是一个引用而已。

transient Node<E> first;
transient Node<E> last;

        空参构造函数,没啥好说的。能看到super()就行了。

 public LinkedList() {
    }

        接收一个集合对象c的构造函数。把这个集合对象c中的所有元素存放到LinkedList中。到底是怎么存放的呢,原来调用了addAll(..)方法。

public LinkedList(Collection<? extends E> c) {
        this();
        addAll(c);
    }

 public boolean addAll(Collection<? extends E> c) {
        return addAll(size, c);
    }

        下面是最精彩的一部分了。addAll(int size,Collection c)方法也不是很复杂。总共有三种情况。

        第一种情况:链表是一个空链表,什么都没有。指向头和尾的first和last指针都是空。

        第二种情况:链表中已经有值了。把这个Collection集合中的所有数据存放到链表的尾部。

        第三种情况:在链表的中间插入Collection集合中的所有元素。

        我们先来分析第一种情况。我们把Collection的中的每一个元素的值遍历出来,为每一个元素值使用一个Node类对象进行封装。为了方便描述,Node对象可以看成是一个节点。Collection集合被封装成了“节点们”。我们在封装这些元素成为节点的同时,记录下它们相邻的节点的地址。最后,再把我们的first和last指针指向第一个节点和最后一个节点。

        第二种情况就是,向已经存在元素的链表末尾追加节点。这个时候就像第一种情况那样,把所有Collection集合中的数据封装成“节点们”,然后使用last指针指向最后一个添加的节点。有人会问,那么first指针指向的是谁呢?这个不用管,链表既然有了元素,那么它必然有一个头节点。在添加头节点的那个时候,我们就已经使用first指向它了,所以现在不用关心头部。

        第三种情况就是在链表中的某个位置添加这个Collection集合的元素。这个就更简单了。首先把Collection集合的所有数据封装成Node类的对象。然后就像下面的例子那样:有“苹果、香蕉、梨子”,现在要在香蕉处插入三个橘子。插入后的效果就是“苹果、橘子1、橘子2、橘子3、香蕉、梨子”。每个节点都有指向前一个和后一个的指针(可以回顾一下节点的示意图)。插入完三个橘子后,只要把苹果的后一个指向橘子1,香蕉的前一个指向橘子3,就行了。因为在中间插入的原因,头部和尾部的指针在插入头部和尾部节点的那个时候,指针就已经指向了它们,现在不用我们关心。

分析LinkedList源码,只需抓住:头节点head、尾节点last、插入处的前驱节点pred、插入处的后继节点succ

        好了,分析结束,看代码咯。三种情况的简单示意图,“·”代表即将要被插入的位置。

        空
        口口口·
        口口·口口

public boolean addAll(int index, Collection<? extends E> c) {
        //检查index是否在指定的范围   index范围是[0,size]
        checkPositionIndex(index);
        //将集合转为数组
        Object[] a = c.toArray();
        //即将要被插入到链表的元素个数,用 int numNew表示
        int numNew = a.length;
        //健壮性检查:你存放0个干哈啊?
        if (numNew == 0)
            return false;
        /**
         * 这里的两个引用就有点意思了。首先你需要知道这两个节点是临时节点。
         * pred:即将要被插入的新节点的前一个节点,因此,插入完成后,pred的下一个节点就是 新节点
         * succ: 插入位置的节点。比如“苹果,香蕉,梨子”,在香蕉处插入三个橘子
         * 插入后的效果就是“苹果,橘子1,橘子2,橘子3,香蕉,梨子”,那么succ代表的就是“香蕉”
         */
        //18年10月7日注:插入处的前驱节点pred、插入处的后继节点succ
        Node<E> pred, succ;
        /**
         * 接下来任务就是确定这两个临时节点的值。
         * 为什么要确定这个值呢?
         * 因为你添加完新节点们以后,不是需要连接上以前的那个链表吗?
         * 好比上面的例子,我插入了三个橘子以后,我还得把苹果连接到橘子1,香蕉连接到橘子3。这个例子里面的succ就是“香蕉”
         * 因此这两个临时节点的指针我们得确定好了
         */
        if (index == size) {
            /**
             * 情况一:空链表,index = size = 0
             * 情况二:在非空链表的末尾添加元素,这是通过构造函数传进来的
             * 两种情况的共同点就是插入位置的后一个节点是null,即succ = null
             * 那么pred节点就是链表的最后一个节点了。
             */
            succ = null;
            pred = last;
        } else {
            //否则,succ是当前位置的节点,可以理解为“香蕉”
            succ = node(index);
            //它的前一个节点是pred,即将插入到这个pred节点的后面,succ节点的前面
            pred = succ.prev;
        }
        //插入节点
        for (Object o : a) {
            @SuppressWarnings("unchecked") E e = (E) o;
            //使用Node封装数据,新节点的数据的前一个节点是pred,封装的数据是e
            Node<E> newNode = new Node<>(pred, e, null);
            //前一个节点是空,证明原来的链表是 空链表
            if (pred == null){
                //头指针指向这个新创建的节点
                first = newNode;
            } else{
                //pred节点的下一个节点是 新创建的这个节点
                pred.next = newNode;
            }
            //比如“橘子1”插入完成后,"橘子2"要被插入到“橘子1”之后,那么“橘子1”就被视为了 pred节点
            pred = newNode;
        }
        
        //插入完成以后,我们还需要考虑一下 succ节点 是否为空
        if (succ == null) {
            //如果新节点们的后面没有节点了,那么新节点们的最后一个节点被 视为链表的末尾
            last = pred;
        } else {
            //否则,这就是在非空链表的中间插入的,只需要按照情况三那样。
            //这个时候pred代表的是新插入的节点的最后一个节点,本例子中,新插入的数据的最后一个数据被看成pred,即“橘子3”。succ代表的是"香蕉"
            pred.next = succ;
            //“香蕉”的前一个节点也是这个最后一个节点
            succ.prev = pred;
        }
        //实际个数增加numNew个
        size += numNew;
        //快速失败机制
        modCount++;
        return true;
    }

四、添加和删除方法

        添加到头部。

        思路:

            1.先获取到以前的头部节点,旧节点

            2.封装我们的数据成为,新节点。

            3.再将头部指针指向:新节点

            4.健壮性判断旧头部节点是否为空,然后修改旧头部节点的前指针指向新节点

public void addFirst(E e) {
        linkFirst(e);
    }

   /**
     * Links e as first element.
     */
    private void linkFirst(E e) {
        final Node<E> f = first;
        final Node<E> newNode = new Node<>(null, e, f);
        first = newNode;
        if (f == null)
            last = newNode;
        else
            f.prev = newNode;
        size++;
        modCount++;
    }

        删除头部节点。与添加头部节点类似,考虑到删除旧的头部节点以后,这个新头部是不是空是关键。

        思路:找到以前的头部节点。找到以前的头部节点的下一个节点。把链表头部指针first指向新头部。修改新头部的前一个节点引用为null。

public E removeFirst() {
        final Node<E> f = first;
        if (f == null)
            throw new NoSuchElementException();
        return unlinkFirst(f);
    }

   /**
     * Unlinks non-null first node f.
     */
    private E unlinkFirst(Node<E> f) {
        // assert f == first && f != null;
        final E element = f.item;
        final Node<E> next = f.next;
        f.item = null;
        f.next = null; // help GC
        first = next;
        if (next == null)
            last = null;//现在是把以前唯一的一个元素删除了,因此,现在是一个空链表。
        else
            next.prev = null;
        size--;
        modCount++;
        return element;
    }

        其余方法大同小异,无非就是修改各个节点之间前后的引用,有兴趣的话可以参考JDK源码和API文档。

五、要点

        LinkedList用的是“对象”来存储数据,这个对象是它的内部类对象Node类对象。因此,LinkedList的实际大小限制是堆内存的大小。

        LinkedList是一个双向链表,它的任意一个节点都能获取到前一个节点和后一个节点的数据。

        LinkedList能够存放“null”值。

        特点:有序,可重复,不同步,增删块,查询慢。

        

        

posted @ 2022-07-17 12:16  小大宇  阅读(108)  评论(0编辑  收藏  举报