图解LinkedList

本文基于JDK1.8
看完本篇文章你将学习到:

  • LinkedList可以插入null值的原因
  • LinkedList可以允许重复的原因
  • LinkedList插入快,查询慢的原因
    上一篇说了ArrayList,这篇文章主要谈谈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,所以需要支持removeFirstremoveLast等一系列操作,所以需要记录链表中的首尾节点。

添加元素

假设有如下代码:

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. 接着将LinkedListlast引用指向新创建的节点,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的区别

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

  • 插入速度比较。网上大部分说LinkedList插入比ArrayLst快。这种说法是不准确的。LinkedList做插入、删除的时候,慢在寻址,快在只需要改变前后Node的引用地址,而ArrayList做插入、删除的时候,慢在数组元素的批量copy,快在寻址。
    所以如果待插入的元素位置在数据结构的前半段尤其是非常靠前时,ArrayList需要拷贝大量的元素,势必LinkedList会更快;如果带插入元素位置在数据结构后半段尤其是非常靠后,ArrayList需要拷贝的元素个数会越来越少,所以速度也会提升,甚至超过LinkedList
  • ArrayLst基于动态数组,所以内存上是连续的,而LinkedList基于链表,内存上不需要保持连续。
  • 一般遍历LinkedList时最好不要使用普通for循环,而是使用迭代器代替。

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

总结

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

微信搜索Java成神之路或扫描下方二维码,发现更多Java有趣知识,让我们一起成长!
Java成神之路

  • 7
    点赞
  • 9
    收藏
    觉得还不错? 一键收藏
  • 3
    评论
LinkedListJava中的一个类,它实现了List接口和Deque接口,可以被看作是一个顺序容器、队列和栈。LinkedList的遍历过程和查找过程类似,可以从头节点开始往后遍历。然而,LinkedList不擅长随机位置访问,如果使用随机访问遍历LinkedList,效率会很低。通常情况下,我们会使用foreach循环来遍历LinkedList,因为foreach最终会转换成迭代器形式。LinkedList的遍历核心就是它的迭代器实现。[1] LinkedList的继承体系较为复杂,它继承自AbstractSequentialList类,并实现了List和Deque接口。AbstractSequentialList是一个基于顺序访问的接口,通过继承此类,子类只需实现部分代码即可拥有完整的一套访问某种序列表的接口。LinkedList还实现了Deque接口,Deque又继承自Queue接口,因此LinkedList具备了队列的功能。[2][3] LinkedList的实现方式决定了所有与下标有关的操作都是线性时间复杂度,而在首段或末尾删除元素只需要常数时间复杂度。LinkedList没有实现同步(synchronized),如果需要多个线程并发访问,可以使用Collections.synchronizedList()方法对其进行包装。[2] 总结来说,LinkedList是一个灵活的数据结构,可以用作顺序容器、队列和栈。它的遍历过程需要注意效率问题,不适合随机位置访问。LinkedList的继承体系较为复杂,继承自AbstractSequentialList类,并实现了List和Deque接口。LinkedList的实现方式决定了与下标有关的操作是线性时间复杂度,而在首段或末尾删除元素只需要常数时间复杂度。[1][2][3]
评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值