1. 程式人生 > >為什麼我要放棄javaScript資料結構與演算法(第五章)—— 連結串列

為什麼我要放棄javaScript資料結構與演算法(第五章)—— 連結串列

這一章你將會學會如何實現和使用連結串列這種動態的資料結構,這意味著我們可以從中任意新增或移除項,它會按需進行擴張。

本章內容

  • 連結串列資料結構
  • 向連結串列新增元素
  • 從連結串列移除元素
  • 使用 LinkedList 類
  • 雙向連結串列
  • 迴圈連結串列

第五章 連結串列

連結串列資料結構

要儲存多個元素,陣列(或列表)可能是最常見的資料結構了。然後這種資料結構有一個缺點:陣列的大小是固定的,從陣列的起點或中間插入或移除項的成本有點高,因為需要移動元素。

連結串列儲存有序的元素集合,但不同於陣列,連結串列中的元素在記憶體並不是連續放置的。每個元素由一個儲存元素本身的節點和一個指向下一個元素的引用(也稱指標或連結)組成,下圖展示了一個連結串列的結構。

連結串列資料結構

相對於傳統的陣列,連結串列的一個好處在於,新增或者移除元素的時候不需要移動其他元素,然後,連結串列需要使用指標,因為實現連結串列時需要額外注意。陣列的另一個細節是可以直接訪問任何位置的任何元素,而想要訪問連結串列中間的一個元素,需要從起點(表頭)開始迭代列表直到找到所需的元素。

現實中也有一些連結串列的例子,第一個例子就是康加舞隊,每個人就是一個元素,手就是鏈向下一個人的指標,可以向佇列彙總增加人——只需要找到想加入的點,斷開連線,插入一個人,再重新連線起來。

另外一個例子就是尋寶遊戲,你有一條線索,這條線索是指向尋找下一個線索的地點的指標,你順著這條鏈去下一個地點,得到另一條指向再下一處的線索。得到列表中間的線索的唯一方法,就是從起點(第一個線索)順著列表尋找。

還有一個可能就是用來說明連結串列中的最流行的例子,那就是火車。一列火車是由一系列車廂(也稱車皮)組成的。每節車廂或車皮都互相連線。可以很容易的分離開一節車皮,改變它的位置,新增或移除它。

建立連結串列

瞭解連結串列是什麼之後,就要開始實現我們的資料結構了,一下是我們的 LinkedList 類的骨架:

function LinkedList(){
    let Node = function(element){ // 需要一個Node輔助類,表示要加入列表的項,element 代表要新增到列表中的值, next d代表指向列表的下一個節點向的指標
        this.element = element;
        this.next = null;
    }

    let length = 0; // 儲存列表項的數量 length 屬性
    let head = null; // 儲存第一個節點的引用在 head 變數

    this.append = function(element){};
    this.insert = function(position,element){}
    this.removeAt = function(position){}
    this.remove = function(element){}
    this.indexOf = function(position){}
    this.isEmpty = function(){}
    this.size = function(){}
    this.getHead = function(){}
    this.toString = function(){}
    this.print = function(){}

}

LinkedList 類的方法的功能

  • append(element):向列表尾部新增一個新的項
  • insert(position,element):向列表的特定位置插入一個新的項
  • removeAt(position):從列表的特定位置移除一項
  • remove(element):從列表中移除一項
  • indexOf(element):返回元素在列表中的索引。如果列表中沒有該元素則返回-1
  • isEmpty():如果連結串列中不包含任何元素,返回true,如果連結串列的長度大於0則返回 false
  • size():返回連結串列包含的元素個數,與數字的 length 屬性類似
  • toString():由於列表項使用 Node 類,就需要重寫繼承自 JavaScript 物件預設的 toString 方法,讓其只輸出元素的值。

向連結串列尾部追加元素

向 LinkedList 物件尾部新增一個元素時,可能有兩種場景,列表為空,新增的是第一個元素,或者列表不為空,向追加元素。

this.append = function(element){
    let node = new Node(element),current;
    if(head === null){
        head = node;
    }else{
        current = head;
        // 迴圈列表,直到找到最後一項
        while(current.next){
            current = current.next;
        }
        // 當current.next元素為null時,找到最後一項,將其 next 賦為 node,建立連線
        current.next = node;
    }
    length++;  // 更新列表的長度
};

可以在 append 函式 加上return node ,通過下面的程式碼來使用和測試目前建立的資料結構

let list = new LinkedList();
console.log(list.append(15)); // Node {element: 15, next: null}
console.log(list.append(10)); // Node {element: 10, next: null}

從連結串列中移除元素

移除元素也有兩種場景:第一種是移除第一個元素,第二種是移除第一個以外的任一元素。我們要實現兩種 remove 方法:第一種是從特定位置移除第一個元素,第二種是根據元素的值移除元素。

首先先實現移除特定位置的元素

this.removeAt = function(position){
    // 檢查越界值
    if(position >-1 && position < length){
        let current = head,previous,index = 0;

        // 移除第一項
        if(position === 0){
            head = current.next;
            console.log(current.element);
        }else{
            while(index++ < position){
                previous = current;
                current = current.next;
            }
            // 將 previous 與 current 的下一項連線起來,跳過 current,從移除它
            previous.next = current.next;
        }
        length --;
        console.log(current.element);
        return current.element;
    }else{              
        return null;
    }           
}

如果想要移除第一個元素(position=0),要做的就是讓 head 指向列表的第二個元素,我們用 current 變數穿甲一個對列表中第一個元素的應用,這樣 current 變數就是對列表中第一個元素的引用,如果吧 head 賦值為 current.next 就會移除第一個元素。

如果我們要移除列表的最後一項或者是中間的一項,為此,需要依靠一個細節來迭代列表,知道到達目標位置(index++ < position),使用一個用於內部控制和遞增的 index 變數,current 變數總是對所迴圈列表的當前元素的引用(current = current.next),我們還需要一個對當前元素的前一個元素的引用(previous = current),它被命名為 previous。

因此,要從列表中移除當前元素,要做的就是將 previous.next 和 current.next 連結起來,這樣當前元素就會被丟棄在計算機記憶體中,等著被垃圾回收站清除。

對於最後一個元素,在(while(index++ < position))跳出迴圈時, current 變數總是對列表中最後一個元素的引用(要移除的元素)。current.next 的值將是 null(因為它是最後一個元素)。由於還保留了對 previous 的引用(當前元素的前一個元素),previous 就指向了 current 。那麼要移除 current,要做的就是把 previous.next 的值改變為 current.next 。

在任意位置插入元素

實現 insert 方法,使用這個方法可以在任意位置插入一個元素。

this.insert = function(position,element){
    // 檢查越界值
    if(position >=0 && position <= length){
        let node = new Node(element),
        current = head,
        previous,
        index = 0;
        if(position === 0 ){ // 在第一個位置新增
            node.next = current;
            head = node;
        }else{
            while(index++ < position){
                previous = current;
                current = current.next;
            }
            node.next = current;
            previous.next = node;
        }
        length++;
        return true;
    }else{
        return false;
    }
}

current 變數是對列表中第一個元素的引用,我們需要做的是把 node.next 的值設為 current (列表中的第一個元素),現在 head 和 node.next 都指向了 current ,接下來要做的就是把 head 的引用改為 node ,這樣列表中就有了一個新元素。

現在來處理第二種場景,在列表中間或者末尾新增一個新的元素。首先,迴圈訪問列表,找打目標為位置,當跳出迴圈的時候, current 變數將是對想要插入新元素的位置之後一個元素的引用,而 previous 將是對想要插入新元素的位置之前的一個元素的引用。在這種情況下,我門要在 previous 和 current 之間新增新項。因此,需要將新項(node)和當前連結起來(node.next = current),然後需要改變 previous 和 current z之間的連結,我們還需要讓 previous.next 指向 node。

實現其他方法

toString方法

this.toString = function(){
    let current = head,
    string = '';
    while(current){
        string += current.element + (current.next ? '-':'');
        current = current.next;
    }
    return string;
}

賦值current為 head, 迴圈訪問 current,將 current 變數當做索引,初始化用於拼接元素的變數(string)。通過 current 來檢查元素是否存在,如果列表為空或是到達列表中最後一個元素的下一位(null),while 迴圈中的程式碼就不會執行,就可以得到元素的內容,將其拼接到字串中,最後,迭代下一個元素。最後,返回列表內容的字串。

indexOf方法

indexOf方法接受一個元素的值,如果在列表中找到它,就返回元素的位置,否則返回 -1

this.indexOf = function(element){
    let current = head,
    index = 0;
    while(current){
        if(element === current.element){
            return index;
        }
        index++;
        current = current.next;
    }
    return -1;
}

迴圈變數 current,它的初始值是 head ,利用index 來計算位置數。訪問元素,檢查當前元素是否是我們要找的,如果是,就返回它的位置,不是就繼續計數,檢查列表中的下一個節點。

如果列表為空,或是到達列表的尾部(current = current.next 將是 null),迴圈就不會執行。如果沒有找到值就返回 -1 。

實現了上面的方法,就可以實現remove等其他方法了

remove方法

this.remove = function(element){
    let index = this.indexOf(element);
    return this.removeAt(index);
}

isEmpty、size 和 getHead方法

isEmpty和size 和之前章節實現一模一樣

this.isEmpty = function(){
    return length === 0;
}

this.size = function(){
    return length;
}

還有 getHead方法

this.getHead = function(){
    return head;
}

head 變數是 LinkedList 類的私有變數,我們如果需要在類的實現外部迴圈訪問列表,就需要提供一種獲取類的第一個元素的方法。

整個 LinkedList函式

function LinkedList(){
    let Node = function(element){ // 需要一個Node輔助類,表示要加入列表的項,element 代表要新增到列表中的值, next d代表指向列表的下一個節點向的指標
        this.element = element;
        this.next = null;
    }

    let length = 0; // 儲存列表項的數量 length 屬性
    let head = null; // 儲存第一個節點的引用在 head 變數

    this.append = function(element){
        let node = new Node(element),current;
        if(head === null){
            head = node;
        }else{
            current = head;
            // 迴圈列表,直到找到最後一項
            while(current.next){
                current = current.next;
            }
            // 當current.next元素為null時,找到最後一項,將其 next 賦為 node,建立連線
            current.next = node;
        }
        length++;  // 更新列表的長度
    };

    this.insert = function(position,element){
        // 檢查越界值
        if(position >=0 && position <= length){
            let node = new Node(element),
            current = head,
            previous,
            index = 0;
            if(position === 0 ){ // 在第一個位置新增
                node.next = current;
                head = node;
            }else{
                while(index++ < position){
                    previous = current;
                    current = current.next;
                }
                node.next = current;
                previous.next = node;
            }
            length++;
            return true;
        }else{
            return false;
        }
    }

    this.removeAt = function(position){
        // 檢查越界值
        if(position >-1 && position < length){
            let current = head,previous,index = 0;

            // 移除第一項
            if(position === 0){
                head = current.next;
                console.log(current.element);
            }else{
                while(index++ < position){
                    previous = current;
                    current = current.next;
                }
                // 將 previous 與 current 的下一項連線起來,跳過 current,從移除它
                previous.next = current.next;
            }
            length --;
            return current.element;
        }else{              
            return null;
        }           
    }

    this.remove = function(element){
        let index = this.indexOf(element);
        return this.removeAt(index);
    }

    this.indexOf = function(element){
        let current = head,
        index = 0;
        while(current){
            if(element === current.element){
                return index;
            }
            index++;
            current = current.next;
        }
        return -1;
    }

    this.isEmpty = function(){
        return length === 0;
    }

    this.size = function(){
        return length;
    }

    this.getHead = function(){
        return head;
    }

    this.toString = function(){
        let current = head,
        string = '';
        while(current){
            string += current.element + (current.next ? '-':'');
            current = current.next;
        }
        return string;
    }

}

雙向連結串列

連結串列有多種不同的型別,這一節介紹 雙向連結串列,雙向連結串列和普通連結串列的區別在於,在連結串列中,一個節點只有鏈向下一個連結,而在雙向連結串列中,連結是雙向的:一個鏈向下一個元素,另一個鏈向前一個元素。如下圖所示

雙向連結串列

先從實現 DoublyLinkedList 類所需要的變動開始

function DoublyLinkedList(){
    let Node = function(elememt){
        this.elememt = elememt;
        this.next = null;
        this.prev = null; // 新增的
    }
    let length = 0;
    let head = null;
    let tail = null // 新增的

    // 這裡是方法
}

可以看出,Node 類中新增了 prev屬性(一個新指標),在 DoublyLinkedList 類裡也有用來儲存對列表最後一項的引用的 tail 屬性。

