4-玩转数据结构-链表
本章我們介紹鏈表
前面我們已經介紹了動態數組,棧和隊列。
它們的底層依托靜態數組;靠resize解決固定容量問題
鏈表是我們接觸的第一個真正的動態數組。
為什么鏈表很重要
鏈表是重點,也是難點。它是最簡單動態數據結構;后續我們還會學習更多的,比如二分搜索樹,平衡二叉樹,紅黑樹,后面很多的動態數據結構都可以在理解鏈表的基礎上學習。
鏈表可以讓我們更深入的理解引用(C++中指針),內存管理等有更深理解。對于更深入的理解遞歸有好處,樹形中遞歸必須理解。
鏈表可以輔助組成其他數據結構。
鏈表Linked List
數據存儲在“節點”(Node)中;
class Node{E e;Node next; }車廂和車廂進行連接,使用next進行連接。
最后一個節點的next指向空,說明這個節點是最后一個節點了。優點:真正的動態,不需要處理固定容量的問題
不像數組一下子必須new出來一片空間,需要考慮空間不夠用或浪費。鏈表是你需要多少個數據,就生成多少個節點將他掛接起來,這就是所謂的動態的意思。
缺點: 喪失了隨機訪問的能力。不能像數組一樣,給定一個索引直接拿出對應元素。底層機制中數組開辟的空間在內存中是連續分布的,我們可以直接尋找索引對應的偏移,直接計算出數據所存儲的內存地址,直接用O(1)復雜度拿出。鏈表靠next連接,每個節點存儲地址不同,我們只能通過next順藤摸瓜找到我們要找的元素。
數組最好用于索引有語意的情況。scores[2] 2是學號,身份證號不能做索引;最大的優點:支持快速查詢。
我們在編寫動態數組,但是其實這類索引沒有語義的情況更適合鏈表。
鏈表不適合用于索引有語意的情況。最大的優點:動態
什么時候適合使用數組,什么時候適合使用鏈表。
鏈表實現
package cn.mtianyan;public class LinkedList<E> {// private設計,不被用戶感知private class Node{public E e;public Node next; // c++實現時是指針public Node(E e, Node next) {this.e = e;this.next = next;}public Node(E e) {this.e = e;this.next = null;}public Node() {this(null,null);}@Overridepublic String toString() {return "Node[" +"e=" + e +", next=" + next +']';}} }上面是我們對于鏈表節點的設計。注意private設計,以及Node的成員變量Node
應該有一個鏈表頭,聲明出LinkedList基本的成員變量。
private Node head;private int size;public LinkedList() {head = null;size = 0;}public LinkedList(Node head, int size) {this.head = head;this.size = size;}/*** 從數組創建鏈表的方法,待完善。** @param e*/public LinkedList(E[] e){}/*** 獲取鏈表中元素個數** @return*/public int getSize(){return size;}/*** 返回鏈表是否為空** @return*/public boolean isEmpty(){return size == 0;}上面是鏈表中應該有的成員變量和一些普通方法。
在鏈表頭添加元素是非常方便的,數組在數組尾部添加元素不用挪位會非常方便。數組中有size指向下一個空位置跟蹤隊尾,鏈表中有head來標識鏈表的頭部。
node.next = head head = node public void addFirst(E e){// Node node = new Node(e);// node.next = head;// head = node;// 上面三行代碼的等價實現head = new Node(e,head); // 值為e的Node的next是head;head = 這個Nodesize++;}上面有兩種等價的實現。
在索引為2的地方添加元素666,要找到之前的節點。關鍵:找到要添加的節點的前一個節點。前一個節點要特殊處理
順序是很重要的,不能顛倒。否則會丟失原本的prev.next。大多時候順序可以省下一個old的備份臨時變量。
/*** 在鏈表的index(0-based)位置添加新的元素e* 在鏈表中不是一個常用的操作,練習題用,面試用。* @param index* @param e*/public void add(int index,E e){// index可以取到size,在鏈表末尾空位置添加元素。if (index < 0 || index >size){throw new IllegalArgumentException("Add failed. Illegal index");}Node prevNode = head;// 因為有了dummyHead,多遍歷一次,遍歷index次for (int i = 0; i < index-1; i++) {// 驗證。 12 index 1添加,index-1=0一次也不執行,正好是head。符合// 驗證。 1234 index 2添加,index-1=1 運行一次pre指向head下一個也就是2,符合。prevNode = prevNode.next;}// Node insertNode = new Node(e);// insertNode.next = prevNode.next;// prevNode.next = insertNode;prevNode.next = new Node(e,prevNode.next); // 后半截是前兩句完成任務size++;}鏈表的添加操作時,要找的是前一個節點。而我們之前定義的頭結點因為沒有前一個節點,需要進行特殊處理,這樣不夠優雅。而如果我們往前面加一個虛擬的頭結點,則可以將我們現在的頭結點和其他節點統一起來。
private Node dummyHead;public LinkedList() {dummyHead = new Node(null,null);size = 0;}虛擬頭結點對用戶屏蔽不可見。
/*** 在鏈表的index(0-based)位置添加新的元素e* 在鏈表中不是一個常用的操作,練習題用,面試用。* @param index* @param e*/public void add(int index,E e){// index可以取到size,在鏈表末尾空位置添加元素。if (index < 0 || index >size){throw new IllegalArgumentException("Add failed. Illegal index");}Node prevNode = dummyHead;// 因為有了dummyHead,多遍歷一次,遍歷index次for (int i = 0; i < index; i++) {// 驗證。 12 index 1添加,index-1=0一次也不執行,正好是head。符合// 驗證。 1234 index 2添加,index-1=1 運行一次pre指向head下一個也就是2,符合。prevNode = prevNode.next;}// Node insertNode = new Node(e);// insertNode.next = prevNode.next;// prevNode.next = insertNode;prevNode.next = new Node(e,prevNode.next); // 后半截是前兩句完成任務size++;}/*** 在鏈表頭添加新元素e*/public void addFirst(E e){add(0,e);}/*** 在鏈表末尾添加新的元素e*/public void addLast(E e){add(size,e);}添加元素操作時,注意指向,以及循環次數的驗證。
/*** 獲得鏈表的第index(0-based)位置元素* 鏈表中不是常用操作,練習用* @param index* @return*/public E get(int index){// index不可以取到size,索引從0開始,最多取到size-1if (index < 0 || index >=size){throw new IllegalArgumentException("Add failed. Illegal index");}Node cur = dummyHead.next; // 從索引為0元素開始// 下面與找index-1個節點保持一致。上面執行了一次。所以從index-1個元素變成了找index個元素。for (int i = 0; i < index; i++) {cur = cur.next;}return cur.e;}public E getFirst(){return get(0);}public E getLast(){return get(size-1);}插入時我們要尋找的是index的前一個位置,而get時,我們要找的就是index的當前位置,因此要多找一次,在for循環不變情況下,從虛擬頭結點下一個節點開始遍歷。
/*** 修改鏈表的第index(0-based)個位置的元素為e* 在鏈表中不是一個常用的操作,練習用*/public void set(int index,E e){// index不可以取到size,索引從0開始,最多取到size-1if (index < 0 || index >=size){throw new IllegalArgumentException("Set failed. Illegal index");}Node cur = dummyHead.next; // 從索引為0元素開始// 下面與找index-1個節點保持一致。上面執行了一次。所以從index-1個元素變成了找index個元素。for (int i = 0; i < index; i++) {cur = cur.next;}cur.e = e;}/*** 查找鏈表中是否有元素e*/public boolean contains(E e){Node cur = dummyHead.next;while (cur != null){if (cur.e.equals(e)){return true;}cur = cur.next;}return false;} @Overridepublic String toString() {StringBuilder res = new StringBuilder(); // Node cur = dummyHead.next; // while (cur != null){ // res.append(cur.e +"->"); // cur = cur.next; // } // res.append("NULL");res.append("head: ");for (Node cur=dummyHead.next;cur !=null;cur=cur.next){res.append(cur.e +"->");}res.append("NULL");return res.toString();}兩種不同的遍歷方式是等價的。
package cn.mtianyan;public class Main {public static void main(String[] args) {LinkedList<Integer> linkedList = new LinkedList<>();for (int i = 0; i < 5; i++) {linkedList.addFirst(i);System.out.println(linkedList);}linkedList.add(2,888);System.out.println(linkedList);} }運行結果:
刪除元素
刪除索引為2位置的元素
要找到它之前的元素。
prev.next = delNode.next delNode.next = null- 鏈表元素刪除時常見的錯誤。
cur 指向cur.next的位置。本質是對于引用概念糊涂,Java中類的對象都是一個引用,理解成一個實際內存的指向。cur = cur.next從原來指的位置,指到下一個位置,但對于鏈表來說沒有發生任何改變。要想改變鏈表就應該改變節點的next指向。
/*** 刪除鏈表中指定index位置的元素* @param index* @return*/public E remove(int index){if (index < 0 || index >=size){throw new IllegalArgumentException("Set failed. Illegal index");}Node prev = dummyHead;for (int i = 0; i < index; i++) {prev = prev.next;}Node retNode = prev.next;prev.next = retNode.next;retNode.next = null;size--;return retNode.e;}public E removeFirst(){return remove(0);}public E removeLast(){return remove(size-1);} linkedList.remove(2);System.out.println(linkedList);linkedList.removeFirst();System.out.println(linkedList);linkedList.removeLast();System.out.println(linkedList);運行結果:
鏈表時間復雜度分析
- 添加操作:
O(n)是因為往鏈表尾部添加,要遍歷整個鏈表節點。O(n/2)可以看做操作中間的節點。
- 刪除操作:
- 修改操作:
- 查找操作:
get 和 contains 都是O(n/2) find操作是根據元素找index,鏈表中index沒啥用。
看起來,鏈表的增刪改查全都是O(n)級別的,比數組看起來差。鏈表沒有索引,無法像數組一樣快速訪問。
此時我們能利用的方法復雜度都是O(1)了;鏈表的改進,比數組節省空間。最基礎動態數據結構,對二叉樹平衡二叉樹的學習都能有輔助作用。
鏈表實現棧
只對鏈表頭進行操作,也就是只能對一端進行操作,很明顯是棧。隊列是要對兩端都進行操作的。鏈表頭作為棧頂。
Interface Stack<E> implement LinkedListStack<E>int getSize();boolean isEmpty();void push(E e);E pop();E peek();比較兩個棧的性能差異。
package cn.mtianyan;public class LinkedListStack<E> implements Stack<E> {private LinkedList<E> list;public LinkedListStack() {list = new LinkedList<>();}@Overridepublic int getSize() {return list.getSize();}@Overridepublic boolean isEmpty() {return list.isEmpty();}@Overridepublic void push(E e) {list.addFirst(e);}@Overridepublic E pop() {return list.removeFirst();}@Overridepublic E peek() {return list.getFirst();}@Overridepublic String toString() {StringBuilder res = new StringBuilder();res.append("LinkedList Stack :");res.append(list);return res.toString();} } public static void main(String[] args) {LinkedListStack stack = new LinkedListStack();for (int i = 0; i < 5; i++) {stack.push(i);System.out.println(stack);}stack.pop();System.out.println(stack);}運行結果:
package cn.mtianyan;import java.util.Random;public class mainTwoTest {// 測試使用stack運行opCount個push和pop操作所需要的時間,單位:秒private static double testStack(Stack<Integer> stack, int opCount){long startTime = System.nanoTime();Random random = new Random();for(int i = 0 ; i < opCount ; i ++)stack.push(random.nextInt(Integer.MAX_VALUE));for(int i = 0 ; i < opCount ; i ++)stack.pop();long endTime = System.nanoTime();return (endTime - startTime) / 1e9;}public static void main(String[] args) {int opCount = 100000000;ArrayStack<Integer> arrayStack = new ArrayStack<>();double time1 = testStack(arrayStack, opCount);System.out.println("ArrayStack, time: " + time1 + " s");LinkedListStack<Integer> linkedListStack = new LinkedListStack<>();double time2 = testStack(linkedListStack, opCount);System.out.println("LinkedListStack, time: " + time2 + " s");// 其實這個時間比較很復雜,因為LinkedListStack中包含更多的new操作} }其實這個時間是比較不確定誰大誰小的。
運行結果:
100000000 數據:
10000000 數據:
1000000 數據:
100000 數據:
基本可以看出,數據量小于100萬的時候LinkedList比較有優勢,數據量大時ArrayList更優。但它們實際是同樣級別時間復雜度的,最多相差幾倍。
鏈表實現隊列
隊列勢必會在鏈表的兩端同時操作,一端為O(1)一端為O(n);使用數組時我們也遇到了這個問題,因此我們產生了使用循環隊列的方式。
鏈表中我們為什么對于鏈表頭部的操作都簡單一些呢,因為我們有一個標識的head。那么想讓尾部也可以操作簡單,設置一個tail變量。從兩端插入元素都是很容易的。
tail端前一個節點不容易找,得遍歷一遍。此時: head添加刪除都容易,tail添加容易,刪除不易。
因此隊列從head端刪除元素,從tail端插入元素。head 隊首負責出隊,tail隊尾負責入隊。由于沒有dummyHead,要注意鏈表為空的情況
package cn.mtianyan;public class LinkedListQueue<E> implements Queue<E> {private class Node{public E e;public Node next;public Node(E e, Node next){this.e = e;this.next = next;}public Node(E e){this(e, null);}public Node(){this(null, null);}@Overridepublic String toString(){return e.toString();}}private Node head, tail;private int size;public LinkedListQueue(){head = null;tail = null;size = 0;}@Overridepublic int getSize(){return size;}@Overridepublic boolean isEmpty(){return size == 0;}@Overridepublic void enqueue(E e){// 如果隊尾為空,說明隊列是空的。因為tail一直指向最后一個非空節點。if(tail == null){tail = new Node(e);head = tail;}else{// 使用tail.next把新Node掛載上來。tail.next = new Node(e);// tail后挪tail = tail.next;}size ++;}@Overridepublic E dequeue(){if(isEmpty())throw new IllegalArgumentException("Cannot dequeue from an empty queue.");Node retNode = head;head = head.next; // head后移retNode.next = null; // 元素置空if(head == null) // 如果頭結點都沒得刪了tail = null;size --;return retNode.e;}@Overridepublic E getFront(){if(isEmpty())throw new IllegalArgumentException("Queue is empty.");return head.e;}@Overridepublic String toString(){StringBuilder res = new StringBuilder();res.append("Queue: front ");Node cur = head;while(cur != null) {res.append(cur + "->");cur = cur.next;}res.append("NULL tail");return res.toString();}public static void main(String[] args){LinkedListQueue<Integer> queue = new LinkedListQueue<>();for(int i = 0 ; i < 5 ; i ++){queue.enqueue(i);System.out.println(queue);if(i % 3 == 2){queue.dequeue();System.out.println(queue);}}} }運行結果:
測試性能差異:
package cn.mtianyan;import java.util.Random;public class MainThree {// 測試使用q運行opCount個enqueueu和dequeue操作所需要的時間,單位:秒private static double testQueue(Queue<Integer> q, int opCount){long startTime = System.nanoTime();Random random = new Random();for(int i = 0 ; i < opCount ; i ++)q.enqueue(random.nextInt(Integer.MAX_VALUE));for(int i = 0 ; i < opCount ; i ++)q.dequeue();long endTime = System.nanoTime();return (endTime - startTime) / 1000000000.0;}public static void main(String[] args) {int opCount = 100000;ArrayQueue<Integer> arrayQueue = new ArrayQueue<>();double time1 = testQueue(arrayQueue, opCount);System.out.println("ArrayQueue, time: " + time1 + " s");LoopQueue<Integer> loopQueue = new LoopQueue<>();double time2 = testQueue(loopQueue, opCount);System.out.println("LoopQueue, time: " + time2 + " s");LinkedListQueue<Integer> linkedListQueue = new LinkedListQueue<>();double time3 = testQueue(linkedListQueue, opCount);System.out.println("LinkedListQueue, time: " + time3 + " s");} }運行結果:
鏈表是一種適合用來學習遞歸的數據結構。下一章我們將對于鏈表和遞歸的相關知識進行學習。
《新程序員》:云原生和全面數字化實踐50位技術專家共同創作,文字、視頻、音頻交互閱讀總結
以上是生活随笔為你收集整理的4-玩转数据结构-链表的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 最佳实践系列丨Docker EE 服务发
- 下一篇: JAVA学习之路 (三) 运算符