數據結構與算法的重溫之旅(四)——鏈表

1、鏈表概念

還記得上一篇文章中數組的概念嗎,其實數組和鏈表都是線性表的一種,不過數組與鏈表有一點不一樣的是,鏈表是不須要連續內存空間來存儲,它經過指針將一組零散的內存塊串聯起來使用。鏈表它的結構五花八門,經常使用的三種鏈表結構分別是單鏈表、雙向鏈表、循環鏈表和雙向循環鏈表。首先咱們先將最簡單的單鏈表。程序員

2、單鏈表

上面有講,鏈表是經過指針將一組零散的內存塊串聯在一塊兒,其中這個內存塊咱們稱之爲鏈表的結點。爲了將全部的結點串起來,每一個結點除了存儲數據以外還會記錄鏈表上下一個結點的地址,這個記錄下一個結點地址指針稱做後繼指針next。單鏈表以下圖:算法

在圖裏有兩個結點是十分特殊的,一個是頭結點,另外一個是尾結點。其中頭結點是用來記錄鏈表的基地址,有了它,咱們能夠遍歷整條鏈表。而尾結點的特殊地方是指針不是指向下一個結點,而是指向一個空地址NULL,這裏表示的是鏈表最後一個結點。數組

和數組同樣,鏈表也支持數據的增長、刪除和查找操做。因爲數組是連續的,在進行隨機的查找操做的時候時間複雜度是O(1),而鏈表的話因爲內存地址是不連續的,在單鏈表中每一個結點只知道下一個結點的內存地址,因此在執行隨機查找操做的時候是依次遍歷整個鏈表,所以最好時間複雜度是O(1),最壞時間複雜度爲O(n),平均時間複雜度爲O(n)。瀏覽器

不過鏈表比數組多了一個有點就是鏈表在執行增長和刪除操做的時候所要消耗的時間複雜度比數組小不少。因爲數組是用連續的內存空間來存儲數據,當執行刪除或者增長操做的時候,爲了保證數組空間的連續性,就必須對指定添加位置或者刪除位置後面的數據進行數據遷移。而鏈表因爲不是連續的內存空間來存儲數據,而且鏈表當中的結點是知道下一個結點的內存地址,當咱們執行添加操做的時候就只需把當前結點next指針指向要插入的數據,要插入的數據的next指針指向原來當前結點的下一個結點內存地址便可。同理在執行刪除操做的時候只需把當前要被刪除結點的上一個結點next指針指向被刪除結點的下一個結點便可。因爲增長和刪除操做不須要對被操做結點後面數據進行遷移,因此增長和刪除操做的時間複雜度都爲O(1)。圖解以下:緩存

下面用JavaScript來實現單鏈表,能夠把代碼直接在瀏覽器上運行:bash

// 結點類
class Node {
    /**
     * @param {string} element 鏈表結點值
     * **/
    constructor (element) {
        this.element = element
        this.next = null
    }
}

// 鏈表類
class linkedList {
    constructor () {
        this.countLength = 0 // 鏈表長度
        this.head = null // 頭節點
    }
    /**
     * @return {number} 鏈表長度
     * @description 獲取鏈表長度方法
     * **/
    getLinkLength () {
        return this.countLength
    }
    /**
     * @param {string} item 鏈表結點的值
     * @return {linkedList} 返回鏈表結點
     * @description 鏈表查詢方法
     * **/
    find (item) {
        let currNode = this.head
        while (currNode.element !== item) {
            currNode = currNode.next
        }
        return currNode
    }
    /**
     * @param {string} newElement 新的結點的值
     * @param {string} item 被插入結點的值
     * @description 鏈表結點插入方法
     * **/
    insertNode (newElement, item) {
        let newNode = new Node(newElement)
        if (this.head == null) {
            this.head = newNode
        }
        else {
            let currentNode = this.find(item)
            newNode.next = currentNode.next
            currentNode.next = newNode
        }
        ++this.countLength
    }
    /**
     * @param {string} item 被刪除結點的值
     * @description 鏈表結點刪除方法
     * **/
    removeNode (item) {
        let currNode = this.findPrevious(item)
        if (currNode.next != null) {
            currNode.next = currNode.next.next
            --this.countLength
        }
        else if (currNode && !currNode.next) {
            this.head = this.head.next
            --this.countLength
        }
    }
    /**
     * @param {string} item 查找結點的值
     * @return {linkedList} 返回一個當前要查找的上一個結點
     * @description 查找鏈表上一個結點
     * **/
    findPrevious (item) {
        let currNode = this.head
        while (currNode.next != null && currNode.next.element !== item) {
            currNode = currNode.next
        }
        return currNode
    }
    /**
     * @param {string} newItem 修改的新值
     * @param {string} item 修改的舊值
     * @description 鏈表結點編輯方法
     * **/
    editNode (newItem, item) {
        let currentNode = this.find(item)
        currentNode.element = newItem
    }
    /**
     * 鏈表展現方法
     * **/
    displayList () {
        let currNode = this.head
        while (currNode) {
            console.log(currNode.element)
            currNode = currNode.next
        }
    }
}

let myLink = new linkedList()
myLink.insertNode('head')
myLink.insertNode('ha', 'head')
myLink.insertNode('ho', 'head')
myLink.insertNode('he', 'head')
myLink.removeNode('head')
myLink.displayList()
console.log(myLink.getLinkLength())複製代碼


3、循環鏈表

其實循環鏈表是一個特殊的單鏈表。上面的單鏈表的尾結點的next指針指向的是NULL,而循環鏈表裏的尾結點next指針倒是頭結點。循環鏈表的有點是從尾結點到頭結點比較方便,這種結構的鏈表特別適合解決具備環形數據結構,好比約瑟夫問題,下一篇文章會講解約瑟夫問題。循環鏈表的圖解以下:數據結構

下面用JavaScript來實現一個循環鏈表,其實實現的代碼和上面的單鏈表差很少,只不過在插入和刪除的時候要判斷邊界:post

// 結點類
class Node {
    /**
     * @param {string} element 鏈表結點值
     * **/
    constructor (element) {
        this.element = element
        this.next = null
    }
}

// 鏈表類
class linkedList {
    constructor () {
        this.countLength = 0 // 鏈表長度
        this.head = null // 頭節點
    }
    /**
     * @return {number} 鏈表長度
     * @description 獲取鏈表長度方法
     * **/
    getLinkLength () {
        return this.countLength
    }
    /**
     * @param {string} item 鏈表結點的值
     * @return {linkedList} 返回鏈表結點
     * @description 鏈表查詢方法
     * **/
    find (item) {
        let currNode = this.head
        while (currNode.element !== item) {
            currNode = currNode.next
        }
        return currNode
    }
    /**
     * @param {string} newElement 新的結點的值
     * @param {string} item 被插入結點的值
     * @description 鏈表結點插入方法
     * **/
    insertNode (newElement, item) {
        let newNode = new Node(newElement)
        if (this.head == null) {
            this.head = newNode
        }
        else {
            let currentNode = this.find(item)
            if (currentNode.next == null || currentNode.next.element === this.head.element) {
                newNode.next = this.head
            }
            else {
                newNode.next = currentNode.next
            }

            currentNode.next = newNode
        }
        ++this.countLength
    }
    /**
     * @param {string} item 被刪除結點的值
     * @description 鏈表結點刪除方法
     * **/
    removeNode (item) {
        let currNode = this.findPrevious(item)
        if (currNode.next != null) {
            if (currNode.next.element === this.head.element) {
                this.head = currNode.next.next
                currNode.next = this.head
            }
            else {
                currNode.next = currNode.next.next
            }
            --this.countLength
        }
        else if (currNode && !currNode.next) {
            this.head = null
            --this.countLength
        }
    }
    /**
     * @param {string} item 要被查找結點的值
     * @return {linkedList} 返回一個當前要查找的上一個結點
     * @description 查找鏈表上一個結點
     * **/
    findPrevious (item) {
        let currNode = this.head
        while (currNode.next != null && currNode.next.element !== item) {
            currNode = currNode.next
        }
        return currNode
    }
    /**
     * @param {string} newItem 修改的新值
     * @param {string} item 修改舊值的舊值
     * @description 鏈表結點編輯方法
     * **/
    editNode (newItem, item) {
        let currentNode = this.find(item)
        currentNode.element = newItem
    }
    /**
     * 鏈表展現方法
     * **/
    displayList () {
        let currNode = this.head
        while (currNode) {
            console.log(currNode.element)
            currNode = currNode.next
            if (currNode.element === this.head.element) break
        }
    }
}

let myLink = new linkedList()
myLink.insertNode('head', '')
myLink.insertNode('ha', 'head')
myLink.insertNode('ho', 'head')
myLink.insertNode('he', 'head')
myLink.removeNode('head')
myLink.displayList()
console.log(myLink.getLinkLength())複製代碼


4、雙向鏈表

上面說到在單鏈表中,結點的next指針是指向它的下一個結點的內存地址,而雙向鏈表則比單鏈表多了一個前驅指針prev指向它的上一個結點的內存地址。圖解圖下:性能

單鏈表和雙向鏈表從結構上看的話,因爲雙向鏈表比單鏈表多了一個指向上一個結點的前驅指針,因此它的空間佔用比單鏈表的大。不過因爲比單鏈表多了一個前驅指針,使得它比單鏈表更加的靈活。好比在刪除操做中,若是指定單鏈表某個結點刪除的話,假設該結點不是頭結點和尾結點,那麼在刪除以前咱們是必需要知道該結點的上一點結點的內存地址,但因爲單鏈表是沒法往前遍歷,因此須要遍歷一遍鏈表來找到該結點的上一個結點的內存地址,纔可以執行刪除操做。而雙向鏈表因爲有一個前驅指針,因此並不須要遍歷一遍鏈表才能進行刪除操做。因此,整個刪除流程雙向鏈表比單鏈表要快,它的時間複雜度爲O(1)。學習

可能各位同窗看到這裏會有點懵。這麼上面說到單鏈表執行刪除操做所要的時間複雜度是O(1),而這裏倒是O(n)呢?其實單鏈表和雙向鏈表執行刪除操做的時間都是爲O(1),刪除操做即把當前結點的上個結點的next指針指向當前結點下個結點的內存地址便可,可是它們中間整個刪除流程有點區別,單鏈表是須要遍歷鏈表找到前驅結點才能執行刪除操做,而雙向鏈表則不用遍歷鏈表便可執行刪除操做,這一點是有明顯的差別的。

從這裏咱們能夠發現,雖然雙向鏈表犧牲了空間上的性能,卻帶來時間上性能的提高,在就是以空間換時間的設計思想。當咱們的內存充足的時候,爲了縮短程序運行時間,能夠犧牲必定量的空間來換取時間性能上的提高。相反,若是空間資源比較短缺,咱們則能夠犧牲一點時間來換取空間上的優化。

下面用JavaScript來實現雙向鏈表結構,方法比上面循環鏈表簡單的地方在於不用判斷插入和刪除的是否在頭尾結點,比單鏈表複雜的一點是在多了一個前驅指針,可是少了一個刪除的時候須要上一個結點的遍歷:

// 結點類
class Node {
    /**
     * @param {string} element 鏈表結點值
     * **/
    constructor (element) {
        this.element = element
        this.next = null
        this.prev = null
    }
}

// 鏈表類
class linkedList {
    constructor () {
        this.countLength = 0 // 鏈表長度
        this.head = null // 頭節點
    }
    /**
     * @return {number} 鏈表長度
     * @description 獲取鏈表長度方法
     * **/
    getLinkLength () {
        return this.countLength
    }
    /**
     * @param {string} item 鏈表結點的值
     * @return {linkedList} 返回鏈表結點
     * @description 鏈表查詢方法
     * **/
    find (item) {
        let currNode = this.head
        while (currNode.element !== item) {
            currNode = currNode.next
        }
        return currNode
    }
    /**
     * @param {string} newElement 新的結點的值
     * @param {string} item 被插入結點的值
     * @description 鏈表結點插入方法
     * **/
    insertNode (newElement, item) {
        let newNode = new Node(newElement)
        if (this.head == null) {
            this.head = newNode
        }
        else {
            let currentNode = this.find(item)
            newNode.next = currentNode.next
            currentNode.next = newNode
            newNode.prev = currentNode
            if (newNode.next) {
                newNode.next.prev = newNode
            }
        }
        ++this.countLength
    }
    /**
     * @param {string} item 被刪除結點的值
     * @description 鏈表結點刪除方法
     * **/
    removeNode (item) {
        let currNode = this.find(item).prev
        if (currNode) {
            currNode.next = currNode.next.next
            if (currNode.next.next) {
                currNode.next.prev = currNode
            }
        }
        else {
            this.head = this.head.next
            this.head.prev = null
        }
        --this.countLength
    }
    /**
     * @param {string} newItem 修改的新值
     * @param {string} item 修改的舊值
     * @description 鏈表結點編輯方法
     * **/
    editNode (newItem, item) {
        let currentNode = this.find(item)
        currentNode.element = newItem
    }
    /**
     * 鏈表展現方法
     * **/
    displayList () {
        let currNode = this.head
        while (currNode) {
            console.log(currNode.element)
            currNode = currNode.next
        }
    }
}

let myLink = new linkedList()
myLink.insertNode('head')
myLink.insertNode('ha', 'head')
myLink.insertNode('ho', 'head')
myLink.insertNode('he', 'head')
myLink.removeNode('head')
myLink.displayList()
console.log(myLink.getLinkLength())複製代碼

5、雙向循環鏈表

這個鏈表鏈表其實就是循環鏈表和雙向鏈表的合體,比循環鏈表在空間上佔用更多資源,不過同時也更加的靈活。圖解以下:

下面用JavaScript來實現雙向循環鏈表,若是讀者按照上面那樣可以熟練的寫出單鏈表、雙向鏈表和循環鏈表的話,相信這裏的雙向循環鏈表寫起來則十分的駕輕就熟:


// 結點類
class Node {
    /**
     * @param {string} element 鏈表結點值
     * **/
    constructor (element) {
        this.element = element
        this.next = null
        this.prev = null
    }
}

// 鏈表類
class linkedList {
    constructor () {
        this.countLength = 0 // 鏈表長度
        this.head = null // 頭節點
    }
    /**
     * @return {number} 鏈表長度
     * @description 獲取鏈表長度方法
     * **/
    getLinkLength () {
        return this.countLength
    }
    /**
     * @param {string} item 鏈表結點的值
     * @return {linkedList} 返回鏈表結點
     * @description 鏈表查詢方法
     * **/
    find (item) {
        let currNode = this.head
        while (currNode.element !== item) {
            currNode = currNode.next
        }
        return currNode
    }
    /**
     * @param {string} newElement 新的結點的值
     * @param {string} item 被插入結點的值
     * @description 鏈表結點插入方法
     * **/
    insertNode (newElement, item) {
        let newNode = new Node(newElement)
        if (this.head == null) {
            this.head = newNode
        }
        else {
            let currentNode = this.find(item)
            if (currentNode.next) {
                newNode.next = currentNode.next
            }
            else {
                newNode.next = this.head
            }
            currentNode.next = newNode
            newNode.prev = currentNode
            if (newNode.next) {
                newNode.next.prev = newNode
            }
        }
        ++this.countLength
    }
    /**
     * @param {string} item 被刪除結點的值
     * @description 鏈表結點刪除方法
     * **/
    removeNode (item) {
        let currNode = this.find(item)
        if (currNode.prev) {
            if (currNode.element === this.head.element) {
                this.head = this.head.next
            }
            currNode.prev.next = currNode.next
            currNode.next.prev = currNode.prev
        }
        else {
            this.head = null
        }
        --this.countLength
    }
    /**
     * @param {string} newItem 修改的新值
     * @param {string} item 修改的舊值
     * @description 鏈表結點編輯方法
     * **/
    editNode (newItem, item) {
        let currentNode = this.find(item)
        currentNode.element = newItem
    }
    /**
     * 鏈表展現方法
     * **/
    displayList () {
        let currNode = this.head
        while (currNode) {
            console.log(currNode.element)
            currNode = currNode.next
            if (currNode.element === this.head.element) break
        }
    }
}

let myLink = new linkedList()
myLink.insertNode('head')
myLink.insertNode('ha', 'head')
myLink.insertNode('ho', 'head')
myLink.insertNode('he', 'head')
myLink.removeNode('head')
myLink.displayList()
console.log(myLink.getLinkLength())複製代碼

6、數組與鏈表的對比

經過前面內容的學習,你應該已經知道,數組和鏈表是兩種大相徑庭的內存組織方式。正是由於內存存儲的區別,它們插入、刪除、隨機訪問操做的時間複雜度正好相反。不過,數組和鏈表的對比,並不能侷限於時間複雜度。並且,在實際的軟件開發中,不能僅僅利用複雜度分析就決定使用哪一個數據結構來存儲數據。

數組簡單易用,在實現上使用的是連續的內存空間,能夠藉助 CPU 的緩存機制,預讀數組中的數據,因此訪問效率更高。而鏈表在內存中並非連續存儲,因此對 CPU 緩存不友好,沒辦法有效預讀。數組的缺點是大小固定,一經聲明就要佔用整塊連續內存空間。若是聲明的數組過大,系統可能沒有足夠的連續內存空間分配給它,致使「內存不足(out of memory)」。若是聲明的數組太小,則可能出現不夠用的狀況。這時只能再申請一個更大的內存空間,把原數組拷貝進去,很是費時。鏈表自己沒有大小的限制,自然地支持動態擴容,我以爲這也是它與數組最大的區別。

除此以外,若是你的代碼對內存的使用很是苛刻,那數組就更適合你。由於鏈表中的每一個結點都須要消耗額外的存儲空間去存儲一份指向下一個結點的指針,因此內存消耗會翻倍。並且,對鏈表進行頻繁的插入、刪除操做,還會致使頻繁的內存申請和釋放,容易形成內存碎片,若是是 Java 語言,就有可能會致使頻繁的 GC(Garbage Collection,垃圾回收)。因此,在咱們實際的開發中,針對不一樣類型的項目,要根據具體狀況,權衡到底是選擇數組仍是鏈表。

7、關於指針

上面講了那麼多,反覆的提到一個關鍵字指針。在C或C++語言裏,就有指針這個概念,可是在JavaScript或者Java語言中,聽到引用這個概念比較多,幾乎不多有聽到指針這個概念。其實指針和引用是用一個東西,都是存儲所指對象的內存地址。對於指針的理解,有一句話總結:將某個變量賦值給指針,實際上就是將這個變量的地址賦值給指針,或者反過來講,指針中存儲了這個變量的內存地址,指向了這個變量,經過指針就能找到這個變量。

8、總結

關於鏈表這個概念,上面已經講述的差很少了,下面這裏提幾點在寫鏈表代碼時要注意的事項和技巧:

1.警戒指針丟失和內存泄漏

指針丟失多數出如今增長或刪除操做上,以下面代碼所示:

p.next = x; // 將 p 的 next 指針指向 x 結點;
x.next = p.next; // 將 x 的結點的 next 指針指向 b 結點;

這裏插入了一個結點x,這行代碼裏,p結點的next指針被指向到x結點上,按道理x結點的next指針是要指向p結點的next指針的結點,可是因爲這行代碼的順序問題,x的next指針指回x結點,使得鏈表在x結點處發生了斷裂。因此在執行鏈表的插入和刪除操做時要十分注意順序。在C語言裏,因爲內存垃圾須要程序員本身回收,因此在執行鏈表刪除的時候要記得回收掉垃圾,防止內存泄漏。

2.利用帶頭鏈表來簡化實現難度

從上面的這四個代碼來看,咱們能夠看出,針對鏈表的插入、刪除操做,須要對插入第一個結點和刪除最後一個結點的狀況進行特殊處理。這樣代碼實現起來就會很繁瑣,不簡潔,並且也容易由於考慮不全而出錯。如何來解決這個問題呢?

這裏就能夠利用哨兵結點來解決了。哨兵,解決的是國家之間的邊界問題。同理,這裏說的哨兵也是解決「邊界問題」的,不直接參與業務邏輯。若是咱們引入哨兵結點,在任什麼時候候,無論鏈表是否是空,head 指針都會一直指向這個哨兵結點。咱們也把這種有哨兵結點的鏈表叫帶頭鏈表,相反,沒有哨兵結點的鏈表就叫做不帶頭鏈表

下面用圖和代碼來讓各位同窗一目瞭然:

// 結點類
class Node {
    /**
     * @param {string} element 鏈表結點值
     * **/
    constructor(element) {
        this.element = element
        this.next = null
    }
}

// 鏈表類
class linkedList {
    constructor () {
        this.head = new Node('head')
    }
    /**
     * @param {string} item 鏈表結點的值
     * @return {linkedList} 返回鏈表結點
     * @description 鏈表查詢方法
     * **/
    find (item) {
        let currNode = this.head
        while (currNode.element != item) {
            currNode = currNode.next
        }
        return currNode
    }
    /**
     * @param {string} newElement 新的結點的值
     * @param {string} item 被插入結點的值
     * @description 鏈表結點插入方法
     * **/
    insertNode (newElement, item) {
        let newNode = new Node(newElement)
        let current = this.find(item)
        newNode.next = current.next
        current.next = newNode
    }
    /**
     * @param {string} item 要被查找結點的值
     * @return {linkedList} 返回一個當前要查找的上一個結點
     * @description 查找鏈表上一個結點
     * **/
    findPrevious (item) {
        let currNode = this.head
        while (!(currNode.next == null) && (currNode.next.element != item)) {
            currNode = currNode.next
        }
        return currNode
    }
    /**
     * @param {string} item 被刪除結點的值
     * @description 鏈表結點刪除方法
     * **/
    removeNode (item) {
        let prevNode = this.findPrevious(item)
        if (!(prevNode.next == null)) {
            prevNode.next = prevNode.next.next
        }
    }
    /**
     * @param {string} newItem 修改的新值
     * @param {string} item 修改的舊值
     * @description 鏈表結點編輯方法
     * **/
    editNode (item, newItem) {
        let element = this.find(item)
        element.element = newItem
    }
    /**
     * 鏈表展現方法
     * **/
    displayList () {
        let currNode = this.head
        while (!(currNode.next == null)) {
            console.log(currNode.next.element)
            currNode = currNode.next
        }
    }
}複製代碼

3.注意鏈表的邊界問題

軟件開發中,代碼在一些邊界或者異常狀況下,最容易產生 Bug。鏈表代碼也不例外。要實現沒有 Bug 的鏈表代碼,必定要在編寫的過程當中以及編寫完成以後,檢查邊界條件是否考慮全面,以及代碼在邊界條件下是否能正確運行。

下面有幾個注意點須要你們注意的:

1.若是鏈表爲空時,代碼是否能正常工做?

2.若是鏈表只包含一個結點時,代碼是否能正常工做?

3.若是鏈表只包含兩個結點時,代碼是否能正常工做?

4.代碼邏輯在處理頭結點和尾結點的時候,是否能正常工做?

當你寫完鏈表代碼以後,除了看下你寫的代碼在正常的狀況下可否工做,還要看下在上面我列舉的幾個邊界條件下,代碼仍然可否正確工做。若是這些邊界條件下都沒有問題,那基本上能夠認爲沒有問題了。固然,邊界條件不止我列舉的那些。針對不一樣的場景,可能還有特定的邊界條件,這個須要你本身去思考,不過套路都是同樣的。 


上一篇文章:數據結構與算法的重溫之旅(三)——數組​​​​​​​

下一篇文章:數據結構與算法的重溫之旅(五)——如何運用鏈表​​​​​​​ 

相關文章
相關標籤/搜索