Java集合进阶之ArrayList与LinkedList

ArrayList类与LinkedList类概述

ArrayList和LinkedList是Java集合类中常用的List类,其中我们众所周知的区别有

  1. ArrayList底层是数组,支持随机访问;LinkedList底层是链表,不支持随机访问;
  2. LinkedList由于底层是链表,所以其remove操作会比ArrayList快很多;
  3. LinkedList常用于栈(Stack)与队列(Queue)

预备知识

我们需要先明确ArrayList与LinkedList的预备知识,比如其底层大概实现等

ArrayList的底层概述

首先我们进入ArrayList的底层,我们可以看到这个字段

transient Object[] elementData;

这个字段代表着我们所可以使用到的实际的内容,可以看到他就是一个数组

既然是一个数组对象,众所周知,数组对象可以使用length方法获取其长度值,但是我们可以看到ArrayList的源码中还有一个这个字段

private int size;

很多小伙伴们就会疑惑,在我们平时的理解中,好像更多使用的size,那么size与elementData的length有什么区别呢?这个地方我们稍后再说明,大家现在可以知道其底层是一个数组,然后有个size字段代表其大小即可;

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中LinkedList的链表中的节点类;

我们暂时知道这些基础知识,为后面的理解做好准备;

容器嘛,最为核心的属性便是增、删、改、查这四个属性了,而我们今天来深入底层源码,来学习二者的设计思想与比较二者的不同。

增大家很好理解,利用之前学习过的数据结构与算法的知识,大家都可以大概猜测到ArrayList与LinkedList的增的大概实现;

  • ArrayList由于底层是数组,那么其便有个游标代表其现在元素在容器中的哪个位置了,需要插入的时候就游标向后移动;
  • LinkedList由于底层是链表,那么我们直接把新元素接到容器尾部就好了;

现在我们来比较其新增的操作所耗费的时间:

	//ArrayList
	@Test
    public void testAdd(){
        ArrayList list = new ArrayList<Integer>();
        Long before = System.currentTimeMillis();
        for(int i=0;i<10000000;i++){
            list.add(i);
        }
        Long after = System.currentTimeMillis();
        System.out.println(after-before);
    }
    
    //LinkedList
    @Test
    public void Test(){
        LinkedList list = new LinkedList<Integer>();
        Long before = System.currentTimeMillis();
        for(int i=0;i<10000000;i++){
            list.add(i);
        }
        Long after = System.currentTimeMillis();
        System.out.println(after-before);
    }
//运行结果
//ArrayList
2493
//LinkedList
7179

我们可以看到,在10000000次的循环新增过程所耗费的时间,ArrayList比LinkedList要短很多,那么我们来看下底层是如何实现的,为什么会有如此大的性能差距;

ArrayList的增与扩容机制

关于在讲述到ArrayList的增的机制的时候,必定离不开其扩容机制,我们首先看一下当我们新建一个ArrayList的时候,构造函数做了哪些操作;


	private static final Object[] DEFAULTCAPACITY_EMPTY_ELEMENTDATA = {};

	public ArrayList() {
        this.elementData = DEFAULTCAPACITY_EMPTY_ELEMENTDATA;
    }
    

我们可以看到,其为elementData数组赋值了一个空数组,可以看到此时并没有对于size进行修改;

然后我们来观察其add()方法的源码

	public boolean add(E e) {
        ensureCapacityInternal(size + 1);  // Increments modCount!!
        elementData[size++] = e;
        return true;
    }

我们可以看到,在add的时候,先进行 ensureCapacityInternal 方法,这个方法从词义上理解,大概是一个调整容量的方法,我们继续往下看,可以看到将size+1传参入该方法中

	private void ensureCapacityInternal(int minCapacity) {
        ensureExplicitCapacity(calculateCapacity(elementData, minCapacity));
    }

其将之前的size+1作为 minCapacity 再次传参到 calculateCapacity 方法中,然后在执行 ensureExplicitCapacity 方法,我们依次来看下其源码:

	private static final int DEFAULT_CAPACITY = 10;

	private static int calculateCapacity(Object[] elementData, int minCapacity) {
        if (elementData == DEFAULTCAPACITY_EMPTY_ELEMENTDATA) {
            return Math.max(DEFAULT_CAPACITY, minCapacity);
        }
        return minCapacity;
    }

