Java基础系列:了解LinkedList

来,进来的小伙伴们,咱们认识一下。html

我是俗世游子,在外流浪多年的Java程序猿java

这两天看到太多小伙伴在秀公司“10.24程序员节”的福利,我认可我酸了o(╥﹏╥)onode

上一节咱们聊过了ArrayList,对其底层结构和源码实现进行了了解,那这节咱们来聊一聊关于它的“兄弟集合”:LinkedList程序员

集合之LinkedList

特性

一样属于List的子类,那么也就一样拥有了其特色:api

  • 有序
  • 不重复

LinkedList结构

能够看到,LinkedList除了实现List接口外,还实现了Queue接口,在Java中,该接口定义的是队列的,向咱们以后要聊到的数组

等等的都是属于该类的实现安全

基于这种方式,那么咱们的LinkedList也适合作队列的处理场景,好比:数据结构

  • FIFO
  • 堆,栈等

链表的介绍

LinkedList底层是基于双向链表的方式来存储的,那确定有人在想,什么是链表呢?咱们这就来聊一聊oracle

什么是链表

链表是一种在逻辑上连续,可是物理存储上非连续的存储结构,其保证逻辑连续是经过指针指向来肯定顺序的。ide

链表结构

上面看到的是单向链表,能够看到:

  • 在链表中,每一个节点称为Node,其中包含两个部分
    • data:具体存储数据
    • next:是指针的指向,指向下一个Node

还有一种双向链表的形式

双向链表

看上图:

  • 节点中,额外多了个一个prev的指向,双向链表的节点是两两互相指向

LinkedList就是采用的双向链表的形式,下面咱们来看具体的代码

链表的操做

背景:这里已双向链表为例

对链表操做,实际上就是修改指针的指向,好比

  • 插入元素

头尾的插入很是简单,直接指向 nextprev 就能够了,这里咱们看插入到中间

双向链表元素插入到中间

  • 移除元素

移除元素和插入元素很相似,无非就是将指定元素删除掉,而后将指针指向下一个节点

移除元素

前面的链表介绍都是为以后作铺垫,咱们继续来看今天的主角:LinkedList

LinkedList详细介绍

LinkedList底层是采用双向链表的结构来进行数据存储的,能够说,LinkedList全部的操做都是针对引用指向来进行操做的。下面来看具体的方法

咱们是这么操做LinkedList

LinkedList<String> linkedList = new LinkedList<>();
// Arrays.asList("item1", "item2"):为了方便演示
LinkedList<String> linkedList2 = new LinkedList<>(Arrays.asList("item1", "item2"));

一样,咱们仍是经过构造方法来看:

public LinkedList() {
}

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

ArrayList不一样,这里只有两个构造方法。

  • LinkedList中,初始长度是不须要设置的,并且也不须要扩容操做。

  • 理论状况下,只要内存足够大,那么LinkedList就能够一直存储下去

不须要设置初始长度和底层存储结构有关,若是想不明白能够先去上一节看一看数组的介绍

不过也有说,LinkedList是存在最大容量的:不能超过Integer.MAX_VALUE

你们能够查找下资料,好好验证下该说法(本人没有在源码中发现对应的验证)

同时,咱们来看一看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;
    }
}

双向链表节点:在代码中对应的具体实现。

这里我须要让你们考虑一个问题:

ArrayList和LinkedList一样存储了100W的数据,哪一种集合占用的空间更大?

具体的操做方法

插入元素的方法

前面也说到了,LinkedList除了实现List接口外,还实现了Queue接口,天然也重写其对应的方法,下面咱们一一来看看:

linkedList.add("item2");
linkedList.add(1, "item3");
linkedList.addFirst("item0");
linkedList.addLast("item9");
咱们对比add(e)addLast(e)的源代码
public boolean add(E e) {
    linkLast(e);
    return true;
}

public void addLast(E e) {
    linkLast(e);
}

