聊聊Java中的LinkedList实现原理

前言

谈到Java中的List,一般我们使用最多的就是ArrayList。众所周知,ArrayList是使用数组来实现,然而还有另外一种数据结构也能实现List,就是我们熟悉的链表。在Java中对应的就是LinkedList,下面我们就通过分析Java的LinkedList的源码来了解其中的实现。

LinkedList的实现

LinkedList的定义

public class LinkedList<E>
    extends AbstractSequentialList<E>
    implements List<E>, Deque<E>, Cloneable, java.io.Serializable {
    
    
}

可以看出,LinkedList除了实现List接口外,还实现了Deque接口。Deque代表双端队列,双端队列是一个神奇的数据结构,栈和队列都是一种特殊的双端队列(栈只操作头部,而队列则头尾两端都操作)。
而Deque又继承了Queue接口,因此我们使用LinkedList既可以实现队列,又可以实现栈,成为人生赢家(另外一个人生赢家是ArrayDeque,底层结构是数组实现,在不考虑线程安全的前提下,可以代替Java中的Stack类)。

下面是Deque和Queue的定义。

Queue的定义

public interface Queue<E> extends Collection<E> {
    
    
	/**
	* 在尾部添加元素
	* 当队列已满时抛出IllegalStateException
	*/
	boolean add(E e);
	
	/**
	* 在尾部添加元素
	*/
	boolean offer(E e);
	
	/**
	* 返回头部元素,并从队列中删除
	* 当队列为空时抛出NoSuchElementException
	*/
	E remove();
	
	/**
	* 返回头部元素,并从队列中删除
	*/
	E poll();
	
	/**
	* 查看头部元素,但不改变队列
	* 当队列为空时抛出NoSuchElementException
	*/
	E element();
	
	/**
	* 查看头部元素,但不改变队列
	*/
	E peek();
}

可以看出,Queue的操作都是成对出现的,通俗讲就是区分了正常版和抛异常版,这是因为根据队列的性质决定不同的处理方式,例如对于有界队列,当队列已满,可能就需要抛出异常,而对于LinkedList而言,由于底层是链表形式实现,所以无所谓队列长度限制,两种方法都是一样的功能。

Deque的定义

public interface Deque<E> extends Queue<E> {
    
    
	// 栈的经典方法
	void push(E e);
	E pop();

	// 双端队列的具体方法
	E getFirst();
	E getLast();
	boolean offerFirst(E e);
	boolean offerLast(E e);
	E peekFirst();
	E peekLast();
	E pollFirst();
	E pollLast();
	E removeFirst();
	E removeLast();
}

这里可以看出,Deque继承了Queue接口,相比Queue的方法,Deque又增加了push()、pop()两个方法,这两个就是典型的栈操作的方法,都是操作头部元素。而后面的一些xxxFirst()和xxxLast()都是具体操作头尾元素的方法。

LinkedList的内部实现

介绍完LinkedList的方法定义,下面我们来看LinkedList的实现。LinkedList的内部实现是双向链表,每个节点都会记录自己的前驱节点和后继节点,这样的好处是插入元素的时候很容易就能找到自己的前驱和后继节点。

public class LinkedList<E>
    extends AbstractSequentialList<E>
    implements List<E>, Deque<E>, Cloneable, java.io.Serializable {
    
    
    
	transient int size = 0;
	transient Node<E> first;		// 头节点
	transient Node<E> last;			// 尾节点
	
	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维护了三个变量,链表的大小加上一个头节点和一个尾节点,下面我们看看各个操作的方法是如何实现的。

添加元素

    public boolean add(E e) {
    
    
        linkLast(e);
        return true;
    }
	
	void linkLast(E e) {
    
    
        final Node<E> l = last;
        final Node<E> newNode = new Node<>(l, e, null);		// #1
        last = newNode;		// #2
        if (l == null)		// #3
            first = newNode;
        else
            l.next = newNode;
        size++;		// #4
        modCount++;
    }

add()方法相对队列而言,其实就是往队列的尾部添加一个元素,当添加一个新元素时:

  1. 首先会new一个新的节点,维护好前驱和后继节点的关系。
  2. 尾指针last指向这个新的节点(这是必然的)。
  3. 判断特殊情况,如果队列一开始是空的,说明头指针first和last都是一样,first指针指向新元素,否则用一开始的尾指针l的后继节点next指向新元素。
  4. 队列数量+1。

根据索引访问元素

	public E get(int index) {
    
    
        checkElementIndex(index);		// 检查索引位置是否有效
        return node(index).item;
    }
    
    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;
        }
    }

由于链表的特性,查找一个元素时,只能一个个元素去遍历,平均时间复杂度为O(n)。
这里有个小优化,当遍历元素时,先判断查找索引和队列的一半(size >> 1)比较大小,如果索引小于中点,说明目标离头节点比较近,从头节点开始遍历,如相反,则从尾节点开始遍历。

插入元素

插入元素和查找元素的平均时间复杂度一样,都是O(n)。

    public void add(int index, E element) {
    
    
        checkPositionIndex(index);		// 检查索引位置是否有效

        if (index == size)
            linkLast(element);
        else
            linkBefore(element, node(index));
    }
    
    void linkBefore(E e, Node<E> succ) {
    
    
        // assert succ != null;
        final Node<E> pred = succ.prev;		// #1
        final Node<E> newNode = new Node<>(pred, e, succ);		// #2
        succ.prev = newNode;		// #3
        if (pred == null)		// #4
            first = newNode;
        else
            pred.next = newNode;
        size++;
        modCount++;
    }

如果要插入的索引刚好是队列的长度,说明就是最简单的从队列尾增加元素,方法linkLast()在上面的添加方法中介绍过,这里就不赘述了。
这里主要看linkBefore()方法:

  1. 找到succ(后继节点)和pred(前驱节点),succ是通过前面的node()方法中已经定位到要插入的元素。
  2. new一个新节点newNode,维护好新的关系(在pred和succ之间)
  3. 维护后继节点的前驱节点为新插入的元素newNode
  4. 判断特殊情况:由于插入是在找到的元素前面插入,如果找到的元素本身是头节点,就需要更新头节点为新插入的newNode。普通情况下,只需将找到的前驱节点的next指针指向新元素即可。

根据索引删除元素

根据索引删除元素依然需要先查找到元素,再对前后的元素进行一些指针操作,达到删除的目的。平均时间复杂度是O(n)。

	public E remove(int index) {
    
    
        checkElementIndex(index);
        return unlink(node(index));
    }
    
	E unlink(Node<E> x) {
    
    
        // assert x != null;
        final E element = x.item;
        final Node<E> next = x.next;		// #1
        final Node<E> prev = x.prev;

        if (prev == null) {
    
    		// #2
            first = next;
        } else {
    
    
            prev.next = next;
            x.prev = null;
        }

        if (next == null) {
    
    		// #3
            last = prev;
        } else {
    
    
            next.prev = prev;
            x.next = null;
        }

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

删除节点的基本思路就是将被删除节点的前驱和后置节点连接起来,步骤如下:

  1. 先找到要删除元素的前驱节点prev和后置节点next
  2. 特殊情况:判断前驱节点是否为空,如果为空,说明被删除的元素本身就是头元素,此时要维护fisrt指针为后继节点。普通情况下,只需将前驱节点的next指针指向被删除元素的后置节点即可。
  3. 与#2同理,处理特殊情况:判断后驱节点是否为空,如果为空,说明被删除的元素本身就是尾元素,此时要维护last指针为前驱节点。普通情况下,只需将后继节点的prev指针指向被删除元素的前驱节点即可。
  4. 删除元素之后记得维护队列数量-1

总结

LinkedList使用双向链表来实现List的功能,相比ArrayList,它有如下的特点:

  1. 空间占用较小,不需要提前分配内存。
  2. 不可以随机访问,通过索引查找元素时,平均时间复杂度为O(n)。
  3. 同时实现了队列和栈的特性。
  4. 对头元素和尾元素的操作效率很高,均为O(1)。
参考资料

[1].Java编程的逻辑 马俊昌 著

猜你喜欢

转载自blog.csdn.net/vipshop_fin_dev/article/details/111397643