深入淺出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的底層源碼,這樣解釋,我覺得更加容易理解。希望對大家有所幫助,尤其是新手小夥伴。

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