算法入門 - 鏈表的實現及應用(Java版本)


之前我們學習了動態數組,雖然比原始數組的功能強大了不少,但還不是完全純動態的(基於靜態數組實現的)。這回要講的鏈表則是正兒八經的動態結構,是一種非常靈活的數據結構。

鏈表的基本結構

鏈表由一系列單一的節點組成,將它們一個接一個地鏈接起來,就形成了鏈表。鏈表雖然沒有長度上的限制,但是節點之間需要儲存關聯關系。所以可以很自然地想到,你得知道前一個元素是啥,才能在它后面繼續接新的元素。如果后面沒元素可接,那么就在鏈表尾部接一個空值,代表鏈表結束。
我們從一個空鏈表開始,依次往鏈表中添加元素:

把整個過程拆分開來,可以分為以下幾步:
1.初始鏈表為空;
2.添加元素3后,3后面再無元素,所以要接一個空節點;
3.再依次添加元素4、6,結束后在末尾再接個空節點。
所以,如果用代碼表示鏈表的結構,就可以這樣描述:

public class LinkedList<E>{
    // 節點類
    private class Node {
        public E element;   // 節點儲存的元素值
        public Node next;   // 指向的下一個鏈接節點

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

        public Node(E element) {
            this.element = element;
            this.next = null;
        }

        public Node() {

        }
    }

    private Node head;  // 頭節點
    private int size;   // 鏈表長度

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

我們用 head 表示頭節點,即鏈表頭部的節點。容易知道,每個鏈表只有一個頭節點,且初始頭節點為空。

帶索引的鏈表

上面說了,如果要添加節點,需要知道前一個節點是啥。其實在一般情況下(只在鏈表尾部添加),前一個節點總是這個鏈表的最后一個節點。但是這里我們搞得稍微復雜一點,把鏈表也設計成可以根據索引,在鏈表的任何位置添加節點(雖然正常情況下鏈表無索引一說)。如果是這樣的話,要在 index 處添加節點,就得從 head 開始遍歷,知道 index-1 處(方便起見就叫 prev 節點)的節點是誰,然后再把 prev.next 指向新節點。這里有一個細節,就是如果 index 后面還有節點的話,就需要先把新節點的 next 指向 index節點(即prev.next),再把 prev 節點的next指向新節點。假如有一個長度為3的鏈表,現在要在index=1處添加新節點:

這里由於 head 節點正好就是 prev 節點,所以不用遍歷。
如果是往頭節點的位置添加元素的話,是沒有prev節點的,所以需要特殊處理:

對於刪除節點來說,也需要對頭節點做特殊處理。但是這種特殊處理意味着更多的代碼,而且每次都要進行條件判斷。如果能在 head 頭節點前面再增加一個節點,而這個節點本身又不參與存儲元素,應該就能解決我們的問題。

dummyHead - 頭節點的prev節點

dummyHead 就是我們為了方便添加頭節點而新增的節點,dummy 的意思是它不是真正的節點,對外也無法訪問。一個含有 dummyHead 的初始化鏈表如下:

轉換成代碼的話,就是這樣:

public class LinkedList<E>{
    // 節點類
    private class Node{
    ... ...
    }

    private Node dummyHead;  // dummyHead節點
    private int size;   // 鏈表長度

    public LinkedList(){
        this.dummyHead= new Node();  // 生成dummyHead節點
        this.size = 0;
    }
}

可以看見,我們只聲明了 dummyHead,而沒有聲明 head 頭節點,因為 dummyHead 的下一個節點指向的就是 head 節點,如果想訪問 head 節點,直接調用 dummyHead.next 就可以了。
有了 dummyHead,無論是添加還是刪除節點,我們都可以遵循同一流程,而不必對誰特殊對待,影響整體性能。
節點添加流程:

節點刪除流程:

節點訪問流程:

我們之前一直着重在說節點增刪的問題,其實訪問節點比較簡單,只要從頭節點開始(dummyHead.next),遍歷到索引位置,即可訪問到目標節點。

代碼實現

基於以上邏輯,我們就可以實現鏈表了。

package com.algorithm.linkedlist;

import java.lang.String;

// 添加head元素和索引元素分情況處理
public class LinkedList<E> {
    // 節點類
    private class Node {
        public E element;   // 節點儲存的元素值
        public Node next;   // 指向的下一個鏈接節點

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

