純數據結構Java實現(3/11)(鏈表)

  • 2019 年 10 月 3 日
  • 筆記

題外話: 篇幅停了一下,特意去看看其他人寫的類似的內容;然後發現類似部落客喜歡畫圖,喜歡講解原理。

(於是我就在想了,理解數據結構的確需要畫圖,但我的文章寫給懂得人看,只配少量圖即可,省事兒)

下面正題開始


一般性的,都能想到 dummy head 的技巧以及Java中LinkedList(底層是雙向(循環)鏈表)。

Leetcode 返回一個頭結點對象,就算返回整個鏈表了,而我們自己實現一般會 new 一個鏈表對象實例,然後調用該實例的各類方法來操作整個鏈表。

單鏈表

基本認識

之前寫的動態數組並非真正動態,因為其內部封裝的是一個容量不可變的靜態數組。

而這裡的鏈表則是真正的動態數據結構(不需要處理固定容量問題,即增刪效率高,但由於不知道實際地址/索引,所以也喪失了隨機訪能力)。

輔助其他數據結構:二分搜索樹,AVL/紅黑樹,它們基於鏈表實現。

基本構成: 節點 + 指針。

class Node {      E e;      Node next;  }
  • 最後一個節點一般指向 null

為了方便或者統一操作,一般會有 Node head,頭結點。

  • 頭結點的存在一般是為了在頭部操作 (就像動態數組的新元素索引始終是 size 位置)
  • 一般直接用頭結點指向首個節點(第一個節點即 head,但它不存儲元素) dummy head

之所以用 dummy head 的原因,其實是為了操作簡便。(不用也可以,但實現上的寫法就…)

  • 打個比方,你要刪除/增加某個節點時,一般情況而言,一定要知道刪除節點的前一個節點(在頭部則沒有必要);一般都是通過循環遍歷往後先找到特定節點,但是如果沒有 dummy head,那麼就要區分是在頭結點還是中間節點操作(在腦海中想一下就知道了)。

有了 dummy head,頭結點前面也有節點了,所以整個操作行為是統一的,一致的,不需要再做情況區分。

(下面有案例)

實現框架

先把實現的框架列一下,大致如下:

