1. 程式人生 > >最基礎的動態數據結構:鏈表

最基礎的動態數據結構:鏈表

truct exc shadow equal this stack 讓其 靜態 垃圾

什麽是鏈表

鏈表是一種線性結構,也是最基礎的動態數據結構。我們在實現動態數組、棧以及隊列時,底層都是依托的靜態數組,靠resize來解決固定容量的問題,而鏈表是真正的動態數據結構。學習鏈表這種數據結構,能夠更深入的理解引用(或者指針)以及遞歸。其中鏈表分為單鏈鏈表和雙鏈鏈表,本文中所介紹的是單鏈鏈表。

鏈表中的數據是存儲在一個個的節點中,如下這是一個最基本的節點結構:

class Node {
    E e;
    Node next;  // 節點中持有下一個節點的引用
}

我們可以將鏈表想象成火車,每一節車廂就是一個節點,乘客乘坐在火車的車廂中,就相當於元素存儲在鏈表的節點中。火車的每一節車廂都連接著下一節車廂,就像鏈表中的節點都會持有下一個節點的引用。火車的最後一節車廂沒有連接任何車廂,就像鏈表中末尾的節點指向null一樣:

技術分享圖片

鏈表優缺點:

  • 優點:真正的動態結構,不需要處理固定容量的問題,從中間插入、刪除節點很方便,相較於數組要靈活
  • 缺點:喪失了隨機訪問的能力,不能像數組那種直接通過索引訪問

廢話不多說,我們開始來編寫鏈表這個數據結構吧,首先來實現鏈表中的節點結構以及鏈表的一些簡單方法,代碼如下:

/**
 * @program: Data-Structure
 * @description: 鏈表數據結構實現
 * @author: 01
 * @create: 2018-11-08 15:37
 **/
public class LinkedList<E> {
    /**
     * 鏈表中的節點結構
     */
    private class Node {
        E e;
        Node next;

        public Node() {
            this(null, null);
        }

        public Node(E e) {
            this(e, null);
        }

        public Node(E e, Node next) {
            this.e = e;
            this.next = next;
        }

        @Override
        public String toString() {
            return e.toString();
        }
    }

    /**
     * 頭節點
     */
    private Node head;

    /**
     * 鏈表中元素的個數
     */
    private int size;

    public LinkedList() {
        this.head = null;
        this.size = 0;
    }

    /**
     * 獲取鏈表中的元素個數
     *
     * @return 元素個數
     */
    public int getSize() {
        return size;
    }

    /**
     * 鏈表是否為空
     *
     * @return 為空返回true,否則返回false
     */
    public boolean isEmpty() {
        return size == 0;
    }    
}

在鏈表中添加元素

我們在為數組添加元素時,最方便的添加方式就是從數組後面進行添加,因為size總是指向數組最後一個元素+1的位置,所以利用size變量我們可以很輕易的完成元素的添加。

而在鏈表中則相反,我們在鏈表頭添加新的元素最方便,因為鏈表內維護了一個head變量,即鏈表的頭部,我們只需要將新的元素放入一個新的節點中,然後將新節點內的next變量指向head,最後把head指向這個新節點就完成了元素的添加:
技術分享圖片

我們來實現這個在鏈表頭添加新的元素的方法,代碼如下:

/**
 * 在鏈表頭添加新的元素e
 *
 * @param e 新的元素
 */
public void addFirst(E e) {
    Node node = new Node(e);
    node.next = head;
    head = node;

    // 以上三句代碼完全可以直接使用以下一句代碼完成,
    // 但為了讓邏輯更清晰所以這裏特地將代碼分解了
    // head = new Node(e, head);

    size++;
}

然後我們來看看如何在鏈表中指定的位置插入新的節點,雖然這在鏈表中不是一個常用的操作,但是有些鏈表相關的題目會涉及到這種操作,所以我們還是得了解一下。例如我們現在要往“索引”為2的位置插入一個新的節點,該如何實現:
技術分享圖片

