數據結構之鏈表(Linked list)

說明:如果仔細閱讀完全文後,可能感覺有些不統一,這裡先說明下原因。
鏈表尾引用不統一:在介紹單鏈表時,只有一個鏈表首部的引用(head) 指向第一個節點。你看到後面關於雙鏈表及循環列表時,除了指向第一個節點的引用 還有指向最後一個節點(尾部)的引用。
這樣做主要是,鏈表設計可能包含尾部的引用,也可能不包含,在最後關於時間複雜度的對比也做了區分。個人也傾向添加尾部引用,但為了完整性 以及 更好的對比理解有無尾部引用的差異,所以在單鏈表 是沒有尾部引用設計 來實現。單鏈表 總結的比較詳細,每個關鍵操作 都有程式碼及示意圖。
命名不統一是單鏈表時 首部引用使用的head,而雙鏈表直接使用的JDK源碼中的程式碼介紹(沒做任何改動) first和last。那時單鏈表已經弄好了,程式碼及示意圖改起來比較麻煩,而且個人更喜歡head的命名。
這些完全不會影響理解的。
 

鏈表概述

鏈表(Linked list)也是一種線性數據結構,通過每個節點中的指針(引用)依次連接 形成的順序存儲。

單鏈表(Singly linked list)

單鏈表中每個元素(鏈表中稱為節點,node)包含兩部分:數據部分 和 一個指向下一個節點的引用。最後一個節點指向為null。
鏈表的入口點稱為head,它是第一個節點的引用。如果鏈表為空 則head指向為null。
示意圖:

雙鏈表(Doubly linked list)

雙鏈表中每個節點包含3個部分:數據部分 和兩個引用,一個指向下個節點(next) 另一個指向前一個節點(previous)。
示意圖:

循環鏈表(Circular linked list)

最後一個節點引用 指向 第一個節點(或者head)。
單鏈表和雙鏈表都可以形成循環鏈表,如下示意圖(上面一個是單循環鏈表,後面一個是雙循環鏈表):
 

鏈表操作

單鏈表操作

節點定義:定義節點中的數據項 和 下一個節點引用。
//E->anyType object
private static class Node<E> {
    //數據項
    private E data;
    //指向next節點的引用
    private Node<E> next;
 
    //構造
    public Node(E data, Node<E> next) {
        this.data = data;
        this.next = next;
    }
}
head定義:指向第一個節點(即鏈表首部)的引用,初始化為null
private Node<E> head = null;

插入

  • 插入鏈表首部

創建一個節點,節點的next指向head的引用,然後讓head指向新建的節點即可。
若head為null,即當前鏈表為空,即向鏈表中插入新建的節點, 節點的next指向null。
public void addFirst(E item) {
    head = new Node<E>(item, head);
}
變化如下示意圖
  • 插入目標節點的前面

目標節點數據為key,在目標節點前插入新的節點(數據為toInsert)。
具體過程看下面程式碼及注釋。
public void insertBefore(E key, E toInsert) {
   //若head為null,即鏈表為空,不存在目標節點 直接返回
   if(head == null) return;
   //若head指向的鏈表第一個節點 就是目標節點,即要把新建節點插入鏈表首部
   if(head.data.equals(key)) {
      addFirst(toInsert);
      return;
   }
 
   Node<E> prev = null;
   Node<E> curr = head;
   //curr定義了 指向當前節點,prev指向當前節點的上個節點。一直順序查找,若找到目標節點(數據為key),則curr指向目標節點,prev指向了目標節點的上個節點。
   while(curr != null && !curr.data.equals(key)) {
      prev = curr;
      curr = curr.next;
   }
   //若curr不為空,即找到目標節點,在curr和prev之間插入了新節點(數據為toInsert)。若curr為null,即沒找到。
   if(curr != null) prev.next = new Node<E>(toInsert, curr);
}
示意圖如下(鏈表不為空 且 目標節點不是第一個節點):
  • 插入某個節點後面

相比上面(插入某節點的前面)比較容易,只需一個指向當前節點的臨時變數即可。查找到目標節點後,即在之後插入新節點即可。
public void insertAfter(E key, E toInsert) {
   Node<E> curr = head;
   //查找目標節點,找到即curr指向目標節點
   while(curr != null && !curr.data.equals(key)) {
       curr = curr.next;
   }
 
   //curr不為null,即找到目標節點。在之後插入即可。
   if(curr != null)
       curr.next = new Node<E>(toInsert, curr.next);
}
示意圖:
  • 插入鏈表尾部

若head為null,則鏈表為空。插入鏈表首部即可。
若不為空,使用臨時變數tmp從head依次往後遍歷,當tmp的next為null時即鏈表尾部,插入到後面即可。
public void addLast(E item) {
   if(head == null) {
       addFirst(item);
   } else {
      Node<E> tmp = head;
      while(tmp.next != null) tmp = tmp.next;
      tmp.next = new Node<E>(item, null);
   }
}

刪除

