詳細分析連結串列的資料結構的實現過程(Java 實現)

踏雪彡尋梅發表於2020-08-31

連結串列的資料結構的實現過程(Java 實現)

前言

在前面實現的三種線性資料結構:動態陣列棧和佇列 雖然對使用者而言實現了動態的功能,但在底層上還是依託著靜態陣列,使用 resize 方法解決固定容量的問題,從根本上來說還不是真正的動態。

而對於連結串列而言,則是真正的動態資料結構

因為連結串列的實現是將一個個節點靠地址的指向將這些節點掛接起來而組成的。

簡單來說,每一次在連結串列上新增新資料就是在一個已有節點的指標域上指定它的下一個節點的地址為存放新資料的節點的地址。這樣子,不論是從底層上還是使用者的角度上,都不用擔心容量的問題,所以連結串列是真正的動態資料結構。

同樣,連結串列也是一個很重要的資料結構。對於連結串列而言,它是最簡單的動態資料結構,可以幫助我們更深入地理解引用(指標)、更深入地理解遞迴以及可以用來輔助組成其他的資料結構。

基本概念

連結串列的基本結構

對連結串列而言,資料是儲存在“節點”(Node)中的,可以使用一個資料域來儲存資料,這裡我稱為 element;然後節點中還有一個用來指向下一個節點位置的節點域,一般稱為 next。而對於連結串列的結尾,一般是以 NULL 作為結尾,所以連結串列中的最後一個節點的節點域 next 指向的是 NULL。

圖示如下:

連結串列的基本結構-1

所以可以先暫時設計連結串列的基本結構程式碼如下:

/**
 * 連結串列類
 * 支援泛型
 *
 * @author 踏雪彡尋梅
 * @date 2020-02-03 - 21:08
 */
public class LinkedList<E> {
    /**
     * 連結串列的節點
     * 對於使用者而言,不需要知道連結串列的底層結構是怎樣的,只需要知道連結串列是一種線性資料結構,可以增刪改查資料
     */
    private class Node {
        /**
         * 節點儲存的資料
         */
        public E element;

        /**
         * 用於指向下一個節點,使節點與節點之間掛接起來組成連結串列
         */
        public Node next;

        /**
         * 建構函式
         * 構造一個存有資料並指向了下一個節點的節點
         *
         * @param element 存往該節點中的資料
         * @param next 該節點的下一個節點
         */
        public Node(E element, Node next) {
            this.element = element;
            this.next = next;
        }

        /**
         * 建構函式
         * 構造一個存有資料但沒有指向下一個節點的節點
         *
         * @param element 存往該節點中的資料
         */
        public Node(E element) {
            this(element, null);
        }

        /**
         * 建構函式
         * 構造一個空節點
         */
        public Node() {
            this(null, null);
        }

        /**
         * 重寫 toString 方法以顯示節點中儲存的資料資訊
         *
         * @return 返回節點中儲存的資料資訊
         */
        @Override
        public String toString() {
            return element.toString();
        }
    }
}

從以上設計也可簡單的分析連結串列的優缺點如下:

  • 優點:真正的動態結構,不需要處理固定容量的問題。

  • 缺點:喪失了隨機訪問的能力。即不像陣列一樣可以通過索引快速地獲取到資料。

綜上,可簡單對比陣列和連結串列的使用場景如下:

  • 陣列最好用於索引有語意的情況,不適合用於索引沒有語意的情況。

    • 有語意的情況:如一個班級中第二名的分數可這樣表示:score[2]。

    • 陣列也可以沒有語意,並不是任何時候索引都是有語意的,不是所有有語意的這樣的一個標誌就適合做索引,如身份證號:身份證號的儲存會存在空間的浪費(有些索引不是身份證號碼)。

      • 如:身份證號為 41222197801015333 的某個人表示為:person[41222197801015333]
    • 最大的優點:支援快速查詢。

  • 相比陣列,將一個靜態陣列改變為一個動態陣列,就是在對於不方便使用索引的時候處理有關資料儲存的問題,對於這樣的儲存資料的需求使用連結串列是更合適的。所以連結串列不適合用於索引有語意的情況,更適合處理索引沒有語意的情況。

    • 因為連結串列最大的優點是動態儲存。

另外,對於檢視連結串列中的各個元素,也是需要一一遍歷過去的,那麼此時就需要一個變數 head 來指向連結串列頭部的位置,以便檢視連結串列資訊所用。同時因為有了這個變數 head 來指向連結串列頭的位置,那麼往連結串列頭部新增新元素是十分方便的,這和之前實現的陣列資料結構在陣列尾部新增元素十分方便是同一個道理,陣列中有 size 變數指向下一個新元素位置跟蹤尾部。

此時連結串列的結構如下圖所示:

連結串列的基本結構-2

此時設計連結串列基本結構程式碼如下,其中使用了一個變數 size 來實時記錄連結串列元素的個數以及增加了兩個基本方法用於獲取連結串列當前元素個數和判斷連結串列是否為空:

/**
 * 連結串列類
 * 支援泛型
 *
 * @author 踏雪彡尋梅
 * @date 2020-02-03 - 21:08
 */
public class LinkedList<E> {
    /**
     * 連結串列的節點
     * 對於使用者而言,不需要知道連結串列的底層結構是怎樣的,只需要知道連結串列是一種線性資料結構,可以增刪改查資料
     */
    private class Node {
        /**
         * 節點儲存的資料
         */
        public E element;

        /**
         * 用於指向下一個節點,使節點與節點之間掛接起來組成連結串列
         */
        public Node next;

        /**
         * 建構函式
         * 構造一個存有資料並指向了下一個節點的節點
         *
         * @param element 存往該節點中的資料
         * @param next 該節點的下一個節點
         */
        public Node(E element, Node next) {
            this.element = element;
            this.next = next;
        }

        /**
         * 建構函式
         * 構造一個存有資料但沒有指向下一個節點的節點
         *
         * @param element 存往該節點中的資料
         */
        public Node(E element) {
            this(element, null);
        }

        /**
         * 建構函式
         * 構造一個空節點
         */
        public Node() {
            this(null, null);
        }

        /**
         * 重寫 toString 方法以顯示節點中儲存的資料資訊
         *
         * @return 返回節點中儲存的資料資訊
         */
        @Override
        public String toString() {
            return element.toString();
        }
    }

    /**
     * 連結串列的頭節點
     * 儲存第一個元素的節點
     */
    private Node head;

    /**
     * 連結串列當前元素個數
     */
    private int size;

    /**
     * 建構函式
     * 構造一個空連結串列
     */
    public LinkedList() {
        head = null;
        size = 0;
    }

    /**
     * 獲取連結串列中的當前元素個數
     *
     * @return 返回連結串列當前元素個數
     */
    public int getSize() {
        return size;
    }

    /**
     * 判斷連結串列是否為空
     *
     * @return 連結串列為空返回 true;否則返回 fasle
     */
    public boolean isEmpty() {
        return size == 0;
    }
}