我们可以看到,在进入该方法之后,我们先进行判断,对象数组是否为 DEFAULTCAPACITY_EMPTY_ELEMENTDATA 常量,这个常量就是在空参构造函数运行的时候的所赋值的常量,也就是说,如果容器是空的,那么将 DEFAULT_CAPACITYminCapacity 比较得出较大值,否则返回 minCapacity ,这一步主要是将其调整为所预设好的容器大小;

我们再来看 ensureExplicitCapacity 方法

	protected transient int modCount = 0;

	private void ensureExplicitCapacity(int minCapacity) {
        modCount++;

        // overflow-conscious code
        if (minCapacity - elementData.length > 0)
            grow(minCapacity);
    }

这个modCount相当于是指该容器结构被改变了多少次,不太重要这里不多赘述;主要是看下面的判断语句,我们可以看到,他把 minCapacity 与数组的长度进行比较,如果 minCapacity 大于数组的长度了,那么我们便需要进行 grow 方法扩容;

小伙伴们可能还是觉得很迷惑,扩容啥意思啥情况,我来简单跟大家阐述一下: ArrayList 实现的是一个叫做动态数组的结构,所以我们会每次设置一个固定大小的数组,size代表的是当前的容器用到哪一位了,如果size超过容器大小了,我们便进行 grow 方法扩容。

简而言之:
size=游标
elementData.length=容器大小

我们继续看grow函数

	private void grow(int minCapacity) {
        // overflow-conscious code
        // 获取当前容器容量
        int oldCapacity = elementData.length;
        // 获取未来的容器容量,此处位运算可以看做旧容量*1.5倍
        int newCapacity = oldCapacity + (oldCapacity >> 1);
        // 如果扩容不够,直接把游标赋值给新容量
        if (newCapacity - minCapacity < 0)
            newCapacity = minCapacity;
        // 如果新容量超过预设最大数组容量的操作,忽略不管
        if (newCapacity - MAX_ARRAY_SIZE > 0)
            newCapacity = hugeCapacity(minCapacity);
        // minCapacity is usually close to size, so this is a win:
        elementData = Arrays.copyOf(elementData, newCapacity);
    }

ArrayList增加元素总结

由此我们便可以看到其新增一个元素时候的操作了,我们来简单概括一下:

  1. 先调整容器大小
  2. 如果游标比容器大小大,那么便进入扩容,否则进入步骤4
  3. 扩容以1.5倍进行扩容,扩容后用 Arrays.copyOf 方法复制原数组到新数组
  4. 将新元素赋值到size位,size++
  5. 返回成功

我们来分析一下整个过程的时间复杂度,可以看到单次增加新元素的复杂度是 o(1) ,直接接到数组尾部;但是如果要进行扩容的话,那么我们会有一个将原数组复制的过程,此过程明显为 o(n) ;但是根据极客时间的资料说明,因为大多数时候,我们都是进行增加新元素的 o(1) 操作,也就是说,我们可以把复制的 o(n) 操作抽象平摊一下,那么其实总体看来add还是 o(1) 的时间复杂度;

结论:add()的时间复杂度为o(1)

LinkedList的增机制

老规矩,我们先看空参构造函数

	public LinkedList() {
    }

我们发现它就是一个空方法,没有任何操作;

LinkedList插入尾部

我们来看其add方法的源码

	public boolean add(E e) {
        linkLast(e);
        return true;
    }

我们可以看到其内部是有一个linkLast方法,从单词意思可以看到其是在链表尾部连接元素;

	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++;
    }

其主要过程便是:

  1. 先获取之前的尾结点
  2. 根据元素创建新节点
  3. 将新节点赋值给尾结点
  4. 判断之前的尾结点是否为null
  5. 是的话将新节点赋值给头结点
  6. 不是的话进行旧尾结点与新尾结点的连接
  7. size长度加一
  8. modCount修改次数加一

这个插入尾部十分简单,一路看下来就可以理解;

但是我们都知道,既然是一个链表,而且LinkedList经常作为栈与队列来使用,那么不得不说一下LinkedList的头插

LinkedList插入头部