雙向連結串列提供了兩種迭代列表的方式:從頭到尾,或者從尾到頭。我們也可以方位一個特定節點的下一個或者是上一個元素。在單向連結串列中,如果迭代列表時錯過了要找的元素,就需要回到列表起點,重新迭代。這是雙向連結串列的一個優點。

在任意位置插入新元素

向雙向連結串列中插入一個新項跟(單向)連結串列非常相類似。區別在於,連結串列只要控制一個 next 指標,而雙向連結串列則要同時控制 next 和 prev (previous,前一個)這兩個指標。

this.insert = function(position,elememt){
    // 檢查越界值
    if(position >= 0 && position <= length){
        let node = new Node(elememt),
        current = head,
        previous,
        index = 0;
        if(position === 0){ // 在第一個位置新增
            if(!head){
                head = node;
                tail = node;
            }else{
                node.next = current;
                current.prev = node;
                head = node;
            }
        }else if(position === length){ // 最後一項
            current = tail;
            current.next = node;
            node.prev = current;
            tail = node;
        }else{
            while (index++ < position) {
                previous = current;
                current = current.next;
            }
            node.next = current;
            previous.next = node;

            current.prev = node;
            node.prev = previous;
        }
        length++
        return true;
    }else{
        return false;
    }
}   

在列表的第一個位置(列表的起點)插入一個新元素,如果列表為空(if(!head)),那隻需將 head 和 tail 都指向這個新節點。如果不為空, current 變數將是對列表中的第一個元素的引用。就像我們在連結串列中所做的,把 node.next 設為 current ,而head 將指標指向 node (它被設為列表中的第一個元素)。不同之處,我們還需要為指向上一個元素的指標設一個值。current.prev 指標將由 指向 null 變成指向 新元素(current.prev = node)。node.prev 指標已經是 null,因此不需要在更新任何東西了。

假如我們要在列表最後新增一個新元素。這是一個特殊情況,因為我們還控制著指向最後一個元素的指標(tail)。current 變數將引用最後一個元素(current = tail)。然後分開建立第一個連結:node.prev 將引用current。current.next 指標(指向null)將指向 node (由於建構函式,node.next 已經指向了 null)。然後只剩下一件事,就是更新 tail ,它將由 指向 current 變成指向 node 。

第三種場景:在列表中插入一個新元素,就像我們在之前方法中所做,迭代列表,知道到達要找的位置(while (index++ < position))。我們將在 current 和 previous 元素之間插入新元素。首先,node.next 將指向 current ,而 previous.next 將指向 node,這樣就不會跌勢節點之間的連結。然後需要處理所有的連結:current.prev 將指向node ,而 node.prev 將指向 previous

從任意位置移除元素

從雙向連結串列中移除元素跟連結串列非常類似。唯一區別就是還需要設定一個位置的指標。

this.removeAt = function(position){
    // 檢查越界值
    if(position > -1 && position < length){
        let current = head,
        previous,
        index = 0;
        // 移除第一項
        if(position === 0){
            head = current.next;
            // 如果只有一項,更新 tail
            if(length === 1){
                tail = null;
            }else{
                head.prev = null;
            }
        }else if(position === length-1){ // 最後一項
            current = tail;
            tail = current.prev;
            tail.next = null;
        }else{
            while (index++ < position) {
                previous = current;
                current = current.next;
            }
            // 將 previous 與 current 的下一項連線起來——跳過 current
            previous.next = current.next;
            current.next.prev = previous;
        }
        length --;
        return current.elememt;
    }else{
        return null;
    }
}

我們需要處理三種場景,從頭部、從中間和從尾部移除一個元素。

移除第一個元素。current 變數是對列表中第一個元素的引用,也就是我們想要移除的遠古三。需要做的就是改變 head 的引用,將其從 current 改為下一個元素(head = current.next;),但是我們還需要更新 current.next 指向上一個元素的指標(因為第一個元素的 prev 指標是 null)。因此,把 head.prev 的引用改為 null(因為 head 也指向了列表中的第一個元素,或者也可以用 current.next.prev )。由於還需要控制 tail 的引用,我們可以檢測要移除的是否是第一個元素,如果是,只需要把 tail 也設為 null。