        public Node(E element) {
            this.element = element;
            this.next = null;
        }

        public Node() {

        }
    }

    private Node dummyHead;  // 鏈表dummy節點
    private int size;   // 鏈表長度

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

    public int getSize() {
        return size;
    }

    public boolean isEmpty() {
        return getSize() == 0;
    }

    // 添加節點
    public void add(int index, E element) {
        if (index < 0 || index > getSize()) throw new IllegalArgumentException("index must > 0 and <= size!");
        // prev節點的初始值為dummyHead
        Node prev = dummyHead;
        // 通過遍歷找到prev節點
        for (int i = 0; i < index; i++) prev = prev.next;
        // 將new Node的next節點指向prev.next,再把prev節點的next指向new Node
        prev.next = new Node(element, prev.next);
        size++;
    }

    public void addFirst(E element) {
        add(0, element);
    }

    public void addLast(E element) {
        add(getSize(), element);
    }

    // 移除節點
    public E remove(int index) {
        if (index < 0 || index >= getSize()) throw new IllegalArgumentException("index must > 0 and < size!");
        if (getSize() == 0) throw new IllegalArgumentException("Empty Queue, please enqueue first!");
        // prev節點的初始值為dummyHead
        Node prev = dummyHead;
        // 通過遍歷找到prev節點
        for (int i = 0; i < index; i++) prev = prev.next;
        // 儲存待刪除節點
        Node delNode = prev.next;
        // 跳過delNode
        prev.next = delNode.next;
        // 待刪除節點后接null
        delNode.next = null;
        size--;
        return delNode.element;
    }

    public E removeFirst() {
        return remove(0);
    }

    public E removeLast() {
        return remove(getSize() - 1);
    }

    // 查找元素所在節點位置
    public int search(E element) {
        // 從頭節點開始遍歷
        Node current = dummyHead.next;
        for (int i = 0; i < getSize(); i++) {
            if (element.equals(current.element)) return i;
            current = current.next;
        }
        return -1;
    }

    // 判斷節點元素值
    public boolean contains(E element) {
        return search(element) != -1;
    }

    // 獲取指定位置元素值
    public E get(int index) {
        if (index < 0 || index >= getSize()) throw new IllegalArgumentException("index must > 0 and < size!");
        // 從頭節點開始遍歷
        Node current = dummyHead.next;
        for (int i = 0; i < index; i++) current = current.next;
        return current.element;
    }

    public E getFirst() {
        return get(0);
    }

    public E getLast() {
        return get(getSize() - 1);
    }

    // 設置節點元素值
    public void set(int index, E element) {
        // 從頭節點開始遍歷
        Node current = dummyHead.next;
        for (int i = 0; i < index; i++) current = current.next;
        current.element = element;
    }

    @Override
    public String toString() {
        StringBuilder str = new StringBuilder();
        str.append(String.format("LinkedList: size = %d\n", getSize()));
        Node current = dummyHead.next;
        for (int i = 0; i < getSize(); i++) {
            str.append(current.element).append("->");
            current = current.next;
        }
        str.append("null");
        return str.toString();
    }

