閱讀原始碼,通過LinkedList回顧基礎

希夷小道發表於2020-11-03

前言

本文基於jdk1.8

書接上回,在簡單介紹ArrayList的時候,提到了ArrayList實現了RandomAccess介面,擁有隨機訪問的能力,當時說到了這個介面配合LinkedList理解更容易。今天就來還願了,開始閱讀LinkedList。

LinkedList也算我們比較常用的幾個集合之一了,對普通程式設計師來說,

List list1 = new ArrayList()
List list2 = new LinkedList(),

該怎麼選擇?

其實二者最大的區別在於實現方式的不同,只看名稱我們也能知道, ArrayList基於陣列,而LinkedList基於連結串列,所以關鍵在於陣列和連結串列有啥區別。

說到這,是不是又說明了一個很重要的道理,基礎,基礎,基礎。如果想成為一個真正的程式設計師,不管你是科班還是半路出家,都要下功夫夯實基礎。

說回正題,ArrayList基於陣列,查詢快(按索引查詢),增刪慢;LinkedList基於連結串列,增刪快,查詢慢。但這只是相對的,僅僅知道這兩點,遠遠不夠,所以,繼續往下看。

類簽名

public class LinkedList<E>
    extends AbstractSequentialList<E>
    implements List<E>, Deque<E>, Cloneable, java.io.Serializable

LinkedList繼承體系

鑑於前一篇有很多遺漏,這裡多說明一下:

泛型

集合類在1.5開始的版本中都採用了泛型,也就是,最主要的作用就是編譯期檢查,避免新增進不合理的型別。簡單說:

不用泛型,List list1 = new LinkedList();此時預設型別是Object,也就是可以新增任意型別元素,在取出元素時,就需要強制型別轉換,增加了出錯機率。

使用泛型,List list2 = new LinkedList();其中,等號右邊String可以省略。此時,編譯時會執行檢察,新增非String型別元素直接編譯不通過,而獲取時也不需要強制型別轉換。當然,這裡涉及到了不同時期的型別擦除,不是本文重點,後期有需要的話再專門寫一下。

因為我們使用集合絕大部分情況都是希望儲存同一種型別,所以使用泛型(提前宣告型別)很重要。這裡也體現了一種思想:錯誤越早暴露越好。

Serializable和Cloneable

實現Cloneable, Serializable介面,具有克隆和序列化的能力。

Deque

實現了Deque介面, 而Deque介面又繼承了Queue介面,這也意味著LinkedList可以當佇列使用,實現“先進先出”

List和AbstractList

在上一篇文章中,有一個細節沒有說到,可能很多人也有疑問, 為啥抽象類AbstractList已經實現了List介面,ArrayList在繼承AbstractList的同時還要再次實現List介面?換到今天的主角,LinkedList繼承了AbstractSequentialList,而AbstractSequentialList繼承了AbstractList,為啥LinkedList還要單獨實現List介面?

AbstractList和List介面

在Stack Overflow上看到兩個回答:

一位網友說問過了設計這個類的作者本人,作者本人表示這是當時設計時的一個缺陷,就一直遺留下來了。(當然,我個人覺得這個說法有待考證)。

第二位網友舉例表明了不直接再次實現List介面,使用代理時可能會出現意想不到的結果。(從實際的角度看有道理,但是細想之下集合類在jdk1.2已經出現,代理類出現在 1.3,邏輯上有點疑問。)

我個人的理解:

大神在設計集合類的時候充分考慮到了將來優化時的情況。

具體來講,這裡主要在於如何理解介面和抽象類的區別,尤其是在java8之前。介面是一種規範,方便規劃體系,抽象類已經有部分實現,更多的是幫助我們減少重複程式碼,換言之, 這裡的抽象類就相當於一個工具類,只不過恰好實現了List介面,而且鑑於java單繼承,抽象類有被替換的可能。

