資料結構之LinkedList | 讓我們一塊來學習資料結構

guojikun發表於2021-04-29

highlight: monokai
theme: vue-pro

上一篇文章中使用列表(List)對資料排序,當時底層儲存資料的資料結構是陣列。本文將討論另外一種列表:連結串列。我們會解釋為什麼有時連結串列優於陣列,還會實現一個基於物件的連結串列。下面讓我們一起來學習LinkedList

陣列的缺點

在很多程式語言中,陣列的長度是固定的,所以當陣列已被資料填滿時,再要加入新的元素就會非常困難。在陣列中,新增和刪除元素也很麻煩,因為需要將陣列中的其他元素向前或向後平移,以反映陣列剛剛進行了新增或刪除操作。然而,JavaScript 的陣列並不存在上述問題,因為使用 split() 方法不需要再訪問陣列中的其他元素了。

avaScript 中陣列的主要問題是,它們被實現成了物件,與其他語言(比如 C++ 和 Java)的陣列相比,效率很低。

定義連結串列

連結串列是由一組節點組成的集合。每個節點都使用一個物件的引用指向它的後繼。指向另一個節點的引用叫做鏈。下圖展示了一個連結串列。

企業微信截圖_20210429090547.png
陣列元素靠它們的位置進行引用,連結串列元素則是靠相互之間的關係進行引用。在上圖中,我們說 李四 跟在 張三 後面,而不說 李四 是連結串列中的第二個元素。遍歷連結串列,就是跟著連結,從連結串列的首元素一直走到尾元素(但這不包含連結串列的頭節點,頭節點常常用來作為連結串列的接入點)。圖中另外一個值得注意的地方是,連結串列的尾元素指向一個 null 節點。

然而要標識出連結串列的起始節點卻有點麻煩,許多連結串列的實現都在連結串列最前面有一個特殊節點,叫做頭節點。經過改造之後,上圖中的連結串列成了下面的樣子。

企業微信截圖_20210429091827.png

設計一個基於物件的連結串列

Node類

Node 類包含兩個屬性:element 用來儲存節點上的資料,next 用來儲存指向下一個節點的
連結。我們使用一個class來建立節點:

class Node {
    constructor(element) {
        this.element = element;
        this.next = null;
    }
}

LinkedList類

LList 類提供了對連結串列進行操作的方法。該類的功能包括插入刪除節點、在列表中查詢給
定的值。該類也有一個建構函式,連結串列只有一個屬性,那就是使用一個 Node 物件來儲存該
連結串列的頭節點。
該類如下所示

class LinkedList {
    constructor() {
        this.head = new Node("head");
    }
    find() { }
    insert() { }
    findPrevious() { }
    remove() { }
    display() { }
}

head 節點的 next 屬性被初始化為 null,當有新元素插入時,next 會指向新的元素,所以在這裡我們沒有修改 next 的值。

插入新節點

該方法向連結串列中插入一個節點。向連結串列中插入新節點時,需要明確指出要在哪個節點前面或後面插入。首先介紹如何在一個已知節點後面插入元素。

在一個已知節點後面插入元素時,先要找到“後面”的節點。為此,建立一個輔助方法find(),該方法遍歷連結串列,查詢給定資料。如果找到資料,該方法就返回儲存該資料的節點。find() 方法的實現程式碼如下所示:

find(element) {
    let current = this.head;
    while (current.element !== element) {
        current = current.next;
    }
    return current;
}

find() 方法演示瞭如何在連結串列上進行移動。首先,建立一個新節點,並將連結串列的頭節點賦給這個新建立的節點。然後在連結串列上進行迴圈,如果當前節點的 element 屬性和我們要找的資訊不符,就從當前節點移動到下一個節點。如果查詢成功,該方法返回包含該資料的節點;否則,返回 null

一旦找到“後面”的節點,就可以將新節點插入連結串列了。首先,將新節點的 next 屬性設定為“後面”節點的 next 屬性對應的值。然後設定“後面”節點的 next 屬性指向新節點。

insert() 方法的定義如下:

insert(element) {
    const newNode = new Node(element);
    const curNode = this.find(element);

    newNode.next = cur.next;
    curNode.next = newNode;
}

移除節點

在之前我們已經可以實現插入節點了,有新增自然就有移除。現在讓我們來實現remove()方法。

