什麼是LinkedList
和ArrayList不同,LinkedList是基於鏈表實現的線性數據結構。節點之間訪問不是通過下標進行,而是通過指針。同時,LinkedList實現了List接口和Deque接口。在Deque接口中提供了許多有用的方法,我們下面會選一些詳細說。
這是LinkedList裏面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;
}
}
- item: 數據
- next: 下一個節點
- prev: 上一個節點
可以看到,LinkedList是雙向鏈表,因爲每個節點都有指向前驅後繼節點的指針。
他的數據結構類似這樣:
| prev | node1 | next | -> | prev | node2 | next | -> | prev | node3 | next | -> | prev | node4 | next |
LinkedList是基於鏈表實現的,因此我們的重點就是掌握節點之間是如何添加/刪除的。掌握了節點是如何修改指針之後,無論是需要第一個元素,還是最後一個元素,還是要獲取其中任意一個元素,我們都能夠自己實現了。
LinkedList的add()
public boolean add(E e) {
linkLast(e);
return true;
}
void linkLast(E e) {
final Node<E> l = last; // 1
final Node<E> newNode = new Node<>(l, e, null); // 2
last = newNode; // 3
if (l == null)
first = newNode;
else
l.next = newNode;
size++;
modCount++;
}
- add() 方法調用
linkLast()
方法 - linkLast()方法分以下幾步
- 讓一個
l
節點指向last
- 創建一個新的節點
newNode
,並且把前驅結點設置爲last
- 讓
last
指向新創建的節點newNode
- 設置
l.next
爲新創建的節點
- 讓一個
這就實現了把一個新的節點插入到鏈表末尾的功能。我們可以畫圖來表示:
- | last.prev | last | null | - 這是last節點的數據結構
- | null | data | null | - 這是新節點的數據結構
- | last.prev| last | null | -> | last | data | null | - 把 e 的前驅節點設置爲 last
- | last.prev| last | data | -> | last | data | null | - 把 l 的後繼節點設置爲 newNode
通過這4步,就成功的把兩個節點連接在一起了,而且新創建的節點在last節點之後,成爲新的尾節點
用文字來總結就是:
- 持有一個對last節點的引用
- 創建新的節點
- 新節點的前驅設置爲last
- last節點的後繼設置爲新節點
注意:註釋3這一步,last = newNode,其實在後面做也可以,只要保證last指向的是最後一個元素地址就可以了。
說完了添加操作,我們再來看看鏈表的移除。
LinkedList的remove()
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;
}
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;
size--;
modCount++;
return element;
}
從代碼量來看,remove
() 方法比add()
要複雜,實際上也確實是的。
想象一下,一條隊伍裏相鄰的兩個人互相手牽手,如果要在隊尾加上一個人,那麼只是最後一個人和新來的人牽手就可以了。
但是如果是中間某個人要離開隊伍了,那麼和他相鄰的兩個人(前驅後繼)都會受到影響。在鏈表裏面也是一樣的,如果我們要進行中間元素的刪除操作,那麼需要修改的節點是3個。當然插入中間元素也是一樣的意思。
下面進入源碼分析階段:
- remove(): 遍歷找到要刪除的元素
- 如果是null,那麼找data == null的節點 (null 不能用equals())
- 如果不爲null,那麼就找 data.equals(x.item) 的節點,反正是找到第一個值相等的元素
找到之後就進入unlink()
方法:
- 首先持有x節點的前驅後繼節點的指針
- 判斷是不是頭節點(沒有prev),是的話直接讓
first
指向 x.next 就可以了 - 不是頭節點的話,讓前驅節點的後繼節點設爲x的後繼節點,也就是把x從前驅節點中去掉了
- 再把x的前驅節點設爲null,也就是鬆開自己和前面一個人的手(去掉自己的前驅節點)
- 同樣的方法處理後繼節點
如果通過畫圖來展示的話就是:
第一種情況:頭節點
| null | x | x.next | -> | x | a | a.next |
直接讓first = x.next,也就是讓 first 指向 a,完成 x 的刪除操作
第二種情況:中間節點
| x | first | first.next | -> | first | x | x.next | -> | x | b | b.next |
首先讓 first.next -> b ( x.next ),此時變成了
| x | first | b | -> | first | x | x.next | -> | x | b | b.next |
然後修改x的前驅爲null,此時變成了
| x | first | b | -> | null | x | x.next | -> | x | b | b.next |
這就解除了和前驅結點之間的聯繫,然後相同的思路處理後繼節點即可。
第三種情況:尾節點
| x.prev | x | null |
這種情況我們讓last指向x.prev就可以了,尾節點就從x變成了它的前一個節點
LinkedList的一些細節
通過我們上面總結的添加和移除元素的思路,再配合Deque接口裏面的許多方法,可以實現很多有意思的操作。
有意思的查找Node算法
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;
}
}
雖然鏈表一般情況下來說通過下標來查找,時間複雜度爲O(n),但是LinkedList裏面還是用了點小技巧進行優化,通過類似二分法的思想,把整個鏈表平分爲兩部分,判斷傳入的下標是在前半部分還是後半部分,如果是前半部分,那麼從頭開始找。如果是後半部分,那麼就從尾開始找。這樣做的好處是:
- 把O(n) 的時間複雜度縮短爲O(n/2)
- 利用了雙向鏈表的優勢,可以從前往後,也可以從後往前
- 同樣用了位運算來判斷index的位置,優化計算效率
LinkedList如何通過下標插入?
LinkedList相比ArrayList的一個優勢就是插入刪除更加高效,因爲只要修改相鄰節點就可以了,但是ArrayList需要進行數組的複製移動。需要的內存和時間都更多。
而LinkedList不僅實現了在頭/尾插入刪除元素,還能指定下標進行添加/刪除操作。具體是怎麼做到的呢?
public void add(int index, E element) {
checkPositionIndex(index);
if (index == size)
linkLast(element);
else
linkBefore(element, node(index));
}
核心方法其實就是上面說的node
,傳入index然後返回找到的node,然後再通過我們在上面說的,修改前驅後繼節點來實現插入操作。
Deque的peekFrist() 和 getFirst()有什麼區別?
Deque裏面這兩個實現方法:
public E peekFirst() {
final Node<E> f = first;
return (f == null) ? null : f.item;
}
public E element() {
return getFirst();
}
public E getFirst() {
final Node<E> f = first;
if (f == null)
throw new NoSuchElementException();
return f.item;
}
- peek(): 返回 null 或者 元素數據
- element(): 拋出NoSuchElementException() 或者 元素數據
也就是說,如果我們可以接受null的話,可以使用peekFirst(),類似的方法還有peekLast() 和 getLast()。
LinkedList的clear()
clear():
public void clear() {
// Clearing all of the links between nodes is "unnecessary", but:
// - helps a generational GC if the discarded nodes inhabit
// more than one generation
// - is sure to free memory even if there is a reachable Iterator
for (Node<E> x = first; x != null; ) {
Node<E> next = x.next;
x.item = null;
x.next = null;
x.prev = null;
x = next;
}
first = last = null;
size = 0;
modCount++;
}
爲了能夠讓JVM進行GC,把所有的對象設置爲null,包括first & last。
總結
LinkedList的操作都圍繞着如果管理相鄰節點的前驅後繼指針。只要把這個弄清楚,那麼無論是添加還是刪除都是比較容易寫出來的。如果大家覺得不好理解,可以在紙上把數據結構畫出來,在每一步操作之後修改對應的節點指針,會好理解一點。
LinkedList實現的Deque接口,提供了許多有用的方法,如果我們需要進行頻繁插入刪除操作的話,可以優先考慮LinkedList而不是ArrayList,並且看看裏面有沒有我們需要的實現~
在這裏也推薦一些鏈表相關的算法題,可以通過做題的方式檢驗一下自己是不是真的掌握了:
- LeetCode.19 刪除鏈表倒數第N位節點
- LeetCode.21 合併兩個有序鏈表
- LeetCode.206 反轉鏈表
- LeetCode.237 刪除鏈表中的節點