Java 面試題:說一下 ArrayList 和 LinkedList 的區別?

語言: CN / TW / HK

theme: jzman

本文已收錄到  GitHub · AndroidFamily,有 Android 進階知識體系,歡迎 Star。技術和職場問題,請關注公眾號 [彭旭銳] 私信我提問。

前言

大家好,我是小彭。

在上一篇文章裡,我們聊到了基於動態陣列 ArrayList 線性表,今天我們來討論一個基於連結串列的線性表 —— LinkedList。


學習路線圖:


1. LinkedList 的特點

1.1 說一下 ArrayList 和 LinkedList 的區別?

  • 1、資料結構: 在資料結構上,ArrayList 和 LinkedList 都是 “線性表”,都繼承於 Java 的 List 介面。另外 LinkedList 還實現了 Java 的 Deque 介面,是基於連結串列的棧或佇列,與之對應的是 ArrayDeque 基於陣列的棧或佇列;
  • 2、執行緒安全: ArrayList 和 LinkedList 都不考慮執行緒同步,不保證執行緒安全;
  • 3、底層實現: 在底層實現上,ArrayList 是基於動態陣列的,而 LinkedList 是基於雙向連結串列的。事實上,它們很多特性的區別都是因為底層實現不同引起的。比如說:
    • 在遍歷速度上: 陣列是一塊連續記憶體空間,基於區域性性原理能夠更好地命中 CPU 快取行,而連結串列是離散的記憶體空間對快取行不友好;
    • 在訪問速度上: 陣列是一塊連續記憶體空間,支援 O(1) 時間複雜度隨機訪問,而連結串列需要 O(n) 時間複雜度查詢元素;
    • 在新增和刪除操作上: 如果是在陣列的末尾操作只需要 O(1) 時間複雜度,但在陣列中間操作需要搬運元素,所以需要 O(n)時間複雜度,而連結串列的刪除操作本身只是修改引用指向,只需要 O(1) 時間複雜度(如果考慮查詢被刪除節點的時間,複雜度分析上依然是 O(n),在工程分析上還是比陣列快);
    • 額外記憶體消耗上: ArrayList 在陣列的尾部增加了閒置位置,而 LinkedList 在節點上增加了前驅和後繼指標。

1.2 LinkedList 的多面人生

在資料結構上,LinkedList 不僅實現了與 ArrayList 相同的 List 介面,還實現了 Deque 介面(繼承於 Queue 介面)。

Deque 介面表示一個雙端佇列(Double Ended Queue),允許在佇列的首尾兩端操作,所以既能實現佇列行為,也能實現棧行為。

Queue 介面:

| 拒絕策略 | 拋異常 | 返回特殊值 | | ------ | --------- | -------- | | 入隊(隊尾) | add(e) | offer(e) | | 出隊(隊頭) | remove() | poll() | | 觀察(隊頭) | element() | peek() |

Queue 的 API 可以分為 2 類,區別在於方法的拒絕策略上:

  • 拋異常:

    • 向空佇列取資料,會丟擲 NoSuchElementException 異常;
    • 向容量滿的佇列加資料,會丟擲 IllegalStateException 異常。
  • 返回特殊值:

    • 向空佇列取資料,會返回 null;
    • 向容量滿的佇列加資料,會返回 false。

Deque 介面:

Java 沒有提供標準的棧介面(很好奇為什麼不提供),而是放在 Deque 介面中:

| 拒絕策略 | 拋異常 | 等價於 | | ------ | ------- | ------------- | | 入棧 | push(e) | addFirst(e) | | 出棧 | pop() | removeFirst() | | 觀察(棧頂) | peek() | peekFirst() |

除了標準的佇列和棧行為,Deque 介面還提供了 12 個在兩端操作的方法:

| 拒絕策略 | 拋異常 | 返回值 | | ---- | --------------------------- | --------------------------- | | 增加 | addFirst(e)/ addLast(e) | offerFirst(e)/ offerLast(e) | | 刪除 | removeFirst()/ removeLast() | pollFirst()/ pollLast() | | 觀察 | getFirst()/ getLast() | peekFirst()/ peekLast()


2. LinkedList 原始碼分析

這一節,我們來分析 LinkedList 中主要流程的原始碼。

2.1 LinkedList 的屬性

  • LinkedList 底層是一個 Node 雙向連結串列,Node 節點中會持有資料 E 以及 prev 與next 兩個指標;
  • LinkedList 用 firstlast 指標指向連結串列的頭尾指標。

LinkedList 的屬性很好理解的,不出意外的話又有小朋友出來舉手提問了:

  • 🙋🏻‍♀️ 疑問 1:為什麼欄位都不宣告 private 關鍵字?

這個問題直接回答吧。我的理解是:因為內部類在編譯後會生成獨立的 Class 檔案,如果外部類的欄位是 private 型別,那麼編譯器就需要通過方法呼叫,而 non-private 欄位就可以直接訪問欄位。

  • 🙋🏻‍♀️ 疑問 2:為什麼欄位都宣告 transient 關鍵字?

這個問題我們在分析原始碼的過程中回答。

疑問比 ArrayList 少很多,LinkedList 真香(還是別高興得太早吧)。

```java public class LinkedList extends AbstractSequentialList implements List, Deque, Cloneable, java.io.Serializable {

// 疑問 1:為什麼欄位都不宣告 private 關鍵字?
// 疑問 2:為什麼欄位都宣告 transient 關鍵字?
// 元素個數
transient int size = 0;

// 頭指標
transient Node<E> first;

// 尾指標
transient Node<E> last;

// 連結串列節點
private static class Node<E> {
    // 節點資料
    // (型別擦除後:Object item;)
    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;
    }
}

} ```

2.2 LinkedList 的構造方法

LinkedList 有 2 個構造方法:

  • 1、無參構造方法: no-op;
  • 2、帶集合的構造: 在連結串列末尾新增整個集合,內部呼叫了 addAll 方法將整個集合新增到陣列的末尾。

```java // 無參構造方法 public LinkedList() { }

// 帶集合的構造方法 public LinkedList(Collection<? extends E> c) { this(); addAll(c); }

// 在連結串列尾部新增集合 public boolean addAll(Collection<? extends E> c) { // 索引為 size,等於在連結串列尾部新增 return addAll(size, c); } ```

2.3 LinkedList 的新增方法

LinkedList 提供了非常多的 addXXX 方法,內部都是呼叫一系列 linkFirstlinkLastlinkBefore 完成的。如果在連結串列中間新增節點時,會用到 node(index) 方法查詢指定位置的節點。

其實,我們會發現所有新增的邏輯都可以用 6 個步驟概括:

  • 步驟 1: 找到插入位置的後繼節點(在頭部插入就是 first,在尾部插入就是 null);
  • 步驟 2: 構造新節點;
  • 步驟 3: 將新節點的 prev 指標指向前驅節點(在頭部插入就是 null,在尾部插入就是 last);
  • 步驟 4: 將新節點的 next 指標指向後繼節點(在頭部插入就是 first,在尾部插入就是 null);
  • 步驟 5: 將前驅節點的 next 指標指向新節點(在頭部插入沒有這個步驟);
  • 步驟 6: 將後繼節點的 prev 指標指向新節點(在尾部插入沒有這個步驟)。

分析一下新增方法的時間複雜度,區分在連結串列兩端或中間新增元素的情況共:

  • 如果是在連結串列首尾兩端新增: 只需要 O(1) 時間複雜度;
  • 如果在連結串列中間新增: 由於需要定位到新增位置的前驅和後繼節點,所以需要 O(n) 時間複雜度。如果事先已經獲得了新增位置的節點,就只需要 O(1) 時間複雜度。

新增方法

```java public void addFirst(E e) { linkFirst(e); }

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

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

public void add(int index, E element) { checkPositionIndex(index); if (index == size) // 在尾部新增 linkLast(element); else // 在指定位置新增 linkBefore(element, node(index)); }

public boolean addAll(Collection<? extends E> c) { return addAll(size, c); }

// 在連結串列頭部新增 private void linkFirst(E e) { // 1. 找到插入位置的後繼節點(first) final Node f = first; // 2. 構造新節點 // 3. 將新節點的 prev 指標指向前驅節點(null) // 4. 將新節點的 next 指標指向後繼節點(f) // 5. 將前驅節點的 next 指標指向新節點(前驅節點是 null,所以沒有這個步驟) final Node newNode = new Node<>(null, e, f); // 修改 first 指標 first = newNode; if (f == null) // f 為 null 說明首個新增的元素,需要修改 last 指標 last = newNode; else // 6. 將後繼節點的 prev 指標指向新節點 f.prev = newNode; size++; modCount++; }

// 在連結串列尾部新增 void linkLast(E e) { final Node l = last; // 1. 找到插入位置的後繼節點(null) // 2. 構造新節點 // 3. 將新節點的 prev 指標指向前驅節點(l) // 4. 將新節點的 next 指標指向後繼節點(null) final Node newNode = new Node<>(l, e, null); // 修改 last 指標 last = newNode; if (l == null) // l 為 null 說明首個新增的元素,需要修改 first 指標 first = newNode; else // 5. 將前驅節點的 next 指標指向新節點 l.next = newNode; // 6. 將後繼節點的 prev 指標指向新節點(後繼節點是 null,所以沒有這個步驟) size++; modCount++; }

// 在指定節點前新增 // 1. 找到插入位置的後繼節點 void linkBefore(E e, Node succ) { final Node pred = succ.prev; // 2. 構造新節點 // 3. 將新節點的 prev 指標指向前驅節點(pred) // 4. 將新節點的 next 指標指向後繼節點(succ) final Node newNode = new Node<>(pred, e, succ); succ.prev = newNode; if (pred == null) first = newNode; else // 5. 將前驅節點的 next 指標指向新節點 pred.next = newNode; size++; modCount++; }

// 在指定位置新增整個集合元素 // index 為 0:在連結串列頭部新增 // index 為 size:在連結串列尾部新增 public boolean addAll(int index, Collection<? extends E> c) { checkPositionIndex(index); // 事實上,c.toArray() 的實際型別不一定是 Object[],有可能是 String[] 等 // 不過,我們是通過 Node中的item 承接的,所以不用擔心 ArrayList 中的 ArrayStoreException 問題 Object[] a = c.toArray(); // 新增的陣列為空,跳過 int numNew = a.length; if (numNew == 0) return false;

// 1. 找到插入位置的後繼節點
// pred:插入位置的前驅節點
// succ:插入位置的後繼節點
Node<E> pred, succ;
if (index == size) {
    succ = null;
    pred = last;
} else {
    // 找到 index 位置原本的節點,插入後變成後繼節點
    succ = node(index);
    pred = succ.prev;
}
// 插入集合元素
for (Object o : a) {
    E e = (E) o;
    // 2. 構造新節點
    // 3. 將新節點的 prev 指標指向前驅節點
    Node<E> newNode = new Node<>(pred, e, null);
    if (pred == null)
        // pred 為 null 說明是在頭部插入,需要修改 first 指標
        first = newNode;
    else
        // 5. 將前驅節點的 next 指標指向新節點
        pred.next = newNode;
    // 修改前驅指標
    pred = newNode;
}

if (succ == null) {
    // succ 為 null 說明是在尾部插入,需要修改 last 指標
    last = pred;
} else {
    // 4. 將新節點的 next 指標指向後繼節點
    pred.next = succ;
    // 6. 將後繼節點的 prev 指標指向新節點
    succ.prev = pred;
}
// 數量增加 numNew
size += numNew;
modCount++;
return true;

}

// 將 LinkedList 轉化為 Object 陣列 public Object[] toArray() { Object[] result = new Object[size]; int i = 0; for (Node x = first; x != null; x = x.next) result[i++] = x.item; return result; } ```

在連結串列中間新增節點時,會用到 node(index) 方法查詢指定位置的節點。可以看到維持 first 和 last 頭尾節點的作用又發揮出來了:

  • 如果索引位置小於 size/2,則從頭節點開始找;
  • 如果索引位置大於 size/2,則從尾節點開始找。

雖然,我們從複雜度分析的角度看,從哪個方向查詢是沒有區別的,時間複雜度都是 O(n)。但從工程分析的角度看還是有區別的,從更靠近目標節點的位置開始查詢,實際執行的時間會更短。

查詢指定位置節點

java // 尋找指定位置的節點,時間複雜度:O(n) Node<E> node(int index) { if (index < (size >> 1)) { // 如果索引位置小於 size/2,則從頭節點開始找 Node<E> x = first; for (int i = 0; i < index; i++) x = x.next; return x; } else { // 如果索引位置大於 size/2,則從尾節點開始找 Node<E> x = last; for (int i = size - 1; i > index; i--) x = x.prev; return x; } }

LinkedList 的刪除方法其實就是新增方法的逆運算,我們就不重複分析了。

```java // 刪除頭部元素 public E removeFirst() { final Node f = first; if (f == null) throw new NoSuchElementException(); return unlinkFirst(f); }

// 刪除尾部元素 public E removeLast() { final Node l = last; if (l == null) throw new NoSuchElementException(); return unlinkLast(l); }

// 刪除指定元素 public E remove(int index) { checkElementIndex(index); return unlink(node(index)); } ```


2.4 LinkedList 的迭代器

Java 的 foreach 是語法糖,本質上也是採用 iterator 的方式。由於 LinkedList 本身就是雙向的,所以 LinkedList 只提供了 1 個迭代器:

  • ListIterator listIterator(): 雙向迭代器

與其他容器類一樣,LinkedList 的迭代器中都有 fail-fast 機制。如果在迭代的過程中發現 expectedModCount 變化,說明資料被修改,此時就會提前丟擲 ConcurrentModificationException 異常(當然也不一定是被其他執行緒修改)。

```java public ListIterator listIterator(int index) { checkPositionIndex(index); return new ListItr(index); }

// 非靜態內部類 private class ListItr implements ListIterator { private Node lastReturned; private Node next; private int nextIndex; // 建立迭代器時會記錄外部類的 modCount private int expectedModCount = modCount;

ListItr(int index) {
    next = (index == size) ? null : node(index);
    nextIndex = index;
}

public E next() {
    // 更新 expectedModCount
    checkForComodification();
    ...
}
...

} ```

2.5 LinkedList 的序列化過程

  • 🙋🏻‍♀️ 疑問 2:為什麼欄位都宣告 transient 關鍵字?

LinkedList 重寫了 JDK 序列化的邏輯,不序列化連結串列節點,而只是序列化連結串列節點中的有效資料,這樣序列化產物的大小就有所降低。在反序列時,只需要按照物件順序依次新增到連結串列的末尾,就能恢復連結串列的順序。

```java // 序列化和反序列化只考慮有效資料

// 序列化過程 private void writeObject(java.io.ObjectOutputStream s) throws java.io.IOException { // Write out any hidden serialization magic s.defaultWriteObject();

// 寫入連結串列長度
s.writeInt(size);

// 寫入節點上的有效資料
for (Node<E> x = first; x != null; x = x.next)
    s.writeObject(x.item);

}

// 反序列化過程 private void readObject(java.io.ObjectInputStream s) throws java.io.IOException, ClassNotFoundException { // Read in any hidden serialization magic s.defaultReadObject();

// 讀取連結串列長度
int size = s.readInt();

// 讀取有效元素並用 linkLast 新增到連結串列尾部
for (int i = 0; i < size; i++)
    linkLast((E)s.readObject());

} ```

2.6 LinkedList 如何實現執行緒安全?

有 5 種方式:

  • 方法 1 - 使用 Collections.synchronizedList 包裝類: 原理也是在所有方法上增加 synchronized 關鍵字;
  • 方法 2 - 使用 ConcurrentLinkedQueue 容器類: 基於 CAS 無鎖實現的執行緒安全佇列;
  • 方法 3 - 使用 LinkedBlockingQueue 容器: 基於加鎖的阻塞佇列,適合於帶阻塞操作的生產者消費者模型;
  • 方法 4 - 使用 LinkedBlockingDeque 容器: 基於加鎖的阻塞雙端佇列,適合於帶阻塞操作的生產者消費者模型;
  • 方法 5 - 使用 ConcurrentLinkedDeque 容器類: 基於 CAS 無鎖實現的執行緒安全雙端佇列。

3. 總結

  • 1、LinkedList 是基於連結串列的線性表,同時具備 List、Queue 和 Stack 的行為;

  • 2、在查詢指定位置的節點時,如果索引位置小於 size/2,則從頭節點開始找,否則從尾節點開始找;

  • 3、LinkedList 重寫了序列化過程,只處理連結串列節點中有效的元素;

  • 4、LinkedList 和 ArrayList 都不考慮執行緒同步,不保證執行緒安全。

在上一篇文章裡,我們提到了 List 的陣列實現 ArrayList,而 LinkedList 不僅是 List 的連結串列實現,同時還是 Queue 和 Stack 的連結串列實現。那麼,在 Java 中的 Queue 和 Stack 的陣列實現是什麼呢,這個我們在下篇文章討論,請關注。


本文為稀土掘金技術社群首發簽約文章,14天內禁止轉載,14天后未獲授權禁止轉載,侵權必究!