1. 程式人生 > >資料結構-連結串列

資料結構-連結串列

本章將討論另一種列表: 連結串列 . 解釋為什麼有時連結串列優於陣列, 還會實現一個基於物件的連結串列.

陣列的缺點

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

定義連結串列

由一組節點組成的集合. 每一個節點都使用一個物件的引用指向它的後繼. 指向另一個節點的引用叫做鏈.圖片名稱

陣列元素靠它們的位置進行引用, 連結串列元素則是靠相互之間的關係進行引用. 在上圖中, 我們說99

跟在12後面, 而不說99是連結串列中的第二個元素. 遍歷連結串列, 就是跟著連線, 從連結串列的首元素一直走到尾元素(但這不包含連結串列的頭結點, 頭結點常常永愛作為連結串列的接入點). 值得注意的是, 連結串列的尾元素指向一個null節點.

然鵝要標識出連結串列的起始節點卻有點麻煩, 許多連結串列的實現都是在連結串列最前面有一個特殊節點, 叫做 頭節點.

連結串列中插入一個節點的效率很高. 向連結串列中插入一個節點, 需要修改它前面的節點(前驅), 使其事項新加入的節點, 而新加入的節點則指向原來前驅指向的節點.

從連結串列中刪除一個元素也很簡單. 將待刪除元素的前驅節點指向待刪除元素的後繼節點, 同時將待刪除元素指向null

, 元素就刪除成功了.

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

我們設計的連結串列包含兩個類. Node類用於表示節點, LinkedList類提供了插入節點、刪除節點、顯示列表元素的方法, 以及其他一些輔助方法.

Node類

Node類包含兩個屬性: element用來儲存節點上的資料, next用來儲存指向下一個節點的連結.

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

LinkedList類

LList類提供了對連結串列進行操作的方法. 該類的功能包括插入刪除節點、在列表中查詢給定的值.

class LList {
    constructor() {
        this._head = new Node('head');
    }
    _find(item) {
        let currNode = this._head;
        while (currNode.element != item) {
            currNode = currNode.next;
        };
        return currNode;
    }
    _findPrevious(item) {
        let currNode = this._head;
        while (currNode.next !== null && currNode.next.element !== item) {
            currNode = currNode.next;
        };
        return currNode;  
    }
    insert(newElement, item) {
        const newNode = new Node(newElement);
        const current = this._find(item);
        newNode.next = current.next;
        current.next = newNode
    }
    remove(item) {
        const prevNode = this._findPrevious(item);
        if (prevNode.next !== null) {
            prevNode.next = prevNode.next.next
        }
    }
    display() {
        let currNode = this._head;
        while (!(currNode.next === null)) {
            console.log(currNode.next.element);
            currNode = currNode.next;
        }
    }
};

插入新節點insert()該方法向連結串列中插入一個節點. 向連結串列中插入新節點時, 需要明確指出要在哪個節點前面或後面插入元素.

在一個已知節點後面插入元素時, 先要找到 後面 的節點. 為此, 建立一個輔助方法find(), 該方法遍歷連結串列, 查詢給定資料. 如果找到資料, 該方法就返回儲存該資料的節點.find()方法演示瞭如何在連結串列上進行移動. 首先, 建立一個新節點, 並將連結串列的頭節點賦給這個新建立的節點. 然後再連結串列上進行迴圈, 如果當前節點的element屬性和我們要找的資訊不符, 就從當前節點移動到下一個節點. 如果查詢成功, 則返回該資料的節點; 否則返回null.

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

在測試之前我們定義一個display()方法, 該方法用來顯示連結串列中的元素.display()先將列表的頭節點賦給一個變數, 然後迴圈遍歷連結串列, 當節點的next屬性為null時迴圈結束. 為了只顯示包含資料的節點(換句話說, 不顯示頭節點), 程式只訪問當前節點的下一個節點中儲存的資料: currNode.next.element.

測試程式:

const letters = new LList();
letters.insert('a', 'head');
letters.insert('b', 'a');
letters.insert('c', 'b');
letters.insert('d', 'c');
letters.display();

輸出:

a
b
c
d

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

remove()方法中最重要的一行程式碼prevNode.next = prevNode.next.next;這裡跳過了待刪除節點, 讓 前一個 節點指向了待刪除節點的後一個節點.

測試程式:

const letters = new LList();
letters.insert('a', 'head');
letters.insert('b', 'a');
letters.insert('c', 'b');
letters.insert('d', 'c');
letters.display();

letters.remove('d');
console.log('')
letters.display();

輸出:

a
b
c
d

a
b
c

雙向連結串列

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

首先我們要為Node類增加一個previous屬性:

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

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

...
insert(newElement, item) {
    const newNode = new Node(newElement);
    const current = this._find(item);
    newNode.next = current.next;
    newNode.previous = current;
    current.next = newNode;
}
...

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

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

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

...
_findLast() {
    let currNode = this._head;
    while (currNode != null) {
        currNode = currNode.next;
    };

    return currNode;
}
...

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

...
dispReverse() {
    let currNode = this._head;
    currNode = this._findLast();
    while (currNode.previous != null) {
        console.log(currNode.element);
        currNode = currNode.previous;
    }
}
...

全部程式碼:

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

class LList {
    constructor() {
        this._head = new Node('head');
    }
    _find(item) {
        let currNode = this._head;
        while (currNode.element != item) {
            currNode = currNode.next;
        };
        return currNode;
    }
    _findPrevious(item) {
        let currNode = this._head;
        while (currNode.next !== null && currNode.next.element !== item) {
            currNode = currNode.next;
        };
        return currNode;  
    }
    _findLast() {
        let currNode = this._head;
        while (currNode.next != null) {
            currNode = currNode.next;
        };

        return currNode;
    }
    insert(newElement, item) {
        const newNode = new Node(newElement);
        const current = this._find(item);
        newNode.next = current.next;
        newNode.previous = current;
        current.next = newNode
    }
    remove(item) {
        const currNode = this._find(item);
        if (currNode.next !== null) {
            currNode.previous.next = currNode.next;
            currNode.next.previous = currNode.previous;
            currNode.next = null;
            currNode.previous = null;
        } else {
            currNode.previous.next = null;
        }
    }
    display() {
        let currNode = this._head;
        while (!(currNode.next === null)) {
            console.log(currNode.next.element);
            currNode = currNode.next;
        }
    }
    dispReverse() {
        let currNode = this._head;
        currNode = this._findLast();
        while (currNode.previous !== null) {
            console.log(currNode.element);
            currNode = currNode.previous;
        }
    }
};

const letters = new LList();
letters.insert('a', 'head');
letters.insert('b', 'a');
letters.insert('c', 'b');
letters.insert('d', 'c');
letters.display();
letters.dispReverse();

letters.remove('d');
letters.remove('b');
console.log('')
letters.dispReverse();

程式輸出:

a
b
c
d
d
c
b
a

c
a

迴圈連結串列

迴圈連結串列和單向連結串列相似, 節點型別都是一樣的. 唯一的區別是, 在建立迴圈連結串列時, 讓其頭節點的next屬性指向它本身._head.next = _head這種行為會傳導至連結串列中的每一個節點, 使得每一個節點的next屬性都是指向連結串列的頭節點. 換句話說, 連結串列的尾節點指向頭節點, 形成了一個迴圈連結串列.

如果你希望可以從後面向前遍歷連結串列, 但是又不想付出額外代價來建立一個雙向連結串列, 那麼就需要使用迴圈連結串列. 從迴圈連結串列的尾節點向後移動, 就等於從後向前遍歷連結串列.

建立迴圈連結串列, 只需要修改單向連結串列的LList類的建構函式:

class LList {
    constructor() {
        this._head = new Node('head');
        this._head.next = this._head;
    }
    ...
}

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