package linkedlist;    public class LinkedList<E> {        //定義一個內部類,作為節點類      private class Node {          public E e;          public Node next; //便於 LinkedList 訪問            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 int size;      private Node head; //頭結點        //構造函數      public LinkedList() {          head = null;          size = 0;      }          public int getSize() {          return size;      }        public boolean isEmpty() {          return size == 0;      }      }

然後再來實現其中的增刪改查,此時先不設置虛擬頭節點。

添加操作

這裡實現的頭部添加 (後續再擴展其他添加):

    public void addFirst(E e) {          /*          Node node = new Node(e);          node.next = head;          head = node;          */            //簡寫          head.next = new Node(e, head);            //維護鏈表長度          size++;      }

某個位置插入元素:

  • 情況1: 鏈表中間的節點,先找到相應位置前一個節點,然後創建新節點,插入

11-14-16-155340513.png

11-58-14-155403496.png

  • 情況2: 如果是第一個節點,那麼是不存在前一個節點的。直接用 addFirst 的方式
      //指定的 index 位置添加元素 (先要找到 index 前一個位置)      // index 從 0 ~ size-1      public void add(int index, E e) {          // 索引有問題          if (index < 0 || index > size) { //當 index == size 時,表示在末尾添加              throw new IllegalArgumentException("Add Failed, Illegal index");          }          if (index == 0) {              addFirst(e);          } else {              Node prev = head;              //找到指定位置前一個節點              for (int i = 0; i < index - 1; i++) {                  prev = prev.next;              }              //創建一個新節點              /*Node node = new Node(e);              node.next = prev.next;              prev.next = node;*/                //簡寫              prev = new Node(e, prev.next);              size++;          }      }

(可以看到上面確實是區分不同的情況了的)

此時在末尾添加元素,即 index = size 的位置添加,直接調用 addLast 即可:

    //在末尾添加元素      public void addLast(E e){          add(size, e);      }

頭結點優化

不著急往後探索,這裡先把頭節點優化一下,即加入 dummy head,統一整個操作流程。

上面的操作 add ,由於鏈表頭結點 head 並沒有前面一個節點,所以插入的時候確實要特殊一些。(如果第一個節點之前有節點,那麼整個操作就統一了)

優化方法,在頭結點前面添加一個 虛擬節點,即不存儲任意元素的節點。

12-07-49-162019518.png

內部機制,用戶(client) 不知道虛擬節點的存在。(只是為了方便邏輯操作)。

相關修改:

構造函數需要修改,初始化 LinkedList 的時候就要創建一個節點

    public LinkedList1() {          dummyHead = new Node(null, null);          size = 0;      }

添加元素可以統一用 add,然後讓 addFirst 和 addLast 調用 add 方法即可。

    //指定的 index 位置添加元素 (先要找到 index 前一個位置)      // index 從 0 ~ size-1      public void add(int index, E e) {          // 索引有問題          if (index < 0 || index > size) { //當 index == size 時,表示在末尾添加              throw new IllegalArgumentException("Add Failed, Illegal index");          }            //因為在實際 index 取值範圍內,總能找到相關節點的前一個節點          Node prev = dummyHead;          //找 index 之前的節點          for(int i = 0; i < index; i++){              prev = prev.next;          }          prev = new Node(e, prev.next);          size++;      }        //頭部插入      public void addFirst(E e) {          add(0, e);      }        //在末尾添加元素      public void addLast(E e){          add(size, e);      }

虛擬頭結點的引入,方便了其他許多鏈表的操作(只要涉及類似的遍歷查找)

獲取操作

    //獲取某元素      public E get(int index) {          //先檢查索引的合法性          if(index<0 || index > size-1) {              throw new IllegalArgumentException("Get Failed, Illegal index");          }            // 和前面找 index 節點前一個節點不同(那裡是從第一個節點前面的虛擬節點開始)          // 這裡就要找 index 節點,索引從 dummyHead.next 開始,即真正的第一個節點開始          Node ret = dummyHead.next;          for(int i =0; i < index; i++) {              ret = ret.next;          }          return ret.e;      }

獲取第一個元素,最後一個:

    //獲取第一個      public E getFirst() {          return get(0);      }        //獲取最後一個      public E getLast() {          return get(size -1);      }

修改元素

把 index 位置的元素修改為 E。

(找到節點,然後替換裡面的元素 e)

    public void set(int index, E e) {          //先檢查索引的合法性          if (index < 0 || index > size - 1) {              throw new IllegalArgumentException("Get Failed, Illegal index");          }          //找到節點,然後替換裡面的元素          Node curr = dummyHead.next;          for (int i = 0; i < index; i++) {              curr = curr.next;          }          curr.e = e;      }  

查找元素

一直遍歷到元素末尾,然後尋找尾巴。

    //查找元素      public boolean contains(E e) {          Boolean ret = false;          //在 size 範圍內遍歷查找          Node curr = dummyHead.next;          /*for(int i=0; i<size; i++){              if(curr.e.equals(e)){                  ret = true;                  break;              }              curr = curr.next;          }*/            //其實可以用 while 循環 (多判斷一次 size 位置)          while(curr != null) {              //當前節點是有效節點              if(curr.e.equals(e)){                  ret = true;                  break;              }              curr = curr.next;          }          return ret;      }

遍歷列印

多種循環的寫法:

    //列印方法      @Override      public String toString() {          StringBuilder res = new StringBuilder();            //從頭遍歷到尾巴          /*Node curr = dummyHead.next;          while(curr != null) {              res.append(curr + "->");              curr = curr.next;          }*/          //簡寫          for(Node curr = dummyHead.next; curr != null; curr = curr.next) {              res.append(curr + "->");          }          res.append("null");          return res.toString();      }

簡單測試一下:

    //測試元素      public static void main(String[] args) {          LinkedList1<Integer> linkedlist = new LinkedList1<>();          //放入元素 0, 1, 2, 3, 4          for(int i =0; i < 5; i++) {              linkedlist.addFirst(i); //O(1)              System.out.println(linkedlist);          }            System.out.println(linkedlist);            //嘗試插入一個元素          linkedlist.add(1, 100); // 4, 100, 2, 3, 1, 0, null          System.out.println(linkedlist);        }

列印結果:

0->null  1->0->null  2->1->0->null  3->2->1->0->null  4->3->2->1->0->null  4->3->2->1->0->null  4->100->3->2->1->0->null

刪除元素

還是要 先找到前一個節點 。(也就是說還是藉助虛擬頭結點)

12-14-10-171749816.png

簡單一句話,然 delNode 和原來的鏈表脫離。(delNode 置空非必須)

編碼實現:

    //刪除元素      public E remove(int index){          if (index < 0 || index > size - 1) {              throw new IllegalArgumentException("Delete Failed, Illegal index");          }            //找到相關節點的前一個節點          Node curr = dummyHead;          for(int i = 0; i < index; i++) {              curr = curr.next;          }          Node delNode = curr.next;          //刪除          curr.next = delNode.next;          delNode.next = null;            //必須維護 size          size--;            return delNode.e;      }        //刪除第一個節點      public E removeFirst() {          return remove(0);      }        //刪除最後一個節點      public E removeLast() {          return remove(size-1);      }        //刪除指定元素      public void removeElem(E e) {          //從 dummyHead 開始找,找到就刪除,否則就不刪除          Node curr = dummyHead;          boolean found = false;          while (curr.next != null) {              if (curr.next.e.equals(e)) {                  found = true;                  //刪除操作                  Node delNode = curr.next;                  curr.next = delNode.next;                  delNode.next = null;                  size--;                  break;              }              curr = curr.next;          }            if (!found) {              throw new RuntimeException("要刪除的元素不存在");          }      }

測試一下:

    //測試元素      public static void main(String[] args) {          LinkedList1<Integer> linkedlist = new LinkedList1<>();          //放入元素 0, 1, 2, 3, 4          for(int i =0; i < 5; i++) {              linkedlist.addFirst(i); //O(1)              System.out.println(linkedlist);          }            System.out.println(linkedlist);            //嘗試插入一個元素          linkedlist.add(1, 100); // 4, 100, 2, 3, 1, 0, null          System.out.println(linkedlist);              //嘗試刪除 index = 1 位置的 100          linkedlist.remove(1);          System.out.println(linkedlist); //4->3->2->1->0->null            //刪除最後一個元素 0          linkedlist.removeLast();          System.out.println(linkedlist); //4->3->2->1->null            //刪除第一個元素          linkedlist.removeFirst();          System.out.println(linkedlist); //3->2->1->null            //刪除指定元素          linkedlist.removeElem(3);          linkedlist.removeElem(1);          //linkedlist.removeElem(null);          System.out.println(linkedlist);      }

時間複雜度

鏈表雖然不移動元素,但是涉及到從前往後找到(檢查)相應的位置/元素。

添加操作:

  • addFirst(), O(1) 因為採用的是頭插法
  • addLast(), O(n) 涉及循環遍歷到尾部,然後插入
  • add(), O(n) 其實是 O(n/2) 即 O(n)

刪除操作:

同上。

修改操作: O(n)。

查找操作:

get(), contains(), find() 一律 O(n),因為並不支援隨機訪問呀。

單鏈表應用

鏈棧

上面也說了,如果只在鏈表頭增刪時,它的整體複雜度是 O(1),這不正好用於棧么?

  • 簡單記憶一下,同側操作
  • 棧的底層實現是鏈表,而不是動態數組了
package stack;    import linkedlist.LinkedList1;  //這是有 dummy head優化的鏈表實現    public class LinkedListStack<E> implements Stack<E>{        //鏈棧內部實際採用鏈表存儲      private LinkedList1<E> list;          public LinkedListStack(){          list = new LinkedList1<>();      }        @Override      public boolean isEmpty() {          return list.isEmpty();      }        @Override      public int getSize() {          return list.getSize();      }        @Override      public E pop() {          return list.removeFirst();      }        @Override      public E peek() {          return list.getFirst();      }        @Override      public void push(E e) {          list.addFirst(e);      }        @Override      public String toString() {          StringBuilder res = new StringBuilder();          res.append("Stack: top [");          res.append(list);          res.append("]");          return res.toString();      }        public static void main(String[] args) {          LinkedListStack<Integer> stack = new LinkedListStack<>();          //放入元素 0, 1, 2, 3, 4          for(int i =0; i < 5; i++) {              stack.push(i); //O(1)              System.out.println(stack);          }            System.out.println(stack);          System.out.println(stack.peek());            //彈出一個元素          stack.pop();          System.out.println(stack);      }  }

測試結果:

Stack: top [0->null]  Stack: top [1->0->null]  Stack: top [2->1->0->null]  Stack: top [3->2->1->0->null]  Stack: top [4->3->2->1->0->null]  Stack: top [4->3->2->1->0->null]  4  Stack: top [3->2->1->0->null]

和數組實現的棧的不同,數組是在尾巴上插入,可能涉及動態擴容,均攤複雜度是 O(1),而鏈棧始終就是O(1)。

  • 但是 linkedlist 的 new 操作時非常耗時的 (特別是大量對象創建)
  • 真實運行結果是不確定的 (ArrayStack VS LinkedListStack),因為數量級一致

鏈隊列

因為隊列涉及頭和尾的操作,所以如果用鏈表,那一般要添加一個尾指針。

因為 head 和 tail 都是指針,所以入隊和出隊相當於改變指向那麼簡單,但誰做頭誰做尾巴?(相當於 head, tail 指針往哪個方向移動)

如果要刪除 tail 元素並不容易(無法做到O(1)),因為刪除元素要知道 tail 前面一個元素。但是 tail 增加,則可以直接添加。(head不用管, 它的增刪都比較容易)

所以結論顯而易見:

  • tail 用作隊尾 (即用於增加元素, tail 指針右移)
  • head 用作隊首 (刪除元素,出隊)

此時還需要 dummy head 么,分析上面的 tail, head,顯然不需要操作統一了,所以不需要啞結點。

這裡就不復用 LinkedList 了,而是專門再在內部實現鏈式存儲。(Node 內部類還是需要的)

特別注意:

  • 鏈表為空的情況
  • 只有一個元素的情況,此時即便是出隊,也要 head = tail = null;
//內部採用鏈式存儲的隊列  public class LinkedQueue<E> implements Queue<E> {        //定義一個內部類,作為節點類      private class Node {          public E e;          public Node next; //便於 LinkedList 訪問            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 head, tail;      private int size;        //構造器      public LinkedQueue() {          head = tail = null;          size = 0;      }        @Override      public boolean isEmpty() {          return size == 0;      }        @Override      public int getSize() {          return size;      }        @Override      public E dequeue() {          //出隊操作,在隊首          //沒有元素肯定就不能出隊          if (isEmpty()) {              //或者 head = null              throw new IllegalArgumentException("Cannot dequeue from an empty queue");          }          //正常出隊,提取 head          Node retNode = head; //tail,考慮只有一個元素的隊列          head = retNode.next;          retNode.next = null;//遊離對象            //僅在只有一個元素的隊列,需要維護 tail          if (head == null) {              tail = null;          }            size--;          return retNode.e;      }        @Override      public E getFront() {          if (isEmpty()) {              //或者 head = null              throw new IllegalArgumentException("Cannot dequeue from an empty queue");          }          return head.e; // 返回隊首即可      }        @Override      public void enqueue(E e) {          //入隊操作,在尾部操作          if (tail == null) { //說明此時隊列是空的,即 tail 和 head 都為空              tail = new Node(e);              head = tail;          } else {              tail.next = new Node(e);              tail = tail.next;          }          size++;      }        @Override      public String toString() {          StringBuilder res = new StringBuilder();          res.append("Queue: front[ ");          for(Node curr = head; curr != null; curr = curr.next){              res.append(curr.e + "->");          }          res.append("null ] tail");          return res.toString();      }        public static void main(String[] args) {          LinkedQueue<Integer> queue = new LinkedQueue<>();            //存儲  11 個元素看看          for(int i=0; i<11; i++){              queue.enqueue(i);              System.out.println(queue); // 在 10 個元素滿的時候回擴容          }          //出隊試試          System.out.println("------出隊");          queue.dequeue();          System.out.println(queue);      }  }

運行結果如下:

Queue: front[ 0->null ] tail  Queue: front[ 0->1->null ] tail  Queue: front[ 0->1->2->null ] tail  Queue: front[ 0->1->2->3->null ] tail  Queue: front[ 0->1->2->3->4->null ] tail  Queue: front[ 0->1->2->3->4->5->null ] tail  Queue: front[ 0->1->2->3->4->5->6->null ] tail  Queue: front[ 0->1->2->3->4->5->6->7->null ] tail  Queue: front[ 0->1->2->3->4->5->6->7->8->null ] tail  Queue: front[ 0->1->2->3->4->5->6->7->8->9->null ] tail  Queue: front[ 0->1->2->3->4->5->6->7->8->9->10->null ] tail  ------出隊  Queue: front[ 1->2->3->4->5->6->7->8->9->10->null ] tail

到這裡,單鏈表基本探究完畢了。

其他鏈表

下面說的這些鏈表其實也很常用,但是個人要去實現的話,就費事兒啊

(除非你是大學教師,或者學生,或者自由作家,有的是時間耐得住寂寞,磨啊)

雙向鏈表

這個維護代價其實有點大,有點就是節點之間的聯繫更加方便了。(單鏈表時也會維護尾指針)

  • 比如尾端刪除,不用從頭開始找尾端前一個元素了,避免了 O(n) 複雜度

12-47-47-212128032.png

沒有對比就沒有傷害,要找我前一個節點是吧,直接給你(不要循環了)。其他操作則沒有太多變化(需要頭結點優化)。由於有額外的變數需要維護,所以並不見得簡單。

class Node {      E e;      Node prev, next;  }

循環鏈表

jdk 中 linkedlist 貌似經過一陣子去環優化,可能,因為不要環效率也不差。

循環鏈表一般都是基於雙向鏈表的

不用畫圖了,直接認為尾部元素直接指向 dummy head 即可。

此時不需要 tail,因為在 dummyHead 的前面添加一個元素,就相當於在結尾添加元素了。

(引入的環會導致操作有些許變化,比如遍歷)

數組鏈表

  • 數組中除了存儲值,還存儲了下一個節點的索引,那麼就相當於數組鏈表了。
  • 不依賴數組本身的 index,而依賴於自身存儲的數字索引。

12-55-51-212826173.png

有點兒類似於資料庫存儲設計中的無限級欄位,即某個元素要存儲其父元素位置(parentId)。


畢竟還是基礎數據結構,沒有太複雜;這種 link 的思想用於樹(二叉樹,多叉樹)很平常。

老規矩,程式碼參考的話,我放在了 gayhub, FYI。