標籤:方法 rem 成員 table 反射 mod c++ 獲得 構造
1.初識LinkedList
上一篇中講解了ArrayList,本篇文章講解一下LinkedList的實現。
LinkedList是基於鏈表實現的,所以先講解一下什麼是鏈表。鏈表原先是C/C++的概念,是一種線性儲存結構,意思是將要儲存的資料存在一個儲存單元裡面,這個儲存單元裡面除了存放有待儲存的資料以外,還儲存有其下一個儲存單元的地址(下一個儲存單元的地址是必要的,有些儲存結構還存放有其前一個儲存單元的地址),每次尋找資料的時候,通過某個儲存單元中的下一個儲存單元的地址尋找其後面的那個儲存單元。
這麼講可能有點抽象,先提一句,LinkedList是一種雙向鏈表,雙向鏈表我認為有兩點含義:
1、鏈表中任意一個儲存單元都可以通過向前或者向後定址的方式擷取到其前一個儲存單元和其後一個儲存單元
2、鏈表的尾節點的後一個節點是鏈表的頭結點,鏈表的頭結點的前一個節點是鏈表的尾節點
2.LinkedList資料結構原理
LinkedList底層的資料結構是基於雙向迴圈鏈表的,且頭結點中不存放資料,如下:
既然是雙向鏈表,那麼必定存在一種資料結構——我們可以稱之為節點,節點執行個體儲存業務資料、前一個節點的位置資訊和後一個節點位置資訊,如所示:
3.私人屬性
LinkedList中定義了兩個私人屬性:
private transient Entry<E> header = new Entry<E>(null, null, null);private transient int size = 0;
header是雙向鏈表的前端節點,它是雙向鏈表節點所對應的類Entry的執行個體。Entry中包含成員變數: previous, next,element。其中,previous是該節點的上一個節點,next是該節點的下一個節點,element是該節點所包含的值。
size是雙向鏈表中節點執行個體的個數。
首先來瞭解節點類Entry類的代碼。
private static class Entry<E> { E element; Entry<E> next; Entry<E> previous; Entry(E element, Entry<E> next, Entry<E> previous) { this.element = element; this.next = next; this.previous = previous; }}
看到LinkedList的Entry中的"E element",就是它真正儲存的資料。"Entry<E> next"和"Entry<E> previous"表示的就是這個儲存單元的前一個儲存單元的引用地址和後一個儲存單元的引用地址。用圖表示就是:
4.建構函式
LinkedList提供了兩個構造方法,如下所示:
public LinkedList() { header.next = header.previous = header;}public LinkedList(Collection<? extends E> c) { this(); addAll(c);}
第一個構造方法不接受參數,將header執行個體的previous和next全部指向header執行個體(注意,這個是一個雙向迴圈鏈表,如果不是迴圈鏈表,空鏈表的情況應該是header節點的前一節點和後一節點均為null),這樣整個鏈表其實就只有header一個節點,用於表示一個空的鏈表。
執行完建構函式後,header執行個體自身形成一個閉環,如所示:
第二個構造方法接收一個Collection參數c,調用第一個構造方法構造一個空的鏈表,之後通過addAll將c中的元素全部添加到鏈表中。
5.四個關注點在LinkedList上的答案
| 關 注 點 |
結 論 |
| LinkedList是否允許空 |
允許 |
| LinkedList是否允許重複資料 |
允許 |
| LinkedList是否有序 |
有序 |
| LinkedList是否安全執行緒 |
非安全執行緒 |
二、添加元素
首先看下LinkedList添加一個元素是怎麼做的,假如我有一段代碼:
1 public static void main(String[] args)2 {3 List<String> list = new LinkedList<String>();4 list.add("111");5 list.add("222");6 }
逐行分析main函數中的三行代碼是如何執行的,首先是第3行,看一下LinkedList的源碼:
1 public class LinkedList<E> 2 extends AbstractSequentialList<E> 3 implements List<E>, Deque<E>, Cloneable, java.io.Serializable 4 { 5 private transient Entry<E> header = new Entry<E>(null, null, null); 6 private transient int size = 0; 7 8 /** 9 * Constructs an empty list.10 */11 public LinkedList() {12 header.next = header.previous = header;13 }14 ...15 }
看到,new了一個Entry出來名為header,Entry裡面的previous、element、next都為null,執行建構函式的時候,將previous和next的值都設定為header的引用地址,還是用畫圖的方式表示。32位JDK的字長為4個位元組,而目前64位的JDK一般採用的也是4字長,所以就以4個字長為單位。header引用地址的字長就是4個位元組,假設是0x00000000,那麼執行完"List<String> list = new LinkedList<String>()"之後可以這麼表示:
接著看第4行add一個字串"111"做了什麼:
1 public boolean add(E e) {2 addBefore(e, header);3 return true;4 }
1 private Entry<E> addBefore(E e, Entry<E> entry) {2 Entry<E> newEntry = new Entry<E>(e, entry, entry.previous);3 newEntry.previous.next = newEntry;4 newEntry.next.previous = newEntry;5 size++;6 modCount++;7 return newEntry;8 }
addBefore(E e,Entry<E> entry)方法是個私人方法,所以無法在外部程式中調用(當然,這是一般情況,你可以通過反射上面的還是能調用到的)。
addBefore(E e,Entry<E> entry)先通過Entry的構造方法建立e的節點newEntry(包含了將其下一個節點設定為entry,上一個節點設定為entry.previous的操作,相當於修改newEntry的“指標”),之後修改插入位置後newEntry的前一節點的next引用和後一節點的previous引用,使鏈表節點間的參考關聯性保持正確。之後修改和size大小和記錄modCount,然後返回新插入的節點。
第2行new了一個Entry出來,可能不太好理解,根據Entry的建構函式,我把這句話"翻譯"一下,可能就好理解了:
1、newEntry.element = e;
2、newEntry.next = header;
3、newEntry.previous = header.previous;
header的引用地址為0x00000000,header.previous中已經看到了,也是0x00000000,那麼假設new出來的這個Entry的地址是0x00000001,繼續畫圖表示:
一共五步,每一步的操作步驟都用數字表示出來了:
1、新的entry的element賦值為111;
2、新的entry的next是header的引用地址,header的引用地址是0x00000000,所以新的entry的next即0x00000000;
3、新的entry的previous是header的previous,header的previous是0x00000000,所以新的entry的next即0x00000000;
4、"newEntry.previous.next = newEntry",首先是newEntry的previous,由於newEntry的previous為0x00000000,所以newEntry.previous表示的是header,header的next為newEntry,即header的next為0x00000001;
5、"newEntry.next.previous = newEntry",和4一樣,把header的previous設定為0x00000001;
為什麼要這麼做?還記得雙向鏈表的兩個特點嗎,一是任意節點都可以向前和向後定址,二是整個鏈表頭的previous表示的是鏈表的尾Entry,鏈表尾的next表示的是鏈表的頭Entry。現在鏈表頭就是0x00000000這個Entry,鏈表尾就是0x00000001,可以自己看圖觀察、思考一下是否符合這兩個條件。
最後看一下add了一個字串"222"做了什麼,假設新new出來的Entry的地址是0x00000002,畫圖表示:
還是執行的那5步,圖中每一步都標註出來了,只要想清楚previous、next各自表示的是哪個節點就不會出問題了。
至此,往一個LinkedList裡面添加一個字串"111"和一個字串"222"就完成了。從這張圖中應該理解雙向鏈表比較容易:
1、中間的那個Entry,previous的值為0x00000000,即header;next的值為0x00000002,即tail,這就是任意一個Entry既可以向前尋找Entry,也可以向後尋找Entry。
2、頭Entry的previous的值為0x00000002,即tail,這就是雙向鏈表中頭Entry的previous指向的是尾Entry。
3、尾Entry的next的值為0x00000000,即header,這就是雙向鏈表中尾Entry的next指向的是頭Entry。
三、查看元素
看一下LinkedList的代碼是怎麼寫的:
public E get(int index) { return entry(index).element;}
1 // 擷取雙向鏈表中指定位置的節點 2 private Entry<E> entry(int index) { 3 if (index < 0 || index >= size) 4 throw new IndexOutOfBoundsException("Index: "+index+ 5 ", Size: "+size); 6 Entry<E> e = header; 7 // 擷取index處的節點。 8 // 若index < 雙向鏈表長度的1/2,則從前向後尋找; 9 // 否則,從後向前尋找。 10 if (index < (size >> 1)) { 11 for (int i = 0; i <= index; i++) 12 e = e.next; 13 } else { 14 for (int i = size; i > index; i--) 15 e = e.previous; 16 } 17 return e; 18 }
get(int)方法首先判斷位置資訊是否合法(大於等於0,小於當前LinkedList執行個體的Size),然後遍曆到具體位置,獲得節點的業務資料(element)並返回。
注意:為了提高效率,需要根據擷取的位置判斷是從頭還是從尾開始遍曆。
這段代碼就體現出了雙向鏈表的好處了。雙向鏈表增加了一點點的空間消耗(每個Entry裡面還要維護它的前置Entry的引用),同時也增加了一定的編程複雜度,卻大大提升了效率。
由於LinkedList是雙向鏈表,所以LinkedList既可以向前尋找,也可以向後尋找,第10行~第16行的作用就是:當index小於數組大小的一半的時候(size >> 1表示size / 2,使用移位元運算提升代碼運行效率),從前向後尋找;否則,從後向前尋找。
這樣,在我的資料結構裡面有10000個元素,剛巧尋找的又是第10000個元素的時候,就不需要從頭遍曆10000次了,向後遍曆即可,一次就能找到我要的元素。
注意細節:位元運算與直接做除法的區別。先將index與長度size的一半比較,如果index<size/2,就只從位置0往後遍曆到位置index處,而如果index>size/2,就只從位置size往前遍曆到位置index處。這樣可以減少一部分不必要的遍曆。
四、刪除元素
看完了添加元素,我們看一下如何刪除一個元素。和ArrayList一樣,LinkedList支援按元素刪除和按下標刪除,前者會刪除從頭開始匹配的第一個元素。用按下標刪除舉個例子好了,比方說有這麼一段代碼:
1 public static void main(String[] args)2 {3 List<String> list = new LinkedList<String>();4 list.add("111");5 list.add("222");6 list.remove(0);7 }
也就是我想刪除"111"這個元素。看一下第6行是如何執行的:
1 1 public E remove(int index) {2 2 return remove(entry(index));3 3 }
1 private E remove(Entry<E> e) { 2 if (e == header) 3 throw new NoSuchElementException(); 4 // 保留將被移除的節點e的內容 5 E result = e.element; 6 // 將前一節點的next引用賦值為e的下一節點 7 e.previous.next = e.next; 8 // 將e的下一節點的previous賦值為e的上一節點 9 e.next.previous = e.previous;10 // 上面兩條語句的執行已經導致了無法在鏈表中訪問到e節點,而下面解除了e節點對前後節點的引用11 e.next = e.previous = null;12 // 將被移除的節點的內容設為null13 e.element = null;14 // 修改size大小15 size--;16 modCount++;17 // 返回移除節點e的內容18 return result;19 }
當然,首先是找到元素在哪裡,這和get是一樣的。接著,用畫圖的方式來說明比較簡單:
比較簡單,只要找對引用地址就好了,每一步的操作也都詳細標註在圖上了。
由於刪除了某一節點因此調整相應節點的前後指標資訊,如下:
e.previous.next = e.next;//預刪除節點的前一節點的後指標指向預刪除節點的後一個節點。
e.next.previous = e.previous;//預刪除節點的後一節點的前指標指向預刪除節點的前一個節點。
清空預刪除節點:
e.next = e.previous = null;
e.element = null;
交給gc完成資源回收,刪除操作結束。
與ArrayList比較而言,LinkedList的刪除動作不需要“移動”很多資料,從而效率更高。
這裡我提一點,第3步、第4步、第5步將待刪除的Entry的previous、element、next都設定為了null,這三步的作用是讓虛擬機器可以回收這個Entry。
五、LinkedList和ArrayList的對比
老生常談的問題了,這裡我嘗試以自己的理解盡量說清楚這個問題,順便在這裡就把LinkedList的優缺點也給講了。
1、順序插入速度ArrayList會比較快,因為ArrayList是基於數組實現的,數組是事先new好的,只要往指定位置塞一個資料就好了;LinkedList則不同,每次順序插入的時候LinkedList將new一個對象出來,如果對象比較大,那麼new的時間勢必會長一點,再加上一些引用賦值的操作,所以順序插入LinkedList必然慢於ArrayList
2、基於上一點,因為LinkedList裡面不僅維護了待插入的元素,還維護了Entry的前置Entry和後繼Entry,如果一個LinkedList中的Entry非常多,那麼LinkedList將比ArrayList更耗費一些記憶體
3、有些說法認為LinkedList做插入和刪除更快,這種說法其實是不準確的:
(1)LinkedList做插入、刪除的時候,慢在定址,快在只需要改變前後Entry的引用地址
(2)ArrayList做插入、刪除的時候,慢在數組元素的批量copy,快在定址
所以,如果待插入、刪除的元素是在資料結構的前半段尤其是非常靠前的位置的時候,LinkedList的效率將大大快過ArrayList,因為ArrayList將批量copy大量的元素;越往後,對於LinkedList來說,因為它是雙向鏈表,所以在第2個元素後面插入一個資料和在倒數第2個元素後面插入一個元素在效率上基本沒有差別,但是ArrayList由於要批量copy的元素越來越少,操作速度必然追上乃至超過LinkedList。
從這個分析看出,如果你十分確定你插入、刪除的元素是在前半段,那麼就使用LinkedList;如果你十分確定你刪除、刪除的元素在比較靠後的位置,那麼可以考慮使用ArrayList。如果你不能確定你要做的插入、刪除是在哪兒呢?那還是建議你使用LinkedList吧,因為一來LinkedList整體插入、刪除的執行效率比較穩定,沒有ArrayList這種越往後越快的情況;二來插入元素的時候,弄得不好ArrayList就要進行一次擴容,記住,ArrayList底層數組擴容是一個既消耗時間又消耗空間的操作。
Java集合之LinkedList