雖然鏈表中沒有真正的索引,但是為了實現在指定的位置插入新的節點,我們得引用索引這個概念。如上圖中,把鏈表頭看作是索引0,下一個節點看作索引1,以此類推。然後我們還需要有一個prev變量,通過循環移動這個變量去尋找指定的“索引” - 1 的位置,找到之後將新節點的next指向prev的next,prev的next再指向新的節點,即可完成這個插入節點的邏輯,所以關鍵點就是找到要添加的節點的前一個節點:
技術分享圖片

具體的實現代碼如下:

/**
 * 在鏈表的index(0-based)位置添加新的元素e
 *
 * @param index 元素添加的位置
 * @param e     新的元素
 */
public void add(int index, E e) {
    // 檢查索引是否合法
    if (index < 0 || index > size) {
        throw new IllegalArgumentException("Add failed. Illegal index.");
    }

    // 鏈表頭添加需特殊處理
    if (index == 0) {
        addFirst(e);
    } else {
        Node prev = head;
        // 移動prev到index - 1的位置
        for (int i = 0; i < index - 1; i++) {
            prev = prev.next;
        }

        Node node = new Node(e);
        node.next = prev.next;
        prev.next = node;

        // 同樣,以上三句代碼可以一句代碼完成
        // prev.next = new Node(e, prev.next);

        size++;
    }
}

基於以上這個方法,我們就可以輕易的實現在鏈表末尾添加新的元素:

/**
 * 在鏈表末尾添加新的元素e
 *
 * @param e 新的元素
 */
public void addLast(E e) {
    add(size, e);
}

使用鏈表的虛擬頭節點

在上一小節中,我們實現向指定位置插入元素的代碼裏,需對鏈表頭的位置特殊處理,因為鏈表頭沒有上一個節點。很多時候使用鏈表的都需要進行類似的特殊處理,並不是很優雅,所以本小節就是介紹如何優雅的解決這個問題。

之所以要進行特殊處理,主要原因還是head沒有上一個節點,初始化prev的時候只能指向head,既然這樣我們就給它前面加一個節點好了,這個節點不存儲任何數據,僅作為一個虛擬節點。這也是編寫鏈表結構時經常使用到的技巧,添加這麽一個節點就可以統一鏈表的操作邏輯:
技術分享圖片

修改後的代碼如下:

public class LinkedList<E> {
    ...

    /**
     * 虛擬頭節點
     */
    private Node dummyHead;

    /**
     * 鏈表中元素的個數
     */
    private int size;

    public LinkedList() {
        this.dummyHead = new Node(null, null);
        this.size = 0;
    }

    /**
     * 在鏈表的index(0-based)位置添加新的元素e
     *
     * @param index 元素添加的位置
     * @param e     新的元素
     */
    public void add(int index, E e) {
        if (index < 0 || index > size) {
            throw new IllegalArgumentException("Add failed. Illegal index.");
        }

        Node prev = dummyHead;
        // 移動prev到index前一個節點的位置
        for (int i = 0; i < index; i++) {
            prev = prev.next;
        }

        Node node = new Node(e);
        node.next = prev.next;
        prev.next = node;

        // 同樣,以上三句代碼可以一句代碼完成
        // prev.next = new Node(e, prev.next);

        size++;
    }

    /**
     * 在鏈表頭添加新的元素e
     *
     * @param e 新的元素
     */
    public void addFirst(E e) {
        add(0, e);
    }

    /**
     * 在鏈表末尾添加新的元素e
     *
     * @param e 新的元素
     */
    public void addLast(E e) {
        add(size, e);
    }
}

鏈表的遍歷、查詢和修改

有了以上小節的基礎,接下來我們實現鏈表的遍歷、查詢和修改就很簡單了,代碼如下:

/**
 * 獲取鏈表的第index(0-based)個位置的元素
 *
 * @param index
 * @return
 */
public E get(int index) {
    if (index < 0 || index >= size) {
        throw new IllegalArgumentException("Get failed. Illegal index.");
    }

    Node cur = dummyHead.next;
    for (int i = 0; i < index; i++) {
        cur = cur.next;
    }

    return cur.e;
}

/**
 * 獲取鏈表中的第一個元素
 *
 * @return
 */
public E getFirst() {
    return get(0);
}

/**
 * 獲取鏈表中的最後一個元素
 *
 * @return
 */
public E getLast() {
    return get(size - 1);
}

/**
 * 修改鏈表的第index(0-based)個位置的元素為e
 *
 * @param index
 * @param e
 */
public void set(int index, E e) {
    if (index < 0 || index >= size) {
        throw new IllegalArgumentException("Set failed. Illegal index.");
    }

    Node cur = dummyHead.next;
    for (int i = 0; i < index; i++) {
        cur = cur.next;
    }

    cur.e = e;
}

/**
 * 查找鏈表中是否包含元素e
 *
 * @param e
 * @return
 */
public boolean contain(E e) {
    Node cur = dummyHead.next;
    // 第一種遍歷鏈表的方式
    while (cur != null) {
        if (cur.e.equals(e)) {
            return true;
        }
        cur = cur.next;
    }

    return false;
}

@Override
public String toString() {
    if (isEmpty()) {
        return "[]";
    }

    StringBuilder sb = new StringBuilder();
    sb.append(String.format("LinkedList: size = %d\n", size));
    sb.append("[");
    Node cur = dummyHead.next;
    // 第二種遍歷鏈表的方式
    for (int i = 0; i < size; i++) {
        sb.append(cur.e).append(" -> ");
        cur = cur.next;
    }

    // 第三種遍歷鏈表的方式
    // for (Node cur = dummyHead.next; cur != null; cur = cur.next) {
    //     sb.append(cur.e).append(" -> ");
    // }

    return sb.append("NULL]").toString();
}

從鏈表中刪除元素

最後我們要實現的鏈表操作就是從鏈表中刪除元素,刪除元素就相當於是刪除鏈表中的節點。例如我要刪除”索引“為2的節點,同樣的我們也需要使用一個prev變量循環移動到要刪除的節點的前一個節點上,此時把prev的next拿出來就是待刪除的節點。刪除節點也很簡單,拿出待刪除的節點後,將prev的next指向待刪除節點的next:
技術分享圖片

最後將待刪除的節點指向一個null,讓其脫離鏈表,這樣就能夠快速被垃圾回收,如此一來就完成了節點的刪除:
技術分享圖片

具體的實現代碼如下:

/**
 * 從鏈表中刪除第index(0-based)個位置的元素,並返回刪除的元素
 *
 * @param index
 * @return 被刪除的節點所存儲的元素
 */
public E remove(int 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--;

    return delNode.e;
}

基於以上這個方法,我們就可以很簡單的實現如下兩個方法:

/**
 * 刪除鏈表中第一個元素
 *
 * @return 被刪除的元素
 */
public E removeFirst() {
    return remove(0);
}

/**
 * 刪除鏈表中最後一個元素
 *
 * @return 被刪除的元素
 */
public E removeLast() {
    return remove(size - 1);
}

最後我們來看一下我們實現的這個鏈表增刪查改操作的時間復雜度:

addLast(e)         // O(n)
addFirst(e)        // O(1)
add(index, e)      // O(n)
removeLast()       // O(n)
removeFirst()      // O(1)
remove(index)      // O(n)
set(index, e)      // O(n)
get(index)         // O(n)
contain(e)         // O(n)

使用鏈表實現棧

從鏈表的addFirst和removeFirst方法的時間復雜度可以看到,如果只對鏈表頭進行增、刪操作的復雜度是O(1)的,只查詢鏈表頭的元素復雜度也是O(1)的。這時我們就可以想到使用鏈表來實現棧,用鏈表實現的棧其入棧出棧等操作時間復雜度也都是O(1)的,具體的實現代碼如下:

/**
 * @program: Data-Structure
 * @description: 基於鏈表實現棧數據結構
 * @author: 01
 * @create: 2018-11-08 23:38
 **/
public class LinkedListStack<E> implements Stack<E> {
    private LinkedList<E> linkedList;

    public LinkedListStack() {
        this.linkedList = new LinkedList<>();
    }

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

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

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

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

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

    @Override
    public String toString() {
        if (isEmpty()) {
            return "[]";
        }

        StringBuilder sb = new StringBuilder();
        sb.append(String.format("LinkedListStack: size = %d\n", getSize()));
        sb.append("top [");
        for (int i = 0; i < getSize(); i++) {
            sb.append(linkedList.get(i));
            if (i != getSize() - 1) {
                sb.append(", ");
            }
        }
        return sb.append("]").toString();
    }

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

