JavaScript 實現鏈表(單向)


線性表的一大結構:鏈式存儲結構的一些知識點總結歸納。並使用 JavaScript 來實現簡單的單鏈表。循環鏈表以及雙向鏈表是在單鏈表的基礎上擴展。

code resource

1. 什麼是鏈表。

來看一下 LeetCode 上對鏈表的描述:

鏈表(Linked List)是一種常見的基礎數據結構,是一種線性表,但是並不會按線性的順序存儲數據,
而是在每一個節點裏存到下一個節點的指針(Pointer)。

鏈表

由於不必須按順序存儲,鏈表在插入的時候可以達到 O(1)O(1) 的複雜度,比另一種線性表 —— 順序表
快得多,但是查找一個節點或者訪問特定編號的節點則需要 O(n)O(n) 的時間,而順序表相應的時間複雜
度分別是 O(log\ n)O(log n) 和 O(1)O(1)。

使用鏈表結構可以克服數組鏈表需要預先知道數據大小的缺點,鏈表結構可以充分利用計算機內存空間,
實現靈活的內存動態管理。但是鏈表失去了數組隨機讀取的優點,同時鏈表由於增加了結點的指針域,
空間開銷比較大。

在計算機科學中,鏈表作爲一種基礎的數據結構可以用來生成其它類型的數據結構。鏈表通常由一連串節
點組成,每個節點包含任意的實例數據(data fields)和一或兩個用來指向上一個/或下一個節點的位置
的鏈接(links)。鏈表最明顯的好處就是,常規數組排列關聯項目的方式可能不同於這些數據項目在記憶
體或磁盤上順序,數據的訪問往往要在不同的排列順序中轉換。而鏈表是一種自我指示數據類型,因爲它包
含指向另一個相同類型的數據的指針(鏈接)。

鏈表允許插入和移除表上任意位置上的節點,但是不允許隨機存取。鏈表有很多種不同的類型:單向鏈表,
雙向鏈表以及循環鏈表。

鏈表通常可以衍生出循環鏈表,靜態鏈表,雙鏈表等。對於鏈表使用,需要注意頭結點的使用。

以上大概就是對鏈表的一個說明,那用代碼來展示一個鏈表的話是什麼樣子呢?
我們先從一個簡單的單向鏈表嘗試做一下:

2. 鏈表的表現形式

const linkedList = {
    head: {
        value: 0,
        next: {
            value: 1,
            next: {
                value: 2,
                next: null
            }
        }
    }
};

與圖片上展示的鏈表結構相同:每個節點都有一個 value 屬性,展示當前節點的值,以及一個指向下一個節點(保存指向下一個節點的指針)的屬性 next。這構成了一個節點。多個節點構成了鏈表。同時也可以看到在鏈表的頭部,使用 head 來表示,在鏈表的末端節點 next 指向爲 null

既然知道了鏈表的展現形式,那就嘗試使用代碼將鏈表生成:

3. 鏈表的生成

// 節點
class LinkedNode {
    value: any 
    next: any
    constructor(nodeValue) {
        this.value = nodeValue;
        this.next = null
    }
}
// 鏈表
class LinkedList {
    constructor() {
        // 生成鏈表頭部,鏈表頭部的數據域中可以不存儲任何信息
        // 也可以存儲鏈表長度等信息
        // 這裏爲了區分,使頭節點的數據爲 'head'
        this.head = new LinkedNode("head")
    }
    private head: LinkedNode
}

4. 鏈表的操作

鏈表也應該支持查詢 / 插入 / 刪除 等操作,先來實現鏈表的查詢:

4.1 鏈表的查詢

查找一個元素是否在鏈表中是鏈表操作中比較常用的操作:

    /**
     * @author Frank Wang
     * @method
     * @name find
     * @description 查詢鏈表中是否有符合條件的節點
     * @param nodeValue {any} 所要查詢的 node 節點的 value 值
     * @return {LinkedNode}
     * @example 創建例子。
     * @public
     */
    find(nodeValue: any): LinkedNode {
        let curNode: LinkedNode = this.head

        while (curNode.value != nodeValue) {
            if (!curNode.next) {
                // 如果到鏈表尾部還沒有循環到符合條件的節點
                // 則返回 value 值爲 error 對象的節點
                return new LinkedNode(new Error("無此節點"))
            }
            curNode = curNode.next
        }
        return curNode
    }

主要是一個內部的循環,遍歷整個鏈表,判斷當前節點的 value 值是否與要查找的值相同。
既然有了查找,那我們向鏈表中插入某一節點就有思路了:

4.2 向鏈表中插入節點