有點類似插入。
下面以刪除某指定數據的節點為例。
public void remove(E key) {
   //head為null,即鏈表為空
   if(head == null) throw new RuntimeException("linkedlist is null, cannot delete");
 
   //鏈表第一個節點即目標節點,改變head指向下個節點就可以了。
   if(head.data.equals(key)) {
      head = head.next;
      return;
   }
 
 
   Node<E> prev = null;
   Node<E> curr  = head;
   //查找,若找到即curr指向目標節點,prev指向目標節點的上個節點
   while(curr != null && !curr.data.equals(key)) {
      prev = curr;
      curr = curr.next;
   }
   //curr為null,即沒找到
   if(curr == null) throw new RuntimeException("cannot find your node, cannot delete");
   //curr不為null,刪除目標節點(即改變prev的next 指向curr的next即可)
   prev.next = curr.next;
}
示意圖:
註:上述插入或刪除操作中,都有查找或遍歷的過程。因此查找或遍歷不單獨列出。
 
 

雙鏈表操作

在Java中實現的LinkedList類是一個雙鏈表,因此直接使用了JDK源碼說明(只在程式碼中添加了注釋,沒做任何修改)。
下面是節點及這兩個引用的程式碼:類中定義了兩個引用,分別指向鏈表的首部和尾部,
transient int size = 0;
 
 
/**
* Pointer to first node.
* Invariant: (first == null && last == null) ||
*            (first.prev == null && first.item != null)
*/
transient Node<E> first;//指向首部(第一個節點)
 
 
/**
* Pointer to last node.
* Invariant: (first == null && last == null) ||
*            (last.next == null && last.item != null)
*/
transient Node<E> last;//指向尾部(最後一個節點)
 
 
private static class Node<E> {
    //數據項
    E item;
    //指向下個節點
    Node<E> next;
    //指向上個節點
    Node<E> prev;
 
 
    Node(Node<E> prev, E element, Node<E> next) {
        this.item = element;
        this.next = next;
        this.prev = prev;
    }
}

插入到鏈表首部

具體看下面的程式碼注釋和示意圖。
/**
* Links e as first element.
*/
private void linkFirst(E e) {
    //f指向first指向的節點。即f和first同時指向第一個節點。f在方法中標誌 鏈表插入前的第一個節點
    final Node<E> f = first;
    //創建新的節點,節點的prev指向null,next指向f(即鏈表插入前第一個節點)
    final Node<E> newNode = new Node<>(null, e, f);
    //first指向新節點(鏈表首部已改變)
    first = newNode;
    if (f == null)
        //若f為null,即插入前鏈表是空的,插入到新節點既是開始節點也是結束節點,所以last也指向了它
        last = newNode;
    else
        //如果f不為空,則讓f(插入前的第一個節點)的prev指向新節點就完成了。
        f.prev = newNode;
    size++;
    modCount++;
}
示意圖:
這個是f指向不為空的情況示意圖。
若f為null,則沒有節點指向新建節點,同時新建節點的next指向的是null, 尾部引用last也會指向新建節點。
詳細點就是:
第一步—紅色:final Node<E> f = first;
第二步—橙色: final Node<E> newNode = new Node<>(null, e, f);
第三步—綠色:first = newNode;
第四步—深藍: f.prev = newNode;

插入到某個元素前面

這裡是目標節點已經找到了後的操作。查找目標節點可以參考單鏈表中的程式碼。
下面程式碼:用e新建節點,插入到succ節點之前。
/**
* Inserts element e before non-null Node succ.
*/
void linkBefore(E e, Node<E> succ) {
    // assert succ != null;
    //這裡把succ稱為目標節點。succ在這裡是已經查找到並存在的,查找過程可參考單鏈表中相關程式碼。
    //用pred指向 目標節點succ的上個節點。
    final Node<E> pred = succ.prev;
    //新建節點,prev指向pred 即新建節點的prev指向目標節點的上個節點,next指向目標節點
    final Node<E> newNode = new Node<>(pred, e, succ);
    //目標節點的prev指向 新建節點
    succ.prev = newNode;
    if (pred == null)
        //若pred為null 是目標節點上個節點為null,即鏈表插入前只有一個節點,所以first會指向新建節點。
        first = newNode;
    else
        //若pred不為null,目標節點的上個節點的next指向新節點
        pred.next = newNode;
    size++;
    modCount++;
}

插入到鏈表尾部

因為有兩個引用first、last分別指向鏈表首部和鏈表尾部。這樣尾部操作就變得和首部操作一樣容易,不需要找到鏈表尾部才能插入。看完上面這個應該很簡單,不做解釋了。
/**
* Links e as last element.
*/
void linkLast(E e) {
    final Node<E> l = last;
    final Node<E> newNode = new Node<>(l, e, null);
    last = newNode;
    if (l == null)
        first = newNode;
    else
        l.next = newNode;
    size++;
    modCount++;
}

刪除