    // main函數測試
    public static void main(String[] args) {
        LinkedList<Integer> linkedList = new LinkedList<>();
        for (int i = 0; i < 5; i++) {
            linkedList.add(i, i);
            System.out.println(linkedList);
        }
        // 刪除首尾節點
        linkedList.removeFirst();
        linkedList.removeLast();
        System.out.println(linkedList);
    }
}

/*
輸出內容:
LinkedList: size = 1
0->null
LinkedList: size = 2
0->1->null
LinkedList: size = 3
0->1->2->null
LinkedList: size = 4
0->1->2->3->null
LinkedList: size = 5
0->1->2->3->4->null
LinkedList: size = 3
1->2->3->null
*/

使用鏈表實現棧

實現了鏈表后,我們仿照之前的動態數組,也實現一下棧和隊列這兩種較基礎的數據結構。如果需要了解棧和隊列,可以看之前的這篇文章
在寫代碼前,我們先來分析一下如何實現。棧是一種后進先出的結構,而通過上面對鏈表的學習,可以發現鏈表的 head 頭節點位置與棧的棧頂非常相似,節點可以通過頭節點直接進入鏈表,也可以直接從頭節點脫離鏈表,而且這兩種操作的時間復雜度都是 O(1) 級別的(prev 節點無需移動)。找到了這個特點,就可以很快地利用鏈表實現棧。

我們還是使用之前的接口實現棧:

package com.algorithm.stack;

public interface Stack <E> {
    void push(E element);   // 入棧
    E pop();                // 出棧
    E peek();               // 查看棧頂元素
    int getSize();          // 獲取棧長度
    boolean isEmpty();      // 判斷棧是否為空

}

具體實現:

package com.algorithm.stack;


import com.algorithm.linkedlist.LinkedList;

public class LinkedListStack<E> implements Stack<E>{
    private LinkedList<E> linkedList; // 使用鏈表儲存棧元素

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

    // 把鏈表頭作為棧頂,始終對鏈表頭進行操作
    // 入棧
    @Override
    public void push(E element) {
        linkedList.addFirst(element);
    }

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

    // 查看棧頂元素
    @Override
    public E peek() {
        return linkedList.getFirst();
    }

    // 查看棧中元素個數
    @Override
    public int getSize() {
        return linkedList.getSize();
    }

    // 查看棧是否為空
    @Override
    public boolean isEmpty() {
        return linkedList.isEmpty();
    }

    @Override
    public String toString() {
        return "Stack: top [" + linkedList + "] tail";
    }

    // main函數測試
    public static void main(String[] args) {
        LinkedListStack<Integer> stack = new LinkedListStack<>();
        for (int i=0;i<5;i++){
            stack.push(i);
            System.out.println(stack);
        }
        stack.pop();
        System.out.println(stack);
    }
}

/*
輸出結果:
Stack: top [0->null] tail
Stack: top [1->0->null] tail
Stack: top [2->1->0->null] tail
Stack: top [3->2->1->0->null] tail
Stack: top [4->3->2->1->0->null] tail
Stack: top [3->2->1->0->null] tail
*/

數組棧 VS 鏈表棧

截至目前,我們已經通過兩種方式實現了棧,接下來不妨對比一下兩種實現方式的性能孰高孰低。可以通過出棧和入棧兩種操作進行評估:

package com.algorithm.stack;

import java.util.Random;

public class PerformanceTest {
    public static double testStack(Stack<Integer> stack, int testNum){
        // 起始時間
        long startTime = System.nanoTime();
        // 使用隨機數測試
        Random random = new Random();
        // 入棧測試
        for (int i=0;i<testNum;i++) stack.push(random.nextInt(Integer.MAX_VALUE));
        // 出棧測試
        for (int i=0;i<testNum;i++) stack.pop();
        // 結束時間
        long endTime = System.nanoTime();
        // 返回測試時長
        return (endTime - startTime) / 1000000000.0;

    }

    public static void main(String[] args) {
        // 數組棧
        ArrayStack<Integer> arrayStack = new ArrayStack<>();
        double arrayTime = testStack(arrayStack, 1000000);
        System.out.println("ArrayStack: " + arrayTime);
        // 鏈表棧
        LinkedListStack<Integer> linkedListStack = new LinkedListStack<>();
        double linkedTIme = testStack(linkedListStack, 1000000);
        System.out.println("LinkedListStack: " + linkedTIme);
    }
}

/*
輸出結果:
// testNum = 10萬次的測試結果
ArrayStack: 0.0167257
LinkedListStack: 0.0120104

// testNum = 100萬次的測試結果
ArrayStack: 0.0509282
LinkedListStack: 0.2121052
*/

第一次使用10萬個隨機數進行測試時,兩者的性能差不多,鏈表似乎還有小小的優勢;而當使用100萬個數測試時,鏈表要明顯慢於數組。原因是鏈表在添加節點的過程中,需要不斷地 new 一個新的節點,而這個 new 的過程需要尋找新的地址,所以隨着次數的增大,耗時變得越來越明顯。而數組是先統一申請一批,滿了再繼續通過 resize 申請(個數取決於數組長度)。但是如果先執行鏈表,后執行數組,又會出現不同的結果:

public class PerformanceTest {
    ... ...

