圖解集合2:LinkedList

五月的倉頡發表於2015-12-02

初識LinkedList

上一篇中講解了ArrayList,本篇文章講解一下LinkedList的實現。

LinkedList是基於連結串列實現的,所以先講解一下什麼是連結串列。連結串列原先是C/C++的概念,是一種線性的儲存結構,意思是將要儲存的資料存在一個儲存單元裡面,這個儲存單元裡面除了存放有待儲存的資料以外,還儲存有其下一個儲存單元的地址(下一個儲存單元的地址是必要的,有些儲存結構還存放有其前一個儲存單元的地址),每次查詢資料的時候,通過某個儲存單元中的下一個儲存單元的地址尋找其後面的那個儲存單元。

這麼講可能有點抽象,先提一句,LinkedList是一種雙向連結串列,雙向連結串列我認為有兩點含義:

1、連結串列中任意一個儲存單元都可以通過向前或者向後定址的方式獲取到其前一個儲存單元和其後一個儲存單元

2、連結串列的尾節點的後一個節點是連結串列的頭結點,連結串列的頭結點的前一個節點是連結串列的尾節點

LinkedList既然是一種雙向連結串列,必然有一個儲存單元,看一下LinkedList的基本儲存單元,它是LinkedList中的一個內部類:

private static class Entry<E> {
E element;
Entry<E> next;
Entry<E> previous;
...
}

看到LinkedList的Entry中的"E element",就是它真正儲存的資料。"Entry<E> next"和"Entry<E> previous"表示的就是這個儲存單元的前一個儲存單元的引用地址和後一個儲存單元的引用地址。用圖表示就是:

 

四個關注點在LinkedList上的答案

關  注  點 結      論
LinkedList是否允許空 允許
LinkedList是否允許重複資料 允許
LinkedList是否有序 有序
LinkedList是否執行緒安全 非執行緒安全

 

新增元素

首先看下LinkedList新增一個元素是怎麼做的,假如我有一段程式碼:

1 public static void main(String[] args)
2 {
3     List<String> list = new LinkedList<String>();
4     list.add("111");
5     list.add("222");
6 }

逐行分析main函式中的三行程式碼是如何執行的,首先是第3行,看一下LinkedList的原始碼:

 1 public class LinkedList<E>
 2     extends AbstractSequentialList<E>
 3     implements List<E>, Deque<E>, Cloneable, java.io.Serializable
 4 {
 5     private transient Entry<E> header = new Entry<E>(null, null, null);
 6     private transient int size = 0;
 7 
 8     /**
 9      * Constructs an empty list.
10      */
11     public LinkedList() {
12         header.next = header.previous = header;
13     }
14     ...
15 }

看到,new了一個Entry出來名為header,Entry裡面的previous、element、next都為null,執行建構函式的時候,將previous和next的值都設定為header的引用地址,還是用畫圖的方式表示。32位JDK的字長為4個位元組,而目前64位的JDK一般採用的也是4字長,所以就以4個字長為單位。header引用地址的字長就是4個位元組,假設是0x00000000,那麼執行完"List<String> list = new LinkedList<String>()"之後可以這麼表示:

接著看第4行add一個字串"111"做了什麼:

1 public boolean add(E e) {
2 addBefore(e, header);
3     return true;
4 }
1 private Entry<E> addBefore(E e, Entry<E> entry) {
2 Entry<E> newEntry = new Entry<E>(e, entry, entry.previous);
3 newEntry.previous.next = newEntry;
4 newEntry.next.previous = newEntry;
5 size++;
6 modCount++;
7 return newEntry;
8 }

第2行new了一個Entry出來,可能不太好理解,根據Entry的建構函式,我把這句話"翻譯"一下,可能就好理解了:

1、newEntry.element = e;

2、newEntry.next = header.next;

3、newEntry.previous = header.previous;

header.next和header.previous上圖中已經看到了,都是0x00000000,那麼假設new出來的這個Entry的地址是0x00000001,繼續畫圖表示:

一共五步,每一步的操作步驟都用數字表示出來了:

1、新的entry的element賦值為111;

2、新的entry的next是header的next,header的next是0x00000000,所以新的entry的next即0x00000000;

3、新的entry的previous是header的previous,header的previous是0x00000000,所以新的entry的next即0x00000000;

4、"newEntry.previous.next = newEntry",首先是newEntry的previous,由於newEntry的previous為0x00000000,所以newEntry.previous表示的是header,header的next為newEntry,即header的next為0x00000001;

5、"newEntry.next.previous = newEntry",和4一樣,把header的previous設定為0x00000001;

為什麼要這麼做?還記得雙向連結串列的兩個特點嗎,一是任意節點都可以向前和向後定址,二是整個連結串列頭的previous表示的是連結串列的尾Entry,連結串列尾的next表示的是連結串列的頭Entry。現在連結串列頭就是0x00000000這個Entry,連結串列尾就是0x00000001,可以自己看圖觀察、思考一下是否符合這兩個條件。

最後看一下add了一個字串"222"做了什麼,假設新new出來的Entry的地址是0x00000002,畫圖表示:

還是執行的那5步,圖中每一步都標註出來了,只要想清楚previous、next各自表示的是哪個節點就不會出問題了。

至此,往一個LinkedList裡面新增一個字串"111"和一個字串"222"就完成了。從這張圖中應該理解雙向連結串列比較容易:

1、中間的那個Entry,previous的值為0x00000000,即header;next的值為0x00000002,即tail,這就是任意一個Entry既可以向前查詢Entry,也可以向後查詢Entry

2、頭Entry的previous的值為0x00000002,即tail,這就是雙向連結串列中頭Entry的previous指向的是尾Entry

3、尾Entry的next的值為0x00000000,即header,這就是雙向連結串列中尾Entry的next指向的是頭Entry

 

檢視元素

看一下LinkedList的程式碼是怎麼寫的:

public E get(int index) {
    return entry(index).element;
}
 1 private Entry<E> entry(int index) {
 2     if (index < 0 || index >= size)
 3         throw new IndexOutOfBoundsException("Index: "+index+
 4                                             ", Size: "+size);
 5     Entry<E> e = header;
 6     if (index < (size >> 1)) {
 7         for (int i = 0; i <= index; i++)
 8             e = e.next;
 9     } else {
10         for (int i = size; i > index; i--)
11             e = e.previous;
12     }
13     return e;
14 }

這段程式碼就體現出了雙向連結串列的好處了。雙向連結串列增加了一點點的空間消耗(每個Entry裡面還要維護它的前置Entry的引用),同時也增加了一定的程式設計複雜度,卻大大提升了效率。

由於LinkedList是雙向連結串列,所以LinkedList既可以向前查詢,也可以向後查詢,第6行~第12行的作用就是:當index小於陣列大小的一半的時候(size >> 1表示size / 2,使用移位運算提升程式碼執行效率),向後查詢;否則,向前查詢

這樣,在我的資料結構裡面有10000個元素,剛巧查詢的又是第10000個元素的時候,就不需要從頭遍歷10000次了,向後遍歷即可,一次就能找到我要的元素。

 

刪除元素

看完了新增元素,我們看一下如何刪除一個元素。和ArrayList一樣,LinkedList支援按元素刪除和按下標刪除,前者會刪除從頭開始匹配的第一個元素。用按下標刪除舉個例子好了,比方說有這麼一段程式碼:

1 public static void main(String[] args)
2 {
3     List<String> list = new LinkedList<String>();
4     list.add("111");
5     list.add("222");
6     list.remove(0);
7 }

也就是我想刪除"111"這個元素。看一下第6行是如何執行的:

1 public E remove(int index) {
2     return remove(entry(index));
3 }
 1 private E remove(Entry<E> e) {
 2 if (e == header)
 3     throw new NoSuchElementException();
 4 
 5         E result = e.element;
 6 e.previous.next = e.next;
 7 e.next.previous = e.previous;
 8        e.next = e.previous = null;
 9        e.element = null;
10 size--;
11 modCount++;
12        return result;
13 }

當然,首先是找到元素在哪裡,這和get是一樣的。接著,用畫圖的方式來說明比較簡單:

比較簡單,只要找對引用地址就好了,每一步的操作也都詳細標註在圖上了。

這裡我提一點,第3步、第4步、第5步將待刪除的Entry的previous、element、next都設定為了null,這三步的作用是讓虛擬機器可以回收這個Entry

但是,這個問題我稍微擴充套件深入一點:按照Java虛擬機器HotSpot採用的垃圾回收檢測演算法----根節點搜尋演算法來說,即使previous、element、next不設定為null也是可以回收這個Entry的,因為此時這個Entry已經沒有任何地方會指向它了,tail的previous與header的next都已經變掉了,所以這塊Entry會被當做"垃圾"對待。之所以還要將previous、element、next設定為null,我認為可能是為了相容另外一種垃圾回收檢測演算法----引用計數法,這種垃圾回收檢測演算法,只要物件之間存在相互引用,那麼這塊記憶體就不會被當作"垃圾"對待。

 

插入元素

插入元素就不細講了,看一下刪除元素的原始碼:

public void add(int index, E element) {
    addBefore(element, (index==size ? header : entry(index)));
}
private Entry<E> addBefore(E e, Entry<E> entry) {
Entry<E> newEntry = new Entry<E>(e, entry, entry.previous);
newEntry.previous.next = newEntry;
newEntry.next.previous = newEntry;
size++;
modCount++;
return newEntry;
}

如果朋友們理解了前面的內容,我認為這兩個方法對你來說,應該是很容易看懂的。

 

LinkedList和ArrayList的對比

老生常談的問題了,這裡我嘗試以自己的理解儘量說清楚這個問題,順便在這裡就把LinkedList的優缺點也給講了。

1、順序插入速度ArrayList會比較快,因為ArrayList是基於陣列實現的,陣列是事先new好的,只要往指定位置塞一個資料就好了;LinkedList則不同,每次順序插入的時候LinkedList將new一個物件出來,如果物件比較大,那麼new的時間勢必會長一點,再加上一些引用賦值的操作,所以順序插入LinkedList必然慢於ArrayList