void linkLast(E e) {
    // 获得临时变量尾结点
    final Node<E> l = last;
    // 新节点的上一个节点是以前的尾结点,下一个节点是null
    final Node<E> newNode = new Node<>(l, e, null);
    // 新的节点成为尾结点,
    last = newNode;
    // 若是尾结点是null,这个链表是空的,那么头结点也是新增的节点
    if (l == null)
        first = newNode;
    else
        // 以前尾结点的next是新节点
        l.next = newNode;
    size++;
    modCount++;
}
  • 二者实现方式都是linkLast(e)方法,从字面和具体的实现方法上来看,默认的add(e)方法是将元素添加到了链表的尾部,这里专业名词叫:尾插法
  • addLast(e)就更不用说了,直接将元素添加到尾部

  • 关于linkLast(e)方法注释都已经有了,其实就是在调整指针的指向,总体描述以下图:

尾部插入

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

private void linkFirst(E e) {
    // 获得临时变量头结点
    final Node<E> f = first;
    // 新节点 插入到头部,因此新节点的next指向f
    final Node<E> newNode = new Node<>(null, e, f);
    // 一样,新节点称为新的头
    first = newNode;
    // 若是头结点是null,这个链表是空的,那么尾结点也是新增的节点
    if (f == null)
        last = newNode;
    else
        // 不然的话,新节点的prev指向新节点
        f.prev = newNode;
    size++;
    modCount++;
}
  • 其实,addFirst(e)addLast(e)方法正好相反

这里就不给图了,就是上面插入尾部改为插入头部

add(index, e)

这里咱们要看一下指定位置的插入:

public void add(int index, E element) {
    checkPositionIndex(index);

    if (index == size)
        linkLast(element);
    else
        linkBefore(element, node(index));
}

Node<E> node(int index) {
    // assert isElementIndex(index);

    if (index < (size >> 1)) {
        Node<E> x = first;
        for (int i = 0; i < index; i++)
            x = x.next;
        return x;
    } else {
        Node<E> x = last;
        for (int i = size - 1; i > index; i--)
            x = x.prev;
        return x;
    }
}

linkBefore()方法就不贴出来了,也就是改变prev和next的指向,下面咱们看一下node(index)方法

  • 咱们能够看到,指定位置插入节点,须要先遍历将当前索引上的节点找到,而后在改变指向
  • 一样,这里在遍历的时候采用简单二分法,判断当前索引是在链表的前半段仍是在后半段,减小循环遍历的次数,提升了性能

这里咱们举个例子

好比:LinkedList中存储了500条,咱们须要往250个位置上添加,那么node(index)对应的遍历就是在后半段:

Node<E> x = last;
for (int i = 500 - 1; i > 250; i--)
    x = x.prev;
return x;

// 好比这里是 400
Node<E> x = last;
for (int i = 500 - 1; i > 400; i--)
    x = x.prev;
return x;
  • 这样的状况下,须要循环最少(499-250)次才能找到对应的节点,而若是索引越大,那么循环次数就越小:
  • 基于这种方式咱们得出结论:

虽然遍历采用简单二分法提高了总体遍历的性能,可是若是遍历的节点越靠近中间位置,检索的效率也就越低

这里给你们留一个试验:ArrayList的插入和LinkedList的插入,性能相好比何?须要考虑一下几个方面:

  • ArrayList的扩容问题
  • 插入到头部,中间,尾部的性能

获取元素

直接get(index)的方式
linkedList.get(0);
// 在LinkedList中已经记录了头节点和尾结点,这里就是获得当前的数据就好了
linkedList.getFirst();
linkedList.getLast();
  • 其实这里的get(index)咱们上面已经介绍到了,就是经过node(index)来获得指定索引的数据的
public E get(int index) {
    checkElementIndex(index);
    return node(index).item;
}
Iterator的方式
Iterator<String> iterator = linkedList.iterator();

这种模式咱们就不介绍了,iterator()实现实际上是采用这种方式来作的:

public ListIterator<E> listIterator() {
    return listIterator(0);
}

public ListIterator<E> listIterator(final int index) {
    rangeCheckForAdd(index);

    return new ListItr(index);
}

private class ListItr implements ListIterator<E> {
    private Node<E> lastReturned;
    private Node<E> next;
    private int nextIndex;
    private int expectedModCount = modCount;

    ListItr(int index) {
        // assert isPositionIndex(index);
        next = (index == size) ? null : node(index);
        nextIndex = index;
    }
}

这里默认传入的参数是:0,LinkedList为咱们开放了该方法:

ListIterator<String> listIterator = linkedList.listIterator(0); // ==  linkedList.iterator();

ListIterator和Iterator二者的对比咱们上节也介绍过了

那么,问题来了:在迭代LinkedList的时候咱们该采用那种方式?

咱们来作个实验进行验证:1W的数据,咱们来进行验证

int len = 1_0000;
LinkedList<String> linkedList = new LinkedList<String>() {{
    for (int i = 0; i < len; i++) {
        add("item" + i);
    }
}};

long start = System.currentTimeMillis();
for (int i = 0; i < len; i++) {
    linkedList.get(i);
}
System.out.println("for i 耗时:" + (System.currentTimeMillis() - start));

start = System.currentTimeMillis();
Iterator<String> iterator = linkedList.iterator();
while (iterator.hasNext()) {
    iterator.next();
}
System.out.println("iterator 耗时:" + (System.currentTimeMillis() - start));

猜一猜最终的结果如何?

迭代对比

咱们来想想为何:

  • 上面介绍过,get(index)底层实现是:node(index),最外层每循环一次,node(index)每次都会进行一次二分查找,而后循环迭代索引取出对应的值
  • 若是采用iterator()的话,只会在构造方法中进行一次node(0)的迭代取出第一个节点,由于双向链表的形式,因此经过item.next来就能够取出对应的元素
public E next() {
    checkForComodification();
    if (!hasNext())
        throw new NoSuchElementException();

    lastReturned = next;
    next = next.next;
    nextIndex++;
    return lastReturned.item;
}

因此咱们在LinkedList的迭代的时候,最好采用iterator()的方式

移除元素

咱们都是经过remove()来移除元素,那么对应其中的实现:

E unlink(Node<E> x) {
    // assert x != null;
    // 获得当前元素,当前next和prev的指向对象
    final E element = x.item;
    final Node<E> next = x.next;
    final Node<E> prev = x.prev;

    // 若是prev是null,说明是第一个节点
    if (prev == null) {
        first = next;
    } else {
        // 不然就将当前节点的prev的下一个节点指向当前节点的next
        prev.next = next;
        x.prev = null;
    }

    // 若是next是null,说明是最后一个节点
    if (next == null) {
        last = prev;
    } else {
        // 不然就将当前节点的prev的下一个节点指向当前节点的prev
        next.prev = prev;
        x.next = null;
    }

    x.item = null;
    size--;
    modCount++;
    return element;
}

这里其实就是修改引用的过程,这里就不展现图了,你们感兴趣的话我在后面给出一个站点,你们能够在那个站点上查看对应具体的过程

这里咱们就过了,下面基于LinkedList简单聊一点队列的东西

队列

队列也是咱们经常使用的一种数据结构:并且只容许在一端进行插入操做,另外一端进行删除操做。

这不就是排排站,先插入进来的先被处理掉。

这种结构能够称为先进先出的方式,也就是咱们所说的:FIFO,具体以下:

image-20201024165735272

那么,LinkedList又是怎么作的呢?

简单来两个方法来实现一下:

  • push(e)

将元素推送到由此列表表示的堆栈上

底层实现也很是简单,就是调用以前的addFirst(e)来操做的,都是上面介绍过的

public void push(E e) {
    addFirst(e);
}
  • pollLast(e)

检索并删除此列表的最后一个元素

public E pollLast() {
    final Node<E> l = last;
    return (l == null) ? null : unlinkLast(l);
}

private E unlinkLast(Node<E> l) {
    // assert l == last && l != null;
    final E element = l.item;
    final Node<E> prev = l.prev;
    l.item = null;
    l.prev = null; // help GC
    last = prev;
    if (prev == null)
        first = null;
    else
        prev.next = null;
    size--;
    modCount++;
    return element;
}

根据LinkedList提供的方法就能够有不少中实现方式,只要知足先进先出的方式

线程安全性

ArrayList是同样的,若是只是当作局部变量来使用的话,是不存在线程问题的;可是若是当作共享资源来使用,那么必然是线程不安全的,针对解决方式:

  • 本身加锁
  • 使用Collections#synchronizedList方法的返回值来进行数据操做

文档

更多关于LinkedList使用方法推荐查看其文档:

LinkedListAPI文档

数据结构必备站点

相关文章
相关标签/搜索