highlight: monokai
theme: vue-pro
上一篇文章中使用列表(List)對資料排序,當時底層儲存資料的資料結構是陣列。本文將討論另外一種列表:連結串列。我們會解釋為什麼有時連結串列優於陣列,還會實現一個基於物件的連結串列。下面讓我們一起來學習LinkedList
。
陣列的缺點
在很多程式語言中,陣列的長度是固定的,所以當陣列已被資料填滿時,再要加入新的元素就會非常困難。在陣列中,新增和刪除元素也很麻煩,因為需要將陣列中的其他元素向前或向後平移,以反映陣列剛剛進行了新增或刪除操作。然而,JavaScript 的陣列並不存在上述問題,因為使用 split()
方法不需要再訪問陣列中的其他元素了。
avaScript 中陣列的主要問題是,它們被實現成了物件,與其他語言(比如 C++ 和 Java)的陣列相比,效率很低。
定義連結串列
連結串列是由一組節點組成的集合。每個節點都使用一個物件的引用指向它的後繼。指向另一個節點的引用叫做鏈。下圖展示了一個連結串列。
陣列元素靠它們的位置進行引用,連結串列元素則是靠相互之間的關係進行引用。在上圖中,我們說 李四
跟在 張三
後面,而不說 李四
是連結串列中的第二個元素。遍歷連結串列,就是跟著連結,從連結串列的首元素一直走到尾元素(但這不包含連結串列的頭節點,頭節點常常用來作為連結串列的接入點)。圖中另外一個值得注意的地方是,連結串列的尾元素指向一個 null
節點。
然而要標識出連結串列的起始節點卻有點麻煩,許多連結串列的實現都在連結串列最前面有一個特殊節點,叫做頭節點。經過改造之後,上圖中的連結串列成了下面的樣子。
設計一個基於物件的連結串列
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();
}
測試程式碼
雙向連結串列
儘管從連結串列的頭節點遍歷到尾節點很簡單,但反過來,從後向前遍歷則沒那麼簡單。通過給 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 直觀地展示了該過程。
新的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 所示。
建立迴圈連結串列,只需要修改 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版
如果覺得對您有幫助,動動小手點個贊;您的點贊就是對我最大的認可。