移除最後一個位置的元素。既然已經有了對最後一個元素的引用(tail),我們就不需要為找到它而迭代列表。我們可以把 tail 的引用賦給 current 變數。接下來,就需要吧 tail 的引用更新為列表中的倒數第二個元素(current.prev,或者 tail.prev也可以)。既然 tail 指向了倒數第二個元素,我們需要把 next 指標更新為 null (tail.next = null)。

最後一種場景,從列表中移除一個元素。首先需要迭代列表,直到到達要找的位置。current 變數所引用的就是要移除的遠古三。那麼要移除它,我們可以通過更新 previous.next 和 current.next.prev 的引用,在列表中跳過它。因此,previous.next 將 指向 current.next ,而 current.next.prev 將指向 previous。

完整程式碼

function DoublyLinkedList(){
    let Node = function(elememt){
        this.elememt = elememt;
        this.next = null;
        this.prev = null; // 新增的
    }
    let length = 0;
    let head = null;
    let tail = null // 新增的


    this.append = function(elememt){
        let node = new Node(elememt),
        current;
        if(!head){
            head = node;
            tail = node;
        }else{
            current = tail;
            current.next = node;
            node.prev = current;
            tail = node;
        }
        length++;
    }

    this.insert = function(position,elememt){
        // 檢查越界值
        if(position >= 0 && position <= length){
            let node = new Node(elememt),
            current = head,
            previous,
            index = 0;
            if(position === 0){ // 在第一個位置新增
                if(!head){
                    head = node;
                    tail = node;
                }else{
                    node.next = current;
                    current.prev = node;
                    head = node;
                }
            }else if(position === length){ // 最後一項
                current = tail;
                current.next = node;
                node.prev = current;
                tail = node;
            }else{
                while (index++ < position) {
                    previous = current;
                    current = current.next;
                }
                node.next = current;
                previous.next = node;

                current.prev = node;
                node.prev = previous;
            }
            length++
            return true;
        }else{
            return false;
        }
    }   

    this.removeAt = function(position){
        // 檢查越界值
        if(position > -1 && position < length){
            let current = head,
            previous,
            index = 0;
            // 移除第一項
            if(position === 0){
                head = current.next;
                // 如果只有一項,更新 tail
                if(length === 1){
                    tail = null;
                }else{
                    head.prev = null;
                }
            }else if(position === length-1){ // 最後一項
                current = tail;
                tail = current.prev;
                tail.next = null;
            }else{
                while (index++ < position) {
                    previous = current;
                    current = current.next;
                }
                // 將 previous 與 current 的下一項連線起來——跳過 current
                previous.next = current.next;
                current.next.prev = previous;
            }
            length --;
            return current.elememt;
        }else{
            return null;
        }
    }

    this.remove = function(elememt){
        let index = this.indexOf(elememt);
        this.removeAt(index);
    }   

    this.toString = function(){
        let current = head,
        str = '';
        while (current) {
            str += current.elememt + (current.next?'-':'');
            current = current.next;
        }
        return str;
    }       

    this.indexOf = function(elememt){
        let current = head,
        index = 0;
        while (current) {
            if(current.elememt === elememt){
                return index
            }
            current = current.next;
            index++
        }
        return -1;
    }

    this.isEmpty = function(){
        return length === 0;
    }

    this.size = function(){
        return length;
    }

    this.getHead = function(){
        return head;
    }

}

迴圈連結串列

迴圈連結串列可以像連結串列一樣只有單向引用,也可以像雙向連結串列一樣有雙向引用。迴圈連結串列和連結串列之間唯一的區別在於,最後一個元素指向下一個元素的指標(tail.next)不是引用 null,而是指向第一個元素(head),如下圖所示

迴圈連結串列

雙向連結串列有指向 head 元素的 tail.next ,和指向 tail 元素的 head.prev

雙向迴圈連結串列

小結

這一章中,學習了連結串列這種資料結構,及其辯題雙向連結串列和迴圈連結串列,知道了如何在任意位置新增和移除元素,已經如何迴圈訪問兩邊,比陣列重要的優點就是,無需移動連結串列中的元素,就能輕鬆新增和移除元素。當你需要新增和移除很多元素的時候,最好的選擇就是連結串列,而非陣列。下一章將學習集合,最後一種順序資料結構。

書籍連結: 學習JavaScript資料結構與演算法