1. 程式人生 > >揭祕雙向連結串列LinkedList原始碼

揭祕雙向連結串列LinkedList原始碼

一、LinkedList連結串列的基本結構

        連結串列,可以簡單的理解為一個鏈子。鏈子的特點就是一環套一環。當我們需要某一環的時候,只要我們擁有鏈子的任意一環,都能夠找到我們想要的那一環。LinkedList可以看成是一個雙向的連結串列。我們知道ArrayList內部用的是陣列來儲存資料。而LinkedList用的是“物件”來儲存資料通過原始碼可以知道,此物件來自於一個內部類Node。

        內部類Node的程式碼十分簡單。

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類是一個巢狀類(內部類知識參考)。它的建構函式接收了要被儲存的資料"element"。並且Node建構函式還接收兩個其它Node物件的引用。這是用來記錄前一個Node節點物件和後一個Node節點物件的記憶體地址。

          Node類,如圖,Node類構造一覽無餘。圖片來源:點選開啟連結

        

        一個簡單的連結串列如圖所示,顏色含義參考上一個圖片。圖片來源:點選開啟連結

        

二、LinkedList連結串列的特點

        還是使用我的終極口訣,“序重步+資料結構”。

        序:有序。LinkedList是List介面的實現類,List介面的最大特點就是有角標,因此是有序的。

        重複:元素可以重複。內部使用“Node物件”儲存資料,不同的Node類物件可以封裝相同的資料,因為這兩個Node物件並不是同一個物件。另外,從Node類的建構函式中,並沒有對存放的元素element進行“非空”的限制,因此LinkedList可以存放“null”,即LinkedList支援存放"null"值

        同步:不同步。LinkedList是java.util包下的類,是快速失敗的,因此不同步。(快速失敗可以參考上一篇ArrayList原始碼分析)

        資料結構的影響:增刪速度快,查詢速度慢。其實增刪之前還要定位到刪除位置,比ArrayList也快不到哪裡去。

三、原始碼分析

        size表示LinkedList物件中存放的實際元素的個數。

transient int size = 0;

        first代表的是當前LinkedList的頭節點,last代表的是末尾節點,這兩個引用可以看成是指標。它們用於指向頭和尾,可以看到它們並沒有被初始化,例如 first = new Node<T>(); 其實它們只是一個引用而已。

transient Node<E> first;
transient Node<E> last;

        空參建構函式,沒啥好說的。能看到super()就行了。

 public LinkedList() {
    }

        接收一個集合物件c的建構函式。把這個集合物件c中的所有元素存放到LinkedList中。到底是怎麼存放的呢,原來呼叫了addAll(..)方法。

public LinkedList(Collection<? extends E> c) {
        this();
        addAll(c);
    }

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

        下面是最精彩的一部分了。addAll(int size,Collection c)方法也不是很複雜。總共有三種情況。

        第一種情況:連結串列是一個空連結串列,什麼都沒有。指向頭和尾的first和last指標都是空。

        第二種情況:連結串列中已經有值了。把這個Collection集合中的所有資料存放到連結串列的尾部。

        第三種情況:在連結串列的中間插入Collection集合中的所有元素。

        我們先來分析第一種情況。我們把Collection的中的每一個元素的值遍歷出來,為每一個元素值使用一個Node類物件進行封裝。為了方便描述,Node物件可以看成是一個節點。Collection集合被封裝成了“節點們”。我們在封裝這些元素成為節點的同時,記錄下它們相鄰的節點的地址。最後,再把我們的first和last指標指向第一個節點和最後一個節點。

        第二種情況就是,向已經存在元素的連結串列末尾追加節點。這個時候就像第一種情況那樣,把所有Collection集合中的資料封裝成“節點們”,然後使用last指標指向最後一個新增的節點。有人會問,那麼first指標指向的是誰呢?這個不用管,連結串列既然有了元素,那麼它必然有一個頭節點。在新增頭節點的那個時候,我們就已經使用first指向它了,所以現在不用關心頭部。

        第三種情況就是在連結串列中的某個位置新增這個Collection集合的元素。這個就更簡單了。首先把Collection集合的所有資料封裝成Node類的物件。然後就像下面的例子那樣:有“蘋果、香蕉、梨子”,現在要在香蕉處插入三個橘子。插入後的效果就是“蘋果、橘子1、橘子2、橘子3、香蕉、梨子”。每個節點都有指向前一個和後一個的指標(可以回顧一下節點的示意圖)。插入完三個橘子後,只要把蘋果的後一個指向橘子1,香蕉的前一個指向橘子3,就行了。因為在中間插入的原因,頭部和尾部的指標在插入頭部和尾部節點的那個時候,指標就已經指向了它們,現在不用我們關心。

分析LinkedList原始碼,只需抓住:頭節點head、尾節點last、插入處的前驅節點pred、插入處的後繼節點succ

        好了,分析結束,看程式碼咯。三種情況的簡單示意圖,“·”代表即將要被插入的位置。

        空
        口口口·
        口口·口口

