把玩演算法 | 連結串列

qi發表於2021-08-21

基礎

把玩演算法 | 陣列中已經對陣列進行了詳細的說明,本文介紹另外一種比較常見的基礎資料結構:連結串列。連結串列是一種線性表,通常由一連串的節點組成,資料存放在節點中,每一個節點裡存放下一個節點的指標。

與陣列相比,使用連結串列可以克服陣列需要預先知道資料大小的缺點,連結串列結構可以充分的利用記憶體空間。但是陣列失去了陣列隨機讀取的特點,同時連結串列由於增加了指向下一個節點的指標,空間開銷會大一些。

連結串列一般有單向連結串列、雙向連結串列、迴圈連結串列等。

單向連結串列

在單向連結串列的每個節點中存放的是資料和下一個節點的連結,這個連結指向下一個節點,最後一個節點則指向一個空值,如下圖所示:

把玩演算法 | 連結串列

遍歷整個單向連結串列時,需要從上一個節點往下一個節點遍歷,直到遍歷到最後一個節點為止。由於每個節點只存放了下一個節點的連結,所以只能從上一個節點訪問下一個節點,而不能從下一個節點訪問上一個節點。

雙向連結串列

雙向連結串列是一種更加複雜的連結串列。每個節點有兩個連結:一個連結指向上一個節點,一個連結指向下一個節點。第一個節點的上節點為空值,最後一個節點的下一個節點為空值,如下圖所示:

把玩演算法 | 連結串列

由於雙向連結串列不僅存放了上一個節點的連結,也存放了下一個節點的連結,這樣就可以從任何一個節點訪問上一個節點,當然也可以訪問下一個節點,以至整個連結串列。

迴圈連結串列

在迴圈連結串列中,首節點和尾節點被連結在一起。這種方式在單向連結串列和雙向連結串列中皆可實現。迴圈連結串列可以被視為“無頭無尾”,遍歷連結串列時,你可以開始於任何一個節點後沿著連結串列的任意一個方向直到返回開始的節點,如下圖所示:

把玩演算法 | 連結串列

接下來看一下API的定義:

API

pubic class LinkedList<Element> implements Iterable<Element>

            LinkedList()                        建立一個空的連結串列
            void add(Element e)                 新增一個元素
            Element get(int index)              獲取指定位置的元素
            void set(int index, Element e)      設定指定位置的元素
            Element remove(int index)           移除指定位置的元素
            int size()                          獲取陣列的大小

可以看到,上面這份API和在把玩演算法 | 陣列中的API差不多,包含了線性表所需要的一些基本的操作。

雙向連結串列的實現

下面是雙向連結串列的骨架:

public class LinkedList<Element> implements Iterable<Element> {
    private Node<Element> first; // 首節點
    private Node<Element> last;  // 尾節點
    private int size;            // 連結串列的大小

    private static class Node<Element> {
        Element element;
        Node prev;
        Node next;

        Node(Node prev, Element element, Node next) {
            this.element = element;
            this.prev = prev;
            this.next = next;
        }
    }
    public int size() { return size; }
    // 詳細的實現見下面的說明
    public void add(Element e)
    public Element get(int index)
    public void set(int index, Element e)
    public boolean remove(Element e)
}

使用例項變數first, last來記錄首節點和尾節點,方便從頭或者從尾對連結串列進行遍歷,同時用size記錄連結串列的大小。在內部建立了一個靜態內部類Node來表示雙向連結串列的一個節點,element是我們要儲存的值,prev指向上一個節點,next指向下一個節點。這樣我們的節點的資料結構已經有了,接下來看下各個具體操作的詳細實現。

詳細的實現見github:LinkedList

新增

新增元素時,首先記錄尾節點l,然後建立一個新節點,新節點的prevlnext為null,將last設定為新的節點,最後將l節點的last設定為新節點。此外,新增第一個元素時,由於firstlast都為null,只需要將firstlast都設定為新的節點即可。相關程式碼如下:

public void add(Element e) {
    Node<Element> l = last;
    Node<Element> newNode = new Node<>(l, e, null);
    last = newNode;
    if (l == null) {
        first = newNode;
    } else {
        l.next = newNode;
    }
    size++;
}

詳細的過程,請檢視下圖:

把玩演算法 | 連結串列

獲取

