計算機程式的思維邏輯 (39) - 剖析LinkedList

swiftma發表於2016-11-07

本系列文章經補充和完善,已修訂整理成書《Java程式設計的邏輯》(馬俊昌著),由機械工業出版社華章分社出版,於2018年1月上市熱銷,讀者好評如潮!各大網店和書店有售,歡迎購買:京東自營連結

計算機程式的思維邏輯 (39) -  剖析LinkedList

上節我們介紹了ArrayList,ArrayList隨機訪問效率很高,但插入和刪除效能比較低,我們提到了同樣實現了List介面的LinkedList,它的特點與ArrayList幾乎正好相反,本節我們就來詳細介紹LinkedList。

除了實現了List介面外,LinkedList還實現了Deque和Queue介面,可以按照佇列、棧和雙端佇列的方式進行操作,本節會介紹這些用法,同時介紹其實現原理。

我們先來看它的用法。

用法

構造方法

LinkedList的構造方法與ArrayList類似,有兩個,一個是預設構造方法,另外一個可以接受一個已有的Collection,如下所示:

public LinkedList()
public LinkedList(Collection<? extends E> c)
複製程式碼

比如,可以這麼建立:

List<String> list = new LinkedList<>();
List<String> list2 = new LinkedList<>(
        Arrays.asList(new String[]{"a","b","c"}));
複製程式碼

List介面

LinkedList與ArrayList一樣,同樣實現了List介面,而List介面擴充套件了Collection介面,Collection又擴充套件了Iterable介面,所有這些介面的方法都是可以使用的,使用方法與上節介紹的一樣,本節就不再贅述了。

佇列 (Queue)

LinkedList還實現了佇列介面Queue,所謂佇列就類似於日常生活中的各種排隊,特點就是先進先出,在尾部新增元素,從頭部刪除元素,它的介面定義為:

public interface Queue<E> extends Collection<E> {
    boolean add(E e);
    boolean offer(E e);
    E remove();
    E poll();
    E element();
    E peek();
} 
複製程式碼

Queue擴充套件了Collection,它的主要操作有三個:

  • 在尾部新增元素 (add, offer)
  • 檢視頭部元素 (element, peek),返回頭部元素,但不改變佇列
  • 刪除頭部元素 (remove, poll),返回頭部元素,並且從佇列中刪除

每種操作都有兩種形式,有什麼區別呢?區別在於,對於特殊情況的處理不同。特殊情況是指,佇列為空或者佇列為滿,為空容易理解,為滿是指佇列有長度大小限制,而且已經佔滿了。LinkedList的實現中,佇列長度沒有限制,但別的Queue的實現可能有。

在佇列為空時,element和remove會丟擲異常NoSuchElementException,而peek和poll返回特殊值null,在佇列為滿時,add會丟擲異常IllegalStateException,而offer只是返回false。

把LinkedList當做Queue使用也很簡單,比如,可以這樣:

Queue<String> queue = new LinkedList<>();

queue.offer("a");
queue.offer("b");
queue.offer("c");

while(queue.peek()!=null){
    System.out.println(queue.poll());    
}
複製程式碼

輸出為:

a
b
c
複製程式碼

我們在介紹函式呼叫原理的時候介紹過棧,棧也是一種常用的資料結構,與佇列相反,它的特點是先進後出、後進先出,類似於一個儲物箱,放的時候是一件件往上放,拿的時候則只能從上面開始拿。

Java中有一個類Stack,用於表示棧,但這個類已經過時了,我們不再介紹,Java中沒有單獨的棧介面,棧相關方法包括在了表示雙端佇列的介面Deque中,主要有三個方法:

void push(E e);
E pop();
E peek();
複製程式碼

解釋下:

  • push表示入棧,在頭部新增元素,棧的空間可能是有限的,如果棧滿了,push會丟擲異常IllegalStateException。
  • pop表示出棧,返回頭部元素,並且從棧中刪除,如果棧為空,會丟擲異常NoSuchElementException。
  • peek檢視棧頭部元素,不修改棧,如果棧為空,返回null。

把LinkedList當做棧使用也很簡單,比如,可以這樣:

Deque<String> stack = new LinkedList<>();

stack.push("a");
stack.push("b");
stack.push("c");

while(stack.peek()!=null){
    System.out.println(stack.pop());    
}
複製程式碼

輸出為:

c
b
a
複製程式碼

雙端佇列 (Deque)

棧和佇列都是在兩端進行操作,棧只操作頭部,佇列兩端都操作,但尾部只新增、頭部只檢視和刪除,有一個更為通用的操作兩端的介面Deque,Deque擴充套件了Queue,包括了棧的操作方法,此外,它還有如下更為明確的操作兩端的方法:

void addFirst(E e);
void addLast(E e);
E getFirst();
E getLast();
boolean offerFirst(E e);
boolean offerLast(E e);
E peekFirst();
E peekLast();
E pollFirst();
E pollLast();
E removeFirst();
E removeLast();
複製程式碼