連結串列的基本操作的實現

在連結串列中新增元素

在連結串列頭新增元素

在上文介紹過,在連結串列頭部新增元素是十分方便的,所以先實現這個操作。

對於這個操作,實現的具體步驟如下:

  1. 建立一個新節點 newNode 儲存新元素 newElement,新節點的節點域 next 指向 NULL。

  2. 將 newNode 的 節點域 next 指向當前連結串列頭 head,使新節點掛接在連結串列頭部。即 newNode.next = head。

  3. 最後將 head 指向 newNode,使連結串列頭為新增的節點。即 head = newNode。

綜上,在連結串列頭新增過程如下圖所示:

在連結串列頭新增新元素

設計在連結串列頭部新增元素程式碼如下所示:

/**
 * 在連結串列頭新增新的元素 newElement
 *
 * @param newElement 新元素
 */
public void addFirst(E newElement) {
    // 建立一個新節點儲存新元素,該節點的 next 指向 NULL
    Node newNode = new Node(newElement);
    // 使 newNode 的 next 指向連結串列頭
    newNode.next = head;
    // 將連結串列頭設為連結串列新新增的新節點
    head = newNode;
    // 以上三行程式碼可使用 Node 的另一個建構函式簡寫為:
    // head = new Node(newElement, head);
    
    // 維護 size,連結串列當前元素個數 + 1
    size++;
}

在連結串列指定位置處新增元素

除了在連結串列頭部新增元素,還可以指定一個位置來進行新增元素。這個操作在連結串列的操作中不常使用,一般常出現在試題中,這裡實現出來用來幫助深入理解連結串列的思維。

對於這個操作,指定的新增元素位置這裡設計為用 index 表示(從 0 開始計數),實現的具體步驟如下:

  1. 判斷指定的新增位置 index 是否為合法值。

  2. 使用一個節點變數 prev 來找到指定插入位置 index 的前一個節點位置,初始時 prev 指向連結串列頭 head。

  3. 建立一個新節點 newNode 儲存新元素 newElement,新節點的節點域 next 指向 NULL。

  4. 使用 prev 找到指定位置 index 的前一個位置(index - 1 處,即插入位置的前一個節點)後,將 newNode 的 next 指向 prev 的 next 指向的節點,即將新節點掛接在插入位置的原節點前面。(newNode.next = prev.next)

  5. 將 prev 的 next 指向新節點 newNode,即將連結串列前後都掛接了起來。此時新節點處於 index 處,而原來處於 index 的節點和之後的節點都往後挪了一個位置。(prev.next = newNode)

對於以上步驟,關鍵在於找到要新增的節點的前一個節點。

而找到前一個節點這個操作有一個特殊情況,即指定新增位置 index 為 0 的時候,也就是將元素新增到連結串列頭,而連結串列頭是沒有前一個節點的(對於連結串列頭沒有前一個節點後續會實現一個虛擬頭節點放置到連結串列頭的前一個節點,方便連結串列的操作)。

所以這個操作需要進行特殊處理

使用一個判斷判斷 index 是否為 0,如果為 0 使用前面實現的 addFirst(E newElement) 方法將新節點新增到連結串列頭。

綜上,在連結串列指定位置處新增元素過程如下圖所示:

在連結串列指定位置新增新元素

需要注意的是 newNode.next = prev.next 和 prev.next = newNode 的順序不能相反,否則將會出現錯誤,具體結果圖示如下:

在連結串列指定位置新增新元素-錯誤示例

設計在連結串列指定位置處新增元素程式碼如下所示:

/**
 * 在連結串列的指定位置 index(從 0 開始計數)處新增新元素 newElement
 *
 * @param index 指定的新增位置,從 0 開始計數
 * @param newElement 新元素
 */
public void add(int index, E newElement) {
    // 判斷 index 是否合法
    if (index < 0 || index > size) {
        throw new IllegalArgumentException("Add failed.Illegal index.");
    }

    if (index == 0) {
        // 如果 index 為 0,使用 addFirst(E newElement) 方法將新元素新增到連結串列頭。
        addFirst(newElement);
    } else {
        // 否則將新元素新增到 index 處
        // 找到 index 的前一個節點
        Node prev = head;
        for (int i = 0; i < index - 1; i++) {
            prev = prev.next;
        }

        // 建立一個新節點儲存新元素,該節點的 next 指向 NULL
        Node newNode = new Node(newElement);
        // 將新節點新增到 index 處
        newNode.next = prev.next;
        prev.next = newNode;
        // 以上三行程式碼可使用 Node 的另一個建構函式簡寫為:
        // prev.next = new Node(newElement, prev.next);

        // 維護 size,連結串列當前元素個數 + 1
        size++;
    }
}

由以上實現可複用其實現一個在連結串列末尾新增新元素的方法 addLast:

/**
 * 在連結串列末尾新增新的元素 newElement
 * @param newElement 新元素
 */
public void addLast(E newElement) {
    add(size, newElement);
}

連結串列的虛擬頭節點

在上面實現的在連結串列指定位置處新增元素中,可以發現有一個特殊情況為指定在頭部新增元素時,頭部元素沒有前一個節點,所以需要做一個特殊處理。為了讓這個操作統一為每個節點都可以找到前置節點,需要在連結串列中設定一個虛擬頭節點 dummyHead這個節點這裡設計為不儲存資料,只用於指向連結串列中的第一個元素。

新增虛擬頭節點後的連結串列基本結構圖示如下:

連結串列的基本結構-3

此時更改連結串列的實現程式碼如下:

/**
 * 連結串列類
 * 支援泛型
 *
 * @author 踏雪彡尋梅
 * @date 2020-02-03 - 21:08
 */
public class LinkedList<E> {
    /**
     * 連結串列的節點
     * 對於使用者而言,不需要知道連結串列的底層結構是怎樣的,只需要知道連結串列是一種線性資料結構,可以增刪改查資料
     */
    private class Node {
        /**
         * 節點儲存的資料
         */
        public E element;

        /**
         * 用於指向下一個節點,使節點與節點之間掛接起來組成連結串列
         */
        public Node next;

        /**
         * 建構函式
         * 構造一個存有資料並指向了下一個節點的節點
         *
         * @param element 存往該節點中的資料
         * @param next 該節點的下一個節點
         */
        public Node(E element, Node next) {
            this.element = element;
            this.next = next;
        }

        /**
         * 建構函式
         * 構造一個存有資料但沒有指向下一個節點的節點
         *
         * @param element 存往該節點中的資料
         */
        public Node(E element) {
            this(element, null);
        }

        /**
         * 建構函式
         * 構造一個空節點
         */
        public Node() {
            this(null, null);
        }

        /**
         * 重寫 toString 方法以顯示節點中儲存的資料資訊
         *
         * @return 返回節點中儲存的資料資訊
         */
        @Override
        public String toString() {
            return element.toString();
        }
    }
    