public boolean addAll(int index, Collection<? extends E> c) {
        //檢查index是否在指定的範圍   index範圍是[0,size]
        checkPositionIndex(index);
        //將集合轉為陣列
        Object[] a = c.toArray();
        //即將要被插入到連結串列的元素個數,用 int numNew表示
        int numNew = a.length;
        //健壯性檢查:你存放0個幹哈啊?
        if (numNew == 0)
            return false;
        /**
         * 這裡的兩個引用就有點意思了。首先你需要知道這兩個節點是臨時節點。
         * pred:即將要被插入的新節點的前一個節點,因此,插入完成後,pred的下一個節點就是 新節點
         * succ: 插入位置的節點。比如“蘋果,香蕉,梨子”,在香蕉處插入三個橘子
         * 插入後的效果就是“蘋果,橘子1,橘子2,橘子3,香蕉,梨子”,那麼succ代表的就是“香蕉”
         */
        //18年10月7日注:插入處的前驅節點pred、插入處的後繼節點succ
        Node<E> pred, succ;
        /**
         * 接下來任務就是確定這兩個臨時節點的值。
         * 為什麼要確定這個值呢?
         * 因為你新增完新節點們以後,不是需要連線上以前的那個連結串列嗎?
         * 好比上面的例子,我插入了三個橘子以後,我還得把蘋果連線到橘子1,香蕉連線到橘子3。這個例子裡面的succ就是“香蕉”
         * 因此這兩個臨時節點的指標我們得確定好了
         */
        if (index == size) {
            /**
             * 情況一:空連結串列,index = size = 0
             * 情況二:在非空連結串列的末尾新增元素,這是通過建構函式傳進來的
             * 兩種情況的共同點就是插入位置的後一個節點是null,即succ = null
             * 那麼pred節點就是連結串列的最後一個節點了。
             */
            succ = null;
            pred = last;
        } else {
            //否則,succ是當前位置的節點,可以理解為“香蕉”
            succ = node(index);
            //它的前一個節點是pred,即將插入到這個pred節點的後面,succ節點的前面
            pred = succ.prev;
        }
        //插入節點
        for (Object o : a) {
            @SuppressWarnings("unchecked") E e = (E) o;
            //使用Node封裝資料,新節點的資料的前一個節點是pred,封裝的資料是e
            Node<E> newNode = new Node<>(pred, e, null);
            //前一個節點是空,證明原來的連結串列是 空連結串列
            if (pred == null){
                //頭指標指向這個新建立的節點
                first = newNode;
            } else{
                //pred節點的下一個節點是 新建立的這個節點
                pred.next = newNode;
            }
            //比如“橘子1”插入完成後,"橘子2"要被插入到“橘子1”之後,那麼“橘子1”就被視為了 pred節點
            pred = newNode;
        }
        
        //插入完成以後,我們還需要考慮一下 succ節點 是否為空
        if (succ == null) {
            //如果新節點們的後面沒有節點了,那麼新節點們的最後一個節點被 視為連結串列的末尾
            last = pred;
        } else {
            //否則,這就是在非空連結串列的中間插入的,只需要按照情況三那樣。
            //這個時候pred代表的是新插入的節點的最後一個節點,本例子中,新插入的資料的最後一個數據被看成pred,即“橘子3”。succ代表的是"香蕉"
            pred.next = succ;
            //“香蕉”的前一個節點也是這個最後一個節點
            succ.prev = pred;
        }
        //實際個數增加numNew個
        size += numNew;
        //快速失敗機制
        modCount++;
        return true;
    }

四、新增和刪除方法

        新增到頭部。

        思路:

            1.先獲取到以前的頭部節點,舊節點

            2.封裝我們的資料成為,新節點。

            3.再將頭部指標指向:新節點

            4.健壯性判斷舊頭部節點是否為空,然後修改舊頭部節點的前指標指向新節點

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

   /**
     * Links e as first element.
     */
    private void linkFirst(E e) {
        final Node<E> f = first;
        final Node<E> newNode = new Node<>(null, e, f);
        first = newNode;
        if (f == null)
            last = newNode;
        else
            f.prev = newNode;
        size++;
        modCount++;
    }

        刪除頭部節點。與新增頭部節點類似,考慮到刪除舊的頭部節點以後,這個新頭部是不是空是關鍵。

        思路:找到以前的頭部節點。找到以前的頭部節點的下一個節點。把連結串列頭部指標first指向新頭部。修改新頭部的前一個節點引用為null。

public E removeFirst() {
        final Node<E> f = first;
        if (f == null)
            throw new NoSuchElementException();
        return unlinkFirst(f);
    }

   /**
     * Unlinks non-null first node f.
     */
    private E unlinkFirst(Node<E> f) {
        // assert f == first && f != null;
        final E element = f.item;
        final Node<E> next = f.next;
        f.item = null;
        f.next = null; // help GC
        first = next;
        if (next == null)
            last = null;//現在是把以前唯一的一個元素刪除了,因此,現在是一個空連結串列。
        else
            next.prev = null;
        size--;
        modCount++;
        return element;
    }

        其餘方法大同小異,無非就是修改各個節點之間前後的引用,有興趣的話可以參考JDK原始碼和API文件。

五、要點

        LinkedList用的是“物件”來儲存資料,這個物件是它的內部類物件Node類物件。因此,LinkedList的實際大小限制是堆記憶體的大小。

        LinkedList是一個雙向連結串列,它的任意一個節點都能獲取到前一個節點和後一個節點的資料。

        LinkedList能夠存放“null”值。

        特點:有序,可重複,不同步,增刪塊,查詢慢。