在 JavaScript 中學習資料結構與演算法

Surmon發表於2017-06-24

這是一本5萬字元(中文約2w)的小書,可能需要幾個小時閱讀,需要幾天或更多時間去消化。部分程式碼還不能正確地跑起來,有錯別字,有不準確的概念...,但這不妨礙它作為你一個野生前端學習資料結構與演算法的啟蒙文章,期待你的一針見血、刀刀致命?

對任何專業技術人員來說,理解資料結構都非常重要。作為軟體開發者,我們要能夠用程式語言和資料結構來解決問題。程式語言和資料結構是這些問題解決方案中不可或缺的一部分。如果選擇了不恰當的資料結構,可能會影響所寫程式的效能。因此,瞭解不同資料結構和它們的適用範圍十分重要。

一句話:演算法即原力,即正義

本文主要講述Javascript中實現棧、佇列、連結串列、集合、字典、雜湊表、樹、圖等資料結構,以及各種排序和搜尋演算法,包括氣泡排序、選擇排序、插入排序、歸併排序、快速排序、順序搜尋、二分搜尋,最後還介紹了動態規劃和貪心演算法等常用的高階演算法及相關知識。

在閱讀之前假設你已瞭解並可以熟練使用Javascript編寫應用程式。

概覽

資料結構

  • :一種遵從先進後出 (LIFO) 原則的有序集合;新新增的或待刪除的元素都儲存在棧的末尾,稱作棧頂,另一端為棧底。在棧裡,新元素都靠近棧頂,舊元素都接近棧底。
  • 佇列:與上相反,一種遵循先進先出 (FIFO / First In First Out) 原則的一組有序的項;佇列在尾部新增新元素,並從頭部移除元素。最新新增的元素必須排在佇列的末尾。
  • 連結串列:儲存有序的元素集合,但不同於陣列,連結串列中的元素在記憶體中並不是連續放置的;每個元素由一個儲存元素本身的節點和一個指向下一個元素的引用(指標/連結)組成。
  • 集合:由一組無序且唯一(即不能重複)的項組成;這個資料結構使用了與有限集合相同的數學概念,但應用在電腦科學的資料結構中。
  • 字典:以 [鍵,值] 對為資料形態的資料結構,其中鍵名用來查詢特定元素,類似於 Javascript 中的Object
  • 雜湊:根據關鍵碼值(Key value)直接進行訪問的資料結構;它通過把關鍵碼值對映到表中一個位置來訪問記錄,以加快查詢的速度;這個對映函式叫做雜湊函式,存放記錄的陣列叫做雜湊表。
  • :由 n(n>=1)個有限節點組成一個具有層次關係的集合;把它叫做“樹”是因為它看起來像一棵倒掛的樹,也就是說它是根朝上,而葉朝下的,基本呈一對多關係,樹也可以看做是圖的特殊形式。
  • :圖是網路結構的抽象模型;圖是一組由邊連線的節點(頂點);任何二元關係都可以用圖來表示,常見的比如:道路圖、關係圖,呈多對多關係。

演算法

排序演算法

  • 氣泡排序:比較任何兩個相鄰的項,如果第一個比第二個大,則交換它們;元素項向上移動至正確的順序,好似氣泡上升至表面一般,因此得名。
  • 選擇排序:每一次從待排序的資料元素中選出最小(或最大)的一個元素,存放在序列的起始位置,以此迴圈,直至排序完畢。
  • 插入排序:將一個資料插入到已經排好序的有序資料中,從而得到一個新的、個數加一的有序資料,此演算法適用於少量資料的排序,時間複雜度為 O(n^2)。
  • 歸併排序:將原始序列切分成較小的序列,只到每個小序列無法再切分,然後執行合併,即將小序列歸併成大的序列,合併過程進行比較排序,只到最後只有一個排序完畢的大序列,時間複雜度為 O(n log n)。
  • 快速排序:通過一趟排序將要排序的資料分割成獨立的兩部分,其中一部分的所有資料都比另外一部分的所有資料都要小,然後再按此方法對這兩部分資料分別進行上述遞迴排序,以此達到整個資料變成有序序列,時間複雜度為 O(n log n)。

搜尋演算法

  • 順序搜尋:讓目標元素與列表中的每一個元素逐個比較,直到找出與給定元素相同的元素為止,缺點是效率低下。
  • 二分搜尋:在一個有序列表,以中間值為基準拆分為兩個子列表,拿目標元素與中間值作比較從而再在目標的子列表中遞迴此方法,直至找到目標元素。

其他

貪心演算法:在對問題求解時,不考慮全域性,總是做出區域性最優解的方法。

動態規劃:在對問題求解時,由以求出的區域性最優解來推導全域性最優解。

複雜度概念:一個方法在執行的整個生命週期,所需要佔用的資源,主要包括:時間資源、空間資源。

資料結構

棧是一種遵從先進後出 (LIFO) 原則的有序集合;新新增的或待刪除的元素都儲存在棧的末尾,稱作棧頂,另一端為棧底。在棧裡,新元素都靠近棧頂,舊元素都接近棧底。

通俗來講,一摞疊起來的書或盤子都可以看做一個棧,我們想要拿出最底下的書或盤子,一定要現將上面的移走才可以。

棧也被用在程式語言的編譯器和記憶體中儲存變變數、方法呼叫。

在 Javascript 中我們可以使用陣列的原生方法實現一個棧/佇列的功能,鑑於學習目的,我們使用類來實現一個棧。

class Stack {

    constructor() {
        this.items = []
    }

    // 入棧
    push(element) {
         this.items.push(element)
    }

    // 出棧
    pop() {
        return this.items.pop()
    }

    // 末位
    get peek() {
        return this.items[this.items.length - 1]
    }

    // 是否為空棧
    get isEmpty() {
        return !this.items.length
    }

    // 尺寸
    get size() {
        return this.items.length
    }

    // 清空棧
    clear() {
        this.items = []
    }

    // 列印棧資料
    print() {
        console.log(this.items.toString())
    }
}複製程式碼

使用棧類:

// 例項化一個棧
const stack = new Stack()
console.log(stack.isEmpty) // true

// 新增元素
stack.push(5)
stack.push(8)

// 讀取屬性再新增
console.log(stack.peek) // 8
stack.push(11)
console.log(stack.size) // 3
console.log(stack.isEmpty) // false複製程式碼

佇列

與棧相反,佇列是一種遵循先進先出 (FIFO / First In First Out) 原則的一組有序的項;佇列在尾部新增新元素,並從頭部移除元素。最新新增的元素必須排在佇列的末尾。

在現實中,最常見的例子就是排隊,吃飯排隊、銀行業務排隊、公車的前門上後門下機制...,前面的人優先完成自己的事務,完成之後,下一個人才能繼續。

在電腦科學中,一個常見的例子就是列印佇列。比如說我們需要列印五份文件。我們會開啟每個文件,然後點選列印按鈕。每個文件都會被髮送至列印佇列。第一個傳送到列印佇列的文件會首先被列印,以此類推,直到列印完所有文件。

同樣的,我們在 Javascript 中實現一個佇列類。

class Queue {

    constructor(items) {
        this.items = items || []
    }

    enqueue(element){
        this.items.push(element)
    }

    dequeue(){
        return this.items.shift()
    }

    front(){
        return this.items[0]
    }

    clear(){
        this.items = []
    }

    get size(){
        return this.items.length
    }

    get isEmpty(){
        return !this.items.length
    }

    print() {
        console.log(this.items.toString())
    }
}複製程式碼

使用佇列類:

const queue = new Queue()
console.log(queue.isEmpty) // true

queue.enqueue('John')
queue.enqueue('Jack')
queue.enqueue('Camila')
console.log(queue.size) // 3
console.log(queue.isEmpty) // false
queue.dequeue()
queue.dequeue()
queue.print() // 'Camila'複製程式碼

優先佇列

佇列大量應用在電腦科學以及我們的生活中,我們在之前話題中實現的預設佇列也有一些修改版本。

其中一個修改版就是優先佇列。元素的新增和移除是基於優先順序的。一個現實的例子就是機場登機的順序。頭等艙和商務艙乘客的優先順序要高於經濟艙乘客。在有些國家,老年人和孕婦(或 帶小孩的婦女)登機時也享有高於其他乘客的優先順序。

另一個現實中的例子是醫院的(急診科)候診室。醫生會優先處理病情比較嚴重的患者。通常,護士會鑑別分類,根據患者病情的嚴重程度放號。

實現一個優先佇列,有兩種選項:設定優先順序,然後在正確的位置新增元素;或者用入列操作新增元素,然後按照優先順序移除它們。在下面示例中,我們將會在正確的位置新增元素,因此可以對它們使用預設的出列操作:

class PriorityQueue {

    constructor() {
        this.items = []
    }

    enqueue(element, priority){
        const queueElement = { element, priority }
        if (this.isEmpty) {
            this.items.push(queueElement)
        } else {
            const preIndex = this.items.findIndex((item) => queueElement.priority < item.priority)
            if (preIndex > -1) {
                this.items.splice(preIndex, 0, queueElement)
            } else {
                this.items.push(queueElement)
            }
        }
    }

    dequeue(){
        return this.items.shift()
    }

    front(){
        return this.items[0]
    }

    clear(){
        this.items = []
    }

    get size(){
        return this.items.length
    }

