看源码是必不可少的一步,源码的学习通常是将常用的方法的实现理解透彻并能合理的使用,掌握其特性。而输出才能更好的输入,所以写下博客将自己的学习记录下来,对于Java需要掌握的就是各种容器,我们已经上次学习了ArrayList的源码,这次继续来学习LinkedList的源码。
源码学习之 LinkedList
一、LinkedList基础知识
- 节点的定义:
Node
类是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;
}
}
Java中实现的是双向链表,每一个节点既有前驱节点引用prev
指向该节点的前一个节点,也有后继节点引用next
指向该节点的下一个节点。
- LinkedList底层成员变量
(1)size
表示该链表中节点的个数
(2)Node first
表示链表中的第一个节点
(3)Node last
表示链表中最后一个节点。
transient int size = 0;
/**
* Pointer to first node.
* Invariant: (first == null && last == null) ||
* (first.prev == null && first.item != null)
*/
transient Node<E> first;
/**
* Pointer to last node.
* Invariant: (first == null && last == null) ||
* (last.next == null && last.item != null)
*/
transient Node<E> last;
对于size成员变量,还为其封装了一个成员方法用于获取链表的节点个数
一、常用方法实现
1.LinkedList()
LinkedList
提供了一个空的构造方法,对于ArrayList
有一个初始化底层数组大小的构造器,而LinkedList
没有,这是因为ArrayList
底层是基于数组来实现的,在构造时可以传入底层数组的长度,注意!这里的长度指的是底层数组的长度而不是list的初始长度初始时List是没有元素的,长度也是0。而LinkedList
是使用链表结构把一个个节点元素都串接起来,长度是动态变化的,因此也不需要初始化容量。
2. addFirst(E e)
在Java中实现addFirst(E e)
函数时通过在函数中调用私有的成员方法linkFirst()
来实现的,接下来主要就是分析该私有函数。
实现元素在链表中的头插。由于LinkedList的实现是双引用,所以在插入一个节点时要考虑到prv
和next
的改变,以及成员变量first
和last
的改变。
当插入新节点后,首先需要创建节点并初始化节点的前驱和后继节点,接着使得first
节点指向newNode
,然后就需要根据first
引用的指向判断两种情况:
-
插入之前链表为空(first == null):
开始时first
和last
均为null
,当改变first
节点指向后,此时由于last
开始时也为null
,因此需要修改last
引用的指向,使之同样指向newNode
。 -
插入之前链表中已有元素(first != null):
如果插入前已有元素,那么同前一种情况不一样的就是:由于last
引用不受影响因此不需要修改其指向,反而需要使得插入节点的后继节点的prv
引用指向该插入节点。
而为了能进行两种情况在第三步的判断,就需要首先将未插入节点前的first
引用保存下来而且使用final
关键字修饰,使得该引用不会随着后面代码对其修改而修改。
3.addLast(E e)
同样,实现addLast(E e)
函数也是调用类私有方法linkLast(E e)
实现,接下来开始分析该私有函数。
实现元素在链表中的尾插。与头插思想一样,理解了上面的头插,尾插的代码也很好理解了。不过对于尾插,判断的依据就成了last
引用。同样两种情况:
-
插入之前链表为空(last == null)
当插入之前链表为空时,开始时first
和last
均为null
,插入节点后,就应当改变last
节点指向。又因为此时first
开始时也为null
,因此需要修改first
引用的指向,使之同样指向newNode
。 -
插入之前链表中已有元素(last != null)
如果插入前已有元素,那么同前一种情况不一样的就是:由于first
引用不受影响因此不需要修改其指向,反而需要使得插入节点的前驱节点的next
引用指向该插入节点。
而为了能进行两种情况在第三步的判断,就需要首先将未插入节点前的last
引用保存下来而且使用final
关键字修饰,使得该引用不会随着后面代码对其修改而修改。
总体而言只要理解好了头插那么尾插也自然理解了。
分析源码我们发现,
add(E e)
方法也是通过尾插来实现的。
4.removeFirst()
实现一个链表的头删功能。在解析源码时,我们发现在该方法中检验了first
是否为空,如果非空,则在该方法中又调用了类私有方法unlinkFirst
来进行头删。
那么对于unlinkFirst
方法,事实上就是进行了头删操作。首先将第一个节点的成员域都置为空以便垃圾回收器回收,为了修改first
引用指向该节点的后继节点就需要提前保存其后继节点。最后根据该节点的后继节点是否为空的仍然分为两种情况:
- 删掉该节点后,链表为空(first.next == null)
由于删掉该节点后链表为空,也即链表中的first
和last
节点均指向要删除的节点。所以即使是头删也会影响到last
节点,需要将last
节点置空。 - 删掉该节点后,链表中还有节点(first.next != null)
由于链表中不只有一个节点,也即链表头删也不会影响到last
节点,此时需要将要删除元素的后继节点的prev
引用置空。
5.removeLast()
实现链表尾删功能。同样,在该函数中也是判断尾节点是否为空,接着调用私有成员函数unlinkLast
进行尾删。
同样,思路和头删相似。将尾节点的成员域都置空,尾删会影响last
引用,所以将last
引用改为该节点的前驱节点。此时根据前驱结点是否为空又有两种情况:
- 删掉该节点后,链表为空(last.prev == null)
由于删掉该节点后链表为空可知,在未删除之前,链表的first
和last
均指向该节点,当删除后,不仅应该修改last
节点,也应该将first
节点也置为空。 - 删掉该节点后,链表中还有其他节点(last.prev != null)
在此种情况下,删除该节点后不会影响first
引用的指向。所以此时将last修改指向为该节点的前驱结点,同时也需要将该节点的前驱结点的next
成员域置空。
这两种情况均需要提前保存当前节点的前驱结点,这样在接下来的修改中不会收到影响。
6.contains(Object o)
该方法可判断链表中是否存在对象o。contains(Object o)
方法内部也是再次调用成员函数indexOf(Object o)
而该函数的作用就是返回该对象在链表中第一次出现的索引。而contains(Object o)
方法通过判断其返回值来实现其功能。
对于indexOf(Object o)
方法,其实现思想也不复杂,就是将链表遍历一遍与对象o作比较。这里的判断语句用力啊判断对象o是否为空。若为空则可以通过==
操作符判断相等,否则就需要调用equals
方法进行判断相等,至于二者的不同,可以参考文章——>hashCode,equals,== 三者的区别是什么?
7.clear()
通过该方法可以将链表中的所有节点都清空。
实现思想也不复杂,还是遍历整个链表并将每一个链表的成员域都置空。最后将first
和last
也置空。
8.get(int index)
得到index
下标位置的节点。在该方法中又调用了node(int index)
方法。
通过node(int index)
方法可以找到index位置的节点。这里源码通过了一种优化的方式,通过判断index
在前半部分还是后半部分来决定是从前向后遍历还是从后向前遍历。这样就无需将整个链表都遍历。
9.set(int index, E element)
实现将index
位置的节点的值修改为element
。首先对下标进行合法性检验,合法后,就将旧的成员域替换为新的element
。
10.add(int index, E element)
在链表中的指定位置插入指定的元素。 首先对index
进行合法性校验,接着进行优化,如果index
和size
相等,则进行尾插,否则调用linkBefore(E e, Node<E> succ)
进行插入。
linkBefore
方法是在某一结点前插入一个新的节点。这里又涉及到两种情况:
- succ节点就是头节点,插入就变成了头插(succ.prev == null)
先将succ
节点的前驱保存,接着将要插入的节点的prev
域初始化为succ
节点的前驱,next
域初始化为succ
节点。接着使succ
节点的前驱结点指向该插入节点。如果成为头插的情况,那么就需要修改first
引用使其指向插入的节点。
未插入前:
插入后:
- succ节点还有前驱节点(succ.prev != null)
先将succ
节点的前驱保存,接着将要插入的节点的prev
域初始化为succ
节点的前驱,next
域初始化为succ
节点。接着使succ
节点的前驱结点指向该插入节点。如果succ
节点还有前驱节点,就要使succ
的前驱结点的next
域指向该插入节点。
未插入前:
插入后:
源代码:
其实链表的基础操作只要能完全理解一种那么剩下的也都很好理解了,如果对上面的方法实现还有不清楚的,可以试着自己画一下图,自己实现该方法就会学到很多。如果有任何问题欢迎小伙伴们指正,如果对你有帮助欢迎点赞关注,接下来一起学习进步。