        for (int i = 0; i < 5; i++) {
            stack.push(i);
            System.out.println(stack);
        }

        stack.pop();
        System.out.println(stack);
    }
}

帶有尾指針的鏈表:使用鏈表實現隊列

上一小節我們基於鏈表很輕易的就實現了一個棧結構,本小節我們來看看如何使用鏈表實現隊列結構,看看需要對鏈表進行哪些改進。

在編寫代碼之前,我們需要考慮到一個問題,在之前鏈表結構的實現代碼中,只有一個head變量指向頭節點,若我們直接使用這個鏈表實現隊列的話,需要操作鏈尾的元素時,復雜度是O(n)的,因為需要遍歷整個鏈表直到尾節點的位置。那麽該如何避開遍歷,在O(1)的復雜度下快速的找到尾節點呢?答案就是增加一個tail變量,讓這個變量始終指向尾節點即可,這樣我們操作尾節點的復雜度就是O(1)了。

除此之外,使用鏈表實現隊列還有一個問題需要考慮,那就是從哪邊入隊元素,從哪邊出隊元素。我們之前編寫的鏈表代碼中,在鏈首添加元素是O(1)的,也是最簡單方便的,所以我們要將鏈首作為入隊的一端嗎?答案是相反的,應該將鏈首作為出隊的一端,鏈尾作為入隊的一端。

因為我們實現的鏈表是單鏈結構,在這種情況下鏈首無論是作為入隊還是出隊的一端都是可以的,但是鏈尾不可以,鏈尾只能作為入隊的一端。如果將鏈尾作為出隊的一端,那麽出隊的復雜度將是O(n)的,需要遍歷鏈表找到尾節點的上一個節點,然後將該節點的next指向null才能完成出隊的操作。若是雙鏈結構倒是無所謂,只需要通過tail變量就可以獲取到上一個節點,不需要遍歷鏈表去尋找。因此,我們需要將鏈首作為入隊的一端,鏈尾作為出隊的一端,這樣無論是出隊還是入隊的時間復雜度都是O(1)。

具體的實現代碼如下:

/**
 * @program: Data-Structure
 * @description: 基於鏈表實現的隊列數據結構
 * @author: 01
 * @create: 2018-11-09 17:00
 **/
public class LinkedListQueue<E> implements Queue<E> {
    private class Node {
        E e;
        Node next;

        public Node() {
            this(null, null);
        }

        public Node(E e) {
            this(e, null);
        }

        public Node(E e, Node next) {
            this.e = e;
            this.next = next;
        }

        @Override
        public String toString() {
            return e.toString();
        }
    }

    /**
     * 頭節點
     */
    private Node head;

    /**
     * 尾節點
     */
    private Node tail;

    /**
     * 表示隊列中的元素個數
     */
    private int size;

    @Override
    public void enqueue(E e) {
        if (tail == null) {
            // 鏈表沒有元素
            tail = new Node(e);
            head = tail;
        } else {
            // 鏈尾入隊元素
            tail.next = new Node(e);
            tail = tail.next;
        }
        size++;
    }

    @Override
    public E dequeue() {
        if (isEmpty()) {
            throw new IllegalArgumentException("Can‘t dequeue from an empty queue.");
        }

        // 鏈首出隊元素
        Node retNode = head;
        head = head.next;
        retNode.next = null;
        if (head == null) {
            // 隊列裏沒元素的話,尾節點需要置空
            tail = null;
        }
        size--;

        return retNode.e;
    }

    @Override
    public E getFront() {
        if (isEmpty()) {
            throw new IllegalArgumentException("Queue is empty.");
        }

        return head.e;
    }

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

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

    @Override
    public String toString() {
        if (isEmpty()) {
            return "[]";
        }

        StringBuilder sb = new StringBuilder();
        sb.append(String.format("LinkedListQueue: size = %d\n", getSize()));
        sb.append("front [");
        Node cur = head;
        while (cur != null) {
            sb.append(cur.e).append(", ");
            cur = cur.next;
        }

        return sb.append("NULL] tail").toString();
    }
}

最基礎的動態數據結構:鏈表