    /**
     * 連結串列的虛擬頭節點
     * 不儲存資料
     * next 指向連結串列中的第一個元素
     */
    private Node dummyHead;
    
    /**
     * 連結串列當前元素個數
     */
    private int size;

    /**
     * 建構函式
     * 構造一個空連結串列
     */
    public LinkedList() {
        // 建立虛擬頭節點,儲存 null,初始時 next 指向 null
        dummyHead = new Node(null, null);
        size = 0;
    }

    /**
     * 獲取連結串列中的當前元素個數
     *
     * @return 返回連結串列當前元素個數
     */
    public int getSize() {
        return size;
    }

    /**
     * 判斷連結串列是否為空
     *
     * @return 連結串列為空返回 true;否則返回 fasle
     */
    public boolean isEmpty() {
        return size == 0;
    }

    /**
     * 在連結串列的指定位置 index(從 0 開始計數)處新增新元素 newElement
     *
     * @param index 指定的新增位置,從 0 開始計數
     * @param newElement 新元素
     */
    public void add(int index, E newElement) {
        // 判斷 index 是否合法
        if (index < 0 || index > size) {
            throw new IllegalArgumentException("Add failed.Illegal index.");
        }
        
        // 將新元素新增到 index 處
        // 找到 index 的前一個節點
        Node prev = dummyHead;
        for (int i = 0; i < index; i++) {
            prev = prev.next;
        }

        // 建立一個新節點儲存新元素,該節點的 next 指向 NULL
        Node newNode = new Node(newElement);
        // 將新節點新增到 index 處
        newNode.next = prev.next;
        prev.next = newNode;
        // 以上三行程式碼可使用 Node 的另一個建構函式簡寫為:
        // prev.next = new Node(newElement, prev.next);

        // 維護 size,連結串列當前元素個數 + 1
        size++;
    }

    /**
     * 在連結串列頭新增新的元素 newElement
     *
     * @param newElement 新元素
     */
    public void addFirst(E newElement) {
        add(0, newElement);
    }

    /**
     * 在連結串列末尾新增新的元素 newElement
     *
     * @param newElement 新元素
     */
    public void addLast(E newElement) {
        add(size, newElement);
    }
}

此時,在 add 方法的實現中新增新元素的操作就都統一為同一個步驟了,每一個節點都能找到其前置節點。

需要注意的是,和之前 prev 從連結串列的第一個元素開始遍歷尋找更改為了從虛擬頭節點開始遍歷尋找,所以遍歷的終止條件從 i < index - 1 變為了 i < index。為了方便理解這個過程,可以參考以下圖示:

  • 原實現:

    在連結串列指定位置新增新元素原實現

  • 現實現:

    在連結串列指定位置新增新元素現實現

在更改了 add 方法之後,addFirst 方法也可精簡為複用 add 方法就可實現在連結串列頭部新增元素的功能了。這也是使用了虛擬頭節點之後帶來的便利。

連結串列的查詢和修改操作

查詢操作的實現

對於此操作,這裡實現兩個型別的方法用於查詢連結串列中的元素:

  1. get 方法:獲得連結串列中某個位置的元素(位置從 0 開始計數)。該操作在連結串列中不常使用,可以用來加強連結串列的理解。具體實現如下:

    /**
    * 獲得連結串列的第 index 個位置的元素
    * 
    * @param index 需要獲取的元素的位置,從 0 開始計數
    * @return 返回連結串列中的 index 處的元素
    */
    public E get(int index) {
        // 判斷 index 的合法性
        if (index < 0 || index >= size) {
            throw new IllegalArgumentException("Get failed.Illegal index.");
        }
    
        // 從連結串列中第一個元素開始遍歷,找到處於 index 的節點
        Node currentElement = dummyHead.next;
        for (int i = 0; i < index; i++) {
            currentElement = currentElement.next;
        }
        // 返回處於 index 的元素
        return currentElement.element;
    }
    
    • 由以上實現可衍生出兩個方法分別用來獲取連結串列中第一個元素和連結串列中最後一個元素:

      /**
      * 獲得連結串列的第一個元素
      * 
      * @return 返回連結串列的第一個元素
      */
      public E getFirst() {
          return get(0);
      }
      
      /**
      * 獲得連結串列的最後一個元素
      * 
      * @return 返回連結串列的最後一個元素
      */
      public E getLast() {
          // index 從 0 開始計數,size 為當前元素個數,所以最後一個元素的位置對應為 size - 1
          return get(size - 1);
      }
      
  2. contains 方法:判斷使用者給定的一個元素是否存在於連結串列中,存在返回 true,不存在返回 false。具體實現如下:

    /**
    * 查詢連結串列中是否含有元素 element
    * 
    * @param element 需要查詢的元素
    * @return 如果包含 element 返回 true;否則返回 false
    */
    public boolean contains(E element) {
        // 從連結串列中第一個元素開始遍歷,依次判斷是否包含有元素 element
        Node currentElement = dummyHead.next;
        while (currentElement != null) {
            if (currentElement.element.equals(element)) {
                // 相等說明連結串列中包含元素 element,返回 true
                return true;
            }
            currentElement = currentElement.next;
        }
        // 整個連結串列遍歷完還沒有找到則返回 false
        return false;
    }
    

修改操作的實現

對於此操作,實現的目的是修改連結串列中某個位置(位置從 0 開始計數)的元素為指定的新元素。該操作在連結串列中也不常使用,可以用來加強連結串列的理解。具體實現如下:

/**
 * 修改連結串列的第 index 個位置的元素為 newElement
 *
 * @param index 需要修改的元素的位置,從 0 開始計數
 * @param newElement 替換老元素的新元素
 */
public void set(int index, E newElement) {
    // 判斷 index 的合法性
    if (index < 0 || index >= size) {
        throw new IllegalArgumentException("Set failed.Illegal index.");
    }

    // 從連結串列中第一個元素開始遍歷,找到處於 index 的節點
    Node currentElement = dummyHead.next;
    for (int i = 0; i < index; i++) {
        currentElement = currentElement.next;
    }
    // 修改 index 的元素為 newElement
    currentElement.element = newElement;
}

連結串列的刪除操作

對於刪除操作,目的為刪除連結串列中某個位置的元素並返回刪除的元素。該操作在連結串列中也不常使用,可以用來加強連結串列的理解。實現的步驟如下:

  1. 找到待刪除節點 delNode 的前置節點 prev。

  2. 將 prev 的 next 指向待刪除節點 dalNode 的 next。即越過了 delNode,和它後面的節點掛接了起來。(prev.next = delNode.next)

  3. 將 delNode 的 next 指向 null,至此,delNode 和連結串列脫離關係,從連結串列中被刪除。(delNode.next = null)

  4. 返回 delNode 中儲存的元素 element,即返回刪除的元素。

刪除過程圖示如下:

在連結串列指定位置刪除元素

程式碼具體實現如下:

/**
 * 從連結串列中刪除 index 位置的節點並返回刪除的元素
 *
 * @param index 要刪除節點在連結串列中的位置,從 0 開始計數
 * @return 返回刪除的元素
 */
public E remove(int index) {
    // 判斷 index 的合法性
    if (index < 0 || index >= size) {
        throw new IllegalArgumentException("Remove failed.Illegal index.");
    }

    // 從虛擬頭節點開始遍歷找到待刪除節點的前置節點
    Node prev = dummyHead;
    for (int i = 0; i < index; i++) {
        prev = prev.next;
    }

    // 記錄待刪除節點
    Node delNode = prev.next;

    // 進行刪除操作
    prev.next = delNode.next;
    delNode.next = null;
    // 維護 size,連結串列當前元素個數 - 1
    size--;

    // 返回刪除的元素
    return delNode.element;
}

由以上實現可衍生出兩個方法分別用於刪除連結串列中的第一個元素和最後一個元素:

/**
 * 從連結串列中刪除第一個元素所在的節點並返回刪除的元素
 *
 * @return 返回刪除的元素
 */
public E removeFirst() {
    return remove(0);
}

/**
 * 從連結串列中刪除最後一個元素所在的節點並返回刪除的元素
 *
 * @return 返回刪除的元素
 */
public E removeLast() {
    return remove(size - 1);
}

重寫 toString 方法顯示連結串列中元素資訊

實現到此,已經可以重寫 toString 方法顯示連結串列中元素資訊來測試以上實現的基本操作了,以此驗證設計的邏輯沒有出錯。

對於此方法,這裡設計為下:

/**
 * 重寫 toString 方法,以便觀察連結串列中的元素
 * 
 * @return 返回當前連結串列資訊
 */
@Override
public String toString() {
    StringBuilder result = new StringBuilder();
    result.append(String.format("LinkedList: size = %d, Elements: dummyHead -> ", size));
    // 從連結串列中第一個元素開始遍歷,依次將連結串列中元素資訊新增到結果資訊中
    Node currentElement = dummyHead.next;
    while (currentElement != null) {
        result.append(currentElement + " -> ");
        currentElement = currentElement.next;
    }
    // 以上遍歷的等價寫法:
    // for (Node currentElement = dummyHead.next; currentElement != null; currentElement = currentElement.next) {
    //     result.append(currentElement + " -> ");
    // }
    result.append("NULL");
    return result.toString();
}

接著測試以上實現的基本操作,測試程式碼如下:

/**
 * 測試 LinkedList
 */
public static void main(String[] args) {
    LinkedList<Integer> linkedList = new LinkedList<>();

    // 測試 isEmpty 方法
    System.out.println("==== 測試 isEmpty 方法 ====");
    System.out.println("當前連結串列是否為空: " + linkedList.isEmpty());

    // 測試連結串列的新增操作
    System.out.println("\n==== 測試 addFirst 方法 ====");
    for (int i = 0; i < 5; i++) { 
        linkedList.addFirst(i);
        System.out.println(linkedList); 
    }

    System.out.println("\n==== 測試 add 方法 ====");
    System.out.println("新增 888 到連結串列中的第 2 個位置(從 0 開始計數): ");
    linkedList.add(2, 888);
    System.out.println(linkedList);

    System.out.println("\n==== 測試 addLast 方法 ====");
    linkedList.addLast(999);
    System.out.println(linkedList);

    // 測試 contains 方法
    System.out.println("\n==== 測試 contains 方法 ====");
    System.out.println(linkedList);
    boolean flag = linkedList.contains(888);
    System.out.println("連結串列中是否存在 888: " + flag);
    flag = linkedList.contains(777);
    System.out.println("連結串列中是否存在 777: " + flag);

    // 測試 get 方法
    System.out.println("\n==== 測試 get 方法 ====");
    System.out.println(linkedList);
    Integer element = linkedList.getFirst();
    System.out.println("連結串列中的第一個元素為: " + element);
    element = linkedList.getLast();
    System.out.println("連結串列中的最後一個元素為: " + element);
    element = linkedList.get(3);
    System.out.println("連結串列中的第 3 個位置(從 0 開始計數)的元素為: " + element);

    // 測試 isEmpty 方法
    System.out.println("\n==== 測試 isEmpty 方法 ====");
    System.out.println("當前連結串列是否為空: " + linkedList.isEmpty());

    // 測試 set 方法
    System.out.println("\n==== 測試 set 方法 ====");
    System.out.println(linkedList);
    linkedList.set(3, 12);
    System.out.println("更改連結串列中的第 3 個位置(從 0 開始計數)的元素為 12 後: ");
    System.out.println(linkedList);
    
    // 測試連結串列的刪除操作
    System.out.println("\n==== 測試 remove 方法 ====");
    Integer delElement = linkedList.remove(3);
    System.out.println("刪除連結串列中的第 3 個位置(從 0 開始計數)的元素後: ");
    System.out.println(linkedList);
    System.out.println("刪除的元素為: " + delElement);

    System.out.println("\n==== 測試 removeFirst 方法 ====");
    delElement = linkedList.removeFirst();
    System.out.println("刪除連結串列中的第一個元素後: ");
    System.out.println(linkedList);
    System.out.println("刪除的元素為: " + delElement);

    System.out.println("\n==== 測試 removeLast 方法 ====");
    delElement = linkedList.removeLast();
    System.out.println("刪除連結串列中的最後一個元素後: ");
    System.out.println(linkedList);
    System.out.println("刪除的元素為: " + delElement);
}

測試結果:

==== 測試 isEmpty 方法 ====
當前連結串列是否為空: true

==== 測試 addFirst 方法 ====
LinkedList: size = 1, Elements: dummyHead -> 0 -> NULL
LinkedList: size = 2, Elements: dummyHead -> 1 -> 0 -> NULL
LinkedList: size = 3, Elements: dummyHead -> 2 -> 1 -> 0 -> NULL
LinkedList: size = 4, Elements: dummyHead -> 3 -> 2 -> 1 -> 0 -> NULL
LinkedList: size = 5, Elements: dummyHead -> 4 -> 3 -> 2 -> 1 -> 0 -> NULL

==== 測試 add 方法 ====
新增 888 到連結串列中的第 2 個位置(從 0 開始計數): 
LinkedList: size = 6, Elements: dummyHead -> 4 -> 3 -> 888 -> 2 -> 1 -> 0 -> NULL

==== 測試 addLast 方法 ====
LinkedList: size = 7, Elements: dummyHead -> 4 -> 3 -> 888 -> 2 -> 1 -> 0 -> 999 -> NULL

==== 測試 contains 方法 ====
LinkedList: size = 7, Elements: dummyHead -> 4 -> 3 -> 888 -> 2 -> 1 -> 0 -> 999 -> NULL
連結串列中是否存在 888: true
連結串列中是否存在 777: false

==== 測試 get 方法 ====
LinkedList: size = 7, Elements: dummyHead -> 4 -> 3 -> 888 -> 2 -> 1 -> 0 -> 999 -> NULL
連結串列中的第一個元素為: 4
連結串列中的最後一個元素為: 999
連結串列中的第 3 個位置(從 0 開始計數)的元素為: 2

==== 測試 isEmpty 方法 ====
當前連結串列是否為空: false

==== 測試 set 方法 ====
LinkedList: size = 7, Elements: dummyHead -> 4 -> 3 -> 888 -> 2 -> 1 -> 0 -> 999 -> NULL
更改連結串列中的第 3 個位置(從 0 開始計數)的元素為 12 後: 
LinkedList: size = 7, Elements: dummyHead -> 4 -> 3 -> 888 -> 12 -> 1 -> 0 -> 999 -> NULL

==== 測試 remove 方法 ====
刪除連結串列中的第 3 個位置(從 0 開始計數)的元素後: 
LinkedList: size = 6, Elements: dummyHead -> 4 -> 3 -> 888 -> 1 -> 0 -> 999 -> NULL
刪除的元素為: 12

==== 測試 removeFirst 方法 ====
刪除連結串列中的第一個元素後: 
LinkedList: size = 5, Elements: dummyHead -> 3 -> 888 -> 1 -> 0 -> 999 -> NULL
刪除的元素為: 4

==== 測試 removeLast 方法 ====
刪除連結串列中的最後一個元素後: 
LinkedList: size = 4, Elements: dummyHead -> 3 -> 888 -> 1 -> 0 -> NULL
刪除的元素為: 999

程式已結束,退出程式碼 0

從結果可以看出以上實現的基本操作沒有出現錯誤,說明了實現的邏輯是正確的,接下來對以上實現的基本操作做一些簡單的時間複雜度分析。

連結串列的時間複雜度簡單分析

  • 新增操作

    • addLast 方法:對於該方法,每次都要遍歷整個連結串列進行新增,所以該方法的時間複雜度是 O(n) 級別的。

    • addFirst 方法:對於該方法,每次都是在連結串列頭部做操作,所以該方法的時間複雜度是 O(1) 級別的。

    • add 方法:對於該方法,平均來說,每次新增元素需要遍歷 n/2 個元素,所以該方法的時間複雜度是 O(n/2) = O(n) 級別的。

    • 綜上,新增操作的時間複雜度為 O(n)。

  • 刪除操作

    • removeLast 方法:對於該方法,每次都要遍歷整個連結串列進行刪除,所以該方法的時間複雜度是 O(n) 級別的。

    • removeFirst 方法:對於該方法,每次都是在連結串列頭部做操作,所以該方法的時間複雜度是 O(1) 級別的。

    • remove 方法:對於該方法,平均來說,每次刪除元素需要遍歷 n/2 個元素,所以該方法的時間複雜度是 O(n/2) = O(n) 級別的。

    • 綜上,刪除操作的時間複雜度為 O(n)。

  • 修改操作

    • set 方法:對於該方法,平均來說,每次修改元素需要遍歷 n/2 個元素,所以該方法的時間複雜度是 O(n/2) = O(n) 級別的。

    • 所以修改操作的時間複雜度也為 O(n)。

  • 查詢操作

    • getLast 方法:對於該方法,每次都要遍歷整個連結串列進行查詢,所以該方法的時間複雜度是 O(n) 級別的。

    • getFirst 方法:對於該方法,每次都是在連結串列頭部做操作,所以該方法的時間複雜度是 O(1) 級別的。

    • get 方法:對於該方法,平均來說,每次查詢元素需要遍歷 n/2 個元素,所以該方法的時間複雜度是 O(n/2) = O(n) 級別的。

    • contains 方法:對於該方法,平均來說,每次查詢判斷元素是否存在也是需要遍歷 n/2 個元素,所以該方法的時間複雜度也是 O(n/2) = O(n) 級別的。

    • 綜上,查詢操作的時間複雜度為 O(n)。

  • 所以對於連結串列而言,增刪改查的時間複雜度都是 O(n) 級別的。

  • 但是如果不對連結串列中元素進行修改操作,新增和刪除操作也只針對連結串列頭進行操作和查詢操作也只查連結串列頭的元素的話,此時整體的時間複雜度就是 O(1) 級別的了,又由於連結串列整體是動態的,不會浪費大量的記憶體空間,此時具有一定的優勢,顯而易見滿足這些條件的資料結構為棧,此時就可以使用連結串列來實現棧發揮連結串列的優勢了。當然,對於連結串列而言,還有一些改進方式使其在一些應用場景具有優勢,比如給連結串列新增尾指標後使用連結串列來實現佇列

連結串列的一些改進方式

使用連結串列實現棧

對於棧這個資料結構,它只針對一端進行操作,即針對棧頂進行操作,是一個後入先出的資料結構。

上文說到如果連結串列不使用修改操作,只使用新增、刪除、查詢連結串列頭的操作是滿足棧這個資料結構的特點的。所以可以使用連結串列頭作為棧頂,用連結串列作為棧的底層實現來實現棧這個資料結構,發揮連結串列的動態優勢。最後,再和之前基於動態陣列實現的棧進行一些效率上的對比,檢視兩者的差距。接下來,開始實現使用連結串列實現棧。

對於使用連結串列實現棧,將使用一個 LinkedListStack 類實現之前實現陣列棧時定義的棧的介面 Stack 來實現棧的一系列的操作。

回顧棧的介面 Stack 的實現如下:

/**
 * 定義棧支援的操作的介面
 * 支援泛型
 *
 * @author 踏雪尋梅
 * @date 2020/1/8 - 19:20
 */
public interface Stack<E> {
    /**
     * 獲取棧中元素個數
     *
     * @return 棧中如果有元素,返回棧中當前元素個數;棧中如果沒有元素返回 0
     */
    int getSize();

    /**
     * 判斷棧是否為空
     *
     * @return 棧為空,返回 true;棧不為空,返回 false
     */
    boolean isEmpty();

    /**
     * 入棧
     * 將元素 element 壓入棧頂
     *
     * @param element 入棧的元素
     */
    void push(E element);

    /**
     * 出棧
     * 將當前棧頂元素出棧並返回
     *
     * @return 返回當前出棧的棧頂元素
     */
    E pop();

    /**
     * 檢視當前棧頂元素
     *
     * @return 返回當前的棧頂元素
     */
    E peek();
}

對於 LinkedListStack 類的實現,只需要複用連結串列類中的方法就可實現棧的這些基本操作了,具體實現如下:

/**
 * 基於 LinkedList 實現的連結串列棧
 * 支援泛型
 *
 * @author 踏雪彡尋梅
 * @date 2020/2/5 - 12:22
 */
public class LinkedListStack<E> implements Stack<E> {
    /**
     * 基於該連結串列實現棧
     */
    private LinkedList<E> linkedList;

