Java源码分析之LinkedList

LinkedList与ArrayList正好相对,同样是List的实现类,都有增删改查等方法,但是实现方法跟后者有很大的区别。

先归纳一下LinkedList包含的API

1、构造函数:

①LinkedList()  起始没有元素

②LinkedList(Collection<? extends E> collection)  用另一个集合构造LinkedList

2、增加元素:

①void add(int location, E object)  在指定索引处新增元素

②boolean add(E object)  在链表末尾添加元素,总是返回true

③boolean addAll(int location, Collection<? extends E> collection)  在指定索引处添加一个集合的所有元素,添加成功返回true,否则返回false

④boolean addAll(Collection<? extends E> collection)  在链表末尾添加集合的所有元素,添加成功返回true

⑤void addFirst(E object)  在链表首部添加一个元素

⑥void addLast(E object)  在链表末尾添加一个元素

3、删除元素:

①E remove(int location)  删除指定索引处的元素

②boolean remove(Object object)  删除参数指定的元素,只删除第一次出现的那个

③E removeFirst()  删除头部元素

④E removeLast()  删除尾部元素

⑤boolean removeFirstOccurrence(Object o)  同②,删除第一次出现的元素

⑥boolean removeLastOccurrence(Object o)  删除最后一次出现的指定元素

⑦E remove()    同③,删除头部元素

⑧void clear()    清空所有元素

4、修改元素:

 E set(int location, E object)   修改指定位置的元素值吗,返回修改处旧的元素值

5、查找元素:

①boolean contains(Object object)  确定是否包含指定的元素

②E get(int location)    获取指定索引处的元素

③E getFirst()    获取头部结点的元素值

④E getLast()    获取尾部结点的元素值

⑤int indexOf(Object object)  获取指定元素第一次出现时的索引,找不到返回-1

⑥int lastIndexOf(Object object) 获取指定元素最后一次出现时的索引,找不到返回-1

6、队列相关操作:

(1)boolean offerFirst(E e)  在队首添加元素

(2)boolean offerLast(E e)  在队尾添加元素

(3)E peekFirst()    获取队首元素,没有元素返回null

(4)E peekLast()    获取队尾元素

(5)E pollFirst()     删除队首元素,并返回被删元素,没有元素可删返回null

(6)E pollLast()     删除队尾元素,并返回被删元素,没有元素可删返回null

(7)E pop()       作为栈操作,弹出栈顶元素,即返回并删除队首元素

(8)void push(E e)   作为栈操作,压入新元素,即在队首加入元素

(9)boolean offer(E o)  将指定元素入队,即在队尾加入该元素

(10)E poll()      同pollFirst,删除队首元素,并返回该元素

(11)E remove()    同pollFirst,删除队首元素,并返回该元素

(12)E element()     返回队首元素,但并不删除

 

下面进行API源码分析

public class LinkedList<E> extends AbstractSequentialList<E> implements
        List<E>, Deque<E>, Queue<E>, Cloneable, Serializable

 

因为LinkedList除了实现List接口,还实现了单、双向队列的接口,所以相比ArrayList,还额外包含了大量的队列操作,虽然这些队列操作都可以用对应的List操作替换。

LinkedList内部是用双向链表实现的,而且这个链表还跟普通的链表不太一样,两端的两个null结点被同一个void结点替换掉了,这样就完全可以用这一个结点代表整个列表,从它出发可以实现所有的操作了。

这我认为是设计得最巧妙的一点。

下面来看链表中的结点类定义:

复制代码
    private static final class Link<ET> {
        ET data;          //实际的元素值,也就是我们的结点核心部分

        Link<ET> previous, next;    //同时定义了前向指针和后向指针,分别指向前一个结点和后一个结点,这样正向遍历和反向遍历都能简单地实现了

        Link(ET o, Link<ET> p, Link<ET> n) {    //构造函数,传入元素值、前向结点、后向结点
            data = o;
            previous = p;
            next = n;
        }
    }
复制代码

 