    public static void main(String[] args) {
        // 鏈表棧
        LinkedListStack<Integer> linkedListStack = new LinkedListStack<>();
        double linkedTime = testStack(linkedListStack, 1000000);
        System.out.println("LinkedListStack: " + linkedTime);
        // 數組棧
        ArrayStack<Integer> arrayStack = new ArrayStack<>();
        double arrayTime = testStack(arrayStack, 1000000);
        System.out.println("ArrayStack: " + arrayTime);

    }
}

/*
輸出結果:
LinkedListStack: 0.0368811
ArrayStack: 0.054051
*/

這下鏈表又比數組快了😂!猜想應該是先跑鏈表時,空閑空間比較多,找新地址的開銷還不大。但是如果在數組已經占用了100萬個地址的情況下,再尋找地址就沒那么容易了。

使用鏈表實現隊列

實現了棧,再來看隊列。隊列是一種先進先出的結構,對應到鏈表,可以使用鏈表的 head 頭節點模擬出隊操作(O(1)的時間復雜度)。如果知道了鏈表尾部的位置,就可以通過從鏈表尾部添加節點來模擬入隊操作,並且這個操作的時間復雜度也是 O(1)。所以我們要再多維護一個 tail 節點,意味着我們要對剛才的鏈表稍作調整。
同樣使用之前的隊列接口進行實現:

package com.algorithm.queue;

public interface Queue<E> {
    void enqueue(E element);    // 入隊
    E dequeue();                // 出隊
    E getFront();               // 獲取隊首元素
    int getSize();              // 獲取隊列長度
    boolean isEmpty();          // 判斷隊列是否為空
}

具體實現:

package com.algorithm.queue;

import java.lang.String;

public class LinkedListQueue<E> implements Queue<E>{
    // 節點類
    private class Node{
        public E element;   // 節點儲存的元素值
        public Node next;   // 指向的下一個鏈接節點

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

        public Node(E element){
            this.element = element;
            this.next = null;
        }

        public Node(){

        }
    }

    private Node head, tail;    // 增加tail節點
    private int size;

    public LinkedListQueue(){
        head = tail = null;
        size = 0;
    }

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

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

    // tail入隊
    @Override
    public void enqueue(E element){
        // 如果隊列為空,則將head和tail都置為入隊的第一個節點
        if (tail == null){
            head = tail = new Node(element);
        }else{  // 其他情況下在tail處鏈接即可
            tail.next = new Node(element);
            tail = tail.next;
        }
        size++;
    }

    // head出隊
    @Override
    public E dequeue(){
        if (isEmpty()) throw new IllegalArgumentException("Empty queue, enqueue first!");
        // 將當前head標記為待出隊節點
        Node delNode = head;
        // head.next節點替代當前head
        head = head.next;
        // 將出隊節點置為空,脫離鏈表
        delNode.next = null;
        size--;
        // 如果出隊后head為空,說明隊列為空,則將tail也置為null
        if (head == null) tail = null;
        return delNode.element;
    }

    // 獲取隊首元素
    @Override
    public E getFront(){
        if (isEmpty()) throw new IllegalArgumentException("Empty queue, enqueue first!");
        return head.element;
    }

    @Override
    public String toString(){
        StringBuilder str = new StringBuilder();
        str.append("head [");
        // 從head開始遍歷節點
        Node current = head;
        for (int i=0; i<getSize(); i++) {
            str.append(current.element).append("->");
            current = current.next;
        }
        str.append("null] tail");
        return str.toString();
    }