    /**
     * 建構函式
     * 構造一個空的連結串列棧
     */
    public LinkedListStack() {
        linkedList = new LinkedList<>();
    }

    @Override
    public int getSize() {
        return linkedList.getSize();
    }

    @Override
    public boolean isEmpty() {
        return linkedList.isEmpty();
    }

    @Override
    public void push(E element) {
        linkedList.addFirst(element);
    }

    @Override
    public E pop() {
        return linkedList.removeFirst();
    }

    @Override
    public E peek() {
        return linkedList.getFirst();
    }

    /**
     * 重寫 toString 方法顯示連結串列棧中的各資訊
     *
     * @return 返回連結串列棧的資訊
     */
    @Override
    public String toString() {
        StringBuilder result = new StringBuilder();
        result.append(String.format("LinkedListStack: size = %d, top [ ", getSize()));
        for (int i = 0; i < getSize(); i++) {
            E e = linkedList.get(i);
            result.append(e);
            // 如果不是最後一個元素
            if (i != getSize() - 1) {
                result.append(", ");
            }
        }
        result.append(" ] bottom");
        return result.toString();
    }
}

接下來,對以上實現做一些測試,檢測是否和預期結果不符,測試程式碼如下:

/**
 * 測試 LinkedListStack
 */
public static void main(String[] args) {
    LinkedListStack<Integer> stack = new LinkedListStack<>();

    // 判斷棧是否為空
    System.out.println("==== 測試 isEmpty ====");
    System.out.println("當前棧是否為空: " + stack.isEmpty());

    System.out.println("\n==== 測試連結串列棧的入棧,入棧 10 次 ====");
    for (int i = 0; i < 10; i++) {
        // 入棧
        stack.push(i);
        // 列印入棧過程
        System.out.println(stack);
    }

    System.out.println("\n==== 測試連結串列棧的出棧,出棧 1 次 ====");
    // 進行一次出棧
    stack.pop();
    // 檢視出棧後的狀態
    System.out.println(stack);

    // 檢視當前棧頂元素
    System.out.println("\n==== 測試連結串列棧的檢視棧頂元素 ====");
    Integer topElement = stack.peek();
    System.out.println("當前棧頂元素: " + topElement);

    // 判斷棧是否為空
    System.out.println("\n==== 測試 isEmpty ====");
    System.out.println("當前棧是否為空: " + stack.isEmpty());
}

測試結果:

==== 測試 isEmpty ====
當前棧是否為空: true

==== 測試連結串列棧的入棧,入棧 10 次 ====
LinkedListStack: size = 1, top [ 0 ] bottom
LinkedListStack: size = 2, top [ 1, 0 ] bottom
LinkedListStack: size = 3, top [ 2, 1, 0 ] bottom
LinkedListStack: size = 4, top [ 3, 2, 1, 0 ] bottom
LinkedListStack: size = 5, top [ 4, 3, 2, 1, 0 ] bottom
LinkedListStack: size = 6, top [ 5, 4, 3, 2, 1, 0 ] bottom
LinkedListStack: size = 7, top [ 6, 5, 4, 3, 2, 1, 0 ] bottom
LinkedListStack: size = 8, top [ 7, 6, 5, 4, 3, 2, 1, 0 ] bottom
LinkedListStack: size = 9, top [ 8, 7, 6, 5, 4, 3, 2, 1, 0 ] bottom
LinkedListStack: size = 10, top [ 9, 8, 7, 6, 5, 4, 3, 2, 1, 0 ] bottom

==== 測試連結串列棧的出棧,出棧 1 次 ====
LinkedListStack: size = 9, top [ 8, 7, 6, 5, 4, 3, 2, 1, 0 ] bottom

==== 測試連結串列棧的檢視棧頂元素 ====
當前棧頂元素: 8

==== 測試 isEmpty ====
當前棧是否為空: false

程式已結束,退出程式碼 0

從結果可以看出,實現的結果和預期是相符的,實現了棧的各個基本操作。整體的時間複雜度前面也分析過了,都是對連結串列頭部進行操作,時間複雜度是 O(1) 級別的,並且擁有了連結串列的整體動態性。接下來和之前實現的陣列棧進行一些效率上的對比:

測試程式碼:

import java.util.Random;

/**
 * 對比陣列棧和連結串列棧的效率差距
 *
 * @author 踏雪彡尋梅
 * @date 2020/2/5 - 12:52
 */
public class Main {
    /**
     * 測試使用 stack 執行 opCount 個 push 和 pop 操作所需要的時間,單位: 秒
     *
     * @param stack 測試使用的棧
     * @param opCount 測試的數量級
     * @return 返回測試的執行時間,單位: 秒
     */
    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) / 1000000000.0;
    }

    public static void main(String[] args) {
        int opCount = 10000;

        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");
    }
}

測試結果-1(opCount 為 1 萬時)

測試結果-1

測試結果-2(opCount 為 10 萬時)

測試結果-2

測試結果-3(opCount 為 100 萬時)

測試結果-3

測試結果-4(opCount 為 1000 萬時)

測試結果-4

從這幾個測試結果可以看出在我這臺機器上當資料量較小時,連結串列棧耗時比陣列棧短,隨著資料量增大,陣列棧耗時比連結串列棧短。

但歸根結底,這兩個棧的入棧和出棧操作的時間複雜度是同一級別 O(1) 的。

這種有時快有時慢的情況主要跟內部實現相關:

  • 對於陣列棧來說可能時不時需要重新分配陣列空間進行擴容或者減容,這一操作會消耗一些時間。

  • 對於連結串列棧來說則是有很多 new 新節點 Node 的操作,這些 new Node 的操作也會消耗一些時間。

所以這兩種棧之間的時間對比會出現這種常數倍的差異,屬於正常的情況,它們之間沒有複雜度上的巨大的差異,總體上的時間複雜度還是同一級別的,具體的時間差異最多也就是幾倍的差異,不會產生巨大的差異。

當然,相比陣列棧需要時不時重新分配陣列空間達到動態伸縮容量的目的,連結串列棧的動態性將會顯得更有優勢一些,不需要我們像陣列棧一樣手動地進行伸縮容量處理。

使用連結串列實現佇列

對於佇列這個資料結構,它是針對兩端進行操作,即針對隊首和隊尾進行操作,是一個先入先出的資料結構。

而在之前的連結串列實現中,只有針對連結串列頭的操作是 O(1) 級別的,那麼如果用來實現佇列這種資料結構的話,就會有一端的操作是 O(n) 級別的,為了解決這個問題,可以給連結串列新增一個尾指標 tail 用於追蹤連結串列尾部,達到對連結串列首尾兩端的操作都是 O(1) 級別進而實現佇列的目的。

當給連結串列新增尾指標 tail 後,可以發現在尾部刪除元素是需要從頭遍歷找到前置節點的進行刪除操作的,這個過程是 O(n) 的,不滿足我們的需求;而如果在尾部新增元素的話,就和在連結串列頭新增元素一個道理,是十分方便的,只需要 O(1) 的複雜度。再看回連結串列頭,由之前的實現可以發現在頭部刪除元素非常方便,只需要 O(1) 的複雜度。

