揭祕雙向連結串列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”值。
特點:有序,可重複,不同步,增刪塊,查詢慢。