毫不留情地揭開 ArrayList 和 LinkedList 之間的神祕面紗

沉默王二發表於2020-07-23

先看再點贊,給自己一點思考的時間,思考過後請毫不猶豫微信搜尋【沉默王二】,關注這個靠才華苟且的程式設計師。
本文 GitHub github.com/itwanger 已收錄,裡面還有技術大佬整理的面試題,以及二哥的系列文章。

ArrayListLinkedList 是 List 介面的兩種不同實現,並且兩者都不是執行緒安全的。但初學者往往搞不清楚它們兩者之間的區別,不知道什麼時候該用 ArrayList,什麼時候該用 LinkedList,那這篇文章就來傳道受業解惑一下。

ArrayList 內部使用的動態陣列來儲存元素,LinkedList 內部使用的雙向連結串列來儲存元素,這也是 ArrayList 和 LinkedList 最本質的區別。

注:本文使用的 JDK 原始碼版本為 14,小夥伴如果發現文章中的原始碼和自己本地的不同時,不要擔心,不是我原始碼貼錯了,也不是你本地的原始碼錯了,只是版本不同而已。

由於 ArrayList 和 LinkedList 內部使用的儲存方式不同,導致它們的各種方法具有不同的時間複雜度。先來通過維基百科理解一下時間複雜度這個概念。

在電腦科學中,演算法的時間複雜度(Time complexity)是一個函式,它定性描述該演算法的執行時間。這是一個代表演算法輸入值的字串的長度的函式。時間複雜度常用大 O 符號表述,不包括這個函式的低階項和首項係數。使用這種方式時,時間複雜度可被稱為是漸近的,亦即考察輸入值大小趨近無窮時的情況。例如,如果一個演算法對於任何大小為 n (必須比 大)的輸入,它至多需要 的時間執行完畢,那麼它的漸近時間複雜度是

對於 ArrayList 來說:

1)get(int index) 方法的時間複雜度為 ,因為是直接從底層陣列根據下標獲取的,和陣列長度無關。

public E get(int index) {
    Objects.checkIndex(index, size);
    return elementData(index);
}

這也是 ArrayList 的最大優點。

2)add(E e) 方法會預設將元素新增到陣列末尾,但需要考慮到陣列擴容的情況,如果不需要擴容,時間複雜度為

public boolean add(E e) {
    modCount++;
    add(e, elementData, size);
    return true;
}

private void add(E e, Object[] elementData, int s) {
    if (s == elementData.length)
        elementData = grow();
    elementData[s] = e;
    size = s + 1;
}

如果需要擴容的話,並且不是第一次(oldCapacity > 0)擴容的時候,內部執行的 Arrays.copyOf() 方法是耗時的關鍵,需要把原有陣列中的元素複製到擴容後的新陣列當中。

private Object[] grow(int minCapacity) {
    int oldCapacity = elementData.length;
    if (oldCapacity > 0 || elementData != DEFAULTCAPACITY_EMPTY_ELEMENTDATA) {
        int newCapacity = ArraysSupport.newLength(oldCapacity,
                minCapacity - oldCapacity, /* minimum growth */
                oldCapacity >> 1           /* preferred growth */);
        return elementData = Arrays.copyOf(elementData, newCapacity);
    } else {
        return elementData = new Object[Math.max(DEFAULT_CAPACITY, minCapacity)];
    }
}

3)add(int index, E element) 方法將新的元素插入到指定的位置,考慮到需要複製底層陣列(根據之前的判斷,擴容的話,陣列可能要複製一次),根據最壞的打算(不管需要不需要擴容,System.arraycopy() 肯定要執行),所以時間複雜度為

public void add(int index, E element) {
    rangeCheckForAdd(index);
    modCount++;
    final int s;
    Object[] elementData;
    if ((s = size) == (elementData = this.elementData).length)
        elementData = grow();
    System.arraycopy(elementData, index,
            elementData, index + 1,
            s - index);
    elementData[index] = element;
    size = s + 1;
}

來執行以下程式碼,把沉默王八插入到下標為 2 的位置上。

ArrayList<String> list = new ArrayList<>();
list.add("沉默王二");
list.add("沉默王三");
list.add("沉默王四");
list.add("沉默王五");
list.add("沉默王六");
list.add("沉默王七");
list.add(2"沉默王八");

System.arraycopy() 執行完成後,下標為 2 的元素為沉默王四,這一點需要注意。也就是說,在陣列中插入元素的時候,會把插入位置以後的元素依次往後複製,所以下標為 2 和下標為 3 的元素都為沉默王四。

之後再通過 elementData[index] = element 將下標為 2 的元素賦值為沉默王八;隨後執行 size = s + 1,陣列的長度變為 7。

4)remove(int index) 方法將指定位置上的元素刪除,考慮到需要複製底層陣列,所以時間複雜度為

