1. 程式人生 > >純資料結構Java實現(3/11)(連結串列)

純資料結構Java實現(3/11)(連結串列)

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

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

下面正題開始。


一般性的,都能想到 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 = new Node(e, head);

        //維護連結串列長度
        size++;
    }

在某個位置插入元素:

  • 情況1: 連結串列中間的節點,先找到相應位置前一個節點,然後建立新節點,插入

  • 情況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 並沒有前面一個節點,所以插入的時候確實要特殊一些。(如果第一個節點之前有節點,那麼整個操作就統一了)

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

內部機制,使用者(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

刪除元素

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

簡單一句話,然 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) 複雜度

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

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

迴圈連結串列

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

迴圈連結串列一般都是基於雙向連結串列的。

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

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

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

陣列連結串列

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

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


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

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