把玩算法 | 鏈表


基礎

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

與數組相比,使用鏈表可以克服數組需要預先知道數據大小的缺點,鏈表結構可以充分的利用內存空間。但是數組失去了數組隨機讀取的特點,同時鏈表由於增加了指向下一個節點的指針,空間開銷會大一些。

鏈表一般有單向鏈表、雙向鏈表、循環鏈表等。

單向鏈表

在單向鏈表的每個節點中存放的是數據和下一個節點的鏈接,這個鏈接指向下一個節點,最后一個節點則指向一個空值,如下圖所示:

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

雙向鏈表

雙向鏈表是一種更加復雜的鏈表。每個節點有兩個鏈接:一個鏈接指向上一個節點,一個鏈接指向下一個節點。第一個節點的上節點為空值,最后一個節點的下一個節點為空值,如下圖所示:

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

循環鏈表

在循環鏈表中,首節點和尾節點被鏈接在一起。這種方式在單向鏈表和雙向鏈表中皆可實現。循環鏈表可以被視為“無頭無尾”,遍歷鏈表時,你可以開始於任何一個節點后沿着鏈表的任意一個方向直到返回開始的節點,如下圖所示:

接下來看一下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)。查詢時需要遍歷鏈表的節點。

那什么情況下用鏈表,什么時候用數組呢?插入和刪除操作多的話就用鏈表,需要經常獲取元素時就用數組。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM