為什麼我要放棄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資料結構與演算法