從連結串列中刪除節點時,需要先找到待刪除節點前面的節點。找到這個節點後,修改它的
next 屬性,使其不再指向待刪除節點,而是指向待刪除節點的下一個節點。我們可以定義
一個方法 findPrevious(),來做這件事。該方法遍歷連結串列中的元素,檢查每一個節點的下
一個節點中是否儲存著待刪除資料。如果找到,返回該節點(即“前一個”節點),這樣
就可以修改它的 next 屬性了。findPrevious() 方法的定義如下:

findPrevious(item) {
    let curNode = this.head;
    while (curNode.next !== null && curNode.next.element !== item) {
        curNode = curNode.next;
    }
    return curNode;
}

現在就可以開始寫 remove() 方法了:

remove(item) {
    const prevNode = this.findPrevious(item);
    if (prevNode.next !== null) {
        prevNode.next = prevNode.next.next;
    }
}

檢視連結串列內的元素

現在已經可以開始測試我們的連結串列實現了。然而在測試之前,先來定義一個 display() 方法,該方法用來顯示連結串列中的元素:

display() {
    let target = [];
    let curNode = this.head;
    while (curNode.next !== null) {
        target.push(curNode.next.element);
        curNode = curNode.next;
    }
    return target.join();
}

測試程式碼

測試程式碼.png

雙向連結串列

儘管從連結串列的頭節點遍歷到尾節點很簡單,但反過來,從後向前遍歷則沒那麼簡單。通過給 Node 物件增加一個屬性,該屬性儲存指向前驅節點的連結,這樣就容易多了。此時向連結串列插入一個節點需要更多的工作,我們需要指出該節點正確的前驅和後繼。但是在從連結串列中刪除節點時,效率提高了,不需要再查詢待刪除節點的前驅節點了。圖 6-5 演示了雙向連結串列的工作原理。

修改Node類

首當其衝的是要為 Node 類增加一個 previous 屬性:

class Node {
    constructor(element) {
        this.element = element;
        this.previous = null;
        this.next = null;
    }
}

修改insert()方法

雙向連結串列的 insert() 方法和單向連結串列的類似,但是需要設定新節點的 previous 屬性,使其指向該節點的前驅。該方法的定義如下:

insert(element, item) {
    const newNode = new Node(element);
    const curNode = this.find(item);
    newNode.next = curNode.next;
    newNode.previous = curNode; // 令新節點的previous指向當前節點
    curNode.next = newNode;
}

雙向連結串列的 remove() 方法比單向連結串列的效率更高,因為不需要再查詢前驅節點了。首先需要在連結串列中找出儲存待刪除資料的節點,然後設定該節點前驅的 next 屬性,使其指向待刪除節點的後繼;設定該節點後繼的 previous 屬性,使其指向待刪除節點的前驅。圖 6-6 直觀地展示了該過程。

企業微信截圖_20210429093227.png

新的remove() 方法的定義

remove(item) {
    const curNode = this.find(item);
    if (curNode.next !== null) {
        curNode.previous.next = curNode.next;
        curNode.next.previous = curNode.previous;
        curNode.previous = null;
        curNode.next = null;
    }
}

反向顯示連結串列中的元素

為了完成以反序顯示連結串列中元素這類任務,需要給雙向連結串列增加一個工具方法,用來查詢最後的節點。findLast() 方法找出了連結串列中的最後一個節點,同時免除了從前往後遍歷連結串列之苦:

findLast() {
    let curNode = this.head;
    while (curNode.next !== null) {
        curNode = curNode.next;
    }
    return curNode;
}

有了這個工具方法,就可以寫一個方法,反序顯示雙向連結串列中的元素。dispReverse() 方法如下所示:

displayReverse() {
    let target = [];
    let currNode = this.findLast();
    while (currNode.previous !== null) {
        target.push(currNode.element);
        currNode = currNode.previous;
    }
    return target.join();
}

完整的雙向連結串列實現及測試程式碼

class Node {
    constructor(element) {
        this.element = element;
        this.previous = null;
        this.next = null;
    }
}