LinkedList的结点可以包含很多个,但是其中有一个特殊的结点是必不可少的,它就是void结点

transient Link<E> voidLink;

因为所有操作都是从voidLink这个结点出发的,所以构造函数的工作就只要构造void结点就可以了

复制代码
    //void结点结构跟其他结点的唯一区别就是data数值部分为空,不包含任何值,只有前后向指针
    //初始化的时候把所有指针指向自己,后续有结点添加进来再指向其他结点
    public LinkedList() {
        voidLink = new Link<E>(null, null, null);
        voidLink.previous = voidLink;
        voidLink.next = voidLink;
    }

    public LinkedList(Collection<? extends E> collection) {
        this();
        addAll(collection);
    }
复制代码

 

下面来看添加元素部分,这应该是LinkedList相对于ArrayList最大的优势了,因为链表长度可以无限延长,不像数组存在空间不够,需重新开辟内存空间的问题,更关键的是,省去了扩展空间后大批量元素复制的冗余操作,可以说性能已经大大提升了。同时,链表找到添加位置后,不存在之后的元素全部后移的问题。当然,因为用链表添加元素需要先找到添加位置,寻找添加位置也要花去O(n)的时间(如果提供索引,ArrayList只要O(1)时间就找到),这可能是LinkedList唯一的性能瓶颈吧。

但是LinkedList也提供了一个量记录元素个数

transient int size = 0;

 

这样,在提供索引的情况下,可以与size进行比较,确定要插入的位置是离头结点近,还是离尾结点近(当然这里void结点充当了两个角色)。又因为是双向链表,这样就可以从最近的端点开始循着指针寻找插入位置,大大提高了性能,这可能是设计者选用双向链表而不是单向链表的初衷吧。来看代码:

复制代码
    @Override
    public void add(int location, E object) {
        if (location >= 0 && location <= size) {
            Link<E> link = voidLink;    //定义一个移动的结点指针,从头结点开始
            if (location < (size / 2)) {    //如果小于总长度的一半,意味着离头结点更近。话说这里为什么不用位操作提升下性能?
                for (int i = 0; i <= location; i++) {
                    link = link.next;    //循着next指针顺序找到插入点
                }
            } else {   //离尾结点更近
                for (int i = size; i > location; i--) {
                    link = link.previous;    //循着previous指针逆序找到插入点
                }
            }
            //下面就是大学数据结构课本上面练习了n多遍的结点插入操作,要改变哪些指针一定要小心。不过话说当时我为什么没好好学这门课呢?/(ㄒoㄒ)/~~
            Link<E> previous = link.previous;    //记录下前向结点
            Link<E> newLink = new Link<E>(object, previous, link);  //开辟一段新的内存空间,存储新创建的结点
            previous.next = newLink;    //前向结点的next域指向新结点
            link.previous = newLink;    //后向结点的previous域指向新结点
            size++;    //元素个数加1
            modCount++;    //链表修改次数加1
        } else {
            throw new IndexOutOfBoundsException();
        }
    }
复制代码

 当然,如果不指定索引,只要求往后顺序插入,或者插到头部位置,那就只有优势,没有任何性能瓶颈了,因为只要O(1)时间,且不用担心任何溢出问题。

复制代码
    @Override
    public boolean add(E object) {
        return addLastImpl(object);
    }
    
    //这里将尾部插入的方法抽象了出来,以支持后面出现的队列操作addLast、offer、offerLast,虽然这些操作跟add操作代码相同
    private boolean addLastImpl(E object) {
        Link<E> oldLast = voidLink.previous;      //记录下最后一个非void结点 
        Link<E> newLink = new Link<E>(object, oldLast, voidLink);  //创建新结点
        voidLink.previous = newLink;   //void尾结点的前向指针指向新结点
        oldLast.next = newLink;
        size++;
        modCount++;
        return true;
    }
复制代码

 

头部插入代码类似

