1. 程式人生 > >Java容器框架(三)--LinkedList實現原理

Java容器框架(三)--LinkedList實現原理

1. 簡介

如果對Java容器家族成員不太熟悉,可以先閱讀Java容器框架(一)--概述篇這邊文章,LinkedList類在List家族中具有重要的位置,基本上可以和ArrayList平起平坐,在功能上甚至比ArrayList還要強大。下面我們先來看看LinkedList繼承關係:

public abstract class AbstractSequentialList<E> extends AbstractList<E> 

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

從類繼承關係可以看到它實現了List介面、Deque介面(是一個雙向佇列),因此具有的特性也就顯而易見。本篇文章從下面方法入手,分析其實現過程從而瞭解LinkedList的實現原理:

下面我們一一分析這些方法的實現原理。

2. LinkedList()&LinkedList(Collection<? extends E> c)

LinkedList為我們提供了兩種建構函式,一個為無參構造,一個傳入一個容器作為入參,下面來看看具體的程式碼實現:

public LinkedList() {
}

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

暫時不去分析addAll函式,直接看建構函式發現其實什麼也沒有做,只是另一個容器作為入參的時候,會呼叫addAll函式,不用看原始碼也能猜測到是將容器元素新增到當前LinkList容器中,addAll我們接下來會做分析。

3. add(E e)&add(int index, E element)&addAll(int index, Collection<? extends E> c)

add函式相信我們在熟悉不過,向List中新增元素。

那addAll(int index, Collection<? extends E> c)是什麼意思呢?

它是向List容器中第index+1(由於是從0開始計算的)位置開始將容器c中的元素插入到List容器中,List中index+1開始後面的元素後移。

下面我們看看具體的程式碼實現:

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

transient int size = 0;   // 連結串列節點(元素)的個數
transient Node<E> first;  // 第一個節點(元素)
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;
    }
}

void linkLast(E e) {
    final Node<E> l = last;    // 用一個臨時變數指向最後一個節點
    // 建立一個新的節點,新的節點prev指向當前連結串列的最後一個元素,next為null
    final Node<E> newNode = new Node<>(l, e, null);
    last = newNode;    // 連結串列的last指向新的節點,也就是最後一個節點
    if (l == null)    // 表明剛開始的連結串列是一個空連結串列,則first 也指向新建立的節點(因為當前連結串列只有一個元素)
        first = newNode;
    else
        l.next = newNode;    // 實現雙向連結串列
    size++;
    modCount++;
}

程式碼中註釋非常清楚,add函式中呼叫的是linkLast函式,也就是向連結串列末尾新增元素。因此我們在向LinkedList中呼叫add來新增元素時,預設是新增到連結串列尾部。

  • add(int index, E element)

通過函式名大致能夠猜測到該函式的作用是向LinkedList中某個位置新增元素,那麼具體是怎麼實現的,下面看看原始碼實現:

public void add(int index, E element) {
    checkPositionIndex(index);    // 檢查index 是否合法
    // 如果是最後一個位置,則直接最連結串列尾部新增
    if (index == size)
        linkLast(element);
    else
        linkBefore(element, node(index));
}

private void checkPositionIndex(int index) {
    // 檢查index 是否合法
    if (!isPositionIndex(index))
        throw new IndexOutOfBoundsException(outOfBoundsMsg(index));
}

private boolean isPositionIndex(int index) {
    return index >= 0 && index <= size;
}

// 相當於查詢某個位置的節點(元素)
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;
    }
}

// 關鍵是這個函式, 在succ之前插入資料
void linkBefore(E e, Node<E> succ) {
     // assert succ != null;
     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++;
}

總結:當向LinkedList的index位置新增元素時,首先判斷index位置是否合法,合法則順序查詢index位置的節點(元素),此處的查詢有一個小小的優化,只需要順序查詢到連結串列一半位置即可,找到該節點後,則利用連結串列的特性直接插入元素,因此在效能上要優於ArrayList,首先LinkedList不需要考慮擴容,其次不需要移動插入位置之後的元素。

  • addAll(int index, Collection<? extends E> c)
public boolean addAll(int index, Collection<? extends E> c) {
        checkPositionIndex(index);

        // 為了通用性,可以新增其他型別的資料結構,因此先把傳入的c轉化為陣列;
        Object[] a = c.toArray();
        int numNew = a.length;
        if (numNew == 0)
            return false;

        Node<E> pred, succ;
        // 如果是在預設新增
        if (index == size) {
            succ = null;
            pred = last;
        } else {
            // 找到對應的節點元素
            succ = node(index);
            pred = succ.prev;
        }
        // 把陣列構造成一個連結串列結構
        for (Object o : a) {
            @SuppressWarnings("unchecked") E e = (E) o;
            Node<E> newNode = new Node<>(pred, e, null);
            if (pred == null)
                first = newNode;
            else
                pred.next = newNode;
            pred = newNode;
        }

        if (succ == null) {
            last = pred;
        } else {
            pred.next = succ;
            succ.prev = pred;
        }

        size += numNew;
        modCount++;
        return true;
}

看程式碼不難理解,由於傳入引數是Collection 型別,因此為了通用,首先轉化為具體的陣列,然後將陣列轉化為Node結構新增到連結串列中,至此將新增元素的相關方法分析完成了。

4. remove()&remove(int index)&remove(Object o)

很明顯,這三個函式都是刪除元素的作用,那它們具體是怎樣實現的呢?其實有了瞭解新增元素原理的基礎,刪除元素也就不難了,下面看看具體原始碼:

  • remove()
