前言
本文基於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
鑑於前一篇有很多遺漏,這裡多說明一下:
泛型
集合類在1.5開始的版本中都採用了泛型,也就是
不用泛型,List list1 = new LinkedList();此時預設型別是Object,也就是可以新增任意型別元素,在取出元素時,就需要強制型別轉換,增加了出錯機率。
使用泛型,List
因為我們使用集合絕大部分情況都是希望儲存同一種型別,所以使用泛型(提前宣告型別)很重要。這裡也體現了一種思想:錯誤越早暴露越好。
Serializable和Cloneable
實現Cloneable, Serializable介面,具有克隆和序列化的能力。
Deque
實現了Deque介面, 而Deque介面又繼承了Queue介面,這也意味著LinkedList可以當佇列使用,實現“先進先出”。
List和AbstractList
在上一篇文章中,有一個細節沒有說到,可能很多人也有疑問, 為啥抽象類AbstractList已經實現了List介面,ArrayList在繼承AbstractList的同時還要再次實現List介面?換到今天的主角,LinkedList繼承了AbstractSequentialList,而AbstractSequentialList繼承了AbstractList,為啥LinkedList還要單獨實現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體系下的方法:
這裡主要看兩個,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(藍色部分)
此時複製的引用l本質也指向了null
2.將待新增元素構造為一個節點newNode,prev指向l,也就是null
3.last指向新構造的節點(紅色部分)
4.最初連結串列為空,將first指向新節點
此時,first和last均指向唯一非空節點,當然引用newNode依然存在,但是已經沒有意義。
對於非空連結串列的新增
對應linkLast(E e)方法註釋1、2、3、5
1.複製一個指向尾節點的引用l(藍色部分)
2.將待新增元素構造為一個節點newNode,prev指向尾節點(藍色部分)
3.last指向新構造的節點(紅色部分)
5.將新增前最後元素的next指向新節點(綠色部分)
此時,引用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;
本質是指向null
2.構造新節點,prev指向目標位置的上一個節點,next指向原來目標位置節點
Node<E> newNode = new Node<>(pred, e, succ);
3.目標節點的prev指向待新增新節點
succ.prev = newNode;
4.first指向新節點
first = newNode;
中間位置新增
如圖,假設指定新增到第三個節點,即index=2,則第二個和第三個節點之間必須有斷開的過程。
1.複製指向目標位置的上一個節點引用,也就是第二個節點
Node<E> pred = succ.prev;
2.構造新節點,prev指向複製的上一個節點,next指向原來目標位置上的節點
Node<E> newNode = new Node<>(pred, e, succ);
3.目標節點的prev指向待新增新節點
succ.prev = newNode;
5.目標位置上一節點的next指向新節點
pred.next = newNode;
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體系下的方法。
如果稍微細看一下,會發現上述很多方法基本是重複的,比如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相關的,也有最基礎資料結構的知識,比如連結串列相關的操作。第一次畫圖來說明問題,有時候真的是一圖勝千言。寫到這裡最大的感受是基礎很重要,它決定了你能走多遠。
希望我的文章能給你帶來一絲幫助!