我们查看源码,可以看到一个关键方法:linkFirst

	private void linkFirst(E e) {
        final Node<E> f = first;
        final Node<E> newNode = new Node<>(null, e, f);
        first = newNode;
        if (f == null)
            last = newNode;
        else
            f.prev = newNode;
        size++;
        modCount++;
    }

代码也是一样简单明了,我们来看一下过程

  1. 获取头结点
  2. 根据元素创建节点
  3. 将新元素赋值给头结点
  4. 如果头结点是空,进入步骤5;否则进入步骤6
  5. 将新节点赋值给末节点
  6. 将新节点作为头结点的上一个节点
  7. 大小size+1
  8. 修改次数modCount+1

也十分简单,就是普通的链表新增方法;

LinkedList的增总结

其实很简单,就是普通的头插法与尾插法,我们查阅源码之后,可以看到其实增的方法很多,但是都是基于linkFirst方法与linkLast方法

ArrayList的元素删除原理

	public E remove(int index) {
        rangeCheck(index);

        modCount++;
        E oldValue = elementData(index);

        int numMoved = size - index - 1;
        if (numMoved > 0)
            System.arraycopy(elementData, index+1, elementData, index,
                             numMoved);
        elementData[--size] = null; // clear to let GC do its work

        return oldValue;
    }

其思路很好理解:

  1. 先判断是否超出容器大小
  2. 获取要删除的元素
  3. 获取要移动的位数numMoved
  4. 如果numMoved超过0,那么便将错位复制到数组后面
  5. 元素丢弃尾元素,size自减

这个过程可以看做之前学习c语言的时候,如果要删除数组中间的一个元素,那么后面的元素全部要往前移动一位;而如果删除的是尾部,那么直接把游标往前挪一位即可;

LinkedList的元素删除原理

查阅源码,我们可以看到,虽然LinkedList类的删除元素方法很多,但是万变不离其宗,都是基于以下三个核心方法:

  1. unlink(删除中间元素)
  2. unlinkFirst(删除头部元素)
  3. unlinkLast(删除尾部元素)

我们一个一个来看其实现

unlink

	E unlink(Node<E> x) {
        // assert x != null;
        final E element = x.item;
        final Node<E> next = x.next;
        final Node<E> prev = x.prev;

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

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

        x.item = null;
        size--;
        modCount++;
        return element;
    }
	
	private E unlinkFirst(Node<E> f) {
        // assert f == first && f != null;
        final E element = f.item;
        final Node<E> next = f.next;
        f.item = null;
        f.next = null; // help GC
        first = next;
        if (next == null)
            last = null;
        else
            next.prev = null;
        size--;
        modCount++;
        return element;
    }
	
	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;
    }

平平无奇的删除链表节点,这里就不多赘述了,要注意的就是他有个把对象设置为空,帮助垃圾回收的地方;

ArrayList与LinkedList的删除元素总结

我们可以看到,他们的删除都是使用的很朴素的方式;
由于ArrayList使用的动态数组,那么经常会出现一个数组迁移的过程,所以大部分时间里时间复杂度为o(n);
而由于LinkedList使用的是链表,其删除是删除单个节点,所以复杂度会一直都是o(1);

对于一个容器,最为重要的可能就是查操作了,我们来看一下查操作上的不同;

ArrayList的查找元素原理

emmmm,我仔细看了下源码,其实没什么好说的,简单来说就是

  • get方法是使用随机访问,直接通过数组下标访问
  • indexOf是遍历整个数组,返回元素的下标
  • contains调用了indexOf方法来判断是否有该元素

LinkedList的查找元素原理

先看get方法

	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;
        }
    }

简单来说,便是他进行了一个小技巧处理,先判断我们需要的这个元素是位于序列的前半段还是后半段,然后来进行不同方向的查找;

其他的查找方法便是直接返回字段;

查找元素总结

其实查的话,没有什么优化,只要记住ArrayList使用随机访问即可;

这个查太过于简单了,直接略过,其实就是正常的数组元素修改与链表元素修改

总结

ArrayList与LinkedList是容器中较为简单的两个容器,其中比较常问的是ArrayList的扩容机制以及二者的区别等等,希望读者看完该博客之后能有个清晰的认识

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值