面向介面程式設計的過程中,List list= new LinkedList();如果將來LinkedList有了更好的實現,不再繼承AbstractSequentialList抽象類,由於本身已經直接實現了List介面,只要內部實現符合邏輯,上述舊程式碼不會有問題。相反,如果不實現List,去除繼承AbstractSequentialList抽象類,上述舊程式碼就無法編譯,也就無法“向下相容”。

RandomAccess介面(沒實現)

LinkedList並沒有實現RandomAccess介面,實現這個介面的是ArrayList,之所以放在這裡是為了對比。

注意,這裡說的隨機訪問的能力指的是根據索引訪問,也就是list介面定義的E get(int index)方法,同時意味著ArrayList和LinkedList都必須實現這個方法。

回到問題的本質,為啥基於陣列的ArrayList能隨機訪問,而基於連結串列的LinkedList不具備隨機訪問的能力呢?

還是最基礎的知識:陣列是一塊連續的記憶體,每個元素分配固定大小,很容易定位到指定索引。而連結串列每個節點除了資料還有指向下一個節點的指標,記憶體分配不一定連續,要想知道某一個索引上的值,只能從頭開始(或者從尾)一個一個遍歷。

RandomAccess介面是一個標記介面,沒有任何方法。唯一的作用就是使用instanceof判斷一個實現集合是否具有隨機訪問的能力。

List list1 = new LinkedList();
if (list1 instanceof RandomAccess) {
    //...
}

可能看到這裡還是不大明白,不要緊,這個問題的關鍵其實就是ArrayList和LinkedList對List介面中get方法實現的區別,後文會專門講到。

變數

//實際儲存元素數量
transient int size = 0;

/**
 * 指向頭節點,頭結點不存在指向上一節點的引用
 * 
 * Invariant: (first == null && last == null) ||
 *            (first.prev == null && first.item != null)
 */
transient Node<E> first;

/**
 * 指向尾節點,尾節點不存在指向下一節點的引用
 * Invariant: (first == null && last == null) ||
 *            (last.next == null && last.item != null)
 */
transient Node<E> last;

//節點型別,包含儲存的元素和分別指向下一個和上一個節點的指標
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;
    }
}

注意這裡的節點型別,可以看出,LinkedList實現基於雙向連結串列。為啥用不直接用單向連結串列一鏈到底呢?最主要的原因是為了查詢效率,前面說到連結串列的查詢效率比較低,如果是單向連結串列,按索引查詢時,不管索引處於哪個位置,只能從頭開始,平均需要N次;若是雙向連結串列,先判斷索引處於前半部分還是後半部分,再決定從頭開始還是從尾開始,平均需要N/2次。當然,雙向連結串列的缺點就是需要的儲存空間變大,這從另一個方面體現了空間換時間的思想。

上述兩個變數,first和last,其本質是指向物件的引用,和Student s=new Student()中的s沒有區別,只不過first一定指向連結串列頭節點,last一定指向連結串列尾節點,起到標記作用,讓我們能夠隨時從頭或者從尾開始遍歷。

建構函式

//空參構造
public LinkedList() {
}

//通過指定集合構造LinkedList, 呼叫addAll方法
public LinkedList(Collection<? extends E> c) {
    this();
    addAll(c);
}

常用方法

常用方法比較多(多到一張圖截不下),這裡主要分兩類,一類是List體系,一類是Deque體系.

List體系下的方法:

LinkedList中List體系下的方法

這裡主要看兩個,add,get

add(E e)

新增元素到連結串列末尾,成功則返回true

//新增元素到連結串列末尾,成功則返回true
public boolean add(E e) {
    linkLast(e);
    return true;
}


void linkLast(E e) {
    //1.複製一個指向尾節點的引用l
    final Node<E> l = last;
    //2.將待新增元素構造為一個節點,prev指向尾節點
    final Node<E> newNode = new Node<>(l, e, null);
    //3.last指向新構造的節點
    last = newNode
    //4.如果最初連結串列為空,則將first指向新節點
    if (l == null)
        first = newNode;
    //5.最初連結串列不為空,則將新增前最後元素的next指向新節點
    else
        l.next = newNode;
    
    //儲存的元素數量+1
    size++;
    //修改次數+1
    modCount++;
}

關鍵在於linkLast(E e)方法,分兩種情況,最初是空連結串列新增元素和最初為非空連結串列新增。

這裡涉及到的知識很基礎,還是連結串列的基本操作,但是單純用語言很難描述清楚,所以畫個簡圖表示一下(第一次畫圖,沒法盡善盡美,將就一下吧)

linkLast(E e)方法
雙向連結串列的基本形式

雙向連結串列的基本形式

對於空連結串列的新增

對應linkLast(E e)方法註釋1、2、3、4

空連結串列,沒有節點,意味著first和last都指向null
空連結串列

1.複製一個指向尾節點的引用l(藍色部分)

空連結串列新增1
此時複製的引用l本質也指向了null

2.將待新增元素構造為一個節點newNode,prev指向,也就是null

空連結串列新增2

3.last指向新構造的節點(紅色部分)
空連結串列新增3

4.最初連結串列為空,將first指向新節點
空連結串列新增4

此時,first和last均指向唯一非空節點,當然引用newNode依然存在,但是已經沒有意義。

對於非空連結串列的新增

對應linkLast(E e)方法註釋1、2、3、5

1.複製一個指向尾節點的引用l(藍色部分)
非空新增1

2.將待新增元素構造為一個節點newNode,prev指向尾節點(藍色部分)
非空新增2

3.last指向新構造的節點(紅色部分)
非空新增3

5.將新增前最後元素的next指向新節點(綠色部分)
非空新增5

此時,引用newNode和l引用依然存在,但是已經沒有意義。

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 >= 0 且 index <= size;

如果index==size,則相當於直接在連結串列尾部插入,直接呼叫linkLast方法;

以上不滿足,呼叫linkBefore方法,而linkBefore中呼叫了node(index)。

node(index)

node(index)作用是返回指定索引的節點,這裡用到了我們前面說到的一個知識,先判斷索引在前半部分還是在後半部分,再決定從頭還是從尾開始遍歷。

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

回過頭來看linkBefore,引數分別為待插入的元素,以及指定位置的節點

void linkBefore(E e, Node<E> succ) {
    // assert succ != null;
    //1.複製指向目標位置的上一個節點引用
    final Node<E> pred = succ.prev;
    //2.構造新節點,prev指向目標位置的上一個節點,next指向原來目標位置節點
    final Node<E> newNode = new Node<>(pred, e, succ);
    //3.原來節點prev指向新節點
    succ.prev = newNode;
    //4.如果插在頭結點位置,則first指向新節點
    if (pred == null)
        first = newNode;
    //5.非頭節點,目標位置上一節點的next指向新節點
    else
        pred.next = newNode;
    
    
    size++;
    modCount++;
}

上述過程可以看出,關鍵過程在於linkBefore方法,我們同樣畫圖表示:

頭結點處新增:

1.複製指向目標位置的上一個節點引用

Node<E> pred = succ.prev;

linkBefore頭結點1

本質是指向null

2.構造新節點,prev指向目標位置的上一個節點,next指向原來目標位置節點

Node<E> newNode = new Node<>(pred, e, succ);

linkBefore頭結點2

3.目標節點的prev指向待新增新節點

succ.prev = newNode;

linkBefore頭結點3

4.first指向新節點

first = newNode;

linkBefore頭結點4

中間位置新增

如圖,假設指定新增到第三個節點,即index=2,則第二個和第三個節點之間必須有斷開的過程。
linkBefore中間位置新增

1.複製指向目標位置的上一個節點引用,也就是第二個節點