向傳入的節點的後方插入節點

    /**
     * @author Frank Wang
     * @method
     * @name insert
     * @description 向鏈表中插入節點,第二個參數爲空的話,向尾部插入
     * @param value 向鏈表中插入的節點的 value
     * @param nodeValue 已經存在的 node 節點的 value
     * @return {LinkedNode}
     * @example 創建例子。
     * @public
     */
    insert(value: any, nodeValue: any = null):LinkedNode {
        let newNode: LinkedNode = new LinkedNode(value)
        let curNode: LinkedNode

        if (!nodeValue) {
            // append 的方法
            // 查找倒數第二個節點
            curNode = this.head
            while (curNode.next !== null) {
                curNode = curNode.next
            }
            curNode.next = newNode
            newNode.next = null
        } else {
            curNode = this.find(nodeValue)
            newNode.next = curNode.next
            curNode.next = newNode
        }

        return this.head
    }

插入的方法,接受兩個參數:一個是新的 node 節點的 value,另一個是可選的,已經存在的 node 節點
的 value,如果只傳入一個參數,則往鏈表尾部插入。函數體首先是根據傳入的 value 創建一個 node
實例,以及找到需要插入的node節點。然後將當前節點的 next 賦值給新的節點實例,在將新節點,
賦值給當前節點的 next 值。尤其注意這兩步的操作,是不能調換的,可以想像爲什麼。

4.3 展示鏈表

插入鏈表之後我們肯定是想知道鏈表的當前情況,或者是我們在某個時刻想要查看鏈表的情況,那我們就需要
一個方法來展示鏈表:

    /**
     * @author Frank Wang
     * @method
     * @name console
     * @description 將鏈表所有的值在控制檯打印出來
     * @param 
     * @return {void}
     * @example 創建例子。
     * @public
     */
    console(): void {
        let currNode: LinkedNode = this.head;
        while (currNode.next) {
            console.log(currNode.next.value);
            currNode = currNode.next;
        }
    }

這個是打印出除了頭節點的其他所有節點的 value 值。如果只是單純的獲取節點對象,那麼可以使用:

    show() {
        return this.head
    }

同樣的我們還會遇到刪除某一節點的需求:

4.4 刪除鏈表中某一節點

在解決這個需求之前,我們可以先來分析一下,如何刪除一個鏈表中的節點,就是將此節點的上一節點的
next 指向當前節點的 next。

所以如果我們要刪除某一節點,那麼我們就需要知道當前節點的上一個節點

    private findPrev(value: any) {
        let curNode: LinkedNode = this.head
        while (curNode.next !== null && curNode.next.value !== value) {
            curNode = curNode.next
        }
        return curNode
    }
    /**
     * @author Frank Wang
     * @method
     * @name delete
     * @description 刪除鏈表中的節點
     * @param {nodeValue} 需要刪除的節點的 value
     * @return {LinkedNode}
     * @example 創建例子。
     * @public
     */
    delete(nodeValue: any) {
        const err = "[object Error]"
        const showType = Object.prototype.toString
        let curNode = this.find(nodeValue)
        if (showType.call(curNode) === err) {
            return curNode
        }
        const prevNode = this.findPrev(nodeValue);
        if (prevNode.next !== null) {
            prevNode.next = prevNode.next.next;
        }
        return this.head;

    }

解釋一下上面的邏輯:首先是進行判斷,對錯誤條件儘早返回。然後是使用內部方法 findPrev ,查找
所要刪除節點的上一節點: prevNode
然後就是簡單的 next 指針的改變。

4.5 單向鏈表的總結

單鏈表結構和順序存儲結構做對比:

  • 存儲分配方式
    • 順序存儲結構用一段連續的存儲單元一次存儲線性表的數據元素。
    • 單鏈表採用鏈式存儲結構,用一組任意的存儲單元存放線性表的元素
  • 時間性能
    • 查找
      • 順序存儲結構O(1)
      • 單鏈表O(n)
    • 插入和刪除
      • 順序存儲結構需要平均移動表長一半的元素,時間爲 O(n)
      • 單鏈表再找出某位置的指針後,插入和刪除時間僅爲O(1)
  • 空間性能
    • 順序存儲結構需要預分配存儲空間,分大了,浪費。分小了,易發生上溢。
    • 單鏈表不需要分配存儲空間,只要有就可以分配,元素個數也不受限制。

5. 循環鏈表

將單鏈表中終端節點的指針端由空指針改爲指向頭節點,就使整個單鏈表形成了一個環,這種頭尾相接的單鏈表稱爲單循環鏈表,簡稱循環鏈表。

6. 雙向鏈表

雙向鏈表是在單鏈表的每個節點中,再設置一個指向其前驅節點的指針域。
所以在雙向鏈表中的節點都有兩個指針域,一個指向直接後繼,另一個指向直接前驅。

6.1 插入

如果需要將 s 節點,插入到 p 節點和 p.next 節點之中,那這個插入操作的順序是啥?

  • 把 p 賦值給 s 的前驅
  • 把 p.next 賦值給 s 的後繼節點
  • 把 s 賦值給 p.next 的前驅
  • 把 s 賦值給 p 的後繼

7. 總結

線性表
順序存儲結構 鏈式存儲結構
單鏈表 靜態鏈表 循環鏈表 雙向鏈表

這是鏈表屬於線性表的兩大結構之一:鏈式存儲結構。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章