xxxFirst操作頭部,xxxLast操作尾部。與佇列類似,每種操作有兩種形式,區別也是在佇列為空或滿時,處理不同。為空時,getXXX/removeXXX會丟擲異常,而peekXXX/pollXXX會返回null。佇列滿時,addXXX會丟擲異常,offerXXX只是返回false。

棧和佇列只是雙端佇列的特殊情況,它們的方法都可以使用雙端佇列的方法替代,不過,使用不同的名稱和方法,概念上更為清晰。

Deque介面還有一個迭代器方法,可以從後往前遍歷

Iterator<E> descendingIterator();
複製程式碼

比如,看如下程式碼:

Deque<String> deque = new LinkedList<>(
        Arrays.asList(new String[]{"a","b","c"}));
Iterator<String> it = deque.descendingIterator();
while(it.hasNext()){
    System.out.print(it.next()+" ");
}
複製程式碼

輸出為

c b a 
複製程式碼

用法小結

LinkedList的用法是比較簡單的,與ArrayList用法類似,支援List介面,只是,LinkedList增加了一個介面Deque,可以把它看做佇列、棧、雙端佇列,方便的在兩端進行操作。

如果只是用作List,那應該用ArrayList還是LinkedList呢?我們需要了解下LinkedList的實現原理。

實現原理

內部組成

我們知道,ArrayList內部是陣列,元素在記憶體是連續存放的,但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;
    }
}
複製程式碼

Node類表示節點,item指向實際的元素,next指向下一個節點,prev指向前一個節點。

LinkedList內部組成就是如下三個例項變數:

transient int size = 0;
transient Node<E> first;
transient Node<E> last;
複製程式碼

我們暫時忽略transient關鍵字,size表示連結串列長度,預設為0,first指向頭節點,last指向尾節點,初始值都為null。

LinkedList的所有public方法內部操作的都是這三個例項變數,具體是怎麼操作的?連結關係是如何維護的?我們看一些主要的方法,先來看add方法。

Add方法

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. 建立一個新的節點newNode。prev指向原來的尾節點,如果原來連結串列為空,則為null。程式碼為:
Node<E> newNode = new Node<>(l, e, null);
複製程式碼
  1. 修改尾節點last,指向新的最後節點newNode。程式碼為:
last = newNode;
複製程式碼
  1. 修改前節點的後向連結,如果原來連結串列為空,則讓頭節點指向新節點,否則讓前一個節點的next指向新節點。程式碼為:
if (l == null)
    first = newNode;
else
    l.next = newNode;
複製程式碼
  1. 增加連結串列大小。程式碼為:
size++
複製程式碼

modCount++的目的與ArrayList是一樣的,記錄修改次數,便於迭代中間檢測結構性變化。

我們通過一些圖示來更清楚的看一下,比如說,程式碼為:

List<String> list = new LinkedList<String>();
list.add("a");
list.add("b");
複製程式碼

執行完第一行後,內部結構如下所示:

計算機程式的思維邏輯 (39) -  剖析LinkedList
新增完"a"後,內部結構如下所示:

計算機程式的思維邏輯 (39) -  剖析LinkedList

新增完"b"後,內部結構如下所示:

計算機程式的思維邏輯 (39) -  剖析LinkedList
可以看出,與ArrayList不同,LinkedList的記憶體是按需分配的,不需要預先分配多餘的記憶體,新增元素只需分配新元素的空間,然後調節幾個連結即可。

根據索引訪問元素 get

新增了元素,如果根據索引訪問元素呢?我們看下get方法的程式碼:

public E get(int index) {
    checkElementIndex(index);
    return node(index).item;
}
複製程式碼

checkElementIndex檢查索引位置的有效性,如果無效,丟擲異常,程式碼為:

private void checkElementIndex(int index) {
    if (!isElementIndex(index))
        throw new IndexOutOfBoundsException(outOfBoundsMsg(index));
}

private boolean isElementIndex(int index) {
    return index >= 0 && index < size;
}
複製程式碼

如果index有效,則呼叫node方法查詢對應的節點,其item屬性就指向實際元素內容,node方法的程式碼為:

Node<E> node(int 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;
    }
}
複製程式碼

size>>1等於size/2,如果索引位置在前半部分 (index<(size>>1)),則從頭節點開始查詢,否則,從尾節點開始查詢。

可以看出,與ArrayList明顯不同,ArrayList中陣列元素連續存放,可以直接隨機訪問,而在LinkedList中,則必須從頭或尾,順著連結查詢,效率比較低。

根據內容查詢元素

我們看下indexOf的程式碼:

public int indexOf(Object o) {
    int index = 0;
    if (o == null) {
        for (Node<E> x = first; x != null; x = x.next) {
            if (x.item == null)
                return index;
            index++;
        }
    } else {
        for (Node<E> x = first; x != null; x = x.next) {
            if (o.equals(x.item))
                return index;
            index++;
        }
    }
    return -1;
}
複製程式碼

程式碼也很簡單,從頭節點順著連結往後找,如果要找的是null,則找第一個item為null的節點,否則使用equals方法進行比較。

插入元素

add是在尾部新增元素,如果在頭部或中間插入元素呢?可以使用如下方法:

public void add(int index, E element)
複製程式碼

它的程式碼是:

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

    if (index == size)
        linkLast(element);
    else
        linkBefore(element, node(index));
}
複製程式碼

如果index為size,新增到最後面,一般情況,是插入到index對應節點的前面,呼叫方法為linkBefore,它的程式碼為:

void linkBefore(E e, Node<E> succ) {
    final Node<E> pred = succ.prev;
    final Node<E> newNode = new Node<>(pred, e, succ);
    succ.prev = newNode;
    if (pred == null)
        first = newNode;
    else
        pred.next = newNode;
    size++;
    modCount++;
}
複製程式碼

引數succ表示後繼節點。變數pred就表示前驅節點。目標就是在pred和succ中間插入一個節點。插入步驟是:

  1. 新建一個節點newNode,前驅為pred,後繼為succ。程式碼為:
Node<E> newNode = new Node<>(pred, e, succ);
複製程式碼
  1. 讓後繼的前驅指向新節點。程式碼為:
succ.prev = newNode;
複製程式碼
  1. 讓前驅的後繼指向新節點,如果前驅為空,修改頭節點指向新節點。程式碼為:
if (pred == null)
    first = newNode;
else
    pred.next = newNode;
複製程式碼
  1. 增加長度。

我們通過圖示來更清楚的看下,還是上面的例子,比如,新增一個元素:

list.add(1, "c");
複製程式碼

圖示結構會變為:

計算機程式的思維邏輯 (39) -  剖析LinkedList
可以看出,在中間插入元素,LinkedList只需按需分配記憶體,修改前驅和後繼節點的連結,而ArrayList則可能需要分配很多額外空間,且移動所有後續元素。

刪除元素

我們再來看刪除元素,程式碼為:

public E remove(int index) {
    checkElementIndex(index);
    return unlink(node(index));
}
複製程式碼

通過node方法找到節點後,呼叫了unlink方法,程式碼為:

E unlink(Node<E> x) {
    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;
}
複製程式碼

刪除x節點,基本思路就是讓x的前驅和後繼直接連結起來,next是x的後繼,prev是x的前驅,具體分為兩步:

  1. 第一步是讓x的前驅的後繼指向x的後繼。如果x沒有前驅,說明刪除的是頭節點,則修改頭節點指向x的後繼。
  2. 第二步是讓x的後繼的前驅指向x的前驅。如果x沒有後繼,說明刪除的是尾節點,則修改尾節點指向x的前驅。

我們再通過圖示看下,還是上面的例子,如果刪除一個元素:

list.remove(1);
複製程式碼

圖示結構會變為:

計算機程式的思維邏輯 (39) -  剖析LinkedList

原理小結

以上,我們介紹了LinkedList的內部組成,以及幾個主要方法的實現程式碼,其他方法的原理也都類似,我們就不贅述了。

前面我們提到,對於佇列、棧和雙端佇列介面,長度可能有限制,LinkedList實現了這些介面,不過LinkedList對長度並沒有限制。

LinkedList特點分析

LinkedList內部是用雙向連結串列實現的,維護了長度、頭節點和尾節點,這決定了它有如下特點:

  • 按需分配空間,不需要預先分配很多空間
  • 不可以隨機訪問,按照索引位置訪問效率比較低,必須從頭或尾順著連結找,效率為O(N/2)。
  • 不管列表是否已排序,只要是按照內容查詢元素,效率都比較低,必須逐個比較,效率為O(N)。
  • 在兩端新增、刪除元素的效率很高,為O(1)。
  • 在中間插入、刪除元素,要先定位,效率比較低,為O(N),但修改本身的效率很高,效率為O(1)。

理解了LinkedList和ArrayList的特點,我們就能比較容易的進行選擇了,如果列表長度未知,新增、刪除操作比較多,尤其經常從兩端進行操作,而按照索引位置訪問相對比較少,則LinkedList就是比較理想的選擇。

小結

本節詳細介紹了LinkedList,先介紹了用法,然後介紹了實現原理,最後我們分析了LinkedList的特點,並與ArrayList進行了比較。

用法上,LinkedList是一個List,但也實現了Deque介面,可以作為佇列、棧和雙端佇列使用。實現原理上,內部是一個雙向連結串列,並維護了長度、頭節點和尾節點。

無論是ArrayList還是LinkedList,按內容查詢元素的效率都很低,都需要逐個進行比較,有沒有更有效的方式呢?


未完待續,檢視最新文章,敬請關注微信公眾號“老馬說程式設計”(掃描下方二維碼),深入淺出,老馬和你一起探索Java程式設計及計算機技術的本質。用心原創,保留所有版權。

計算機程式的思維邏輯 (39) -  剖析LinkedList

相關文章