深入浅出LinkedList与ArrayList

引言

本来我写了一篇从源码介绍ArrayList和LinkedList的博文,但是反复考虑之后,觉得并没有多大意义。相信稍微有点基础的都能明白他们基本的原理。所以我又重新写了一篇更高级一些的文章,不再去研究底层的构造。在本篇博文中我会用很多的例子来说明两者的区别,用底层的两者不同实现来讲解,比较关键操作两者的运行时间。最后,我会详细说一下关于List的remove操作。笔者目前整理的一些blog针对面试都是超高频出现的。大家可以点击链接:http://blog.csdn.net/u012403290

技术点

1、抽象数据类型(ADT)
他是指有一组操作的一些对象的集合,比如说表,集合,图等与他们各自对应的添加,删除,包含等一些列的操作就是集合ADT。

2、数组
连续的内存,寻址快,修改困难,下面的图就是数组的模型:
这里写图片描述
数组的数据从0开始,最后一个数据下标为size-1,这也是为什么它的寻址是很快的,同时我们看看它的插入和删除:
这里写图片描述
从图中可以看出,插入和删除会对数据的下标进行重新整理,也就是可能数据需要整体进行往前移动或者往后移动。最坏的情况在arr[0]处进行插入和删除,那么就需要对整个数组进行前后移动,所以这种情况下它的时间复杂度是O(N),但是如果每次插入和删除都是在数组末尾,也就是arr[size-1]的位置,那么它的时间复杂度就是O(1)。关于时间复杂度的计算,在博文《深入理解HashMap》中有写到。

3、链表
分散的内存,寻址慢,修改方便,下面的图就是简单链表的模型:
这里写图片描述
每一个数据称为节点,节点分为元素和下个节点信息两部分,下个节点信息我们称为next链,最后一个元素的next链引用为null。
没确定一个要查找的元素,链表需要从头节点开始遍历,一直找到指定元素为止,这种操作是线性的,所以它的时间复杂度为O(N),这也是为什么它寻址慢的原因。接下来我们看看链表的插入和删除:
这里写图片描述
在链表的新增和删除操作中就比数据会省力很多,比如说要把A2删除,那么只需要把A1中的next链信息改成A3即可,同时,进行Help GC的操作把A2设成null。那么删除操作就结束了。新增,比如说要插入在A1与A2之间,那么只需要把A1的next链信息指向新插入的数据,把新插入的数据的next链信息指向A2即可。这也是为什么链表在做修改的时候会更加的快捷。
这里还存在一个很明显的问题,从图中可以看出,每个节点都仅仅包含了它的后继节点信息,并没有它的前驱节点信息。那么比如说我要删除最后一个元素,那么我就需要遍历到倒数第二个元素,并把它的next链引用设为null才行。这个时候,我们就需要引入一个高级的链表:双链表,以下是它的模型:
这里写图片描述

4、Iterator接口
在java中,Collections是ADT的核心,它包含了很多必要的操作。下面是Collections的源码:

public interface Collection<E> extends Iterable<E> {
//以下都是抽象数据类型的关键操作方法
 int size();
  boolean isEmpty();
  boolean contains(Object o);
  Iterator<E> iterator();
  boolean remove(Object o);
.
.
.
.

在上面的源码中有迭代器Iterator的存在,它是作为视图存在Collections中,我们看看它的源码:


public interface Iterator<E> {
   boolean hasNext();
   E next();
      default void remove() {
        throw new UnsupportedOperationException("remove");
    }
     default void forEachRemaining(Consumer<? super E> action) {//这条是在jdk1.8新增的方法,可以用Lambda来描述
        Objects.requireNonNull(action);
        while (hasNext())
            action.accept(next());
    }
    }

那么Iterator通过上述源码中的这些方法到底是什么意思呢?,比如说next方法:第一次调用next,那么会返回第一项的数据;第二次调用next方法,会返回第二个数据,不要被next误理解,它就是指当前操作元素。hasNext是一个boolean方法,它主要是判断是否存在下一个元素。在jdk1.5中提出的增强for循环(for-each)就是建立在迭代器的基础上。
为什么我介绍迭代器的时候需要把collection一并提及,不知道大家有没有发现其实collection和Iterator都有remove的方法,它们都可以移除集合中的某一个元素,但是他们存在巨大的差别和影响,后面demo中会解释到。

测试ArrayList与LinkedList耗时问题

两者都在List的末尾进行添加数据

package com.brickworkers;

import java.util.ArrayList;
import java.util.LinkedList;
import java.util.List;

/**
 * 
 * @author Brickworker
 * Date:2017年4月17日下午1:53:45 
 * 关于类ListTest.java的描述:关于List的相关例子
 * Copyright (c) 2017, brcikworker All Rights Reserved.
 */
public class ListTest {

    /**
     * 在List的末尾进行数据添加
     */
    public static void addLast(List<Integer> list){
        long startTime = System.currentTimeMillis();
        for (int i = 0; i < 100000; i++) {//存放1000个数据
            list.add(i);
        }
        if (list instanceof ArrayList) {
            System.out.println("ArrayList耗时:"+(System.currentTimeMillis() - startTime));
        }else{
            System.out.println("LinkedList耗时:"+(System.currentTimeMillis() - startTime));
        }
    }

    public static void main(String[] args) {
        List<Integer> arrList = new ArrayList<Integer>();
        List<Integer> linkList = new LinkedList<Integer>();
        addLast(arrList);
        addLast(linkList);
    }
}

//运行结果:
//ArrayList耗时:4
//LinkedList耗时:4

其实add操作在两种List中都是默认添加到最后面的,他们的运行时间基本上是一样的。所以上面的操作其实时间复杂度都为O(N)(插入都是O(1),但是插入的for循环使得变成了O(N))。但是,如果插入的数量足够大,其实是LinkedList开销更加大,因为LinkedList需要生产一个新的Node节点,而ArrayList只需要尾部加一个元素就好了,当然,有的小伙伴会说ArrayList会扩容,但是扩容的时间来说是非常小的。下面是两者add的源码:

//ArrayList

    /**
     * Appends the specified element to the end of this list.
     *
     * @param e element to be appended to this list
     * @return <tt>true</tt> (as specified by {@link Collection#add})
     */
    public boolean add(E e) {
        ensureCapacityInternal(size + 1);  // Increments modCount!!
        elementData[size++] = e;
        return true;
    }

//LinkedList
    /**
     * Appends the specified element to the end of this list.
     *
     * <p>This method is equivalent to {@link #addLast}.
     *
     * @param e element to be appended to this list
     * @return {@code true} (as specified by {@link Collection#add})
     */
    public boolean add(E e) {
        linkLast(e);
        return true;
    }

    /**
     * Links e as last element.
     */
    void linkLast(E e) {
        final Node<E> l = last;
        final Node<E> newNode = new Node<>(l, e, null);
        last = newNode;
        if (l == null)
            first = newNode;
        else
            l.next = newNode;
        size++;
        modCount++;
    }

生产和维护一个新的Node节点,必然会有更大的开销。

在List头部插入新数据
下面这段代码就是在List的头部插入数据,为了简洁,只是贴出一个方法:


    /**
     *  在List的头部进行数据添加
     */
    public static void addFirst(List<Integer> list){
        long startTime = System.currentTimeMillis();
        for (int i = 0; i < 100000; i++) {//存放1000个数据
            list.add(0, i);
        }
        if (list instanceof ArrayList) {
            System.out.println("ArrayList耗时:"+(System.currentTimeMillis() - startTime));
        }else{
            System.out.println("LinkedList耗时:"+(System.currentTimeMillis() - startTime));
        }
    }
//输出结果:
//ArrayList耗时:461
//LinkedList耗时:3

是不是结果非常明显,在这段代码中,LinkedList的执行时间复杂度还是O(N),但是ArrayList已经变成O(N^2)了,因为在ArrayList中在头部添加本身就是一个O(N)的操作。

查询操作
以下是一个普通for循环的计算结果:

    /**
     * 获取List中所有的元素 
     */
    public static void getAll(List<Integer> list){

        for (int i = 0; i < 100000; i++) {//存放1000个数据
            list.add(i);
        }
        long startTime = System.currentTimeMillis();//插完数据后开始计时
        for (int i = 0, sum=0; i < 100000; i++) {
            sum+=list.get(i);
        }
        if (list instanceof ArrayList) {
            System.out.println("ArrayList耗时:"+(System.currentTimeMillis() - startTime));
        }else{
            System.out.println("LinkedList耗时:"+(System.currentTimeMillis() - startTime));
        }
    }
    //运行结果:
    //ArrayList耗时:1
    //LinkedList耗时:4348

再查询操作的时候,显然ArrayList的操作更优越,因为它其实就是一个O(N)的操作,而LinkedList这个时候就是O(N^2)的操作了。
但是,前面提到过Iterator的遍历规则,那么我们这里可以用增强for循环来进行遍历,因为增强for循环是建立在Iterator的基础上的。

    /**
     * for-each获取List中所有的元素 
     */
    public static void getAll2(List<Integer> list){

        for (int i = 0; i < 100000; i++) {//存放1000个数据
            list.add(i);
        }
        long startTime = System.currentTimeMillis();//插完数据后开始计时
        int sum = 0;
        for (Integer integer : list) {
            sum += integer;
        }
        if (list instanceof ArrayList) {
            System.out.println("ArrayList耗时:"+(System.currentTimeMillis() - startTime));
        }else{
            System.out.println("LinkedList耗时:"+(System.currentTimeMillis() - startTime));
        }
    }
//运行结果:
//ArrayList耗时:3
//LinkedList耗时:5

如果用迭代器来实现遍历查询,那么他们的操作就都是O(N)了,两者的耗时就非常相近了。

List删除问题

我之所以把删除单独拿出来,是因为我不仅仅要比较他们的耗时问题,我还要深入研究一个异常,我们在开发的过程中一般人都可能遇到过ConcurrentModificationException的异常,这个异常是因为你在遍历操作集合的时候又修改了集合。我们以删除为例子,来进一步说明。
在上面已经说到,Collections和Iterator都有remove方法,那么我们用Collections进行遍历移除的时候会发生大问题,比如说下面这段代码:

     /**
      * List删除指定数据
      */
    public static void removeOne(List<Integer> list){

        for (int i = 0; i < 100000; i++) {//存放1000个数据
            list.add(i);
        }
        long startTime = System.currentTimeMillis();//插完数据后开始计时
        for (Integer integer : list) {
            if(integer < 100000);
            list.remove(integer);
        }
        if (list instanceof ArrayList) {
            System.out.println("ArrayList耗时:"+(System.currentTimeMillis() - startTime));
        }else{
            System.out.println("LinkedList耗时:"+(System.currentTimeMillis() - startTime));
        }
    }
//输出结果:
//Exception in thread "main" java.util.ConcurrentModificationException
//at java.util.ArrayList$Itr.checkForComodification(Unknown Source)
//at java.util.ArrayList$Itr.next(Unknown Source)
//at com.brickworkers.ListTest.removeOne(ListTest.java:96)
//at com.brickworkers.ListTest.main(ListTest.java:112)

说明在新增和删除某个元素的时候,不能用Collections的remove方法吗?不是的,其实是我们的遍历方法导致这里无法使用。试想,当你进行迭代器遍历的时候,你通过Collctions的remove方法移除了一个元素,那么迭代器是不懂得当删除一项之后如果继续迭代的,只能用它迭代器自身的remove操作才可以。修改以上代码如下:

     /**
      * List删除指定数据
      */
    public static void removeOne(List<Integer> list){

        for (int i = 0; i < 100000; i++) {//存放1000个数据
            list.add(i);
        }
        long startTime = System.currentTimeMillis();//插完数据后开始计时
        Iterator<Integer> iterator = list.iterator();
        while (iterator.hasNext()) {
            if(iterator.next() < 100000){
                iterator.remove();
            }
        }
        if (list instanceof ArrayList) {
            System.out.println("ArrayList耗时:"+(System.currentTimeMillis() - startTime));
        }else{
            System.out.println("LinkedList耗时:"+(System.currentTimeMillis() - startTime));
        }
    }

//输出结果:
//  inkedList耗时:4
//  ArrayList耗时:484

这样一来,就不会抛出异常了,但是还是建议看看不同List中重写的Iterator的具体实现,会有很多的感触。从上面代码可以看出,在使用迭代器的遍历过程中,ListkedList显得更加优越。为什么同样都是迭代方式,但是ArrayList会耗时这么严重呢?因为不论如何,删除之后数组的移动都是会带有巨大的开销。

那么我如何不使用迭代器来遍历List呢?其实是有办法的,我们只要控制好List的元素顺序就可以了,这样就会避免抛出ConcurrentModificationException异常。比如说下面我写的一段demo:

    /**
     * while循环遍历List
     */
    public static void removeOne2(List<Integer> list){
        for (int i = 0; i < 100000; i++) {//存放1000个数据
            list.add(i);
        }
        long startTime = System.currentTimeMillis();//插完数据后开始计时
        int i = 0;
        while(i < list.size()){
            if(list.get(i) < 10000){
                list.remove(i);//这里不进行i++是因为当删除了之后,List自然减小,那么就变成此i非彼i了
            }else{
                i ++;
            }
        }
        if (list instanceof ArrayList) {
            System.out.println("ArrayList耗时:"+(System.currentTimeMillis() - startTime));
        }else{
            System.out.println("LinkedList耗时:"+(System.currentTimeMillis() - startTime));
        }
    }
//输出结果:
//  LinkedList耗时:3653
//  ArrayList耗时:103

可以看出,两者都显得非常笨重,所以如果你要遍历删除,千万不要用这种方法。在这种发放中,LinkedList的遍历本来就是O(N)的时间复杂度,再加上本身的循环,直接就是O(N^2)的操作了。所以以后再遍历删除的时候,尽量使用迭代器来操作。

尾记

不介绍LinkedList和ArrayList的底层源码,这样解释,我觉得更加容易理解。希望对大家有所帮助,尤其是新手小伙伴。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章