Node<E> pred = succ.prev;

linkBefore中間位置新增1

2.構造新節點,prev指向複製的上一個節點,next指向原來目標位置上的節點

Node<E> newNode = new Node<>(pred, e, succ);

linkBefore中間位置新增2

3.目標節點的prev指向待新增新節點

succ.prev = newNode;

linkBefore中間位置新增3

5.目標位置上一節點的next指向新節點

pred.next = newNode;

linkBefore中間位置新增4

get(int index)

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

get方法是按索引獲取元素,本質呼叫node(index),上一部分已經提到了這個方法,雖然雙向連結串列在一定程度上提高了效率,由N減少到N/2,但本質上時間複雜度還是N的常數倍,所以輕易不要用這個方法,在需要隨機訪問的時候應當使用ArrayList,在需要遍歷訪問以及增刪多查詢少的時候考慮LinkedList。之所以要有這個方法是由List介面指定,這個方法也是LinkedList沒有實現RandomAccess介面的原因之一。

Deque體系下的方法:

當我們把LinkedList當佇列和棧使用時,主要用到的就是Deque體系下的方法。

LinkedList中Deque體系下的方法

如果稍微細看一下,會發現上述很多方法基本是重複的,比如push(E e)其實就是呼叫了addFirst(e),

addFirst(e)也是直接呼叫了linkFirst(e);pop()就是直接呼叫了removeFirst();

為啥搞這麼麻煩,一個方法起這麼多名稱?
其實是因為從不同角度來看LinkedList時,它具有不同的角色。可以說它哪裡都能新增,哪裡都能刪除。

具體使用時建議仔細看下對應註釋。

作為佇列

佇列的基本特點是“先進先出”,相當於連結串列尾新增元素,連結串列頭刪除元素。

對應的方法是offer(E e),peek(),poll()

public boolean offer(E e) {
    return add(e);
}


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

可以看出offer方法的本質還是在連結串列末尾新增元素,linkLast(e)方法前面已經講到。

/**
* Retrieves, but does not remove, the head (first element) of this list.
*
* @return the head of this list, or {@code null} if this list is empty
* @since 1.5
*/
public E peek() {
    final Node<E> f = first;
    return (f == null) ? null : f.item;
 }

peek()方法返回佇列第一個元素,但是不刪除元素。也就是說多次peek得到同一個元素。

 /**
 * Retrieves and removes the head (first element) of this list.
 *
 * @return the head of this list, or {@code null} if this list is empty
 * @since 1.5
 */
public E poll() {
    final Node<E> f = first;
    return (f == null) ? null : unlinkFirst(f);
}

poll() 方法返回佇列第一個元素的同時並將其從佇列中刪除。也就是多次poll得到不同元素。

顯然poll方法更符合佇列的概念。

這裡沒有詳細解說刪除相關的方法,是因為如果前面的新增方法細看了,刪除方法也很簡單,無非是越過被刪除的元素連線指標,這裡沒必要浪費篇幅。不妨自己畫一下,有助於理解。

作為棧

棧的基本特點是“先進後出”,相當於連結串列頭部新增元素,頭部刪除元素。

對應的方法是push(E e)和pop()。

public void push(E e) {
    addFirst(e);
}

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

可以看出,push是在呼叫addFirst,進而呼叫linkFirst(e),而在頭部新增元素,add(int index, E element)方法處已經講到了,大體只是方法名不一樣而已。

public E pop() {
    return removeFirst();
}

pop()方法返回並刪除第一個元素。

總結

這篇文章主要講了LinkedList相關的最基本的內容,更多的是回顧一些基礎知識,既有java相關的,也有最基礎資料結構的知識,比如連結串列相關的操作。第一次畫圖來說明問題,有時候真的是一圖勝千言。寫到這裡最大的感受是基礎很重要,它決定了你能走多遠。

希望我的文章能給你帶來一絲幫助!

相關文章