源碼中,首先查找到數據與o相同的第一個節點,在通過unlink刪除該節點,並返回狀態。
查找那考慮了o是否為null,很嚴謹。o==null?get(i)==null:o.equals(get(i))。jdk文檔很多地方說明都可以看到,如果null去執行equals,就會出現Null pointer access:的異常了,值得注意。
/**
* Removes the first occurrence of the specified element from this list,
* if it is present.  If this list does not contain the element, it is
* unchanged.  More formally, removes the element with the lowest index
* {@code i} such that
* <tt>(o==null&nbsp;?&nbsp;get(i)==null&nbsp;:&nbsp;o.equals(get(i)))</tt>
* (if such an element exists).  Returns {@code true} if this list
* contained the specified element (or equivalently, if this list
* changed as a result of the call).
*
* @param o element to be removed from this list, if present
* @return {@code true} if this list contained the specified element
*/
public boolean remove(Object o) {
    //這裡就是區分o是否為null,找到第一個指定element的節點,通過unlink刪除。
    if (o == null) {
        for (Node<E> x = first; x != null; x = x.next) {
            if (x.item == null) {
                unlink(x);
                return true;
            }
        }
    } else {
        for (Node<E> x = first; x != null; x = x.next) {
            if (o.equals(x.item)) {
                unlink(x);
                return true;
            }
        }
    }
    return false;
}
 
 
/**
* Unlinks non-null node x.
*/
E unlink(Node<E> x) {
    // assert x != null;
    final E element = x.item;
    final Node<E> next = x.next;
    final Node<E> prev = x.prev;
    
    if (prev == null) {
        //prev為null 即x是首部(第一個節點),first指向next即可
        first = next;
    } else {
        //prev不為null,x與prev之間關聯即斷開。prev的next指向next了,x的prev為null
        prev.next = next;
        x.prev = null;
    }
 
    //同prev處理類似,斷開x與next聯繫
    if (next == null) {
        last = prev;
    } else {
        next.prev = prev;
        x.next = null;
    }
 
 
    x.item = null;
    size--;
    modCount++;
    return element;
}
示意圖是prev和next都不為null的情況:
橙色—>prev.next = next; x.prev = null;
紅色—> next.prev = prev; x.next = null;
最後x.item E也被置為null,size–。x被刪除。
 
 

循環鏈表

循環鏈表的最後一個節點有指向第一個節點的引用。
僅已單循環鏈表為例。
下面是幾個操作示例,可以看下程式碼和注釋,不做詳細說明了。看過上面應該很容易理解。
private Node<E> head = null;
private Node<E> tail = null;
 
 
private class Node<E> {
    E value;
    Node<E> next;
    public Node(E value) {
        this.value = value;
    }
}
 
 
//往尾部(即tail節點之後)添加節點
public void addNode(E value) {
    Node<E> newNode = new Node<>(value);
    if (head == null) {
        //head為null,即鏈表為空,head指向新建節點。
        head = newNode;
    } else {
        tail.next = newNode;
    }
    //tail指向新節點,即新節點相當於鏈表的尾部
    tail = newNode;
    //tail.next即新節點next指向head,形成環
    tail.next = head;
}
 
 
//查找,鏈表中是否包含數據為searchValue的節點
public boolean containsNode(E searchValue) {
    Node<E> currentNode = head;
    if (head == null) {
        return false;
    } else {
        //以head開始依次向後查找,直到碰到的仍是head停止。找到返回
        do {
            if (currentNode.value == searchValue) {
                return true;
            }
            currentNode = currentNode.next;
        } while (currentNode != head);
        return false;
    }
}
 
 
//刪除查找到的第一個數據為valueToDelete的節點
public void deleteNode(E valueToDelete) {
    Node<E> currentNode = head;
    if (head != null) {
        if (currentNode.value == valueToDelete) {
            //head節點且值為valueToDelete
            head = head.next;
            tail.next = head;
        } else {
            //以head開始依次向後查找,直到碰到的仍是head停止。找到刪除
            do {
                Node<E> next = currentNode.next;
                if (next.value == valueToDelete) {
                    currentNode.next = next.next;
                    break;
                }
                currentNode = currentNode.next;
            } while (currentNode != head);
        }
    }
}
 

鏈表與數組比較

簡單做了個表格

鏈表
數組
動態分配:需要時才分配記憶體
固定分配:大小固定,new時即分配所有記憶體
分散存儲:記憶體中不連續存儲
連續存儲:記憶體中連續存儲
總量限制:由於分散存儲,受記憶體總量限制
使用限制:由於連續存儲,若無合適連續控制項即無法完成分配,且容易形成記憶體碎片
插入/刪除方便:改變節點中指向next的或者prev的引用即可
插入/刪除代價大:需創建新數組並移動元素
有記憶體浪費:節點中需額外存儲next或prev的資訊
無記憶體浪費:數組元素只存放數據
順序訪問:在某個節點沿某一方向只能逐一訪問
隨機訪問:可以直接計算得到某一元素的地址
 
 

鏈表的一些操作複雜度

 
操作
鏈表
數組
訪問(訪問第N個元素)
O(n)
O(1)
插入到首部
O(1)
O(n)
插入到尾部
有尾部引用tail:O(1)
無尾部引用O(n)
O(n)
插入到中部
查找時間+O(1)~=O(n)
O(n)
查找
O(n)
O(n)
刪除
類似插入,看刪除位置或者鏈表的設計
O(n)