class LinkedList {
    constructor() {
        this.head = new Node("head");
    }
    find(item) {
        let current = this.head;
        while (current.element !== item) {
            current = current.next;
        }
        return current;
    }
    insert(element, item) {
        const newNode = new Node(element);
        const curNode = this.find(item);
        newNode.next = curNode.next;
        newNode.previous = curNode;
        curNode.next = newNode;
    }
    findLast() {
        let curNode = this.head;
        while (curNode.next !== null) {
            curNode = curNode.next;
        }
        return curNode;
    }
    remove(item) {
        const curNode = this.find(item);
        if (curNode.next !== null) {
            curNode.previous.next = curNode.next;
            curNode.next.previous = curNode.previous;
            curNode.previous = null;
            curNode.next = null;
        }
    }
    display() {
        let target = [];
        let curNode = this.head;
        while (curNode.next !== null) {
            target.push(curNode.next.element);
            curNode = curNode.next;
        }
        return target.join();
    }
    displayReverse() {
        let target = [];
        let currNode = this.findLast();
        while (currNode.previous !== null) {
            target.push(currNode.element);
            currNode = currNode.previous;
        }
        return target.join();
    }
}

// test code

const linkedList = new LinkedList();
linkedList.insert("張三", "head");
console.log(linkedList.display()); // 張三
linkedList.insert("李四", "張三");
console.log(linkedList.display()); // 張三,李四
linkedList.insert("王五", "李四");
console.log(linkedList.display()); // 張三,李四,王五
linkedList.remove("李四");
console.log(linkedList.display()); // 張三,王五
console.log(linkedList.displayReverse()); // 王五,張三

迴圈連結串列

迴圈連結串列和單向連結串列相似,節點型別都是一樣的。唯一的區別是,在建立迴圈連結串列時,讓其頭節點的 next 屬性指向它本身,即:

head.next = head

這種行為會傳導至連結串列中的每個節點,使得每個節點的 next 屬性都指向連結串列的頭節點。換句話說,連結串列的尾節點指向頭節點,形成了一個迴圈連結串列,如圖 6-7 所示。

企業微信截圖_20210429092257.png

建立迴圈連結串列,只需要修改 LinkedList 類的構造方法(constructor):

class LinkedList {
    constructor() {
        this.head = new Node("head");
        this.head.next = this.head;
    }
    find() { }
    insert() { }
    findPrevious() { }
    remove() { }
    display() { }
}

只需要修改一處,就將單向連結串列變成了迴圈連結串列。但是其他一些方法需要修改才能工作正常。比如,display() 就需要修改,原來的方式在迴圈連結串列裡會陷入死迴圈。while 迴圈的迴圈條件需要修改,需要檢查head節點,當迴圈到head節點時退出迴圈。

迴圈連結串列的display()方法如下:

display() {
    let target = [];
    let curNode = this.head;
    while (curNode.next !== null && curNode.next.element !== "head") {
        target.push(curNode.next.element);
        curNode = curNode.next;
    }
    return target.join();
}

完整的迴圈連結串列實現及測試程式碼

class Node {
    constructor(element) {
        this.element = element;
        this.next = null;
    }
}

class LinkedList {
    constructor() {
        this.head = new Node("head");
        this.head.next = this.head;
    }
    find(item) {
        let current = this.head;
        while (current.element !== item) {
            current = current.next;
        }
        return current;
    }
    insert(element, item) {
        const newNode = new Node(element);
        const curNode = this.find(item);
        newNode.next = curNode.next;
        curNode.next = newNode;
    }
    findPrevious(item) {
        let curNode = this.head;
        while (curNode.next !== null && curNode.next.element !== item) {
            curNode = curNode.next;
        }
        return curNode;
    }
    remove(item) {
        const prevNode = this.findPrevious(item);
        if (prevNode.next !== null) {
            prevNode.next = prevNode.next.next;
        }
    }
    display() {
        let target = [];
        let curNode = this.head;
        while (curNode.next !== null && curNode.next.element !== "head") {
            target.push(curNode.next.element);
            curNode = curNode.next;
        }
        return target.join();
    }
}

// test code

const linkedList = new LinkedList();
linkedList.insert("張三", "head");
console.log(linkedList.display()); // 張三
linkedList.insert("李四", "張三");
console.log(linkedList.display()); // 張三,李四
linkedList.insert("王五", "李四");
console.log(linkedList.display()); // 張三,李四,王五
linkedList.remove("李四");
console.log(linkedList.display()); // 張三,王五

參考資料

  • 資料結構與演算法JavaScript描述
  • 學習JavaScript資料結構與演算法 第3版

如果覺得對您有幫助,動動小手點個贊;您的點贊就是對我最大的認可。

相關文章