2、基於上一點,因為LinkedList裡面不僅維護了待插入的元素,還維護了Entry的前置Entry和後繼Entry,如果一個LinkedList中的Entry非常多,那麼LinkedList將比ArrayList更耗費一些記憶體

3、資料遍歷的速度,看最後一部分,這裡就不細講了,結論是:使用各自遍歷效率最高的方式,ArrayList的遍歷效率會比LinkedList的遍歷效率高一些

4、有些說法認為LinkedList做插入和刪除更快,這種說法其實是不準確的:

(1)LinkedList做插入、刪除的時候,慢在定址,快在只需要改變前後Entry的引用地址

(2)ArrayList做插入、刪除的時候,慢在陣列元素的批量copy,快在定址

所以,如果待插入、刪除的元素是在資料結構的前半段尤其是非常靠前的位置的時候,LinkedList的效率將大大快過ArrayList,因為ArrayList將批量copy大量的元素;越往後,對於LinkedList來說,因為它是雙向連結串列,所以在第2個元素後面插入一個資料和在倒數第2個元素後面插入一個元素在效率上基本沒有差別,但是ArrayList由於要批量copy的元素越來越少,操作速度必然追上乃至超過LinkedList

從這個分析看出,如果你十分確定你插入、刪除的元素是在前半段,那麼就使用LinkedList;如果你十分確定你刪除、刪除的元素在比較靠後的位置,那麼可以考慮使用ArrayList。如果你不能確定你要做的插入、刪除是在哪兒呢?那還是建議你使用LinkedList吧,因為一來LinkedList整體插入、刪除的執行效率比較穩定,沒有ArrayList這種越往後越快的情況;二來插入元素的時候,弄得不好ArrayList就要進行一次擴容,記住,ArrayList底層陣列擴容是一個既消耗時間又消耗空間的操作,在我的文章Java程式碼優化中,第9點有詳細的解讀。

最後一點,一切都是紙上談兵,在選擇了List後,有條件的最好可以做一些效能測試,比如在你的程式碼上下文記錄List操作的時間消耗

 

對LinkedList以及ArrayList的迭代

在我的Java程式碼優化一文中,第19點,專門提到過,ArrayList使用最普通的for迴圈遍歷,LinkedList使用foreach迴圈比較快,看一下兩個List的定義:

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

注意到ArrayList是實現了RandomAccess介面而LinkedList則沒有實現這個介面,關於RandomAccess這個介面的作用,看一下JDK API上的說法:

為此,我寫一段程式碼證明一下這一點,注意,雖然上面的例子用的Iterator,但是做foreach迴圈的時候,編譯器預設會使用這個集合的Iterator,具體可參見foreach迴圈原理。測試程式碼如下:

public class TestMain
{
    private static int SIZE = 111111;
    
    private static void loopList(List<Integer> list)
    {
        long startTime = System.currentTimeMillis();
        for (int i = 0; i < list.size(); i++)
        {
            list.get(i);
        }
        System.out.println(list.getClass().getSimpleName() + "使用普通for迴圈遍歷時間為" + 
                (System.currentTimeMillis() - startTime) + "ms");
        
        startTime = System.currentTimeMillis();
        for (Integer i : list)
        {
            
        }
        System.out.println(list.getClass().getSimpleName() + "使用foreach迴圈遍歷時間為" + 
                (System.currentTimeMillis() - startTime) + "ms");
    }
    
    public static void main(String[] args)
    {
        List<Integer> arrayList = new ArrayList<Integer>(SIZE);
        List<Integer> linkedList = new LinkedList<Integer>();
        
        for (int i = 0; i < SIZE; i++)
        {
            arrayList.add(i);
            linkedList.add(i);
        }
        
        loopList(arrayList);
        loopList(linkedList);
        System.out.println();
    }
}

我擷取三次執行結果:

ArrayList使用普通for迴圈遍歷時間為6ms
ArrayList使用foreach迴圈遍歷時間為12ms
LinkedList使用普通for迴圈遍歷時間為38482ms
LinkedList使用foreach迴圈遍歷時間為11ms
ArrayList使用普通for迴圈遍歷時間為5ms
ArrayList使用foreach迴圈遍歷時間為12ms
LinkedList使用普通for迴圈遍歷時間為43287ms
LinkedList使用foreach迴圈遍歷時間為9ms
ArrayList使用普通for迴圈遍歷時間為4ms
ArrayList使用foreach迴圈遍歷時間為12ms
LinkedList使用普通for迴圈遍歷時間為22370ms
LinkedList使用foreach迴圈遍歷時間為5ms

有了JDK API的解釋,這個結果並不讓人感到意外,最最想要提出的一點是:如果使用普通for迴圈遍歷LinkedList,在大資料量的情況下,其遍歷速度將慢得令人髮指,可參見我的另一篇文章To Java程式設計師:切勿用普通for迴圈遍歷LinkedList

相關文章