本文從鏈表的簡介開始,介紹了鏈表的存儲結構,並根據其存儲結構分析了其存儲結構所帶來的優缺點,進一步咱們經過代碼實現了一個輸入咱們的單向鏈表。而後經過對遞歸過程和內存分配的詳細講解讓你們對鏈表的引用和鏈表反轉有一個深刻的瞭解。單向鏈表實現了兩個版本,分別使用循環和遞歸實現了兩個版本的鏈表,相信你們仔細閱讀本文後會對鏈表和遞歸有一個深入的理解。不再怕面試官讓手寫鏈表或者反轉鏈表了。html
後面會持續更新數據結構相關的博文。java
數據結構專欄:https://www.cnblogs.com/hello-shf/category/1519192.htmlnode
git傳送門:https://github.com/hello-shf/data-structure.gitgit
鏈表是一種物理存儲單元上非連續、非順序的存儲結構,數據元素的邏輯順序是經過鏈表中的指針連接次序實現的。鏈表由一系列結點(鏈表中每個元素稱爲結點)組成,結點能夠在運行時動態生成。每一個結點包括兩個部分:一個是存儲數據元素的數據域,另外一個是存儲下一個結點地址的指針域。github
使用鏈表結構能夠克服數組鏈表須要預先知道數據大小的缺點,鏈表結構能夠充分利用計算機內存空間,實現靈活的內存動態管理。可是鏈表失去了數組隨機讀取的優勢,同時鏈表因爲增長告終點的指針域,空間開銷比較大。面試
前面文章咱們介紹了數組這種數據結構,其最大的優勢就是連續的存儲空間所帶來的隨機訪問的能力,最大的缺點一樣是連續存儲空間所形成的的容量的固定即不具有動態性。對於鏈表恰好相反,其是由物理存儲單元上非連續的存儲結構,這種結構能真正實現數據結構的動態性,但隨之而來的就是喪失了隨機訪問的優勢。正如一句古話-魚和熊掌不可兼得。數組和鏈表正是這種互補的關係。算法
由上面可知,鏈表最大的優勢就在於--動態。數組
以下圖所示,單向鏈表正是以這種方式存儲的。單向鏈表包含兩個域,一個是信息域,一個是指針域。也就是單向鏈表的節點被分紅兩部分,一部分是保存或顯示關於節點的信息,第二部分存儲下一個節點的地址,而最後一個節點則指向一個空值。緩存
雙向鏈表相對於單向鏈表,不過就是在指針域中除了指向下一個元素的指針,還存在一個指向上一個元素的指針。數據結構
循壞鏈表相對於單向鏈表,在最後一個元素的指針域存在一個指向頭節點的指針。使之造成一個環。
首先,爲何咱們要本身實現一個鏈表?你們在找工做面試的時候,一旦被問到數據結構,手寫鏈表應該都是必備的問題。其次,由於鏈表具備自然的遞歸性,鏈表的學習,有助於咱們更深層次的理解遞歸。一樣鏈表的學習對於咱們理解Java中的「引用」有很好的幫助。
對於咱們要實現的鏈表,咱們做以下設計
1 以Node做爲鏈表的基礎存儲結構 2 單向鏈表 3 使用泛型-增長靈活性 4 基本操做:增刪改查等
對於鏈表咱們將數據存儲在一個node節點中,因此咱們要設計一個node。
1 /** 2 * 描述:單向鏈表實現 3 * 對應 java 集合類 linkedList 4 * 5 * @Author shf 6 * @Date 2019/7/18 16:45 7 * @Version V1.0 8 **/ 9 public class MyLinkedList<E> { 10 /** 11 * 私有的 Node 12 */ 13 private class Node{ 14 public E e; 15 public Node next; 16 17 public Node(E e, Node next){ 18 this.e = e; 19 this.next = next; 20 } 21 public Node(E e){ 22 this(e, null); 23 } 24 public Node(){ 25 this(null, null); 26 } 27 } 28 private Node head; 29 private int size; 30 31 public MyLinkedList(){ 32 head = null; 33 size = 0; 34 } 35 public int getSize(){ 36 return this.size; 37 } 38 public boolean isEmpty(){ 39 return size == 0; 40 } 41 }
如上代碼所示,咱們經過定義一個私有的Node類,做爲咱們鏈表的基礎存儲結構。並在MyLinkedList中維護一個 head 屬性,做爲整個鏈表的頭結點。
咱們設計這麼一個方法,就是在鏈表的 index 位置 添加元素,咱們只須要找到index的前一個元素prev,而後讓其next指向咱們要添加的節點newNode,而後讓newNode的next指向prev的next節點便可。可能看着這段話有點繞。好比在 索引爲2的位置添加一個新元素,看下圖:
這樣咱們就將咱們的666元素添加到了索引爲2的位置。具體代碼實現以下所示:
1 /** 2 * 在 index 位置 添加元素 3 * 時間複雜度:O(n) 4 * @param index 5 * @param e 6 */ 7 public void add(int index, E e){ 8 9 if(index < 0 || index > size) 10 throw new IllegalArgumentException("Add failed. Illegal index."); 11 12 if(index == 0) 13 addFirst(e); 14 else{ 15 Node prev = head; 16 for(int i = 0 ; i < index - 1 ; i ++) 17 prev = prev.next; 18 19 // Node node = new Node(e); 20 // node.next = prev.next; 21 // prev.next = node; 22 // 以上三行代碼等價於下面這行代碼 23 24 prev.next = new Node(e, prev.next); 25 size ++; 26 } 27 } 28 /** 29 * 在鏈表頭 添加元素 30 * 時間複雜度:O(1) 31 * @param e 32 */ 33 public void addFirst(E e){ 34 // Node node = new Node(e); 35 // node.next = head; 36 // head = node; 37 // 以上三行代碼等價於下面這行代碼 38 39 head = new Node(e, head); 40 size ++; 41 } 42 43 /** 44 * 在鏈表尾 添加元素 45 * 時間複雜度:O(n) 46 * @param e 47 */ 48 public void addLast(E e){ 49 add(size, e); 50 }
在上面add方法中咱們須要判斷 index == 0 這種特殊狀況。咱們能夠經過將維護的head改成一個虛假的頭節點 dummyHead,來改善咱們的代碼。這也是構造鏈表的通常手段。
對於 head 這種狀況,鏈表的存儲結構以下圖所示:
若是咱們將 MyLinkedList中維護的 head 變成dummyHead,存儲結構以下:
相應的咱們的代碼將進行簡化:
1 private Node dummyHead; 2 private int size; 3 4 public MyLinkedList(){ 5 dummyHead = new Node(); 6 size = 0; 7 } 8 public int getSize(){ 9 return this.size; 10 } 11 public boolean isEmpty(){ 12 return size == 0; 13 } 14 15 /** 16 * 在 index 位置 添加元素 17 * @param index 18 * @param e 19 */ 20 public void add(int index, E e){ 21 if(index < 0 || index > size){ 22 throw new IllegalArgumentException("添加失敗,Index 參數不合法"); 23 } 24 Node prev = dummyHead;// TODO 不理解這一行就是沒有理解java中引用的含義 25 for(int i=0; i< index; i++){ 26 prev = prev.next; 27 } 28 prev.next = new Node(e, prev.next); 29 size ++; 30 } 31 32 /** 33 * 在鏈表頭 添加元素 34 * 時間複雜度:O(1) 35 * @param e 36 */ 37 public void addFirst(E e){ 38 this.add(0, e); 39 } 40 41 /** 42 * 在鏈表尾 添加元素 43 * 時間複雜度:O(n) 44 * @param e 45 */ 46 public void addLast(E e){ 47 this.add(size, e); 48 }
咱們能夠看到,當咱們引入了dummyHead,咱們的代碼更加精練了。後面全部的操做,咱們都依據有dummyHead的代碼來實現。
刪除和添加其實就差很少了,咱們設計一個方法,刪除指定索引位置的元素的方法。以下圖,咱們刪除索引爲2位置的元素666。
如圖所示,咱們只須要找到 因此爲2的前一個元素prev,而後讓其next指向666元素的下一個元素便可。可是別忘了,將666和鏈表斷開鏈接。
1 /** 2 * 刪除鏈表 index 位置的元素 3 * @param index 4 * @return 5 */ 6 public E remove(int index){ 7 if(index < 0 || index >= size){ 8 throw new IllegalArgumentException("操做失敗,Index 參數不合法"); 9 } 10 Node prev = dummyHead; 11 for(int i=0; i< index; i++){ 12 prev = prev.next; 13 } 14 Node rem = prev.next; 15 prev.next = rem.next; 16 rem.next = null;// 看不懂這行就是還沒理解鏈表。將rem斷開與鏈表的聯繫。 17 size--; 18 return rem.e; 19 } 20 21 /** 22 * 刪除 頭元素 23 * @return 24 */ 25 public E removeFirst(){ 26 return remove(0); 27 } 28 29 /** 30 * 刪除 尾元素 31 * @return 32 */ 33 public E removeLast(){ 34 return remove(size - 1); 35 }
首先,咱們在宏觀角度分析,鏈表是有自然遞歸性的,這個你們都明白,咱們想要實現鏈表反轉,無非就是讓每一個元素的next指向前一個元素便可。看圖(加了水印,你們湊活着看吧,做圖很辛苦):
代碼先放到這
1 /** 2 * 鏈表反轉 3 */ 4 public void reverseList(){ 5 dummyHead.next = reverseList(dummyHead.next); 6 } 7 8 /** 9 * 鏈表反轉 - 遞歸實現 10 * @param root 11 * @return 12 */ 13 private Node reverseList(Node root){ 14 if(root.next == null){ 15 return root; 16 } 17 // 先記住 root 的next節點 18 Node temp = root.next; 19 // 遞歸 root 的next節點,並返回root的節點 20 Node node = reverseList(root.next); 21 // 將 root 節點與鏈表斷開鏈接 22 root.next = null; 23 // 讓咱們以前緩存的 root的下一個節點 指向 root節點,這樣就實現了鏈表的反轉 24 temp.next = root; 25 return node; 26 }
看到上面代碼,估計你們會有點頭蒙,而且不知所措,沒問題,繼續往下看,爲了方便描述,咱們加一個參數,遞歸深度。
1 /** 2 * 鏈表反轉 3 */ 4 public void reverseList(){ 5 dummyHead.next = reverseList(dummyHead.next, 0); 6 } 7 /** 8 * 鏈表反轉 - 遞歸實現 9 * @param root 10 * @return 11 */ 12 private Node reverseList(Node root, int deap){ 13 System.out.println("遞歸深度==>" + deap); 14 if(root.next == null){ 15 return root; 16 } 17 // 先記住 root 的next節點 18 Node temp = root.next; 19 // 遞歸 root 的next節點,並返回root的節點 20 Node node = reverseList(root.next, (deap + 1)); 21 // 將 root 節點與鏈表斷開鏈接 22 root.next = null; 23 // 讓咱們以前緩存的 root的下一個節點 指向 root節點,這樣就實現了鏈表的反轉 24 temp.next = root; 25 return node; 26 }
遞歸深度==>0
遞歸深度==>1
遞歸深度==>2
遞歸深度==>3
對於上面這幾行代碼,咱們發現,咱們對 node 什麼都沒作,爲何要返回 node 呢?其實呢,node只是一個引用,node始終指向遞歸深度爲 3的時候,返回的root,也就是 0 這個節點。明確這一點咱們繼續分析。
結合遞歸深度,先分析一下遞歸樹,以下表所示:
遞歸深度 | 遞歸樹(root的指向) | 遞歸樹(temp的指向) | 遞歸樹(node指向) |
0 | 3 | 2 | 0 |
1 | 2 | 1 | 0 |
2 | 1 | 0 | 0 |
3 | 0 |
若是你看上面的遞歸樹對root,temp,node的指向感受還有點懵,不要緊,繼續往下看,咱們從堆棧的內存分佈來講一下各個引用隨遞歸深度的變化。從下圖咱們不難發現,其實在堆裏面始終都是3210四個節點,也就是說,root,temp,node僅僅是堆內存裏面這四個節點的引用而已。到這裏想必你們應該對引用有了一個直觀的理解。
接下來,咱們結合上圖和壓棧出棧的角度對該遞歸代碼的執行順序和堆內存的變化進行一個詳細的分析。
結合上面的遞歸樹和堆棧的內存分佈圖進行一下分析:
第1步:遞歸深度0,temp變量指向遞歸深度爲0的root.next及節點2(2 ==> 1 ==> 0 ==> null)。並將temp變量壓入棧頂。執行遞歸,也就是步驟1。
第2步:遞歸深度1,temp變量指向遞歸深度爲1的root.next及節點1(1 ==> 0 ==> null)。並將temp變量壓入棧頂。執行遞歸,也就是步驟2。
第3步:遞歸深度2,temp變量指向遞歸深度爲1的root.next及節點0( 0 ==> null)。並將temp變量壓入棧頂。執行遞歸,也就是步驟3。
第4步:遞歸深度3,直接返回root == 0(0 == > null)也就是出棧。
第5步:遞歸深度2,當前棧頂元素爲第3步的temp(指向0 == null),node指向 0節點(0 ==> null)(咱們就不提node壓棧出棧的事情了,由於咱們上面分析過node始終是指向0節點的)。
首先看上面的遞歸樹,當前node = 0;root = 1;temp=0;
執行代碼:
root.next = null;這行代碼改變了堆內存中的1節點的指向,將1節點和0幾點斷開了鏈接。及1 ==> null。當前堆內存以下圖1。
temp.next = root;這行代碼將0節點的下一個節點指向root所指向的堆內存也就是1節點。及 0 ==> 1 ==> null。當前堆內存以下圖2。
第6步:return node;node,temp變量出棧。
第7步:遞歸深度1,當前棧頂元素爲第2步的temp(指向節點1 == null)。
首先看上面的遞歸樹,當前node = 0; root = 2;temp = 1;別忘了當前節點1 ==> null,0 == 1 ==> null。
執行代碼:
root.next = null;這行代碼一樣改變了堆內存中2節點的指向,將2節點的和1節點斷開了鏈接。及2 ==> null。當前堆內存以下圖3。
temp.next = root;這行代碼將1節點指向root所指向的堆內存也就是2節點。及1 ==> 2 ==> null。當前堆內存以下圖4所示。
第8步:return node;node, temp變量出棧。
第9步:遞歸深度0,當前棧頂元素爲0步的temp(指向節點2 == null)
首先看上面的遞歸樹,當前node = 0; root = 3;temp = 2;別忘了當前節點2 ==> null,0 == 1 ==> 2 ==> null。
執行代碼:
root.next = null;這行代碼一樣改變了堆內存中3節點的指向,將3節點的和2節點斷開了鏈接。及3 ==> null。當前堆內存以下圖5。
temp.next = root;這行代碼將2節點指向root所指向的堆內存也就是3節點。及2 ==> 3 ==> null。當前堆內存以下圖6所示。
return node;node, temp變量出棧。
OK,終於分析完了,你們應該對遞歸有了一個深入的理解。
其實遞歸反轉鏈表的代碼還能夠更簡練一點:
1 private Node reverseList1(Node node){ 2 if(node.next == null){ 3 return node; 4 } 5 Node cur = reverseList1(node.next); 6 node.next.next = node; 7 node.next = null; 8 return cur; 9 }
關於這些操做,若是前面的增和刪操做看明白了,這些操做就很簡單了。直接上代碼吧。
1 /** 2 * 獲取鏈表的第index個位置的元素 3 * 時間複雜度:O(n) 4 * @param index 5 * @return 6 */ 7 public E get(int index){ 8 if(index < 0 || index >= size){ 9 throw new IllegalArgumentException("獲取失敗,Index 參數非法"); 10 } 11 Node cur = dummyHead.next; 12 for(int i=0; i< index; i++){ 13 cur = cur.next; 14 } 15 return cur.e; 16 } 17 18 /** 19 * 獲取頭元素 20 * 時間複雜度:O(1) 21 * @return 22 */ 23 public E getFirst(){ 24 return get(0); 25 } 26 27 /** 28 * 獲取尾元素 29 * 時間複雜度:O(n) 30 * @return 31 */ 32 public E getLast(){ 33 return get(size - 1); 34 } 35 36 /** 37 * 修改 index 位置的元素 e 38 * 時間複雜度:O(n) 39 * @param index 40 * @param e 41 */ 42 public void set(int index, E e){ 43 if(index < 0 || index >= size){ 44 throw new IllegalArgumentException("操做失敗,Index 參數不合法"); 45 } 46 Node cur = this.dummyHead.next; 47 for(int i=0; i< index; i++){ 48 cur = cur.next; 49 } 50 cur.e = e; 51 } 52 53 /** 54 * 查找鏈表中是否存在元素 e 55 * 時間複雜度:O(n) 56 * @param e 57 * @return 58 */ 59 public boolean contains(E e){ 60 Node cur = dummyHead.next; 61 for(int i=0; i<size; i++){ 62 if(cur.e == e){ 63 return true; 64 } 65 cur = cur.next; 66 } 67 return false; 68 }
關於各個操做的時間複雜度,在每一個方法的註釋中都寫明瞭。鏈表的時間複雜度很穩定,沒什麼好分析的。
/** * 描述:遞歸實現版 * * @Author shf * @Date 2019/7/26 17:04 * @Version V1.0 **/ public class LinkedListR<E> { private class Node{ private Node next; private E e; public Node(E e, Node next){ this.e = e; this.next = next; } public Node(E e){ this(e, null); } public Node(){ this(null, null); } @Override public String toString(){ return e.toString(); } } private Node dummyHead; private int size; public LinkedListR(){ this.dummyHead = new Node(); this.size = 0; } public int size(){ return size; } public boolean isEmpty(){ return size == 0; } /** * 向 index 索引位置 添加元素 e * @param index * @param e */ public void add(int index, E e){ add(index, e, dummyHead, 0); } /** * 向 index 索引位置 添加元素 e 遞歸實現 * @param index 索引位置 * @param e 要添加的元素 e * @param prev index 索引位置的前一個元素 * @param n */ private void add(int index, E e, Node prev, int n){ if(index == n){ size ++; prev.next = new Node(e, prev.next); return; } add(index, e, prev.next, n+1); } /** * 向鏈表 頭 添加元素 * @param e */ public void addFirst(E e){ this.add(0, e); } /** * 向鏈表 尾 添加元素 * @param e */ public void addLast(E e){ this.add(this.size, e); } /** * 獲取索引位置爲 index 處的元素 * @param index * @return */ public E get(int index){ if(index < 0 || index >= size){ throw new IllegalArgumentException("index 參數非法"); } return get(index, 0, dummyHead.next); } private E get(int index, int n, Node node){ if(index == n){ return node.e; } return get(index, (n + 1), node.next); } public E getFirst(){ return this.get(0); } public E getLast(){ return this.get(this.size - 1); } public boolean contains(E e){ return contains(e, dummyHead.next); } private boolean contains(E e, Node node){ if(node == null){ return false; } if(node.e.equals(e)){ return true; } return contains(e, node.next); } public E remove(int index){ if(index < 0 || index >= size){ throw new IllegalArgumentException("Index is illegal"); } return remove(dummyHead, index, 0); } private E remove(Node prev, int index, int n){ if(n == index){ Node cur = prev.next; prev.next = cur.next; cur.next = null; return cur.e; } return remove(prev.next, index, (n + 1)); } public E removeElement(E e){ return removeElement(e, dummyHead); } private E removeElement(E e, Node prev){ if(prev.next != null && e.equals(prev.next.e)){ Node cur = prev.next; prev.next = cur.next; cur.next = null; return cur.e; } return removeElement(e, prev.next); } @Override public String toString(){ StringBuilder res = new StringBuilder(); Node cur = dummyHead.next; while(cur != null){ res.append(cur + "->"); cur = cur.next; } res.append("NULL"); return res.toString(); } }
爲了中華民族的偉大復興,作一個愛國敬業的碼農。
參考文獻:
《玩轉數據結構-從入門到進階-劉宇波》
《數據結構與算法分析-Java語言描述》
若有錯誤還請留言指正。
原創不易,轉載請註明原文地址:http://www.javashuo.com/article/p-ygqqzequ-c.html