    get isEmpty(){
        return !this.items.length
    }

    print() {
        console.log(this.items)
    }
}複製程式碼

優先佇列的使用:

const priorityQueue = new PriorityQueue()
priorityQueue.enqueue('John', 2)
priorityQueue.enqueue('Jack', 1)
priorityQueue.enqueue('Camila', 1)
priorityQueue.enqueue('Surmon', 3)
priorityQueue.enqueue('skyRover', 2)
priorityQueue.enqueue('司馬萌', 1)
priorityQueue.print()

console.log(priorityQueue.isEmpty, priorityQueue.size) // false 6複製程式碼

迴圈佇列

為充分利用向量空間,克服"假溢位"現象的方法是:將向量空間想象為一個首尾相接的圓環,並稱這種向量為迴圈向量。儲存在其中的佇列稱為迴圈佇列(Circular Queue)。這種迴圈佇列可以以單連結串列、佇列的方式來在實際程式設計應用中來實現。

下面我們基於首次實現的佇列類,簡單實現一個迴圈引用的示例:

class LoopQueue extends Queue {

    constructor(items) {
        super(items)
    }

    getIndex(index) {
        const length = this.items.length
        return index > length ? (index % length) : index
    }

    find(index) {
        return !this.isEmpty ? this.items[this.getIndex(index)] : null
    }
}複製程式碼

訪問一個迴圈佇列:

const loopQueue = new LoopQueue(['Surmon'])
loopQueue.enqueue('SkyRover')
loopQueue.enqueue('Even')
loopQueue.enqueue('Alice')
console.log(loopQueue.size, loopQueue.isEmpty) // 4 false

console.log(loopQueue.find(26)) // 'Evan'
console.log(loopQueue.find(87651)) // 'Alice'複製程式碼

連結串列

要儲存多個元素,陣列(或列表)可能是最常用的資料結構。
每種語言都實現了陣列。這種資料結構非常方便,提供了一個便利的[]語法來訪問它的元素。
然而,這種資料結構有一個缺點:在大多數語言中,陣列的大小是固定的,從陣列的起點或中間插入或移除項的成本很高,因為需要移動元素;
儘管 JavaScript 中的Array類方法可以幫我們做這些事,但背後的處理機制同樣如此。

連結串列儲存有序的元素集合,但不同於陣列,連結串列中的元素在記憶體中並不是連續放置的。每個 元素由一個儲存元素本身的節點和一個指向下一個元素的引用(也稱指標或連結)組成。下圖展示了連結串列的結構:

相對於傳統的陣列,連結串列的一個好處在於,新增或移除元素的時候不需要移動其他元素。然而,連結串列需要使用指標,因此實現連結串列時需要額外注意。

陣列的另一個細節是可以直接訪問任何位置的任何元素,而要想訪問連結串列中間的一個元素,需要從起點(表頭)開始迭代列表直到找到所需的元素。

現實中有許多連結串列的例子:一列火車是由一系列車廂/車皮組成的,每節車廂/車皮都相互連線,你很容易分離一節車皮,改變它的位置,新增或移除它。下圖演示了一列火車,每節車皮都是列表的元素,車皮間的連線就是指標:

下面我們使用 Javascript 建立一個連結串列類:

// 連結串列節點
class Node {
    constructor(element) {
        this.element = element
        this.next = null
    }
}

// 連結串列
class LinkedList {

    constructor() {
        this.head = null
        this.length = 0
    }

    // 追加元素
    append(element) {
        const node = new Node(element)
        let current = null
        if (this.head === null) {
            this.head = node
        } else {
            current = this.head
            while(current.next) {
                current = current.next
            }
            current.next = node
        }
        this.length++
    }

    // 任意位置插入元素
    insert(position, element) {
        if (position >= 0 && position <= this.length) {
            const node = new Node(element)
            let current = this.head
            let previous = null
            let index = 0
            if (position === 0) {
                this.head = node
            } else {
                while (index++ < position) {
                    previous = current
                    current = current.next
                }
                node.next = current
                previous.next = node
            }
            this.length++
            return true
        }
        return false
    }

    // 移除指定位置元素
    removeAt(position) {

        // 檢查越界值
        if (position > -1 && position < length) {
            let current = this.head
            let previous = null
            let index = 0
            if (position === 0) {
                this.head = current.next
            } else {
                while (index++ < position) {
                    previous = current
                    current = current.next
                }
                previous.next = current.next
            }
            this.length--
            return current.element
        }
        return null
    }

    // 尋找元素下標
    findIndex(element) {
        let current = this.head
        let index = -1
        while (current) {
            if (element === current.element) {
                return index + 1
            }
            index++
            current = current.next
        }
        return -1
    }

    // 刪除指定文件
    remove(element) {
        const index = this.indexOf(element)
        return this.removeAt(index)
    }

    isEmpty() {
        return !this.length
    }

    size() {
        return this.length
    }

    // 轉為字串
    toString() {
        let current = this.head
        let string = ''
        while (current) {
            string += ` ${current.element}`
            current = current.next
        }
        return string
    }
}複製程式碼

連結串列類的使用:

const linkedList = new LinkedList()

console.log(linkedList)
linkedList.append(2)
linkedList.append(6)
linkedList.append(24)
linkedList.append(152)

linkedList.insert(3, 18)
console.log(linkedList)
console.log(linkedList.findIndex(24))複製程式碼

雙向連結串列

連結串列有多種不同的型別,這一節介紹雙向連結串列。雙向連結串列和普通連結串列的區別在於,在連結串列中, 一個節點只有鏈向下一個節點的連結,而在雙向連結串列中,連結是雙向的:一個鏈向下一個元素, 另一個鏈向前一個元素,如下圖所示:

雙向連結串列提供了兩種迭代列表的方法:從頭到尾,或者反過來。我們也可以訪問一個特定節 點的下一個或前一個元素。在單向連結串列中,如果迭代列表時錯過了要找的元素,就需要回到列表 起點,重新開始迭代。這是雙向連結串列的一個優點。

我們繼續來實現一個雙向連結串列類:

// 連結串列節點
class Node {
    constructor(element) {
        this.element = element
        this.prev = null
        this.next = null
    }
}

// 雙向連結串列
class DoublyLinkedList {

    constructor() {
        this.head = null
        this.tail = null
        this.length = 0
    }

    // 任意位置插入元素
    insert(position, element) {
        if (position >= 0 && position <= this.length){
            const node = new Node(element)
            let current = this.head
            let previous = null
            let index = 0
            // 首位
            if (position === 0) {
                if (!head){
                    this.head = node
                    this.tail = node
                } else {
                    node.next = current
                    this.head = node
                    current.prev = node
                }
            // 末位
            } else if (position === this.length) {
                current = this.tail
                current.next = node
                node.prev = current
                this.tail = node
            // 中位
            } else {
                while (index++ < position) {
                    previous = current
                    current = current.next
                }
                node.next = current
                previous.next = node
                current.prev = node
                node.prev = previous
            }
            this.length++
            return true
        }
        return false
    }

    // 移除指定位置元素
    removeAt(position) {
        if (position > -1 && position < this.length) {
            let current = this.head
            let previous = null
            let index = 0

            // 首位
            if (position === 0) {
                this.head = this.head.next
                this.head.prev = null
                if (this.length === 1) {
                    this.tail = null
                }

            // 末位
            } else if (position === this.length - 1) {
                this.tail = this.tail.prev
                this.tail.next = null

            // 中位
            } else {
                while (index++ < position) {
                     previous = current
                     current = current.next
                }
                previous.next = current.next
                current.next.prev = previous
         }
         this.length--
         return current.element
        } else {
            return null
        }
    }

    // 其他方法...
}複製程式碼

迴圈連結串列

迴圈連結串列可以像連結串列一樣只有單向引用,也可以像雙向連結串列一樣有雙向引用。迴圈連結串列和連結串列之間唯一的區別在於,最後一個元素指向下一個元素的指標(tail.next)不是引用null, 而是指向第一個元素(head),如下圖所示。

雙向迴圈連結串列有指向head元素的tail.next,和指向tail元素的head.prev。

連結串列相比陣列最重要的優點,那就是無需移動連結串列中的元素,就能輕鬆地新增和移除元素。因此,當你需要新增和移除很多元素 時,最好的選擇就是連結串列,而非陣列。

集合

集合是由一組無序且唯一(不能重複)的項組成的。這個資料結構使用了與有限集合相同的數學概念,但應用在電腦科學的資料結構中。

在數學中,集合是一組不同的物件(的集)。

比如說:一個由大於或等於0的證照組成的自然數集合:N = { 0, 1, 2, 3, 4, 5, 6, ... },集合中的物件列表用{}包圍。

集合是由一組一(即不能重的項組成的。這個資料結構使用了與有..合相同的數學..,但應用在.算..學的資料結構中。

目前 ES6 中已內建了 Set 型別的實現,出於學習目的,下面我們依舊使用Javascript建立一個集合類:

class Set {

    constructor() {
        this.items = {}
    }

    has(value) {
        return this.items.hasOwnProperty(value)
    }

    add(value) {
        if (!this.has(value)) {
            this.items[value] = value
            return true
        }     
        return false
    }

    remove(value) {
        if (this.has(value)) {
            delete this.items[value]
            return true
        }
        return false
    }

    get size() {
        return Object.keys(this.items).length
    }

    get values() {
        return Object.keys(this.items)
    }
}複製程式碼

使用集合類:

const set = new Set()
set.add(1)
console.log(set.values)  // ["1"] 
console.log(set.has(1))  // true 
console.log(set.size) // 1 
set.add(2) 
console.log(set.values)  // ["1", "2"] 
console.log(set.has(2))  // true 
console.log(set.size) // 2 
set.remove(1) 
console.log(set.values) // ["2"] 
set.remove(2) 
console.log(set.values) // []複製程式碼

對集合可以進行如下操作:

  • 並集:對於給定的兩個集合,返回一個包含兩個集合中所有元素的新集合。
  • 交集:對於給定的兩個集合,返回一個包含兩個集合中Р有元素的新集合。
  • 差集:對於給定的兩個集合,返回一個包含所有存在於第一個集合且不存在於第二個集合的元素的新集合。
  • 子集:求證一個給定集合是否是另一集合的子集。

並集

並集的數學概念:集合A和B的並集,表示為A∪B,定義如下:A∪B = { x | x∈A ∨ x∈B },意思是x(元素)存在於A中,或x存在於B中。如圖:

我們基於剛才的 Set 類實現一個並集方法:

union(otherSet) {
    const unionSet = new Set()
    this.values.forEach((v, i) => unionSet.add(this.values[i]))
    otherSet.values.forEach((v, i) => unionSet.add(otherSet.values[i]))
    return unionSet
}複製程式碼

交集

並集的數學概念:集合A和B的交集,表示為A∩B,定義如下:A∩B = { x | x∈A ∧ x∈B },意思是x(元素)存在於A中,且x存在於B中。如圖:

我們基於剛才的 Set 類實現一個交集方法:

intersection(otherSet) {
    const intersectionSet = new Set()
    this.values.forEach((v, i) => {
        if (otherSet.has(v)) {
            intersectionSet.add(v)
        }
    })
    return intersectionSet
}複製程式碼

差集

差集的數學概念:集合A和B的差集,表示為A-B,定義如下:A-B = { x | x∈A ∧ x∉B },意思是x(元素)存在於A中,且不x存在於B中。如圖:

我們基於剛才的 Set 類實現一個差集方法:

difference(otherSet) {
    const differenceSet = new Set()
    this.values.forEach((v, i) => {
        if (!otherSet.has(v)) {
            differenceSet.add(v)
        }
    })
    return differenceSet
}複製程式碼

子集

子集的數學概念:集合A是B的子集,或者說集合B包含了集合A,如圖:

我們基於剛才的 Set 類實現一個子集方法:

subset(otherSet) {
    if (this.size > otherSet.size) {
        return false
    } else {
        return !this.values.some(v => !otherSet.has(v))
    } 
}複製程式碼

字典

集合、字典、雜湊表都可以儲存不重複的資料。字典和我們上面實現的集合很像,上面的集合中我們以{ value: value }的形式儲存資料,而字典是以{ key: value }的形式儲存資料,字典也稱作對映。

簡單說:Object 物件便是字典在 Javascript 中的實現。

還是簡單實現一個字典類:

class Dictionary {

    constructor() {
        this.items = {}
    }

    set(key, value) {
        this.items[key] = value
    }

    get(key) {
        return this.items[key]
    }

    remove(key) {
        delete this.items[key]
    }

    get keys() {
        return Object.keys(this.items)
    }

    get values() {

        /*
        也可以使用ES7中的values方法
        return Object.values(this.items)
        */

        // 在這裡我們通過迴圈生成一個陣列並輸出
        return Object.keys(this.items).reduce((r, c, i) => {
            r.push(this.items[c])
            return r
        }, [])
    }
}複製程式碼

使用字典類:

const dictionary = new Dictionary()
dictionary.set('Gandalf', 'gandalf@email.com')
dictionary.set('John', 'johnsnow@email.com')
dictionary.set('Tyrion', 'tyrion@email.com')

console.log(dictionary)
console.log(dictionary.keys)
console.log(dictionary.values)
console.log(dictionary.items)複製程式碼

雜湊

HashTable 類,也叫 HashMap 類,是 Dictionary 類的一種雜湊表實現方式。

雜湊演算法的作用是儘可能快地在資料結構中找到一個值。在上面的例子中,如果要在資料結構中獲得一個值(使用get方法),需要遍歷整個資料結構來得到它。如果使用雜湊函式,就知道值的具體位置,因此能夠快速檢索到該值,雜湊函式的作用是給定一個鍵值,然後返回值在表中的地址。

舉個例子,我們繼續使用上面字典中的程式碼示例。我們將要使用最常見的雜湊函式 - 'lose lose'雜湊函式,方法是簡單地將每個鍵值中的每個字母的ASCII值相加。如下圖:

下面我們在 Javascript 中實現一個雜湊表:

class HashTable {

    constructor() {
        this.table = []
    }

    // 雜湊函式
    static loseloseHashCode(key) {
        let hash = 0
        for (let codePoint of key) {
            hash += codePoint.charCodeAt()
        }
        return hash % 37
    }

    // 修改和增加元素
    put(key, value) {
        const position = HashTable.loseloseHashCode(key)
        console.log(`${position} - ${key}`)
        this.table[position] = value
    }

    get(key) {
        return this.table[HashTable.loseloseHashCode(key)]
    }

    remove(key) {
        this.table[HashTable.loseloseHashCode(key)] = undefined
    }
}複製程式碼

對於 HashTable 類來說,我們不需要像 ArrayList 類一樣從 table 陣列中將位置也移除。由 於元素分佈於整個陣列範圍內,一些位置會沒有任何元素佔據,並預設為undefined值。我們也 不能將位置本身從陣列中移除(這會改變其他元素的位置),否則,當下次需要獲得或移除一個 元素的時候,這個元素會不在我們用雜湊函式求出的位置上。

使用 HashTable 類

const hash = new HashTable()
hash.put('Surmon', 'surmon.me@email.com') // 19 - Surmon
hash.put('John', 'johnsnow@email.com') // 29 - John
hash.put('Tyrion', 'tyrion@email.com') // 16 - Tyrion

// 測試get方法
console.log(hash.get('Surmon')) // surmon.me@email.com
console.log(hash.get('Loiane')) // undefined
console.log(hash)複製程式碼

下面的圖表展現了包含這三個元素的 HashTable 資料結構:

雜湊表和雜湊集合

雜湊表和雜湊對映是一樣的,上面已經介紹了這種資料結構。

在一些程式語言中,還有一種叫作雜湊集合的實現。雜湊集合由一個集合構成,但是插人、 移除或獲取元素時,使用的是雜湊函式。我們可以重用上面實現的所有程式碼來實現雜湊集合, 不同之處在於,不再新增鍵值對,而是隻插入值而沒有鍵。例如,可以使用雜湊集合來儲存所有 的英語單詞(不包括它們的定義)。和集合相似,雜湊集合只儲存唯一的不重複的值。

處理雜湊表中的衝突

有時候,一些鍵會有相同的雜湊值。不同的值在雜湊表中對應相同位置的時候,我們稱其為衝突。如下程式碼:

const hash = new HashTable()
hash.put('Gandalf',    'gandalf@email.com')
hash.put('John', 'johnsnow®email.com')
hash.put('Tyrion', 'tyrion@email.com')
hash.put('Aaron',    'aaronOemail.com')
hash.put('Donnie', 'donnie@email.com')
hash.put('Ana', 'ana©email.com')
hash.put('Jonathan', 'jonathan@email.com')    
hash.put('Jamie', 'jamie@email.com')
hash.put('Sue',    'sueOemail.com')
hash.put('Mindy', 'mindy@email.com')
hash.put('Paul', 'paul©email.com')
hash.put('Nathan', 'nathan@email.com')複製程式碼

在上面程式碼中,Tyrion 和 Aaron 有相同的雜湊值(16),Donnie 和 Ana 有相同的雜湊值(13),Jonathan、Jamie 和 Sue 有相同的雜湊值(5), Mindy 和 Paul 也有相同的雜湊值(32),導致最終的資料物件中,只有最後一次被新增/修改的資料會覆蓋原本資料,進而生效。

使用一個資料結構來儲存資料的目的顯然不是去丟失這些資料,而是通過某種方法將它們全部儲存起來;因此,當這種情況發生的時候就要去解決它。

處理衝突有幾種方法:分離連結、線性探查和雙雜湊法。下面介紹前兩種方法。

分離連結

分離連結法包括為雜湊表的每一個位置建立一個連結串列並將元素儲存在裡面。它是解決衝突的 最簡單的方法,但是它在 HashTable 例項之外還需要額外的儲存空間。

例如,我們在之前的測試程式碼中使用分離連結的話,輸出結果將會是這樣:

  • 在位置5上,將會有包含三個元素的LinkedList例項
  • 在位置13、16和32上,將會有包含兩個元素的LinkedList例項
  • 在位置10、19和29上,將會有包含單個元素的LinkedList例項

對於分離連結和線性探查來說,只需要重寫三個方法:put、get 和 remove 這三個方法,在 每種技術實現中都是不同的。

為了實現一個使用了分離連結的 HashTable 例項,我們需要一個新的輔助類來表示將要加人 LinkedList 例項的元素,在這裡我們可以直接使用連結串列類。

下面我們加入連結串列類重寫三個方法:

put(key, value) {
    const position = HashTable.loseloseHashCode(key)
    if (this.table[position] === undefined) {
        this.table[position] = new LinkedList()
    }
    this.table[position].append({ key, value })
}

get(key) {
    const position = HashTable.loseloseHashCode(key)
    if (this.table[position] === undefined) return undefined
    const getElementValue = node => {
        if (!node && !node.element) return undefined
        if (Object.is(node.element.key, key)) {
            return node.element.value
        } else {
            return getElementValue(node.next)
        }
    }
    return getElementValue(this.table[position].head)
}

remove(key) {
    const position = HashTable.loseloseHashCode(key)
    if (this.table[position] === undefined) return undefined
    const getElementValue = node => {
        if (!node && !node.element) return false
        if (Object.is(node.element.key, key)) {
            this.table[position].remove(node.element)
            if (this.table[position].isEmpty) {
                this.table[position] = undefined
            }
            return true
        } else {
            return getElementValue(node.next)
        }
    }
    return getElementValue(this.table[position].head)
}複製程式碼

線性探查

當想向表中某個位置加人一個新元素的時候,如果索引為 index 的位置已經被佔據了,就嘗試 index+1的位置。如果index+1 的位置也被佔據了,就嘗試 index+2 的位置,以此類推。如下圖:

下面我們使用線性探查重寫三個方法:

put(key, value) {
    const position = HashTable.loseloseHashCode(key)
    if (this.table[position] === undefined) {
        this.table[position] = { key, value }
    } else {
        let index = ++position
        while (this.table[index] !== undefined) {
            index++
        }
        this.table[index] = { key, value }
    }
    this.table[position].append({ key, value })
}

get(key) {
    const position = HashTable.loseloseHashCode(key)
    const getElementValue = index => {
        if (this.table[index] === undefined) return undefined
        if (Object.is(this.table[index].key, key)) {
            return this.table[index].value
        } else {
            return getElementValue(index + 1)
        }
    }
    return getElementValue(position)
}

remove(key) {
    const position = HashTable.loseloseHashCode(key)
    const removeElementValue = index => {
        if (this.table[index] === undefined) return false
        if (Object.is(this.table[index].key, key)) {
            this.table[index] = undefined
            return true
        } else {
            return removeElementValue(index + 1)
        }
    }
    return removeElementValue(position)
}複製程式碼

更多函式

我們實現的"lose lose"雜湊函式並不是一個表現良好的雜湊函式,因為它會產生太多的衝 突。如果我們使用這個函式的話,會產生各種各樣的衝突。一個表現良好的雜湊函式是由幾個方 面構成的:插人和檢索元素的時間(即效能),當然也包括較低的衝突可能性。我們可以在網上 找到一些不同的實現方法。像:djb2、sdbm...,或者也可以實現自己的雜湊函式。

其中一個可以實現的比“lose lose”更好的雜湊函式是 djb2:

static djb2HashCode(key) { 
    let hash = 5381
    for (let codePoint of key) {
        hash = hash * 33 + codePoint.charCodeAt()
    }
    return hash % 1013
}複製程式碼

使用 djb2 函式後,上面的雜湊表示例便不再有衝突。

樹是一種非順序資料結構,一種分層資料的抽象模型,它對於儲存需要快速查詢的資料非常有用。

現實生活中最常見的樹的例子是家譜,或是公司的組織架構圖,如下圖:

一個樹結構包含一系列存在父子關係的節點。每個節點都有一個父節點(除了頂部的第一個 節點)以及零個或多個子節點:

樹大概包含以下幾種結構/屬性:

  • 節點
    • 根節點
    • 內部節點:非根節點、且有子節點的節點
    • 外部節點/頁節點:無子節點的節點
  • 子樹:就是大大小小節點組成的樹
  • 深度:節點到根節點的節點數量
  • 高度:樹的高度取決於所有節點深度中的最大值
  • 層級:也可以按照節點級別來分層

二叉樹和二叉搜尋樹

二叉樹中的節點最多隻能有兩個子節點:一個是左側子節點,另一個是右側子節點。這些定 義有助於我們寫出更高效的向/從樹中插人、查詢和刪除節點的演算法。二叉樹在電腦科學中的 應用非常廣泛。

二叉搜尋樹(BST)是二叉樹的一種,但是它只允許你在左側節點儲存(比父節點)小的值, 在右側節點儲存(比父節點)大(或者等於)的值。上圖中就展現了一棵二叉搜尋樹。

注:不同於之前的連結串列和集合,在樹中節點被稱為"鍵",而不是"項"。

下圖展現了二叉搜尋樹資料結構的組織方式:

同樣的,我們使用 Javascript 實現一個 BinarySearchTree 類。

class Node {
    constructor(key) {
        this.key = key
        this.left = null
        this.right = null
    }
}

class BinarySearchTree {

    constructor() {
        this.root = null
    }

    insert(key) {
        const newNode = new Node(key)
        const insertNode = (node, newNode) => {
            if (newNode.key < node.key) {
                if (node.left === null) {
                    node.left = newNode
                } else {
                    insertNode(node.left, newNode)
                }
            } else {
                if (node.right === null) {
                    node.right = newNode
                } else {
                    insertNode(node.right, newNode)
                }
            }
        }
        if (!this.root) {
            this.root = newNode
        } else {
            insertNode(this.root, newNode)
        }
    }
}複製程式碼

二叉搜尋樹類的使用:

const tree = new BinarySearchTree()
tree.insert(11)
tree.insert(7)
tree.insert(5)
tree.insert(3)
tree.insert(9)
tree.insert(8)
tree.insert(10)
tree.insert(13)
tree.insert(12)
tree.insert(14)
tree.insert(20)
tree.insert(18)
tree.insert(25)複製程式碼

最終構建的樹如下圖:

樹的遍歷

遍歷一棵樹是指訪問樹的每個節點並對它們進行某種操作的過程。但是我們應該怎麼去做呢?應該從樹的頂端還是底端開始呢?從左開始還是從右開始呢?

訪問樹的所有節點有三種方式:中序、先序、後序。

中序遍歷

中序遍歷是一種以上行順序訪問 BST 所有節點的遍歷方式,也就是以從最小到最大的順序訪 問所有節點。中序遍歷的一種應用就是對樹進行排序操作。我們來看它的實現:

inOrderTraverse(callback) {
    const inOrderTraverseNode = (node, callback) => {
        if (node !== null) {
            inOrderTraverseNode(node.left, callback)
            callback(node.key)
            inOrderTraverseNode(node.right, callback)
        }
    }
    inOrderTraverseNode(this.root, callback)
}複製程式碼

inOrderTraverse方法接收一個回撥函式作為引數,回撥函式用來定義我們對遍歷到的每個節點進行的操作,這也叫作訪問者模式。

在之前展示的樹上執行下面的方法:

tree.inOrderTraverse(value => { console.log(value) })複製程式碼

下面的結果將會在控制檯上輸出(每個數字將會輸出在不同的行):

3 5 6 7 8 9 10 11 12 13 14 15 18 20 25

下面的圖描繪了 inOrderTraverse 方法的訪問路徑:

先序遍歷

先序遍歷是以優先於後代節點的順序訪問每個節點的。先序遍歷的一種應用是列印一個結構化的文件。

我們來看實現:

preOrderTraverse(callback) {
    const preOrderTraverseNode = (node, callback) => {
        if (node !== null) {
            callback(node.key)
            preOrderTraverseNode(node.left, callback)
            preOrderTraverseNode(node.right, callback)
        }
    }
    preOrderTraverseNode(this.root, callback)
}複製程式碼

下面的圖描繪了 preOrderTraverse 方法的訪問路徑:

後序遍歷

後序遍歷則是先訪問節點的後代節點,再訪問節點本身。後序遍歷的一種應用是計算一個目錄和它的子目錄中所有檔案所佔空間的大小。

我們來看它的實現:

postOrderTraverse(callback) {
    const postOrderTraverseNode = (node, callback) => {
        if (node !== null) {
            postOrderTraverseNode(node.left, callback)
            postOrderTraverseNode(node.right, callback)
            callback(node.key)
        }
    }
    postOrderTraverseNode(this.root, callback)
}複製程式碼

這個例子中,後序遍歷會先訪問左側子節點,然後是右側子節點,最後是父節點本身。

你會發現,中序、先序和後序遍歷的實現方式是很相似的,唯一不同的是三行程式碼的執行順序。

下面的圖描繪了 postOrderTraverse方法的訪問路徑:

三種遍歷訪問順序的不同:

  • 先序遍歷:節點本身 => 左側子節點 => 右側子節點
  • 中序遍歷:左側子節點 => 節點本身 => 右側子節點
  • 後序遍歷:左側子節點 => 節點本身 => 右側子節點

搜尋樹中的值

在樹中,有三種經常執行的搜尋型別:

  • 最小值
  • 最大值
  • 搜尋特定的值

搜尋最小值和最大值

我們使用下面的樹作為示例:

只用眼睛看這張圖,你能一下找到樹中的最小值和最大值嗎?

實現方法:

min(node) {
    const minNode = node => {
        return node ? (node.left ? minNode(node.left) : node) : null
    }
    return minNode(node || this.root)
}

max(node) {
    const maxNode = node => {
        return node ? (node.right ? maxNode(node.right) : node) : null
    }
    return maxNode(node || this.root)
}複製程式碼

搜尋一個特定的值

search(key) {
    const searchNode = (node, key) => {
        if (node === null) return false
        if (node.key === key) return node
        return searchNode((key < node.key) ? node.left : node.right, key)
    }
    return searchNode(root, key)
}複製程式碼

移除一個節點(待修改)

remove(key) {
    const removeNode = (node, key) => {
        if (node === null) return false
        if (node.key === key) {
            console.log(node)
            if (node.left === null && node.right === null) {
                let _node = node
                node = null
                return _node
            } else {
                console.log('key', key)
            }
        } else if (node.left !== null && node.key > key) {
            if (node.left.key === key) {
                node.left.key = this.min(node.left.right).key
                removeNode(node.left.right, node.left.key)
                return node.left
            } else {
                return removeNode(node.left, key)
            }
        } else if (node.right !== null && node.key < key) {
            if (node.right.key === key) {
                node.right.key = this.min(node.right.right).key
                removeNode(node.right.right, node.right.key)
                return node.right
            } else {
                return removeNode(node.right, key)
            }
        } else {
            return false
        }
        return removeNode((key < node.key) ? node.left : node.right, key)
    }
    return removeNode(this.root, key)
}複製程式碼

更多關於二叉樹的知識

BST存在一個問題:取決於你新增的節點數,樹的一條邊可能會非常深;也就是說,樹的一 條分支會有很多層,而其他的分支卻只有幾層,如下圖所示:

AVL樹:

AVL樹是一種自平衡二叉搜尋樹,AVL樹本質上是帶了平衡功能的二叉查詢樹(二叉排序樹,二叉搜尋樹),在AVL樹中任何節點的兩個子樹的高度最大差別為一,也就是說這種樹會在新增或移除節點時儘量試著成為一棵完全樹,所以它也被稱為高度平衡樹。查詢、插入和刪除在平均和最壞情況下都是 O(log n),增加和刪除可能需要通過一次或多次樹旋轉來重新平衡這個樹。

紅黑樹:

紅黑樹和AVL樹類似,都是在進行插入和刪除操作時通過特定操作保持二叉查詢樹的平衡,從而獲得較高的查詢效能;它雖然是複雜的,但它的最壞情況執行時間也是非常良好的,並且在實踐中是高效的:它可以在O(log n)時間內做查詢,插入和刪除,這裡的 n 是樹中元素的數目。

紅黑樹是每個節點都帶有顏色屬性的二叉查詢樹,顏色或紅色或黑色。在二叉查詢樹強制一般要求以外,對於任何有效的紅黑樹我們增加了如下的額外要求:

  • 節點是紅色或黑色
  • 根節點是黑色
  • 每個葉節點(NIL節點,空節點)是黑色的
  • 每個紅色節點的兩個子節點都是黑色。(從每個葉子到根的所有路徑上不能有兩個連續的紅色節點)
  • 從任一節點到其每個葉子的所有路徑都包含相同數目的黑色節點

這些約束強制了紅黑樹的關鍵性質:從根到葉子的最長的可能路徑不多於最短的可能路徑的兩倍長。結果是這個樹大致上是平衡的。因為操作比如插入、刪除和查詢某個值的最壞情況時間都要求與樹的高度成比例,這個在高度上的理論上限允許紅黑樹在最壞情況下都是高效的,而不同於普通的二叉查詢樹。

紅黑樹和AVL樹一樣都對插入時間、刪除時間和查詢時間提供了最好可能的最壞情況擔保。這不只是使它們在時間敏感的應用如即時應用(real time application)中有價值,而且使它們有在提供最壞情況擔保的其他資料結構中作為建造板塊的價值;例如,在計算幾何中使用的很多資料結構都可以基於紅黑樹。
紅黑樹在函數語言程式設計中也特別有用,在這裡它們是最常用的持久資料結構之一,它們用來構造關聯陣列和集合,在突變之後它們能保持為以前的版本。除了O(log n)的時間之外,紅黑樹的持久版本對每次插入或刪除需要O(log n)的空間。

圖是網路結構的抽象模型。圖是一組由邊連線的節點(或頂點),任何二元關係都可以用圖來表示。

任何社交網路,例如 Facebook、Twitter 和 Google plus,都可以用圖來表示;我們還可以使用圖來表示道路、航班以及通訊狀態,如下圖所示:

一個圖G=(V, E)由以下兀素組成:

  • V: 一組頂點
  • E: 一組邊,連線V中的頂點

下圖表示一個圖:

由一條邊連線在一起的頂點稱為相鄰頂點。比如,A和B是相鄰的,A和D是相鄰的,A和C 是相鄰的,A和E不是相鄰的。

一個頂點的度是其相鄰頂點的數量。比如,A和其他三個頂點相連線,因此,A的度為3; E 和其他兩個頂點相連,因此,E的度為2。

路徑是頂點v1, v2, ...vk的一個連續序列,其中 vi 和 vi+1 是相鄰的。以上圖為例, 其中包含路徑A B E I 和 A C D G。

簡單路徑要求不包含重複的頂點。舉個例子,ADG是一條簡單路徑。除去最後一個頂點(因 為它和第一個頂點是同一個頂點),環也是一個簡單路徑,比如ADC A(最後一個頂點重新回到A )。

如果圖中不存在環,則稱該圖是無壞的。如果圖中每兩個頂點間都存在路徑,則該圖是連通的。

有向圖和無向圖

圖可以是無向的(邊沒有方向)或是有向的(有向圖)。如下圖所示,有向圖的邊有一個方向:

如果圖中每兩個頂點間在雙向上都存在路徑,則該圖是強連通的。例如,C和D是強連通的, 而A和B不是強連通的。

圖還可以是未加權的(目前為止我們看到的圖都是未加權的)或是加權的。如下圖所示,加 權圖的邊被賦予了權值:

我們可以使用圖來解決電腦科學世界中的很多問題,比如搜尋圖中的一個特定頂點或搜尋 一條特定邊,尋找圖中的一條路徑(從一個頂點到另一個頂點),尋找兩個頂點之間的最短路徑, 以及環檢測。

從資料結構的角度來說,我們有多種方式來表示圖。在所有的表示法中,不存在絕對正確的 方式。圖的正確表示法取決於待解決的問題和圖的型別。

鄰接矩陣

圖最常見的實現是鄰接矩陣。每個節點都和一個整數相關聯,該整數將作為陣列的索引。我 們用一個二維陣列來表示頂點之間的連線。如果索引為 i 的節點和索引為 j 的節點相鄰,則array[i][j] ===1,否則array[i][j] === 0,如下圖所示:

不是強連通的圖(稀疏圖)如果用鄰接矩陣來表示,則矩陣中將會有很多0,這意味著我們 浪費了計算機儲存空間來表示根本不存在的邊。例如,找給定頂點的相鄰頂點,即使該頂點只有 一個相鄰頂點,我們也不得不迭代一整行。鄰接矩陣表示法不夠好的另一個理由是,圖中頂點的 數量可能會改變,而2維陣列不太靈活。

鄰接表

我們也可以使用一種叫作鄰接表的動態資料結構來表示圖。鄰接表由圖中每個頂點的相鄰頂 點列表所組成。存在好幾種方式來表示這種資料結構。我們可以用列表(陣列)、連結串列,甚至是 雜湊表或是字典來表示相鄰頂點列表。下面的示意圖展示了鄰接表資料結構。

儘管鄰接表可能對大多數問題來說都是更好的選擇,但以上兩種表示法都很有用,且它們有 著不同的性質(例如,要找出頂點V和W是否相鄰,使用鄰接矩陣會比較快)。在接下來的示例中, 我們將會使用鄰接表表示法。

關聯矩陣

我們還可以用關聯矩陣來表示圖。在關聯矩陣中,矩陣的行表示頂點,列表示邊。如下圖所 示,我們使用二維陣列來表示兩者之間的連通性,如果頂點 v 是邊 e 的入射點,則 array[v][e] === 1; 否則,array [v][e] === 0

關聯矩陣通常用於邊的數量比頂點多的情況下,以節省空間和記憶體。

建立圖類:

class Graph() {

    constructor() {
        this.vertices = []
        this.adjList = new Dictionary()
    }

    // 新增頂點
    addVertex(v) {
        this.vertices.push(v)
        this.adjList.set(v, [])
    }

    // 新增線
    addEdge(v, w) {
        this.adjList.get(v).push(w)
        this.adjList.get(w).push(v)
    }

    toString() {
        return this.vertices.reduce((r, v, i) => {
            return this.adjList.get(v).reduce((r, w, i) => {
                return r + `${w} `
            }, `${r}\n${v} => `)
        }, '')
    }
}複製程式碼

使用圖類:


const graph = new Graph()

;['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I'].forEach(c => graph.addVertex(c))

graph.addEdge('A', 'B')
graph.addEdge('A', 'C')
graph.addEdge('A', 'D')
graph.addEdge('C', 'D')
graph.addEdge('C', 'G')
graph.addEdge('D', 'G')
graph.addEdge('D', 'H')
graph.addEdge('B', 'E')
graph.addEdge('B', 'F')
graph.addEdge('E', 'I')

console.log(graph.toString())

// 輸出
/*
A => B C D 
B => A E F 
C => A D G 
D => A C G H 
E => B I 
F => B 
G => C D 
H => D 
I => E 
*/複製程式碼

圖的遍歷

和樹資料結構類似,我們可以訪問圖的所有節點。有兩種演算法可以對圖進行遍歷:

  • 廣度優先搜尋(Breadth-First Search,BFS)
  • 深度優先搜尋(Depth-First Search,DFS)

圖遍歷可以用來尋找特定的頂點或尋找兩個頂點之間的路徑,檢查圖是否連通,檢查圖是否含有環等。

在實現演算法之前,讓我們來更好地理解一下圖遍歷的思想方法。

圖遍歷演算法的思想是必須追蹤每個第一次訪問的節點,並且追蹤有哪些節點還沒有被完全探 索。對於兩種圖遍歷演算法,都需要明確指出第一個被訪問的頂點。

完全探索一個頂點要求我們檢視該頂點的每一條邊。對於每一條邊所連線的沒有被訪問過的 頂點,將其標註為被發現的,並將其加進待訪問頂點列表中。

為了保證演算法的效率,務必訪問每個頂點至多兩次。連通圖中每條邊和頂點都會被訪問到。

廣度優先搜尋演算法和深度優先搜尋演算法基本上是相同的,只有一點不同,那就是待訪問頂點 列表的資料結構。

  • 深度優先搜尋:桟,通過將頂點存入桟中,頂點是沿著路徑被探索的,存在新的相鄰頂點就去訪問
  • 廣度優先搜尋 :佇列,通過將頂點存入佇列中,最先入佇列的頂點先被探索

廣度優先搜尋

廣度優先搜尋演算法會從指定的第一個頂點開始遍歷圖,先訪問其所有的相鄰點,就像一次訪 問圖的一層。簡單說,就是先寬後深地訪問頂點,如下圖所示:

以下是我們的方法實現的。

維護兩個佇列,分別用於儲存已讀和待讀頂點,兩者具有互斥性,即某頂點在訪問時只會屬於一種型別,本質是通過不斷遞迴將相鄰的頂點進行訪問和維度標為已讀。

讓我們來實現廣度優先搜尋演算法:

// breadth first search
bfs(v, callback) {
    const read = []
    const adjList = this.adjList
    let pending = [v || this.vertices[0]]
    const readVertices = vertices => {
        vertices.forEach(key => {
            read.push(key)
            pending.shift()
            adjList.get(key).forEach(v => {
                if (!pending.includes(v) && !read.includes(v)) {
                    pending.push(v)
                }
            })
            if (callback) callback(key)
            if (pending.length) readVertices(pending)
        })
    }
    readVertices(pending)
}複製程式碼

讓我們執行下面這段程式碼來測試一下這個演算法:

graph.bfs(graph.vertices[0], value => console.log('Visited vertex: ' + value))複製程式碼

輸出結果:

Visited vertex: A
Visited vertex: B
Visited vertex: C
Visited vertex: D
Visited vertex: E
Visited vertex: F
Visited vertex: G
Visited vertex: H
Visited vertex: I複製程式碼

如你所見,頂點被訪問的順序和本節開頭的示意圖中所展示的一致。

使用BFS尋找最短路徑

到目前為止,我們只展示了BFS演算法的工作原理。我們可以用該演算法做更多事情,而不只是輸出被訪問頂點的順序。例如,考慮如何來解決下面這個問題。

給定一個圖G和源頂點v,找出對每個頂點u,u和v之間最短路徑的距離(以邊的數量計)。

對於給定頂點V,廣度優先演算法會訪問所有與其距離為1的頂點,接著是距離為2的頂點,以此類推。所以,可以用廣度優先演算法來解這個問題。我們可以修改bfs方法以返回給我們一些資訊:

  • 從 v 到 u 的距離 d[u]
  • 前溯點 pred[u],用來推匯出從v到其他每個頂點u的最短路徑

讓我們來看看改進過的廣度優先方法的實現:

bfs(v, callback) {
    const read = []
    const distances = []
    const predecessors = []
    const adjList = this.adjList
    const pending = [v || this.vertices[0]]
    const readVertices = vertices => {
        vertices.forEach(key => {
            read.push(key)
            pending.shift()
            distances[key] = distances[key] || 0
            predecessors[key] = predecessors[key] || null
            adjList.get(key).forEach(v => {
                if (!pending.includes(v) && !read.includes(v)) {
                    pending.push(v)
                    distances[v] = distances[key] + 1
                    predecessors[v] = key
                }
            })
            if (callback) callback(key)
            if (pending.length) readVertices(pending)
        })
    }
    readVertices(pending)
    return { distances, predecessors }
}複製程式碼

輸出結果:

distances: [A: 0, B: 1, C: 1, D: 1, E: 2, F: 2, G: 2, H: 2 ,工:3]
predecessors: [A: null, B: "A", C: "A", D: "A", E: "B", F: " B", G: " C", H: "D", I: "E"]複製程式碼

這意味著頂點A與頂點B、C和D的距離為1;與頂點E、F、G和H的距離為2;與頂點I的距離
通過前溯點陣列,我們可以用下面這段程式碼來構建從頂點A到其他頂點的路徑:

distance(fromVertex) {
    const vertices = this.vertices
    const { distances, predecessors } = this.bfs(fromVertex)
    vertices.forEach(toVertex => {
        if (!!distances[toVertex]) {
            let preVertex = predecessors[toVertex]
            let slug = ''
            while (fromVertex !== preVertex) {
                slug = `${preVertex} - ${slug}`
                preVertex = predecessors[preVertex]
            }
            slug = `${fromVertex} - ${slug}${toVertex}`
            console.log(slug)
        }
    })
}複製程式碼

執行該程式碼段,我們會得到如下輸出:

graph.distance(graph.vertices[0])
// 輸出如下:
// A - B
// A - C
// A - D
// A - B - E
// A - B - F
// A - C - G
// A - D - H
// A - B - E - I複製程式碼

這裡,我們得到了從頂點A到圖中其他頂點的最短路徑(衡量標準是邊的數量)。

深度優先搜尋

深度優先搜尋演算法將會從第一個指定的頂點開始遍歷圖,沿著路徑直到這條路徑最後一個頂 點被訪問了,接著原路回退並探索下一條路徑。換句話說,它是先深度後廣度地訪問頂點,如下圖所示:

深度優先搜尋演算法不需要一個源頂點。在深度優先搜尋演算法中,若圖中頂點V未訪問,則訪問該頂點V。

深度優先搜尋演算法核心是遞迴,普通的物件遞迴模型即可滿足需求,對比已讀頂點是否已完全覆蓋即可。

深度優先演算法的實現:

// depth first search
dfs(callback) {
    const read = []
    const adjList = this.adjList
    const readVertices = vertices => {
        vertices.forEach(key => {
            if (read.includes(key)) return false
            read.push(key)
            if (callback) callback(key)
            if (read.length !== this.vertices.length) {
                readVertices(adjList.get(key))
            }
        })
    }
    readVertices(adjList.keys)
}複製程式碼

讓我們執行下面的程式碼段來測試一下df s方法:

graph.dfs(value => console.log('Visited vertex: ' + value))

// 輸出如下:
// Visited vertex: A 
// Visited vertex: B 
// Visited vertex: E 
// Visited vertex: I
// Visited vertex: F 
// Visited vertex: C 
// Visited vertex: D 
// Visited vertex: G 
// Visited vertex: H複製程式碼

下圖展示了該演算法每一步的執行過程:

探索深度優先演算法

到目前為止,我們只是展示了深度優先搜尋演算法的工作原理。我們可以用該演算法做更多的事 情,而不只是輸出被訪問頂點的順序。

對於給定的圖G,我們希望深度優先搜尋演算法遍歷圖G的所有節點,構建“森林”(有根樹的 一個集合)以及一組源頂點(根),並輸出兩個陣列:發現時間和完成探索時間。我們可以修改 dfs方法來返回給我們一些資訊:

  • 頂點 u 的發現時間 d[u]
  • 當頂點 u 被標註為已讀時,u 的完成探索時間
  • 頂點 u 的前溯點 p[u]

讓我們來看看改進了的 DFS 方法的實現:

// depth first search
dfs(callback) {
    let readTimer = 0
    const read = []
    const readTimes = []
    const finishedTimes = []
    const predecessors = []
    const adjList = this.adjList
    const readVertices = (vertices, predecessor) => {
        vertices.forEach(key => {
            readTimer++
            if (adjList.get(key).every(v => read.includes(v)) && !finishedTimes[key]) {
                finishedTimes[key] = readTimer
            }
            if (read.includes(key)) return false
            readTimes[key] = readTimer
            read.push(key)
            if (callback) callback(key)
            predecessors[key] = predecessors[key] || predecessor || null
            if (read.length !== this.vertices.length) {
                readVertices(adjList.get(key), key)
            }
        })
    }
    readVertices(adjList.keys)
    return { readTimes, finishedTimes, predecessors }
}複製程式碼

深度優先演算法背後的思想是什麼?邊是從最近發現的頂點 u 處被向外探索的。只有連線到未發現的頂點的邊才會探索。當 u 所有的邊都被探索了,該演算法回退到 u 被發現的地方去探索其他的邊。這個過程持續到我們發現了所有從原始頂點能夠觸及的頂點。如果還留有任何其他未被發現的頂點,我們對新源頂點重複這個過程,直到圖中所有的頂點都被探索了。

對於改進過的深度優先搜尋,有兩點需要我們注意:

  • 時間(time)變數值的範圍只可能在圖頂點數量的一倍到兩倍之間
  • 對於所有的頂點 u,d[u] < f[u] 意味著,發現時間的值比完成時間的值小,完成時所有頂點都已經被探索過了

演算法

排序和搜尋演算法

假設我們有一個沒有任何排列順序的電話通訊錄。當需要新增聯絡人和電話時,你只能將其寫在下一個空位上。假定你的聯絡人列表上有很多人,某天,你要找某個聯絡人及其電話號碼。但是由於聯絡人列表沒有按照任何順序來組織,你只能逐個檢查,直到找到那個你想要的聯絡人為止。這個方法低效,我們需要在列表上搜尋一個聯絡人,但是那通訊錄列表沒有進行任何組織,那得花多久時間啊?!

因此(還有其他原因),我們需要組織資訊集,比如那些儲存在資料結構裡的資訊。排序和搜尋演算法廣泛地運用在待解決的日常問題中。接下來,我會介紹最常用的排序和搜尋演算法。

排序演算法

氣泡排序

人們開始學習排序演算法時,通常都先學冒泡演算法,因為它在所有排序演算法中最簡單。然而, 從執行時間的角度來看,氣泡排序是最差的一個,接下來你會知曉原因。

氣泡排序比較任何兩個相鄰的項,如果第一個比第二個大,則交換它們。元素項向上移動至 正確的順序,就好像氣泡升至表面一樣,氣泡排序因此得名。

程式碼示例:

Array.prototype.bubbleSort = function() {
    for (let i = 0; i < this.length; i++) {
        for (let j = 0; j < this.length - 1 - i; j++) {
            if (this[j] > this[j + 1]) {
                let aux = this[j]
                this[j] = this[j + 1]
                this[j + 1] = aux
            }
        }
    }
}複製程式碼

下圖展示了氣泡排序演算法的執行過程:

注:氣泡排序演算法的複雜度是 O(n²),並不推薦此演算法。

選擇排序

選擇排序演算法是一種原址比較排序演算法。選擇排序演算法的思路是:找到資料結構中的最小值並 將其放置在第一位,接著找到第二小的值並將其放在第二位,以此類推。

程式碼示例:

Array.prototype.selectionSort = function() {
    let indexMin
    for (let i = 0; i < this.length - 1; i++){
        indexMin = i
        for (var j = i; j < this.length; j++){ 
            if(this[indexMin] > this[j]) {
                indexMin = j
            }
        } 
        if (i !== indexMin){
            let aux = this[i]
            this[i] = this[indexMin]
            this[indexMin] = aux
        }
    }
    return this
}複製程式碼

下圖展示了選擇排序演算法的執行過程:

插入排序

插人排序每次排一個陣列項,以此方式構建最後的排序陣列。假定第一項已經排序了,接著, 它和第二項進行比較,第二項是應該待在原位還是插到第一項之前呢?這樣,頭兩項就已正確排 序,接著和第三項比較(它是該插人到第一、第二還是第三的位置呢?),以此類推。

程式碼示例:

Array.prototype.insertionSort = function() {
    let j
    let temp
    for (let i = 1; i < this.length; i++) {
        j = i
        temp = this[i]
        while (j > 0 && this[j - 1] > temp) {
            this[j] = this[j - 1]
            j--
        } 
        this[j] = temp
        console.log(this.join(', '))
    }
    return this
}複製程式碼

下圖展示了插人排序演算法的執行過程:

排序小型陣列時,此演算法比選擇排序和氣泡排序效能要好。

歸併排序

歸併排序是第一個可以被實際使用的排序演算法。前三個排序演算法效能不好,但歸併排序效能不錯,其複雜度為O(n log^n)。

JavaScript的Array類定義了一個sort函式Array.prototype.sort用以排序JavaScript陣列(我們不必自己實現這個演算法)。ECMAScript沒有定義用哪個排序演算法,所以瀏覽器廠商可以自行去實現演算法。例如,Mozilla Firefox 使用歸併排序作為Array.prototype.sort的實現,而Chrome使用了一個快速排序的變體。

歸併排序是一種分治演算法。其思想是將原始陣列切分成較小的陣列,直到每個小陣列只有一 個位置,接著將小陣列歸併成較大的陣列,直到最後只有一個排序完畢的大陣列。

由於是分治法,歸併排序也是遞迴的:

Array.prototype.mergeSort = function() {
    const merge = (left, right) => {
        const result = []
        let il = 0
        let ir = 0
        while(il < left.length && ir < right.length) {
            if(left[il] < right[ir]) {
                result.push(left[il++])
            } else {
                result.push(right[ir++])
            }
        }
        while (il < left.length) {
            result.push(left[il++])
        }
        while (ir < right.length) {
            result.push(right[ir++])
        }
        return result
    }
    const mergeSortRec = array => {
        if (array.length === 1) {
            return array
        }
        const mid = Math.floor(array.length / 2)
        const left = array.slice(0, mid)
        const right = array.slice(mid, array.length)
        return merge(mergeSortRec(left), mergeSortRec(right))
    }
    return mergeSortRec(this)
}複製程式碼

下圖展示了歸併排序演算法的執行過程:

可以看到,演算法首先將原始陣列分割直至只有一個元素的子陣列,然後開始歸併。歸併過程也會完成排序,直至原始陣列完全合併並完成排序。

快速排序

快速排序也許是最常用的排序演算法了。它的複雜度為O(nlog^n),且它的效能通常比其他的複雜度為O(nlog^n)的排序演算法要好。和歸併排序一樣,快速排序也使用分治的方法,將原始陣列分為較小的陣列(但它沒有像歸併排序那樣將它們分割開)。

快速排序的基本過程:

  1. 首先,從陣列中選擇中間一項作為主元
  2. 建立兩個指標,左邊一個指向陣列第一個項,右邊一個指向陣列最後一個項。移動左指 針直到我們找到一個比主元大的元素,接著,移動右指標直到找到一個比主元小的元素,然後交 換它們,重複這個過程,直到左指標超過了右指標。這個過程將使得比主元小的值都排在主元之 前,而比主元大的值都排在主元之後。這一步叫作劃分操作。
  3. 接著,演算法對劃分後的小陣列(較主元小的值組成的子陣列,以及較主元大的值組成的 子陣列)重複之前的兩個步驟,直至陣列已完全排序。

程式碼示例:

Array.prototype.quickSort = function() {
    const partition = (array, left, right) => {
        var pivot = array[Math.floor((right + left) / 2)]
        let i = left
        let j = right
        while (i <= j) {
            while (array[i] < pivot) {
                i++
            }
            while (array[j] > pivot) {
                j--
            }
            if (i <= j) {
                let aux = array[i]
                array[i] = array[j]
                array[j] = aux
                i++
                j--
            }
        }
        return i
    }
    const quick = (array, left, right) => {
        let index
        if (array.length > 1) {
            index = partition(array, left, right)
            if (left < index - 1) {
                quick(array, left, index - 1)
            }
            if (index < right) {
                quick(array, index, right)
            }
        }
    }
    quick(this, 0, this.length - 1)
    return this
}複製程式碼

搜尋演算法

順序搜尋

順序或線性搜尋是最基本的搜尋演算法。它的機制是,將每一個資料結構中的元素和我們要找的元素做比較。順序搜尋是最低效的一種搜尋演算法。

程式碼示例:

Array.prototype.sequentialSearch = function(item) {
    for (let i = 0; i < this.length; i++) {
        if (item === this[i]) return i
    }
    return -1
}複製程式碼

假定有陣列[5, 4, 3, 2, 1]要搜尋值3,下圖展示了順序搜尋演算法的執行過程:

二分搜尋

二分搜尋演算法的原理和猜數字遊戲類似,就是那個有人說"我正想著一個1到100的數字"的遊戲。我們每回應一個數字,那個人就會說這個數字是高了、低了還是對了。

這個演算法要求被搜尋的資料結構已排序,以下是該演算法遵循的步驟:

  • 選擇陣列的中間值
  • 如果選中值是待搜尋值,演算法執行完畢(值找到了)
  • 如果待搜尋值比選中值要小,則返回步驟1並在選中值左邊的子陣列中尋找
  • 如果待搜尋值比選中值要大,則返回步驟1並在選種值右邊的子陣列中尋找

程式碼例項:

Array.prototype.binarySearch = function(item) {
    this.quickSort()
    let low = 0
    let mid = null
    let element = null
    let high = this.length - 1
    while (low <= high){
        mid = Math.floor((low + high) / 2)
        element = this[mid]
        if (element < item) {
            low = mid + 1
        } else if (element > item) {
            high = mid - 1
        } else {
            return mid
        }
    }
    return -1
}複製程式碼

圖展示了二分搜尋演算法的執行過程:

我們實現的BinarySearchTree類有一個search方法,和這個二分搜尋完全一樣,只不過它是針對樹資料結構的。

演算法補充知識

斐波那契數列

斐波那契數列的定義如下:

  • 1和2的斐波那契數是1
  • n(n > 2) 的斐波那契數是 (n-1)的斐波那契數 + n(n-2)的斐波那契數

程式碼示例:

function fibonacci(num) { 
    if (num === 1 || num === 2) { 
        return 1
    }
    return fibonacci(num - 1) + fibonacci(num - 2)
}複製程式碼

假設需要計算數字6的斐波那契值,則計算過程如下圖:

動態規劃

動態規劃(Dynamic Programming,DP)是一種將複雜問題分解成更小的子問題來解決的優化技術。

上面提到過幾次動態規劃技術。用動態規劃解決的一個問題是圖搜尋中的深度優先搜尋。
要注意動態規劃和分而治之(歸併排序和快速排序演算法中用到的那種)是不同的方法。分而治之方法是把問題分解成相互獨立的子問題,然後組合它們的答案,而動態規劃則是將問題分解成相互依賴的子問題。
另一個例子是上一節解決的斐波那契問題。我們將斐波那契問題分解成如該節圖示的小問題。

用動態規劃解決問題時,要遵循三個重要步驟:

  1. 定義子問題
  2. 實現要反覆執行而解決子問題的部分(可能是遞迴)
  3. 識別並求解出邊界條件

能用動態規劃解決的一些著名的問題如下:

  • 揹包問題:給出一組專案,各自有值和容量,目標是找出總值最大的專案的集合。這個 問題的限制是,總容量必須小於等於“揹包”的容量
  • 最長公共子序列:找出一組序列的最長公共子序列(可由另一序列刪除元素但不改變餘 下元素的順序而得到)
  • 矩陣鏈相乘:給出一系列矩陣,目標是找到這些矩陣相乘的最高效辦法(計算次數儘可能少),相乘操作不會進行,解決方案是找到這些矩陣各自相乘的順序
  • 硬幣找零:給出面額為 d1...dn 的一定數量的硬幣和要找零的錢數,找出有多少種找零的方法
  • 圖的全源最短路徑:對所有頂點對(u, v),找出從頂點u到頂點v的最短路徑。

接下來的例子,涉及硬幣找零問題的一個變種。

最少硬幣找零問題是硬幣找零問題的一個變種。硬幣找零問題是給出要找零的錢數,以及可 用的硬幣面額 d1...dn 及其數量,找出有多少種找零方法。最少硬幣找零問題是給出要找零的錢數, 以及可用的硬幣面額 d1...dn 及其數量,找到所需的最少的硬幣個數。

例如,美國有以下面額(硬幣):d1=1, d2=5, d3=10, d4=25,如果要找36美分的零錢,我們可以用1個25美分、1個10美分和1個便士( 1美分)。

如何將這個解答轉化成演算法?

最少硬幣找零的解決方案是找到 n 所需的最小硬幣數。但要做到這一點,首先得找到對每個 x < n 的解。然後,我們將解建立在更小的值的解的基礎上。

來看看演算法:

class MinCoinChange {

    constructor(coins) {
        this.coins = coins
        this.cache = {}
    }

    makeChange(amount) {
        if (!amount) return []
        if (this.cache[amount]) return this.cache[amount]
        let min = [], newMin, newAmount
        this.coins.forEach(coin => {
            newAmount = amount - coin
            if (newAmount >= 0) {
                newMin = this.makeChange(newAmount)
            }
            if (newAmount >= 0 && 
                 (newMin.length < min.length - 1 || !min.length) && 
                 (newMin.length || !newAmount)) {
                min = [coin].concat(newMin)
            }
        })
        return (this.cache[amount] = min)
    }
}複製程式碼

測試一下演算法:

const rninCoinChange = new MinCoinChange([1, 5, 10, 25])
console.log(minCoinChange.makeChange(36))
// [1, 10, 25]
const minCoinChange2 = new MinCoinChange([1, 3, 4])
console.log(minCoinChange2.makeChange(6))
// [3, 3]複製程式碼

所以,找零錢數為6時,最佳答案是兩枚價值為3的硬幣

貪心演算法

貪心演算法遵循一種近似解決問題的技術,期盼通過每個階段的區域性最優選擇(當前最好的 解),從而達到全域性的最優(全域性最優解)。它不像動態規劃那樣計算更大的格局。

最少硬幣找零問題也能用貪心演算法解決。大部分情況的結果是最優的,不過對有些面額而言, 結果不會是最優的。

來看看演算法:

class MinCoinChange {

    constructor(coins) {
        this.coins = coins
    }

    makeChange(amount) {
        const change = []
        let total = 0
        this.coins.sort((a, b) => a < b).forEach(coin => {
            while ((total + coin) <= amount) {
                change.push(coin)
                total += coin
            }
        })
        return change
    }
}複製程式碼

貪心演算法版本的這個解法很簡單。從最大面額的硬幣開始,拿儘可能多的這種硬幣找零。當無法 再拿更多這種價值的硬幣時,開始拿第二大價值的硬幣,依次繼續。

用和DP方法同樣的測試程式碼測試:

const rninCoinChange = new MinCoinChange ( [ 1, 5, 10, 2 5])
console. log (rninCoinChange. rnakeChange (36))複製程式碼

結果依然是[25, 10, 1],和用DP得到的一樣。下圖闡釋了演算法的執行過程:

然而,如果用[1, 3, 4]面額執行貪心演算法,會得到結果[4, 1, 1]。如果用動態規劃的 解法,會得到最優的結果[3, 3]

比起動態規劃演算法而言,貪心演算法更簡單、更快。然而,如我們所見,它並不總是得到最優 答案。但是綜合來看,它相對執行時間來說,輸出了一個可以接受的解。

大0表示法

大O表示法的概念。是描述演算法的效能和複雜程度。

分析演算法時,時常遇到以下幾類函式:

符號 名稱
O(1) 常數的
O(log(n)) 對數的
O((log(n))c) 對數多項式的
O(n) 線性的
O(n²) 二次的
O(n^c) 多項式的
O(c^n) 指數的

如何衡量演算法的效率?通常是用資源,例如CPU (時間)佔用、記憶體佔用、硬碟佔用和網路佔用。當討論大0表示法時,一般考慮的是CPU (時間)佔用。

讓我們試著用一些例子來理解大0表示法的規則。

O(1)

考慮以下函式:

function increment (nurn) {
return ++nurn
}

假設執行increment函式,執行時間等於X。如果再用不同的引數執行一次 increment 函式,執行時間依然是X。和引數無關,increment 函式的效能都一樣。因此,我們 說上述函式的複雜度是 0(1)(常數)。

O(n)

現在以上文中實現的順序搜尋演算法為例:

Array.prototype.sequentialSearch = function(item) {
    for (let i = 0; i < this.length; i++) {
        if (item === this[i]) return i
    }
    return -1
}複製程式碼

如果將含10個元素的陣列[1, ..., 10]傳遞給該函式,假如搜尋1這個元素,那麼,第一次判斷時就能找到想要搜尋的元素。在這裡我們假設每執行一次迴圈,開銷是1。

現在,假如要搜尋元素11。迴圈會執行10次(遍歷陣列中所有的值,並且找不到要搜尋的 元素,因而結果返回-1)。如果一次迴圈的開銷是1,那麼它執行10次的開銷就是10, 10倍於第一種假設。

現在,假如該陣列有1000個元素([1, ..., 1000])。搜尋1001的結果是迴圈執行了1000次,然後返回-1。

注意,sequentialSearch 函式執行的總開銷取決於陣列元素的個數(陣列大小),而且也 和搜尋的值有關。如果是查詢陣列中存在的值,迴圈會執行幾次呢?如果查詢的是陣列中不存 在的值,那麼迴圈就會執行和陣列大小一樣多次,這就是通常所說的最壞情況。

最壞情況下,如果陣列大小是10,開銷就是10;如果陣列大小是1000,開銷就是1000。可以 得出 sequentialSearch 函式的時間複雜度是 O(n),n是輸人陣列的大小。

O(n²)

用氣泡排序做 O(n²) 的例子:

Array.prototype.bubbleSort = function() {
    for (let i = 0; i < this.length; i++) {
        for (let j = 0; j < this.length - 1 - i; j++) {
            if (this[j] > this[j + 1]) {
                let aux = this[j]
                this[j] = this[j + 1]
                this[j + 1] = aux
            }
        }
    }
}複製程式碼

如果用大小為10的陣列執行 bubbleSort,開銷是100(10²)。如果用大小為100的陣列執行 bubbleSort,開銷就是 10000(100²)。我們每次增加輸人的大小,執行都會越來越久。

時間複雜度 O(n) 的程式碼只有一層迴圈,而 O(n²) 的程式碼有雙層巢狀迴圈;如果演算法有三層遍歷陣列的巢狀迴圈,它的時間複雜度很可能就是 O(n^3)。

下圖比較了前述各個大O符號表示的時間複雜度:

資料結構相關演算法複雜度

下表是常用的資料結構演算法插人、刪除和搜尋操作的時間複雜度:

圖演算法複雜度

下表分別列出了使用表示圖的兩種方式時,圖的儲存空間大小,及其增加頂點、增加邊、刪除頂點、刪除邊、查詢頂點的時間複雜度:

排序演算法複雜度

以下是排序演算法在最好、一般和最差的情況下的時間複雜度:

搜尋演算法複雜度

以下是搜尋演算法的時間複雜度,包括圖的遍歷演算法:


注:文中部分內容或程式碼來自:《學習JavaScript資料結構與演算法》

原文釋出地址:surmon.me/article/55

部分程式碼不是很完善,也可能跑起來會有異常,也可能部分解釋不符合標準;總之有任何可以修訂的,請留言!

相關文章