    public static void main(String[] args) {
        LinkedListQueue<Integer> queue = new LinkedListQueue<>();
        for (int i=0; i<10;i++) {
            queue.enqueue(2*i +1);
            System.out.println("enqueue: " + queue);
            if (i % 2 == 0 && i != 0){
                queue.dequeue();
                System.out.println("dequeue: " + queue);
            }
        }
    }
}

/*
輸出結果:
enqueue: head [1->null] tail
enqueue: head [1->3->null] tail
enqueue: head [1->3->5->null] tail
dequeue: head [3->5->null] tail
enqueue: head [3->5->7->null] tail
enqueue: head [3->5->7->9->null] tail
dequeue: head [5->7->9->null] tail
enqueue: head [5->7->9->11->null] tail
enqueue: head [5->7->9->11->13->null] tail
dequeue: head [7->9->11->13->null] tail
enqueue: head [7->9->11->13->15->null] tail
enqueue: head [7->9->11->13->15->17->null] tail
dequeue: head [9->11->13->15->17->null] tail
enqueue: head [9->11->13->15->17->19->null] tail
*/

可以看到,我們沒有像之前一樣再去維護 dummyHead 節點,因為在模擬隊列時,無論是入隊還是出隊,時間復雜度都是 O(1),不需要遍歷所有節點。加入了 tail 節點后,有點像之前的循環隊列,需要考慮隊列為空時 head 和 tail 的取值問題。

數組隊列 VS 鏈表隊列 VS 循環隊列

我們把之前的循環隊列也加上,比較一下三種隊列在入隊和出隊方面的性能差異:

package com.algorithm.queue;
import java.util.Random;

public class PerformanceTest {
    public static double testQueue(Queue<Integer> queue, int testNum){
        // 起始時間
        long startTime = System.nanoTime();
        // 入棧測試
        Random random = new Random();
        for (int i=0; i< testNum;i++){
            queue.enqueue(random.nextInt(Integer.MAX_VALUE));
        }
        // 出棧測試
        for (int i=0; i< testNum;i++){
            queue.dequeue();
        }
        // 結束時間
        long endTime = System.nanoTime();
        return (endTime - startTime) / 1000000000.0;
    }

    public static void main(String[] args) {
        // 數組隊列
        ArrayQueue<Integer> arrayQueue = new ArrayQueue<>();
        double arrayTime = testQueue(arrayQueue, 100000);
        System.out.println("ArrayQueue: " + arrayTime);
        // 鏈表隊列
        LinkedListQueue<Integer> linkedListQueue = new LinkedListQueue<>();
        double linkedTime = testQueue(linkedListQueue, 100000);
        System.out.println("LinkedQueue: " + linkedTime);
        // 循環隊列
        LoopQueue<Integer> loopQueue = new LoopQueue<>();
        double loopTime = testQueue(loopQueue, 100000);
        System.out.println("LoopQueue: " + loopTime);
    }
}

/*
輸出結果:
ArrayQueue: 3.1286747
LinkedQueue: 0.0070489
LoopQueue: 0.018315
*/

這次鏈表的性能就遠遠大於數組了(試試把鏈表放在最后執行),循環隊列與鏈表的差異還不算太大。因為數組每次出隊都會觸發向左移動元素(數組頭部為隊首),時間復雜度是 O(n) 級別,而鏈表和循環隊列不需要移動,是 O(1) 級別的復雜度(上篇文章計算的循環隊列實際上是 O(2)級別,所以會比鏈表慢一些),所以性能會較優。

總結

至此,我們就學會了鏈表的基本概念和使用方式。相對於數組來說,鏈表有着更加靈活的結構,但鏈表也不是萬能的。通常情況下,數組適合索引有實際意義的場景,例如按照學號存儲成績,如果使用數組,就可以直接使用學號進行訪問,而鏈表則沒有這一優勢。如果索引沒有實際意義,用鏈表就比較合適。


免責聲明!

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



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