根據索引獲取元素時,不像陣列那樣可以直接根據索引直接獲取元素,連結串列是不能直接根據索引來獲取對應的元素的,連結串列需要從頭或者尾遍歷連結串列,直到找到對應索引的元素返回即可。在單向連結串列中,只能從左往右遍歷連結串列,如果索引是連結串列的最後一個元素時,那麼只能遍歷完整個連結串列才能獲取到對應索引的元素。我們實現的雙向連結串列是能從左也能從右遍歷連結串列的,當索引小於連結串列的一般時從左邊遍歷連結串列,當索引大於連結串列的一半時從右邊遍歷連結串列,這樣獲取最後一個元素時只需要遍歷一個節點就能拿到對應的元素。

getNode方法中實現了這樣的根據索引獲取節點的邏輯:

public Element get(int index) {
    return getNode(index).element;
}

private Node<Element> getNode(int index) {
    if (index < size / 2) {
        Node<Element> x = first;
        for (int i = 0; i < index; i++) {
            x = x.next;
        }
        return x;
    } else {
        Node<Element> x = last;
        for (int i = size - 1; i > index; i++) {
            x = x.prev;
        }
        return x;
    }
}

設定

有了上面的getNode方法,設定指定索引的元素值得操作就很簡單了,直接獲取到節點,然後設定值即可,如下:

public void set(int index, Element e) {

    getNode(index).element = e;
}

移除

既然要移除指定的元素,那麼首先需要找到元素對應的節點,然後再進行移除節點的操作。當元素為null和元素不為null時,我們分別用了兩個for迴圈來遍歷整個連結串列,直到找到直到的元素時,然後呼叫unlink()方法來移除對應的節點。

public boolean remove(Element e) {
    if (e == null) {
        for (Node<Element> x = first; x != null; x = x.next) {
            if (x.element == null) {
                unlink(x);
                return true;
            }
        }
    } else {
        for (Node<Element> x = first; x != null; x = x.next) {
            if (e.equals(x.element)) {
                unlink(x);
                return true;
            }
        }
    }
    return false;
}

unlink()的實現要稍微複雜一些,只需要根據待移除元素的上節點是否為null,下節點是否為null來處理即可。

上節點有兩種情況:

  • 為null:這說明待移除節點是首節點,對於這種情況,要移除該節點,只需要將首節點設定為待移除節點的下節點即可。
  • 不為null:這說明待移除節點不是首節點,要移除該節點,需要將上節點的next置為待移除節點的下節點,將x.prev置位null以解除對上節點的連結。
  • 不為null:這說明待移除節點不是首節點,要移除該節點,需要將上節點的next置為待移除節點的下節點,將x.prev置位null以解除對上節點的連結。

下節點也有兩種情況:

  • 為null:這說明待移除節點是尾節點,對於這種情況,要移除該節點,只需要將尾節點設定為待移除節點的上節點即可。
  • 不為null:這說明待移除節點不是尾節點,要移除該節點,需要將下節點的prev置為待移除節點的上節點,將x.next置位null以解除對下節點的連結。
private void unlink(Node<Element> x) {
    Node<Element> prev = x.prev;
    Node<Element> next = x.next;

    if (prev == null) {
        first = next;
    } else {
        prev.next = next;
        x.prev = null;
    }

    if (next == null) {
        last = prev;
    } else {
        next.prev = prev;
        x.next = null;
    }
    size--;
}

移除首節點的圖示如下:

把玩演算法 | 連結串列

移除尾節點的圖示如下:

把玩演算法 | 連結串列

刪除中間節點的示意圖如下:

把玩演算法 | 連結串列

陣列VS連結串列

接下來看下看下陣列和連結串列的優缺點以及什麼情況下用連結串列,什麼時候用陣列。

  • 陣列
    • 優點
      • 能夠隨機查詢,查詢速度快,時間複雜度為O(1)。
    • 缺點:
      • 插入和刪除的效率低,時間複雜度為O(n)。插入和刪除都涉及到移動相關的元素。
      • 資料大小固定,可能會造成空間浪費。
  • 連結串列
    • 優點
      • 插入和刪除效率高,時間複雜度為O(1)。
    • 缺點
      • 不能隨機查詢,查詢效率低,時間複雜度為O(n)。查詢時需要遍歷連結串列的節點。

那什麼情況下用連結串列,什麼時候用陣列呢?插入和刪除操作多的話就用連結串列,需要經常獲取元素時就用陣列。

相關文章