public E remove() {
    // 預設刪除的是連結串列開始的元素
    return removeFirst();
}

public E removeFirst() {
    final Node<E> f = first;
    if (f == null)
        throw new NoSuchElementException();
    return unlinkFirst(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;
}

remove()函式,預設從連結串列的頭部開始刪除資料,remove(int index)函式也很容易理解,刪除指定位置的元素,此處就不在分析了,比較好奇的是remove(Object o)這個函式,當連結串列中存在相同的兩個元素,那麼是如何刪除的呢?

  • remove(Object o)
public boolean remove(Object o) {
    if (o == null) {
        for (Node<E> x = first; x != null; x = x.next) {
            if (x.item == null) {
                 unlink(x);
                 return true;
            }
        }
    } else {
        for (Node<E> x = first; x != null; x = x.next) {
            if (o.equals(x.item)) {
                unlink(x);
                return true;
            }
        }
    }
    return false;
}

// 作用是刪除x節點,返回對應的值
E unlink(Node<E> x) {
        // assert x != null;
        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;    // x節點的資料域、next、prev都設定為null,方便垃圾回收
        size--;
        modCount++;
        return element;
}

從程式碼可以看到,刪除某一個元素是從頭部開始查詢,當找到時就刪除對應節點,即便之後還有相同的元素也不會刪除,刪除成功則返回true,否則為false。

5. set(int index, E element)&get(int index)&listIterator(int index)

  • set(int index, E element)

set函式是用來更新index節點的值,返回舊值,由於存在需要順序遍歷到第index位置,因此時間複雜度為n/2也即為n,原始碼如下:

public E set(int index, E element) {
     checkElementIndex(index);    // 檢查index 位置的合法性
     Node<E> x = node(index);    // 遍歷獲取index位置的節點
     E oldVal = x.item;
     x.item = element;
     return oldVal;
}
  • get(int index)

get函式是返回index位置節點的資料,同set很類似,也需要遍歷到index位置,因此時間複雜度為n/2也即為n,原始碼實現如下:

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

這是返回一個LinkedList的迭代器,通常我們不會直接呼叫此函式,一般是直接呼叫List的iterator(),它最終就是呼叫listIterator(int index),只不過index為0而已,通過迭代器對連結串列進行遍歷,相當於C語言裡面的指標一樣,指向某個元素順序遍歷,因此複雜度為n。此處就不在展示對應的原始碼。

我們都知道對List容器進行遍歷通常有兩種方式,一種為for迴圈直接遍歷,一種通過迭代器方式進行遍歷,那麼到底哪種遍歷方式比較好呢?

  • for迴圈方式遍歷
int size = list.size()
for(int i=0; i<size;i++){                
    System.out.println(list.get(i)+"  ");         
}                                                   
  • 迭代器方式遍歷
Iterator iter = list.iterator();          
while(iter.hasNext()) {                       
    String value = (String)iter.next();       
    System.out.print(value + "  ");           
}                                             

這兩種方式到底哪種效能更優化,還需要看具體是對哪種List容器進行遍歷,如果是ArrayList,由於get函式時間複雜度為1,因此採用for迴圈遍歷要優於迭代器方式,如果是LinkedList,由於get函式(上面已經分析過)還需要對List進行遍歷找到對應位置,因此採用迭代器方式遍歷效能更好,總之,對於陣列結構的線性表採用for迴圈方式遍歷,對於連結串列結構的線性表採用迭代器方式進行遍歷。

分析到此處,我們還需要注意一個點,大家知道for和for-each的區別嗎?

for迴圈在熟悉不過,沒什麼好說的,但是for-each的實現原理有必要了解下,這裡只是給出原理,需要知道具體實現請自行探索,for-each迴圈其實最終是轉化為迭代器的遍歷方式,我們可以通過對ArrayList遍歷檢視:

List<Person> list = new ArrayList();
for (Person per:list) {
    System.out.println(per);
}
我們看看最後轉化為class檔案的程式碼如下:

List<Person> list = new ArrayList();
Iterator var5 = list.iterator();

while(var5.hasNext()) {
    Person per = (Person)var5.next();
    System.out.println(per);
}

總結:因此我們在遍歷ArrayList的時候,最好不要使用for-each而是for,對於LinkedList的遍歷,則建議使用for-each或者直接迭代器遍歷。

6. push(E e)&pop()

這兩個函式其實是屬於Deque範疇,在最開始將LinkedList類結構的時候,可以看到LinkedList實現了Deque介面,也即具有雙向連結串列結構。下面看看這兩個函式的具體實現,其他也有許多函式,僅此拋磚引玉,程式碼都很簡單。

public void push(E e) {
    // 向連結串列頭部新增元素
    addFirst(e);
}

public void addFirst(E e) {
    linkFirst(e);
}
// 向頭部增加節點
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++;
}

// 以上為push的實現

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

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

總結:以上其實無非都是對連結串列進行操作,只是push和pop都是對頭部節點進行操作,因此類似於棧的功能。

總結

至此LinkedList的原始碼分析就結束了,LinkedList是基於雙向連結串列實現,可以快速插入刪除元素,由於儲存有連結串列頭部和尾部的應用(C/C++ 角度可以理解為指標),因此可以方便實現佇列和棧的功能,同時在遍歷連結串列時,建議使用迭代器來完成,而不是通過for+get(index)這種形式來遍歷。