public E remove(int index) {
    Objects.checkIndex(index, size);
    final Object[] es = elementData;

    @SuppressWarnings("unchecked") E oldValue = (E) es[index];
    fastRemove(es, index);

    return oldValue;
}
private void fastRemove(Object[] es, int i) {
    modCount++;
    final int newSize;
    if ((newSize = size - 1) > i)
        System.arraycopy(es, i + 1, es, i, newSize - i);
    es[size = newSize] = null;
}

對於 LinkedList 來說:

1)get(int index) 方法的時間複雜度為 ,因為需要迴圈遍歷整個連結串列。

public E get(int index) {
    checkElementIndex(index);
    return node(index).item;
}

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

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

下標小於連結串列長度的一半時,從前往後遍歷;否則從後往前遍歷,這樣從理論上說,就節省了一半的時間。

如果下標為 0 或者 list.size() - 1 的話,時間複雜度為 。這種情況下,可以使用 getFirst()getLast() 方法。

public E getFirst() {
    final LinkedList.Node<E> f = first;
    if (f == null)
        throw new NoSuchElementException();
    return f.item;
}

public E getLast() {
    final LinkedList.Node<E> l = last;
    if (l == null)
        throw new NoSuchElementException();
    return l.item;
}

first 和 last 在連結串列中是直接儲存的,所以時間複雜度為

2)add(E e) 方法預設將元素新增到連結串列末尾,所以時間複雜度為

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

3)add(int index, E element) 方法將新的元素插入到指定的位置,需要先通過遍歷查詢這個元素,然後再進行插入,所以時間複雜度為

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

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

如果下標為 0 或者 list.size() - 1 的話,時間複雜度為 。這種情況下,可以使用 addFirst()addLast() 方法。

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

linkFirst() 只需要對 first 進行更新即可。

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

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

linkLast() 只需要對 last 進行更新即可。

需要注意的是,有些文章裡面說,LinkedList 插入元素的時間複雜度近似 ,其實是有問題的,因為 add(int index, E element) 方法在插入元素的時候會呼叫 node(index) 查詢元素,該方法之前我們之間已經確認過了,時間複雜度為 ,即便隨後呼叫 linkBefore() 方法進行插入的時間複雜度為 ,總體上的時間複雜度仍然為 才對。

void linkBefore(E e, LinkedList.Node<E> succ) {
    // assert succ != null;
    final LinkedList.Node<E> pred = succ.prev;
    final LinkedList.Node<E> newNode = new LinkedList.Node<>(pred, e, succ);
    succ.prev = newNode;
    if (pred == null)
        first = newNode;
    else
        pred.next = newNode;
    size++;
    modCount++;
}

4)remove(int index) 方法將指定位置上的元素刪除,考慮到需要呼叫 node(index) 方法查詢元素,所以時間複雜度為

public E remove(int index) {
    checkElementIndex(index);
    return unlink(node(index));
}

unlink(LinkedList.Node<E> x) {
    // assert x != null;
    final E element = x.item;
    final LinkedList.Node<E> next = x.next;
    final LinkedList.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;
}

通過時間複雜度的比較,以及原始碼的分析,我相信小夥伴們在選擇的時候就有了主意,對吧?

需要注意的是,如果列表很大很大,ArrayList 和 LinkedList 在記憶體的使用上也有所不同。LinkedList 的每個元素都有更多開銷,因為要儲存上一個和下一個元素的地址。ArrayList 沒有這樣的開銷。

但是,ArrayList 佔用的記憶體在宣告的時候就已經確定了(預設大小為 10),不管實際上是否新增了元素,因為複雜物件的陣列會通過 null 來填充。LinkedList 在宣告的時候不需要指定大小,元素增加或者刪除時大小隨之改變。

另外,ArrayList 只能用作列表;LinkedList 可以用作列表或者佇列,因為它還實現了 Deque 介面。

我在寫這篇文章的時候,遇到了一些問題,所以請教了一些大廠的技術大佬,結果有個朋友說,“如果真的不知道該用 ArrayList 還是 LinkedList,就選擇 ArrayList 吧!”

我當時以為他在和我開玩笑呢,結果通過時間複雜度的分析,好像他說得有道理啊。查詢的時候,ArrayList 比 LinkedList 快,這是毋庸置疑的;插入和刪除的時候,之前有很多資料說 LinkedList 更快,時間複雜度為 ,但其實不是的,因為要遍歷列表,對吧?

反而 ArrayList 更輕量級,不需要在每個元素上維護上一個和下一個元素的地址。

我這樣的結論可能和大多數文章得出的結論不符,那麼我想,選擇權交給小夥伴們,你們在使用的過程中認真地思考一下,並且我希望你們把自己的思考在留言區放出來。


我是沉默王二,一枚有顏值卻靠才華苟且的程式設計師。關注即可提升學習效率,別忘了三連啊,點贊、收藏、留言,我不挑,奧利給

注:如果文章有任何問題,歡迎毫不留情地指正。

如果你覺得文章對你有些幫助歡迎微信搜尋「沉默王二」第一時間閱讀,回覆「小白」更有我肝了 4 萬+字的 Java 小白手冊 2.0 版,本文 GitHub github.com/itwanger 已收錄,歡迎 star。

相關文章