复制代码
    public void addFirst(E object) {
        addFirstImpl(object);
    }

    //抽象出这段头部插入代码是为了同时支持后面的offerFirst以及栈的push操作
    private boolean addFirstImpl(E object) {
        Link<E> oldFirst = voidLink.next;   //记录下头部第一个非void结点
        Link<E> newLink = new Link<E>(object, voidLink, oldFirst);  //创建新结点
        voidLink.next = newLink;   //void头结点的后向指针指向新结点
        oldFirst.previous = newLink;  //原来的第一个含元素的结点修改前向指针,退居二线
        size++;
        modCount++;
        return true;   //永远返回true
    }
复制代码

 

 

  

 下面来看元素的删除操作

相比于ArrayList,LinkedList也是占据优势的,因为只要修改相邻结点的指针即可,而ArrayList存在大量元素前移的操作。如果是给出索引删除结点,LinkedList存在寻找位置的过程,优势不大;但是如果给结点值,两种List都存在沿着路径查找的操作,反而LinkedList更占优势。

复制代码
    @Override
    public E remove(int location) {
        if (location >= 0 && location < size) {
            Link<E> link = voidLink;
            if (location < (size / 2)) {  //仍然先确定从哪端开始较近,更快找到
                for (int i = 0; i <= location; i++) {
                    link = link.next;
                }
            } else {
                for (int i = size; i > location; i--) {
                    link = link.previous;
                }
            }
            //下面只要把删除点相邻的两个结点连接即可,只要O(1)时间
            //被删的结点因为没有索引再指向它,会被jvm垃圾回收器自动回收内存
            Link<E> previous = link.previous;
            Link<E> next = link.next;
            previous.next = next;
            next.previous = previous;
            size--;
            modCount++;
            return link.data;  //返回被删结点的值
        }
        throw new IndexOutOfBoundsException();
    }
复制代码

 

修改操作就不展示代码了,与上面一样,只要先循着路径找到索引位置,修改值就可以了

 

LinkedList最大的劣势在于根据索引查找操作,与上面添加操作的查找过程类似

复制代码
    @Override
    public E get(int location) {
        if (location >= 0 && location < size) {
            Link<E> link = voidLink;
            if (location < (size / 2)) {
                for (int i = 0; i <= location; i++) {
                    link = link.next;
                }
            } else {
                for (int i = size; i > location; i--) {
                    link = link.previous;
                }
            }
            return link.data;
        }
        throw new IndexOutOfBoundsException();
    }
复制代码

 

 

总结:在容器类最基本的增删改查的操作中,借助于链表的LinkedList有优势有劣势。

   优势:①对于增删操作,只要找到了操作位置,都只要O(1)时间就能执行完成。而ArrayList必须进行大量元素一个个前移或后移的操作,要花费O(n)时间。

      ②LinkedList可以灵活地增加元素,而完全不用考虑处理溢出问题。而ArrayList的事先规定的容量是有限的,一旦达到容量上限需要扩容,代价极大,首先要额外再                       开辟另一片更大的内存空间,接着要把原来所有的元素“集体移民”,一个一个复制到新地址空间,这对时间和空间都是极大的损耗。

         劣势:①在增删改查的操作中,查找元素位置的代价较大,不管给没给索引,都要对链表进行O(n)时间的遍历。当然,先行判断起始查找点改善了一些性能。而ArrayList只                    要给出索引就可以在O(1)时间找到指定位置。

            ②每个结点都必须加上两个指针进行包装,大大损耗了内存空间。学过C++的知道,指针也是占用内存空间的,一个指针占4个字节。随着元素个数增加,光指针占用                    的内存就会急剧增长。而ArrayList的每个结点都只有实际的元素值,不用加任何包装。

  

另外,查看LinkedList的源码让我第一次真正感受到了数据结构的重要性,当学会栈、队列、链表、二叉树等常见的操作后,看这些代码就是小菜一碟。也难怪应届生校园招聘的时候,大公司就喜欢出数据结构的题来考我们呢!大二的时候我马马虎虎地对待这门课真是不应该。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值