...
display() {
    let currNode = this._head;
    while (currNode.next !== null && currNode.next.element !== 'head') {
        console.log(currNode.next.element);
        currNode = currNode.next;
    }
}
...

連結串列的其它方法

advance()前移

單向連結串列就可以完成該功能. 但是為了配合後移功能我們採用雙向連結串列.

...
advance(n) {
    while ( n && this._head.next != null) {
        this._head = this._head.next;
        n--;
    };
}
...

使整個連結串列向前移動, 從頭結點開始, 移動幾位就是頭節點賦值為第幾個next節點.

back()後移

與前移不同的後移功能需要在雙向連結串列上實現.

...
back(n) {
    while ( n && this._head.element != 'head') {
        this._head = this._head.previous;
        n--;
    };
}
...

是整個連結串列向後移動, 如果第一個節點(當前節點)為頭節點(head)則不移動.

show()只顯示當前節點資料

...
show() {
    return this._head;
}
...

迴圈連結串列解決猶太曆史學家弗拉維奧·約瑟夫基和他的同伴生存問題.

傳說在公元1 世紀的猶太戰爭中,猶太曆史學家弗拉維奧·約瑟夫斯和他的40個同胞被羅馬士兵包圍。猶太士兵決定寧可自殺也不做俘虜,於是商量出了一個自殺方案。他們圍成一個圈,從一個人開始,數到第三個人時將第三個人殺死,然後再數,直到殺光所有人。約瑟夫和另外一個人決定不參加這個瘋狂的遊戲,他們快速地計算出了兩個位置,站在那裡得以倖存。寫一段程式將n 個人圍成一圈,並且第m個人會被殺掉,計算一圈人中哪兩個人最後會存活。使用迴圈連結串列解決該問題。首先我們看到他們圍成一個圈判斷應該使用迴圈連結串列來處理改問題.完整程式碼:

window.log = console.log.bind(console);
class Node {
    constructor(element) {
        this.element = element;
        this.next = null;
    }
};

class LList {
    constructor() {
        this._head = new Node('head');
        this._head.next = this._head;
        this.currentNode = this._head;
    }
    _find(item) {
        let currNode = this._head;
        while (currNode.element != item) {
            currNode = currNode.next;
        };
        return currNode;
    }
    _findPrevious(item) {
        let currNode = this._head;
        while (currNode.next !== null && currNode.next.element !== item) {
            currNode = currNode.next;
        };
        return currNode;  
    }
    insert(newElement, item) {
        const newNode = new Node(newElement);
        const current = this._find(item);
        newNode.next = current.next;
        current.next = newNode;
    }
    remove(item) {
        const prevNode = this._findPrevious(item);
        if (prevNode.next !== null) {
            prevNode.next = prevNode.next.next
        }
    }
    // 前移
    advance(n) {
        while ( n ) {
            if(this.currentNode.next.element == 'head') {
                this.currentNode = this.currentNode.next.next;
            } else {
                this.currentNode = this.currentNode.next;
            } 
            n--;
        };
    }
    show() {
        return this.currNode;
    }
    count() {
        let currNode = this._head;
        let i = 0;
        while (currNode.next.element != 'head') {
            currNode = currNode.next;
            ++i
        };
        
        return i;
    }
    display() {
        let currNode = this._head;
        
        while (currNode.next !== null && currNode.next.element !== 'head') {
            console.log(currNode.next.element);
            currNode = currNode.next;
        }
    }
};

const p = new LList();

const peopleNum = 40;
for(let i = 1; i <= peopleNum; i++) {
    if(i === 1) {
        p.insert(`people${i}`, 'head');
    } else {
        p.insert(`people${i}`, `people${i - 1}`);
    }
};

p.display();
while (p.count() > 2) {
    p.advance(3);
    p.remove(p.currentNode.element);
    log('/////////////////')
    p.display();
};