上節我們介紹了ArrayList,ArrayList隨機訪問效率很高,但插入和刪除性能比較低,我們提到了同樣實現了List接口的LinkedList,它的特點與ArrayList幾乎正好相反,本節我們就來詳細介紹LinkedList。
除了實現了List接口外,LinkedList還實現了Deque和Queue接口,可以按照隊列、棧和雙端隊列的方式進行操作,本節會介紹這些用法,同時介紹其實現原理。
我們先來看它的用法。
用法
構造方法
LinkedList的構造方法與ArrayList類似,有兩個,一個是默認構造方法,另外一個可以接受一個已有的Collection,如下所示:
public LinkedList() public LinkedList(Collection<? extends E> c)
比如,可以這麼創建:
List<String> list = new LinkedList<>(); List<String> list2 = new LinkedList<>( Arrays.asList(new String[]{"a","b","c"}));
List接口
LinkedList與ArrayList一樣,同樣實現了List接口,而List接口擴展了Collection接口,Collection又擴展了Iterable接口,所有這些接口的方法都是可以使用的,使用方法與上節介紹的一樣,本節就不再贅述了。
隊列 (Queue)
LinkedList還實現了隊列接口Queue,所謂隊列就類似於日常生活中的各種排隊,特點就是先進先出,在尾部添加元素,從頭部刪除元素,它的接口定義為:
public interface Queue<E> extends Collection<E> { boolean add(E e); boolean offer(E e); E remove(); E poll(); E element(); E peek(); }
Queue擴展了Collection,它的主要操作有三個:
每種操作都有兩種形式,有什麼區別呢?區別在於,對於特殊情況的處理不同。特殊情況是指,隊列為空或者隊列為滿,為空容易理解,為滿是指隊列有長度大小限制,而且已經占滿了。LinkedList的實現中,隊列長度沒有限制,但別的Queue的實現可能有。
在隊列為空時,element和remove會拋出異常NoSuchElementException,而peek和poll返回特殊值null,在隊列為滿時,add會拋出異常IllegalStateException,而offer只是返回false。
把LinkedList當做Queue使用也很簡單,比如,可以這樣:
Queue<String> queue = new LinkedList<>(); queue.offer("a"); queue.offer("b"); queue.offer("c"); while(queue.peek()!=null){ System.out.println(queue.poll()); }
輸出為:
a b c
棧
我們在介紹函數調用原理的時候介紹過棧,棧也是一種常用的數據結構,與隊列相反,它的特點是先進後出、後進先出,類似於一個儲物箱,放的時候是一件件往上放,拿的時候則只能從上面開始拿。
Java中有一個類Stack,用於表示棧,但這個類已經過時了,我們不再介紹,Java中沒有單獨的棧接口,棧相關方法包括在了表示雙端隊列的接口Deque中,主要有三個方法:
void push(E e); E pop(); E peek();
解釋下:
把LinkedList當做棧使用也很簡單,比如,可以這樣:
Deque<String> stack = new LinkedList<>(); stack.push("a"); stack.push("b"); stack.push("c"); while(stack.peek()!=null){ System.out.println(stack.pop()); }
輸出為:
c b a
雙端隊列 (Deque)
棧和隊列都是在兩端進行操作,棧只操作頭部,隊列兩端都操作,但尾部只添加、頭部只查看和刪除,有一個更為通用的操作兩端的接口Deque,Deque擴展了Queue,包括了棧的操作方法,此外,它還有如下更為明確的操作兩端的方法:
void addFirst(E e); void addLast(E e); E getFirst(); E getLast(); boolean offerFirst(E e); boolean offerLast(E e); E peekFirst(); E peekLast(); E pollFirst(); E pollLast(); E removeFirst(); E removeLast();
xxxFirst操作頭部,xxxLast操作尾部。與隊列類似,每種操作有兩種形式,區別也是在隊列為空或滿時,處理不同。為空時,getXXX/removeXXX會拋出異常,而peekXXX/pollXXX會返回null。隊列滿時,addXXX會拋出異常,offerXXX只是返回false。
棧和隊列只是雙端隊列的特殊情況,它們的方法都可以使用雙端隊列的方法替代,不過,使用不同的名稱和方法,概念上更為清晰。
Deque接口還有一個迭代器方法,可以從後往前遍歷
Iterator<E> descendingIterator();
比如,看如下代碼:
Deque<String> deque = new LinkedList<>( Arrays.asList(new String[]{"a","b","c"})); Iterator<String> it = deque.descendingIterator(); while(it.hasNext()){ System.out.print(it.next()+" "); }
輸出為
c b a
用法小結
LinkedList的用法是比較簡單的,與ArrayList用法類似,支持List接口,只是,LinkedList增加了一個接口Deque,可以把它看做隊列、棧、雙端隊列,方便的在兩端進行操作。
如果只是用作List,那應該用ArrayList還是LinkedList呢?我們需要了解下LinkedList的實現原理。
實現原理
內部組成
我們知道,ArrayList內部是數組,元素在內存是連續存放的,但LinkedList不是。LinkedList直譯就是鏈表,確切的說,它的內部實現是雙向鏈表,每個元素在內存都是單獨存放的,元素之間通過鏈接連在一起,類似於小朋友之間手拉手一樣。
為了表示鏈接關系,需要一個節點的概念,節點包括實際的元素,但同時有兩個鏈接,分別指向前一個節點(前驅)和後一個節點(後繼),節點是一個內部類,具體定義為:
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類表示節點,item指向實際的元素,next指向下一個節點,prev指向前一個節點。
LinkedList內部組成就是如下三個實例變量:
transient int size = 0; transient Node<E> first; transient Node<E> last;
我們暫時忽略transient關鍵字,size表示鏈表長度,默認為0,first指向頭節點,last指向尾節點,初始值都為null。
LinkedList的所有public方法內部操作的都是這三個實例變量,具體是怎麼操作的?鏈接關系是如何維護的?我們看一些主要的方法,先來看add方法。
Add方法
add方法的代碼為:
public boolean add(E e) { linkLast(e); return true; }
主要就是調用了linkLast,它的代碼為:
void linkLast(E e) { final Node<E> l = last; final Node<E> newNode = new Node<>(l, e, null); last = newNode; if (l == null) first = newNode; else l.next = newNode; size++; modCount++; }
代碼的基本步驟是:
1. 創建一個新的節點newNode。prev指向原來的尾節點,如果原來鏈表為空,則為null。代碼為:
Node<E> newNode = new Node<>(l, e, null);
2. 修改尾節點last,指向新的最後節點newNode。代碼為:
last = newNode;
3. 修改前節點的後向鏈接,如果原來鏈表為空,則讓頭節點指向新節點,否則讓前一個節點的next指向新節點。代碼為:
if (l == null) first = newNode; else l.next = newNode;
4. 增加鏈表大小。代碼為:
size++
modCount++的目的與ArrayList是一樣的,記錄修改次數,便於迭代中間檢測結構性變化。
我們通過一些圖示來更清楚的看一下,比如說,代碼為:
List<String> list = new ArrayList<String>(); list.add("a"); list.add("b");
執行完第一行後,內部結構如下所示:
添加完"a"後,內部結構如下所示:
添加完"b"後,內部結構如下所示:
可以看出,與ArrayList不同,LinkedList的內存是按需分配的,不需要預先分配多余的內存,添加元素只需分配新元素的空間,然後調節幾個鏈接即可。
根據索引訪問元素 get
添加了元素,如果根據索引訪問元素呢?我們看下get方法的代碼:
public E get(int index) { checkElementIndex(index); return node(index).item; }
checkElementIndex檢查索引位置的有效性,如果無效,拋出異常,代碼為:
private void checkElementIndex(int index) { if (!isElementIndex(index)) throw new IndexOutOfBoundsException(outOfBoundsMsg(index)); } private boolean isElementIndex(int index) { return index >= 0 && index < size; }
如果index有效,則調用node方法查找對應的節點,其item屬性就指向實際元素內容,node方法的代碼為:
Node<E> node(int 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; } }
size>>1等於size/2,如果索引位置在前半部分 (index<(size>>1)),則從頭節點開始查找,否則,從尾節點開始查找。
可以看出,與ArrayList明顯不同,ArrayList中數組元素連續存放,可以直接隨機訪問,而在LinkedList中,則必須從頭或尾,順著鏈接查找,效率比較低。
根據內容查找元素
我們看下indexOf的代碼:
public int indexOf(Object o) { int index = 0; if (o == null) { for (Node<E> x = first; x != null; x = x.next) { if (x.item == null) return index; index++; } } else { for (Node<E> x = first; x != null; x = x.next) { if (o.equals(x.item)) return index; index++; } } return -1; }
代碼也很簡單,從頭節點順著鏈接往後找,如果要找的是null,則找第一個item為null的節點,否則使用equals方法進行比較。
插入元素
add是在尾部添加元素,如果在頭部或中間插入元素呢?可以使用如下方法:
public void 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為size,添加到最後面,一般情況,是插入到index對應節點的前面,調用方法為linkBefore,它的代碼為:
void linkBefore(E e, Node<E> succ) { 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++; }
參數succ表示後繼節點。變量pred就表示前驅節點。目標就是在pred和succ中間插入一個節點。插入步驟是:
1. 新建一個節點newNode,前驅為pred,後繼為succ。代碼為:
Node<E> newNode = new Node<>(pred, e, succ);
2. 讓後繼的前驅指向新節點。代碼為:
succ.prev = newNode;
3. 讓前驅的後繼指向新節點,如果前驅為空,修改頭節點指向新節點。代碼為:
if (pred == null) first = newNode; else pred.next = newNode;
4. 增加長度。
我們通過圖示來更清楚的看下,還是上面的例子,比如,添加一個元素:
list.add(1, "c");
圖示結構會變為:
可以看出,在中間插入元素,LinkedList只需按需分配內存,修改前驅和後繼節點的鏈接,而ArrayList則可能需要分配很多額外空間,且移動所有後續元素。
刪除元素
我們再來看刪除元素,代碼為:
public E remove(int index) { checkElementIndex(index); return unlink(node(index)); }
通過node方法找到節點後,調用了unlink方法,代碼為:
E unlink(Node<E> x) { 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; size--; modCount++; return element; }
刪除x節點,基本思路就是讓x的前驅和後繼直接鏈接起來,next是x的後繼,prev是x的前驅,具體分為兩步:
我們再通過圖示看下,還是上面的例子,如果刪除一個元素:
list.remove(1);
圖示結構會變為:
原理小結
以上,我們介紹了LinkedList的內部組成,以及幾個主要方法的實現代碼,其他方法的原理也都類似,我們就不贅述了。
前面我們提到,對於隊列、棧和雙端隊列接口,長度可能有限制,LinkedList實現了這些接口,不過LinkedList對長度並沒有限制。
LinkedList特點分析
LinkedList內部是用雙向鏈表實現的,維護了長度、頭節點和尾節點,這決定了它有如下特點:
理解了LinkedList和ArrayList的特點,我們就能比較容易的進行選擇了,如果列表長度未知,添加、刪除操作比較多,尤其經常從兩端進行操作,而按照索引位置訪問相對比較少,則LinkedList就是比較理想的選擇。
小結
本節詳細介紹了LinkedList,先介紹了用法,然後介紹了實現原理,最後我們分析了LinkedList的特點,並與ArrayList進行了比較。
用法上,LinkedList是一個List,但也實現了Deque接口,可以作為隊列、棧和雙端隊列使用。實現原理上,內部是一個雙向鏈表,並維護了長度、頭節點和尾節點。
無論是ArrayList還是LinkedList,按內容查找元素的效率都很低,都需要逐個進行比較,有沒有更有效的方式呢?
----------------
未完待續,查看最新文章,敬請關注微信公眾號“老馬說編程”(掃描下方二維碼),從入門到高級,深入淺出,老馬和你一起探索Java編程及計算機技術的本質。用心原創,保留所有版權。