所以可以設計為在連結串列頭進行刪除元素的操作,在連結串列尾進行新增元素的操作。即將連結串列頭作為隊首,連結串列尾作為隊尾。

由於在佇列中只需要對兩端進行操作,所以這裡實現佇列時就不復用前面實現的連結串列類了。在之前實現的連結串列類中,設計了虛擬頭節點便於統一操作連結串列中的所有資料。而在現在的佇列實現中只需要在頭部刪除元素在尾部新增元素,所以不需要使用虛擬頭節點。只需要兩個變數 head、tail 分別指向連結串列中的第一個元素和連結串列中的最後一個非 NULL 元素即可。

需要注意的是這樣設計後當佇列為空時,head 和 tail 都指向 NULL。

空連結串列佇列

改進後的連結串列佇列基本結構如下圖所示:

連結串列佇列的基本結構

和之前實現棧一樣,這裡也是實現一個 LinkedListQueue 類實現之前實現陣列佇列時定義的佇列的介面 Queue 來實現佇列的一系列的操作。

回顧佇列的介面 Queue 的實現如下:

/**
 * 定義佇列支援的操作的介面
 * 支援泛型
 *
 * @author 踏雪尋梅
 * @date 2020/1/9 - 16:52
 */
public interface Queue<E> {
    /**
     * 獲取佇列中元素個數
     *
     * @return 佇列中如果有元素,返回佇列中當前元素個數;佇列中如果沒有元素返回 0
     */
    int getSize();

    /**
     * 判斷佇列是否為空
     *
     * @return 佇列為空,返回 true;佇列不為空,返回 false
     */
    boolean isEmpty();

    /**
     * 入隊
     * 將元素 element 新增到隊尾
     *
     * @param element 入隊的元素
     */
    void enqueue(E element);

    /**
     * 出隊
     * 將隊首的元素出隊並返回
     *
     * @return 返回當前出隊的隊首的元素
     */
    E dequeue();

    /**
     * 檢視當前隊首元素
     *
     * @return 返回當前的隊首元素
     */
    E getFront();
}

對於 LinkedListQueue 類的實現,具體實現如下:

/**
 * 連結串列佇列
 * 支援泛型
 *
 * @author 踏雪彡尋梅
 * @date 2020/2/5 - 14:45
 */
public class LinkedListQueue<E> implements Queue<E> {
    /**
     * 連結串列的節點
     * 對於使用者而言,不需要知道連結串列的底層結構是怎樣的,只需要知道連結串列是一種線性資料結構,可以增刪改查資料
     */
    private class Node {
        /**
         * 節點儲存的資料
         */
        public E element;

        /**
         * 用於指向下一個節點,使節點與節點之間掛接起來組成連結串列
         */
        public Node next;

        /**
         * 建構函式
         * 構造一個存有資料並指向了下一個節點的節點
         *
         * @param element 存往該節點中的資料
         * @param next 該節點的下一個節點
         */
        public Node(E element, Node next) {
            this.element = element;
            this.next = next;
        }

        /**
         * 建構函式
         * 構造一個存有資料但沒有指向下一個節點的節點
         *
         * @param element 存往該節點中的資料
         */
        public Node(E element) {
            this(element, null);
        }

        /**
         * 建構函式
         * 構造一個空節點
         */
        public Node() {
            this(null, null);
        }

        /**
         * 重寫 toString 方法以顯示節點中儲存的資料資訊
         *
         * @return 返回節點中儲存的資料資訊
         */
        @Override
        public String toString() {
            return element.toString();
        }
    }

    /**
     * 用於指向連結串列佇列的第一個節點
     */
    private Node head;

    /**
     * 用於指向連結串列佇列的最後一個非 NULL 節點
     */
    private Node tail;

    /**
     * 連結串列佇列當前元素個數
     */
    private int size;

    /**
     * 建構函式
     * 構造一個空的連結串列佇列
     */
    public LinkedListQueue() {
        // 連結串列佇列為空時, head 和 tail 都指向 null
        head = null;
        tail = null;
        size = 0;
    }

    @Override
    public int getSize() {
        return size;
    }

    @Override
    public boolean isEmpty() {
        return size == 0;
    }

    @Override
    public void enqueue(E element) {
        if (tail == null) {
            // 空隊時入隊
            tail = new Node(element);
            head = tail;
        } else {
            // 非空隊時入隊
            tail.next = new Node(element);
            tail = tail.next;
        }
        // 維護 size,佇列當前元素個數 + 1
        size++;
    }

    @Override
    public E dequeue() {
        // 出隊時判斷佇列是否為空
        if (isEmpty()) {
            throw new IllegalArgumentException("Dequeue failed. Cannot dequeue from an empty queue.");
        }
        // 記錄要出隊的節點
        Node dequeueNode = head;
        // 將隊頭節點出隊
        head = head.next;
        dequeueNode.next = null;
        // 如果出隊後佇列為空,維護 tail 指向 null,空隊時 head 和 tail 都指向 null
        if (head == null) {
            tail = null;
        }
        // 維護 size,佇列當前元素個數 - 1
        size--;
        // 返回出隊元素
        return dequeueNode.element;
    }

    @Override
    public E getFront() {
        // 獲取隊頭元素時判斷佇列是否為空
        if (isEmpty()) {
            throw new IllegalArgumentException("GetFront failed. Queue is empty.");
        }
        // 返回隊頭元素
        return head.element;
    }

    /**
     * 重寫 toString 方法顯示連結串列佇列的詳細資訊
     *
     * @return 返回連結串列佇列的詳細詳細
     */
    @Override
    public String toString() {
        StringBuilder result = new StringBuilder();
        result.append(String.format("LinkedListQueue: size: %d, front [ ", getSize()));

        // 從連結串列中第一個元素開始遍歷,依次將連結串列中元素資訊新增到結果資訊中
        Node currentElement = head;
        while (currentElement != null) {
            result.append(currentElement + "->");
            currentElement = currentElement.next;
        }
        result.append("NULL ] tail");
        return result.toString();
    }
}

在實現中需要注意的是在入隊時如果是空佇列需要維護 head 指向 tail,否則 head 會指向 null。以及在出隊時需要判斷出隊後佇列是否為空,如果為空需要維護 tail 指向 null。以及需要注意佇列為空時 head 和 tail 都指向 null。

接下來,對以上實現做一些測試,檢測是否和預期結果不符,測試程式碼如下:

/**
 * 測試 LinkedListQueue
 */
public static void main(String[] args) {
    LinkedListQueue<Integer> queue = new LinkedListQueue<>();

    // 判斷佇列是否為空
    System.out.println("==== 測試 isEmpty ====");
    System.out.println("當前佇列是否為空: " + queue.isEmpty());

    System.out.println("\n==== 測試入隊和出隊, 10 次 入隊, 每 3 次入隊就出隊 1 次====");
    for (int i = 0; i < 10; i++) {
        // 入隊
        queue.enqueue(i);
        // 顯示入隊過程
        System.out.println(queue);

        // 每入隊 3 個元素就出隊一次
        if (i % 3 == 2) {
            // 出隊
            queue.dequeue();
            // 顯示出隊過程
            System.out.println("\n" + queue + "\n");
        }
    }

    // 判斷佇列是否為空
    System.out.println("\n==== 測試 isEmpty ====");
    System.out.println("當前佇列是否為空: " + queue.isEmpty());

    // 獲取隊首元素
    System.out.println("\n==== 測試 getFront ====");
    System.out.println(queue);
    Integer front = queue.getFront();
    System.out.println("當前佇列隊首元素為: " + front);
}

測試結果:

==== 測試 isEmpty ====
當前佇列是否為空: true

==== 測試入隊和出隊, 10 次 入隊, 每 3 次入隊就出隊 1 次====
LinkedListQueue: size: 1, front [ 0->NULL ] tail
LinkedListQueue: size: 2, front [ 0->1->NULL ] tail
LinkedListQueue: size: 3, front [ 0->1->2->NULL ] tail

LinkedListQueue: size: 2, front [ 1->2->NULL ] tail

LinkedListQueue: size: 3, front [ 1->2->3->NULL ] tail
LinkedListQueue: size: 4, front [ 1->2->3->4->NULL ] tail
LinkedListQueue: size: 5, front [ 1->2->3->4->5->NULL ] tail

LinkedListQueue: size: 4, front [ 2->3->4->5->NULL ] tail

LinkedListQueue: size: 5, front [ 2->3->4->5->6->NULL ] tail
LinkedListQueue: size: 6, front [ 2->3->4->5->6->7->NULL ] tail
LinkedListQueue: size: 7, front [ 2->3->4->5->6->7->8->NULL ] tail

LinkedListQueue: size: 6, front [ 3->4->5->6->7->8->NULL ] tail

LinkedListQueue: size: 7, front [ 3->4->5->6->7->8->9->NULL ] tail

==== 測試 isEmpty ====
當前佇列是否為空: false

==== 測試 getFront ====
LinkedListQueue: size: 7, front [ 3->4->5->6->7->8->9->NULL ] tail
當前佇列隊首元素為: 3

程式已結束,退出程式碼 0

從結果可以看出,實現的結果和預期是相符的,實現了佇列的各個基本操作。整體的時間複雜度前面也簡單分析過了,針對連結串列頭部和尾部進行操作,時間複雜度都是 O(1) 級別的,並且擁有了連結串列的整體動態性。接下來和之前實現的陣列佇列和迴圈佇列進行一些效率上的對比:

測試程式碼:

import java.util.Random;

/**
 * 測試 ArrayQueue、LoopQueue 和 LinkedListQueue 的效率差距
 *
 * @author 踏雪尋梅
 * @date 2020/1/8 - 16:49
 */
public class Main2 {
    public static void main(String[] args) {
        // 測試資料量
        int opCount = 10000;

        // 測試陣列佇列所需要的時間
        ArrayQueue<Integer> arrayQueue = new ArrayQueue<>();
        double arrayQueueTime = testQueue(arrayQueue, opCount);
        System.out.println("arrayQueueTime: " + arrayQueueTime + " s.");

        // 測試迴圈佇列所需要的時間
        LoopQueue<Integer> loopQueue = new LoopQueue<>();
        double loopQueueTime = testQueue(loopQueue, opCount);
        System.out.println("loopQueueTime: " + loopQueueTime + " s.");

        // 測試連結串列佇列所需要的時間
        LinkedListQueue<Integer> linkedListQueue = new LinkedListQueue<>();
        double linkedListQueueTime = testQueue(linkedListQueue, opCount);
        System.out.println("linkedListQueueTime: " + linkedListQueueTime + " s.");
    }

    /**
     * 測試使用佇列 queue 執行 opCount 個 enqueue 和 dequeue 操作所需要的時間,單位: 秒
     * @param queue 測試的佇列
     * @param opCount 測試的資料量
     * @return 返回整個測試過程所需要的時間,單位: 秒
     */
    private static double testQueue(Queue<Integer> queue, int opCount) {
        long startTime = System.nanoTime();

        // 用於生成隨機數入隊
        Random random = new Random();

        // opCount 次 enqueue
        for (int i = 0; i < opCount; i++) {
            // 入隊
            queue.enqueue(random.nextInt(Integer.MAX_VALUE));
        }

        // opCount 次 dequeue
        for (int i = 0; i < opCount; i++) {
            // 出隊
            queue.dequeue();
        }

        long endTime = System.nanoTime();

        // 將納秒單位的時間轉換為秒單位
        return (endTime - startTime) / 1000000000.0;
    }
}

測試結果-1(opCount 為 1 萬時)

測試結果-1

測試結果-2(opCount 為 10 萬時)

測試結果-2

測試結果-3(opCount 為 100 萬時)

測試結果-3

從以上幾種結果可以看出,在我這臺機器上陣列佇列耗時比基於陣列的迴圈佇列和連結串列佇列要大的多,基於陣列的迴圈佇列耗時和連結串列佇列相差不大,是常數倍的差異。

對於陣列佇列而言它的入隊操作是 O(1) 級別的、出隊操作是 O(n) 級別的,所以在以上測試中整體時間複雜度是 O(n2) 級別的(進行了 n 次入隊和出隊)。

而基於陣列的迴圈佇列和連結串列佇列的入隊操作和出隊操作都是 O(1) 級別的,所以在以上測試中整體而言時間複雜度是 O(n) 級別的(都進行了 n 次入隊和出隊)。但這兩者有時候也會出現一個快一點一個慢一點的情況,也是和前面的連結串列棧和陣列棧的情況是一樣的,這裡不再闡述。

總而言之基於陣列實現的迴圈佇列和連結串列佇列這兩者是同一級別的複雜度的,相比陣列佇列的時間複雜度快了很多,時間上的差異是巨大的。

最後,連結串列佇列在動態性上相比基於陣列實現的迴圈佇列會更好一些,不需要手動進行伸縮容量的實現。

實現到此處,連結串列的常見基本操作也都實現完成了,對於連結串列而言,也還存在著一些改進方案,比如給節點增加一個前置指標域用於指向當前節點的前置節點,使連結串列變成雙連結串列等等。這裡就不再實現了,具體過程還是大同小異的。

小結

  • 連結串列是一種真正的動態的資料結構,不需要像陣列一樣手動地處理動態伸縮容量。

  • 連結串列在針對頭部和尾部做特殊處理後,可以實現棧和佇列這兩種資料結構,極大地發揮了連結串列的動態特性。


如有寫的不足的,請見諒,請大家多多指教。

相關文章