背景摘要:在List集合中,我們最熟悉的就是ArrayList與LinkedList。一談到它們我們第一個反應就是ArrayList查詢快,LinkedList增刪快,所以在增刪操作頻繁的場景下適合使用LinkedList,而在其他場景ArrayList就夠用了。那麼前篇我們提到了ArrayList源碼分析,今天再來細聊LinkedList。
目錄
一、戀上鍊表
鏈表,它是一種數據結構,分爲單項鍊表與雙向鏈表。而今天要講的LinkedList集合恰好就是雙向鏈表結構的集合。我們要注意,LinkedList集合 不等於鏈表,它只是底層基於鏈表這種數據結構而實現。
首先我們一張圖來簡單瞭解鏈表的單雙向區別。
單向:當前鏈表節點(後面簡稱爲節點或元素)只鏈接下一個節點。1鏈接2,2鏈接3。
雙向:當前節點與上、下一個節點都有鏈接。1鏈接2, 2鏈接1、3, 3鏈接2。
1.1、單向鏈表
那麼我們再仔細琢磨單向鏈表是如何鏈接下一個元素,爲什麼稱作單向。
首先畫出單向鏈表的數據結構,每個節點分爲Item(當前元素值)與Next(下一個元素)兩部分。那麼我們得知在每一個節點中都會包含下一個節點,如1包含2。如果沒有下一個節點,如3沒有,那麼值爲NULL,即代表3爲最後一個元素。
或許聰明的你會回憶發現LinkedList使用的是雙向鏈表,正在疑惑哪裏使用到單向鏈表呢?答案是HashMap。
我們談到HashMap的組成,想到的就是數組+鏈表。那來一起看看JDK1.7中HashMap的一段源碼,該源碼中是一個鏈表結構的靜態內部類,可以看到當前的結構與上圖類似。
那麼再看源碼圖中Entry<K,V>相當於一個節點,Next爲當前節點的下一個節點。固我們得知,HashMap是由鏈表+數組實現,且鏈表的結構爲單向鏈表。
隨後躲在牆角的HashMap:
1.2、雙向鏈表
瞭解完單向鏈表之後,我們再來看看雙向鏈表。
首先雙向鏈表比單向鏈表多了個prev(當前節點的上一個節點),如果當前元素是頭部節點(First),那麼prev值爲NULL值,如圖中節點1的指向。節點2前後都有數據,那麼prev爲1,next爲3。節點3爲尾部節點(Last)固next值爲NULL。
這也就說明雙向鏈表特徵爲前後相呼應。
那麼雙向列表的例子就是今天要細說的LinkedList主角了。
LinkedList:
二、LinkedList
集合源碼分析案例項目碼雲地址:https://gitee.com/yiang-hz/gather
當前LinkedList類地址:https://gitee.com/yiang-hz/gather/tree/master/list/src/main/java/com/yiang/list
類名:YiangLinkedList,由於貼代碼太長,這裏就只做片段分析。可直接複製源碼在IDEA進行調試。
首先依舊還是上次ArrayList源碼分析時的List接口。
public interface YiangList<E> {
int size();
E get(int index);
boolean add(E e);
E remove(int index);
}
2.1、成員變量
這裏分析重要的三個成員變量。集合大小size,集合最後一個元素last,集合第一個元素first。也就是雙向鏈表結構圖中的節點1(first)與節點3(last)。
/**
* 集合大小
*/
transient int size = 0;
/**
* 集合的最後一個元素
*/
transient YiangLinkedList.Node<E> last;
/**
* 集合的第一個元素
*/
transient YiangLinkedList.Node<E> first;
2.2、靜態內部類Node<E>
在雙向鏈表中談到的結構分別是prev、item、next。那麼在LinkedList源碼實現靜態內部類中正好是該三項。
private static class Node<E> {
/** 當前元素 (HZ)*/
E item;
/** 當前元素的上一個元素 (HZ)*/
YiangLinkedList.Node<E> next;
/** 當前元素的下一個元素 (HZ)*/
YiangLinkedList.Node<E> prev;
Node(YiangLinkedList.Node<E> prev, E element, YiangLinkedList.Node<E> next) {
this.item = element;
this.next = next;
this.prev = prev;
}
Node(E element) {
this.item = element;
}
}
所以從該源碼我們得知,LinkedList底層確實是由雙向列表構成。
2.3、LinkedList集合新增快的原因
LinkedList我們都知道新增快,那麼爲什麼快?快在哪裏?源碼中怎麼體現的?我們來一一分析。
/**
* 默認會鏈接元素作爲最後一個元素
* @param e 元素對象
* @return 是否添加成功
*/
@Override
public boolean add(E e) {
addLast(e);
return true;
}
源碼中add方法默認是調用addLast(e)方法的,也就是默認是從最後一個元素節點追加。
/**
* 追加元素,位於最後一個之後
* @param e 元素
*/
void addLast(E e){
//獲取最後一個元素
final YiangLinkedList.Node<E> l = last;
//創建當前元素
final YiangLinkedList.Node<E> node = new YiangLinkedList.Node<>(l, e, null);
//最後一個元素的值等於該元素
last = node;
//如果之前的最後一個元素爲空,那麼代表當前集合爲空,node即成爲最後一個元素
if (null == l){
first = node;
} else {
//否則將當前元素設置爲之前的最後一個元素的下一個值
l.next = node;
}
//大小加1
size++;
}
添加的實現步驟中
首先獲取到之前的最後一個元素L,其次將當前存放的值創建成節點node,那麼node的上一個節點爲L,並將node設置爲last節點。當然這裏需要判斷一種情況,如果L爲空,那麼證明當前集合是空集合,固first也要設置爲node節點。否則代表L不爲空,那麼需要將L的下一個節點設置爲Node。最後進行size+1。
圖示:e爲添加的元素
1.當前集合爲空情況
2.當前集合包含元素情況
那麼我們得知,LinkedList新增快的主要原因是,在添加時只需要獲取最後一個節點Last,並更改Last節點和當前創建節點的引用鏈關係即可。所以相對於ArrayList的數組重新複製要快很多。
2.4、LinkedList刪除背後的歷程
說完了新增快的原因,我們再來看看刪除快的原因以及它的經歷過程。
@Override
public E remove(int index) {
//檢測下標是否越界
checkElementIndex(index);
//刪除
return unlink(node(index));
}
刪除需要檢測下標是否越界(當前傳入的index是否>0,並且是否小於size最大值)。當然還要找到需要刪除的元素,再開始刪除方法。
/**
* 刪除元素 通過鏈表的結構鏈接邏輯實現
*/
E unlink(Node<E> x) {
//當前元素的值, x爲當前元素
final E element = x.item;
//當前元素的上一個元素
YiangLinkedList.Node<E> prev = x.prev;
//當前元素的下一個元素
YiangLinkedList.Node<E> next = x.next;
//刪除時,如果當前的元素的上一個元素爲NULL,那麼代表當前元素爲第一個元素。
if (null == prev){
//所以刪除後設置下一個元素爲first
first = next;
} else{
//否則,代表該元素的上一個元素不爲空,那麼將上一個元素的下一個元素設置爲 當前的next
prev.next = next;
// 如果上一個元素不爲空,那麼刪除後需將當前元素的上一個元素置空
x.prev = null;
}
//如果當前的元素的下一個元素爲NULL,那麼代表當前元素爲最後一個元素。
if (null == next){
//所以刪除後設置上一個元素爲last
last = prev;
} else{
//當前元素的下一個元素不爲空,那麼需要將下一個元素的上一個元素,設置爲 當前的prev
next.prev = prev;
// 如果下一個元素不爲空,那麼刪除後需將當前元素的下一個元素置空
x.next = null;
}
//這裏將值賦值爲空,讓GC進行回收處理
x.item = null;
//將大小size - 1
size--;
return element;
}
刪除主要分爲三種情況:
- 刪除的節點爲頭部節點
- 刪除的節點爲尾部節點
- 刪除的節點前後有相鄰節點。
光看代碼和文字是很枯燥乏味的,我們直接看實現原理圖,再去看代碼是怎麼實現,思路就很清晰了。
第一種情況:
操作步驟:首先是刪除節點1,其次把節點1的下一個節點,也就是節點2的 prev值 設置爲NULL。並將節點2設置爲頭部節點。
第二種情況:
操作步驟:實現與第一種情況類似,首先是刪除節點3,其次把節點3的上一個節點,也就是節點2的 next值 設置爲NULL。並將節點2設置爲尾部節點。
第三種情況:
這種情況最形象的例子就好比三個人(ABC)手牽着手,中間的那個人(B)離開了。那麼A要牽C手,C要牽A手。
操作步驟:
刪除節點2 --> 獲取節點2上一個節點 節點1 。獲取節點2的下一個節點 節點3。 --> 將節點1的 next值 設置爲節點3,將節點3的prev值 設置爲節點1。
那麼當刪除原理圖看完之後,我們再去看代碼中的設計,真是妙啊!
2.5、LinkedList爲什麼查詢慢?
我們知道,在ArrayList中,查詢快的原因是它底層通過數組實現,直接根據索引找到對應的元素即可。那麼在LinkedList集合中是元素一個一個鏈接起來,沒有索引概念的,那麼當我們去進行查找時,就只能根據第一個元素,然後一個一個數位置,數到對應位置數量時才能找到需要查詢的元素。
那麼如果我們要從以下數據中查找 值爲3,也就是第三個節點時。通常情況下指針會從1-3一個一個的去查找,非常耗時,且這裏相當於Mysql的全表檢索,是不是神似根據ID去查詢某條記錄呢?
當然,LinkedList查找並不完全是這樣的,它做了一個優化,稱爲折半查找。
折半查找
我們看完類似於上圖Mysql的全表檢索之後,再來細說LinkedList在此做了什麼優化。
/**
* 根據索引查找到元素
* @param index 索引
* @return 元素
*/
YiangLinkedList.Node<E> node(int index) {
//LinkedList底層通過next與prev去連接元素,那麼查詢時,需要一個一個鏈接
//相當於一次全表查詢,但LinkedList做了一個小優化,那就是 折半查詢
//對比:鏈表 -> 全表查詢 數組 -> 索引查詢
//如果索引下標 小於 集合大小除2 index < size / 2 那麼從 0-5折半查詢
// 否之則從 10 - 6反向查詢 實現折半查詢
// 1.例如索引爲0 size爲10: 0 < 10 / 2,那麼node等於第一個值,並且不會進行循環,直接返回第一個元素
// 2.例如索引爲1 size爲10: 1 < 10 / 2,那麼node等於 第二個值,循環一次。並且返回
if (index < (size >> 1)){
YiangLinkedList.Node<E> node = first;
for (int i = 0; i < index; i++) {
node = node.next;
}
return node;
} else{
//3.例如索引爲6 size爲10: 6 > 10 / 2,那麼node等於倒數第4個值,循環4次。並且返回
YiangLinkedList.Node<E> node = last;
//size - 1 拿到最後一個元素, 因爲下標是從0開始,固需要-1
for (int i = size - 1; i > index ; i--) {
node = node.prev;
}
return node;
}
}
代碼中設計很巧妙,那麼我們簡單畫兩個圖來說明。
從圖中可知,通過算法在查找之前判斷從那邊開始查找更快,從而選擇從頭部節點或者尾部節點開始查找的方式即爲折半查找。而LinkedList查找源碼正是使用該種方法去進行查詢。
總結
首先LinkedList是又鏈表中的雙向鏈表實現,增刪快的原理是只需要操作相鄰兩個數據即可。刪除也需要先查找到當前節點。查詢是通過類似Mysql的全表檢索,雖進行了折半查找,但效率較ArrayList來說還是很低的。最終由於查詢是一個一個遍歷,導致查詢速度過慢,所以日常使用的集合通常是ArrayList,而不是LinkedList。固LinkedList適合在特殊操作頻繁場景可用來提高程序運行效率。