窺探數據結構的世界- ES6版

1. 什麼是數據結構?

數據結構是在計算機中組織和存儲數據的一種特殊方式,使得數據能夠高效地被訪問和修改。更確切地說,數據結構是數據值的集合,表示數據之間的關係,也包括了做用在數據上的函數或操做。javascript

1.1 爲何咱們須要數據結構?

  • 數據是計算機科學當中最關鍵的實體,而數據結構則能夠將數據以某種組織形式存儲,所以,數據結構的價值不言而喻。
  • 不管你以何種方式解決何種問題,你都須要處理數據——不管是涉及員工薪水、股票價格、購物清單,仍是隻是簡單的電話簿問題。
  • 數據須要根據不一樣的場景,按照特定的格式進行存儲。有不少數據結構可以知足以不一樣格式存儲數據的需求。

1.2 八大常見的數據結構

  1. 數組:Array
  2. 堆棧:Stack
  3. 隊列:Queue
  4. 鏈表:Linked Lists
  5. 樹:Trees
  6. 圖:Graphs
  7. 字典樹:Trie
  8. 散列表(哈希表):Hash Tables

在較高的層次上,基本上有三種類型的數據結構:前端

  • 堆棧和隊列是相似於數組的結構,僅在項目的插入和刪除方式上有所不一樣。
  • 鏈表,樹,和圖 結構的節點是引用到其餘節點。
  • 散列表依賴於散列函數來保存和定位數據。

在複雜性方面:java

  • 堆棧和隊列是最簡單的,而且能夠從中構建鏈表。
  • 樹和圖 是最複雜的,由於它們擴展了鏈表的概念。
  • 散列表和字典樹 須要利用這些數據結構來可靠地執行。

就效率而已:node

  • 鏈表是記錄和存儲數據的最佳選擇
  • 而哈希表和字典樹 在搜索和檢索數據方面效果最佳。

2. 數組 - 知識補充

數組是最簡單的數據結構,這裏就不講過多了。
貼一張每一個函數都運行10,000次迭代:python

  • 10,000個隨機密鑰在10,000個對象的數組中查找的執行效率對比圖:
[
  {
    id: "key0",
    content: "I ate pizza 0 times"
  },
  {
    id: "key1",
    content: "I ate pizza 1 times"
  },
  {
    id: "key2",
    content: "I ate pizza 2 times"
  },
  ...
]

["key284", "key958", "key23", "key625", "key83", "key9", ... ]

2.1 for... in爲什麼這麼慢?

for... in語法使人難以置信的緩慢。在測試中就已經比正常狀況下慢近9倍的循環。面試

這是由於for ... in語法是第一個可以迭代對象鍵的JavaScript語句。算法

循環對象鍵({})與在數組([])上進行循環不一樣,編程

由於引擎會執行一些額外的工做來跟蹤已經迭代的屬性。segmentfault

3. 堆棧:Stack


堆棧是元素的集合,能夠在頂部添加項目,咱們有幾個實際的堆棧示例:後端

  • 瀏覽器歷史記錄
  • 撤消操做
  • 遞歸以及其它。

三句話解釋堆棧:

  1. 兩個原則操做:pushpopPush 將元素添加到數組的頂部,而Pop將它們從同一位置刪除。
  2. 遵循"Last In,First Out",即:LIFO,後進先出。
  3. 沒了。

3.1 堆棧的實現。

請注意,下方例子中,咱們能夠顛倒堆棧的順序:底部變爲頂部,頂部變爲底部。

所以,咱們能夠分別使用數組unshiftshift方法代替pushpop

class Stack {
    constructor(...items) {
        this.reverse = false;
        this.stack = [...items];
    }

    push(...items) {
        return this.reverse
            ? this.stack.unshift(...items)
            : this.stack.push(...items);
    }
    
    pop() {
        return this.reverse ? this.stack.shift() : this.stack.pop();
    }
}

const stack = new Stack(4, 5);
stack.reverse = true;
console.log(stack.push(1, 2, 3) === 5) // true
console.log(stack.stack ===[1, 2, 3, 4, 5]) // true

4. 隊列:Queue

在計算機科學中,一個隊列(queue)是一種特殊類型的抽象數據類型或集合。集合中的實體按順序保存。


而在前端開發中,最著名的隊列使用當屬瀏覽器/NodeJs中 關於宏任務與微任務,任務隊列的知識。這裏就再也不贅述了。

在後端領域,用得最普遍的就是消息隊列:Message queue:如RabbitMQActiveMQ等。

以編程思想而言,Queue能夠用兩句話描述:

  • 只是具備兩個主要操做的數組:unshiftpop
  • 遵循"Fist In,first out"即:FIFO,先進先出。

4.1 隊列的實現

請注意,下方例子中,咱們能夠顛倒堆隊列的順序。

所以,咱們能夠分別使用數組unshiftshift方法代替pushpop

class Queue {
    constructor(...items) {
        this.reverse = false;
        this.queue = [...items];
    }

    enqueue(...items) {
        return this.reverse
            ? this.queue.push(...items)
            : this.queue.unshift(...items);
    }

    dequeue() {
        return this.reverse ? this.queue.shift() : this.queue.pop();
    }
}

5. 鏈表:Linked Lists

與數組同樣,鏈表是按順序存儲數據元素。

鏈表不是保留索引,而是指向其餘元素。


第一個節點稱爲頭部(head),而最後一個節點稱爲尾部(tail)。

單鏈表與雙向鏈表:

  • 單鏈表是表示一系列節點的數據結構,其中每一個節點指向列表中的下一個節點。
  • 鏈表一般須要遍歷整個操做列表,所以性能較差。
  • 提升鏈表性能的一種方法是在每一個節點上添加指向列表中上一個節點的第二個指針。
  • 雙向鏈表具備指向其先後元素的節點。

鏈表的優勢:

  • 連接具備常量時間 插入和刪除,由於咱們能夠只更改指針。
  • 與數組同樣,鏈表能夠做爲堆棧運行。

鏈表的應用場景:

連接列表在客戶端和服務器上都頗有用。

  • 在客戶端上,像Redux就以鏈表方式構建其中的邏輯。
  • React 核心算法 React Fiber的實現就是鏈表。

    • React Fiber以前的Stack Reconciler,是自頂向下的遞歸mount/update,沒法中斷(持續佔用主線程),這樣主線程上的佈局、動畫等週期性任務以及交互響應就沒法當即獲得處理,影響體驗。
    • React Fiber解決過去Reconciler存在的問題的思路是把渲染/更新過程(遞歸diff)拆分紅一系列小任務,每次檢查樹上的一小部分,作完看是否還有時間繼續下一個任務,有的話繼續,沒有的話把本身掛起,主線程不忙的時候再繼續。
  • 在服務器上,像Express這樣的Web框架也以相似的方式構建其中間件邏輯。當請求被接收時,它從一箇中間件管道輸送到下一個,直到響應被髮出。

5.1 單鏈表實現

單鏈表的操做核心有:

  • push(value) - 在鏈表的末尾/頭部添加一個節點
  • pop() - 從鏈表的末尾/頭部刪除一個節點
  • get(index) - 返回指定索引處的節點
  • delete(index) - 刪除指定索引處的節點
  • isEmpty() - 根據列表長度返回true或false
  • print() - 返回鏈表的可見表示
class Node {
  constructor(data) {
    this.data = data
    this.next = null
  }
}

class LinkedList {
  constructor() {
    this.head = null
    this.tail = null
    // 長度非必要
    this.length = 0
  }
  push(data) {
    // 建立一個新節點
    const node = new Node(data)
    // 檢查頭部是否爲空
    if (this.head === null) {
      this.head = node
      this.tail = node
    } 
    this.tail.next = node
    this.tail = node
    this.length++
  }
  pop(){
    // 先檢查鏈表是否爲空
    if(this.isEmpty()) {
      return null
    } 
    // 若是長度爲1
    if (this.head === this.tail) {
      this.head = null
      this.tail = null
      this.length--
      return this.tail
    }
    let node = this.tail
    let currentNode = this.head
    let penultimate
    
    while (currentNode) {
      if (currentNode.next === this.tail) {
        penultimate = currentNode
        break
      }
      currentNode = currentNode.next
    }
    
    penultimate.next = null
    this.tail = penultimate
    this.length --
    return node
  }
  
  get(index){
    // 處理邊界條件
    if (index === 0) {
      return this.head
    }
    if (index < 0 || index > this.length) {
      return null
    }
    
    let currentNode = this.head
    let i = 0
    while(i < index) {
      i++
      currentNode = currentNode.next
    }
    return currentNode
    
  }
  delete(index){
    let currentNode = this.head
    
    if (index === 0) {
      let deletedNode
      currentNode.next = this.head
      deletedNode = currentNode
      this.length--
      
      return deletedNode
    }
    
    if (index < 0 || index > this.length) {
      return null
    }
    
    let i = 0
    let previous
    
    while(i < index) {
      i++
      previous = currentNode
      currentNode = currentNode.next
    }
    previous.next = currentNode.next
    this.length--
    return currentNode
  }
  
  isEmpty() {
    return this.length === 0
  }
  print() {
    const list = []
    let currentNode = this.head
    while(currentNode){
      list.push(currentNode.data)
      currentNode = currentNode.next
    }
    return list.join(' => ')
  }
}

測試一下:

const l = new LinkedList()

// 添加節點
const values = ['A', 'B', 'C']
values.forEach(value => l.push(value))

console.log(l)
console.log(l.pop())
console.log(l.get(1))
console.log(l.isEmpty())
console.log(l.print())

5.2 雙向鏈表實現

1. 雙向鏈表的設計

相似於單鏈表,雙向鏈表由一系列節點組成。每一個節點包含一些數據以及指向列表中下一個節點的指針和指向前一個節點的指針。這是JavaScript中的簡單表示:

class Node {
    constructor(data) {
        // data 包含鏈表項應存儲的值
        this.data = data;
        // next 是指向列表中下一項的指針
        this.next = null;
        // prev 是指向列表中上一項的指針
        this.prev = null;
    }
}


仍是敲一遍吧:

class DoublyLinkedList {
    constructor() {
        this.head = null;
        this.tail = null;
    }
    // 各類操做方法
    // ...
}

2. 雙向鏈表的操做方法

  • Append & AppendAt: 在鏈表的尾部/ 指定位置添加節點
append( item ) {
  let node = new Node( item );
  if(!this.head) {
    this.head = node;
    this.tail = node;
  } else {
    node.prev = this.tail;
    this.tail.next = node;
    this.tail = node
  }
}

appendAt( pos, item ) {
   let current = this.head;
   let counter = 1;
   let node = new Node( item );
   if( pos == 0 ) {
     this.head.prev = node
     node.next = this.head
     this.head = node
   } else {
     while(current) {
      current = current.next;
      if( counter == pos ) {
        node.prev = current.prev
        current.prev.next = node
        node.next = current
        current.prev = node
      }
      counter++
    }
  }
}

  • Remove & RemoveAt: 在鏈表的尾部/ 指定位置刪除節點
remove( item ) {
  let current = this.head;
  while( current ) {
    if( current.data === item ) {
      if( current == this.head && current == this.tail ) {
        this.head = null;
        this.tail = null;
      } else if ( current == this.head ) {
        this.head = this.head.next
        this.head.prev = null
      } else if ( current == this.tail ) {
        this.tail = this.tail.prev;
        this.tail.next = null;
      } else {
        current.prev.next = current.next;
        current.next.prev = current.prev;
      }
   }
   current = current.next
  }
}

removeAt( pos ) {
  let current = this.head;
  let counter = 1;
  if( pos == 0 ) {
   this.head = this.head.next;
   this.head.prev = null;
  } else {
   while( current ) {
    current = current.next
    if ( current == this.tail ) {
     this.tail = this.tail.prev;
     this.tail.next = null;
    } else if( counter == pos ) {
     current.prev.next = current.next;
     current.next.prev = current.prev;
     break;
    }
    counter++;
   }
  }
}

  • Reverse: 翻轉雙向鏈表
reverse(){
  let current = this.head;
  let prev = null;
  while( current ){
   let next = current.next
   current.next = prev
   current.prev = next
   prev = current
   current = next
  }
  this.tail = this.head
  this.head = prev
}

  • Swap:兩節點間交換。
swap( nodeOne, nodeTwo ) {
   let current = this.head;
   let counter = 0;
   let firstNode;
   while( current !== null ) {
     if( counter == nodeOne ){
       firstNode = current;
     } else if( counter == nodeTwo ) {
       let temp = current.data;
       current.data = firstNode.data;
       firstNode.data = temp;
     }
     current = current.next;
     counter++;
   }
   return true
}

  • IsEmpty & Length:查詢是否爲空或長度。
length() {
  let current = this.head;
  let counter = 0;
  while( current !== null ) {
   counter++
   current = current.next
  }
  return counter;
}
isEmpty() {
  return this.length() < 1
}

  • Traverse: 遍歷鏈表
traverse( fn ) {
   let current = this.head;
   while( current !== null ) {
    fn(current)
    current = current.next;
   }
   return true;
}


<center>每一項都加10</center>

  • Search:查找節點的索引。
search( item ) {
  let current = this.head;
  let counter = 0;
  while( current ) {
    if( current.data == item ) {
     return counter
    }
    current = current.next
    counter++
  }
  return false;
}

6. 樹:Tree

計算機中常常用到的一種非線性的數據結構——樹(Tree),因爲其存儲的全部元素之間具備明顯的層次特性,所以常被用來存儲具備層級關係的數據,好比文件系統中的文件;也會被用來存儲有序列表等。

  • 在樹結構中,每個結點只有一個父結點,若一個結點無父節點,則稱爲樹的根結點,簡稱樹的根(root)。
  • 每個結點能夠有多個子結點。
  • 沒有子結點的結點稱爲葉子結點。
  • 一個結點所擁有的子結點的個數稱爲該結點的度。
  • 全部結點中最大的度稱爲樹的度。樹的最大層次稱爲樹的深度。

6.1 樹的分類

常見的樹分類以下,其中咱們掌握二叉搜索樹便可。

  • 二叉樹:Binary Search Tree
  • AVL樹:AVL Tree
  • 紅黑樹:Red-Black Tree
  • 線段樹: Segment Tree - with min/max/sum range queries examples
  • 芬威克樹:Fenwick Tree (Binary Indexed Tree)

6.2 樹的應用

  1. DOM樹。每一個網頁都有一個樹數據結構。

  1. VueReactVirtual DOM也是樹。

6.3 二叉樹:Binary Search Tree

  • 二叉樹是一種特殊的樹,它的子節點個數不超過兩個。
  • 且分別稱爲該結點的左子樹(left subtree)與右子樹(right subtree)。
  • 二叉樹常被用做二叉查找樹和二叉搜索樹、或是二叉排序樹(BST)。

6.4 二叉樹的遍歷

按必定的規則和順序走遍二叉樹的全部結點,使每個結點都被訪問一次,並且只被訪問一次,這個操做被稱爲樹的遍歷,是對樹的一種最基本的運算。

因爲二叉樹是非線性結構,所以,樹的遍歷實質上是將二叉樹的各個結點轉換成爲一個線性序列來表示。

按照根節點訪問的順序不一樣,二叉樹的遍歷分爲如下三種:前序遍歷,中序遍歷,後序遍歷;

前序遍歷Pre-Order

根節點->左子樹->右子樹

中序遍歷In-Order

左子樹->根節點->右子樹

後序遍歷Post-Order

左子樹->右子樹->根節點

所以咱們能夠得之上面二叉樹的遍歷結果以下:

  • 前序遍歷:ABDEFGC
  • 中序遍歷:DEBGFAC
  • 後序遍歷:EDGFBCA

6.5 二叉樹的實現

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

class BST {
    constructor() {
        this.root = null
    }
    // 二叉樹的各類操做
    // insert(value) {...}
    // insertNode(root, newNode) {...}
    // ...

1. insertNode& insert:插入新子節點/節點

insertNode(root, newNode) {
    if (newNode.value < root.value) {
      // 先執行無左節點操做
      (!root.left) ? root.left = newNode : this.insertNode(root.left, newNode)
    } else {
      (!root.right) ? root.right = newNode : this.insertNode(root.right, newNode)
    }
  }
  
insert(value) {
    let newNode = new Node(value)
    // 若是沒有根節點
    if (!this.root) {
      this.root = newNode
    } else {
      this.insertNode(this.root, newNode)
    }
}

2. removeNode& remove:移除子節點/節點

removeNode(root, value) {
    if (!root) {
      return null
    }
    
    // 從該值小於根節點開始判斷
    if (value < root.value) {
      root.left = this.removeNode(root.left, value)
      return root
    } else if (value > root.value) {
      root.right = tis.removeNode(root.right, value)
      return root
    } else {
      // 若是沒有左右節點
      if (!root.left && !root.right) {
        root = null
        return root
      }
      
      // 存在左節點
      if (root.left) {
        root = root.left
        return root
      // 存在右節點
      } else if (root.right) {
        root = root.right
        return root
      }
      
      // 獲取正確子節點的最小值以確保咱們有有效的替換
      let minRight = this.findMinNode(root.right)
      root.value = minRight.value
      // 確保刪除已替換的節點
      root.right = this.removeNode(root.right, minRight.value)
      return root
    }
  }
  
remove(value) {
    if (!this.root) {
      return 'Tree is empty!'
    } else {
      this.removeNode(this.root, value)
    }
}

3. findMinNode:獲取子節點的最小值

findMinNode(root) {
    if (!root.left) {
      return root
    } else {
      return this.findMinNode(root.left)
    }
}

4. searchNode & search:查找子節點/節點

searchNode(root, value) {
    if (!root) {
      return null
    }
    
    if (value < root.value) {
      return this.searchNode(root.left, value)
    } else if (value > root.value) {
      return this.searchNode(root.right, value)
    }
    
    return root
}

search(value) {
    if (!this.root) {
      return 'Tree is empty'
    } else {
      return Boolean(this.searchNode(this.root, value))
    }
}
  1. Pre-Order:前序遍歷
preOrder(root) {
    if (!root) {
      return 'Tree is empty'
    } else {
      console.log(root.value)
      this.preOrder(root.left)
      this.preOrder(root.right)
    }
  }
  1. In-Order:中序遍歷
inOrder(root) {
    if (!root) {
      return 'Tree is empty'
    } else {
      this.inOrder(root.left)
      console.log(root.value)
      this.inOrder(root.right)
    }
  }
  1. Post-Order:後序遍歷
postOrder(root) {
    if (!root) {
      return 'Tree is empty'
    } else {
      this.postOrder(root.left)
      this.postOrder(root.right)
      console.log(root.value)
    }
}

7. 圖:Graph

圖是由具備邊的節點集合組成的數據結構。圖能夠是定向的或不定向的。

圖的介紹普及,找了一圈文章,仍是這篇最佳:

Graphs—-A Visual Introduction for Beginners

7.1 圖的應用

在如下場景中,你都使用到了圖:

  • 使用搜索服務,如Google,百度。
  • 使用LBS地圖服務,如高德,谷歌地圖。
  • 使用社交媒體網站,如微博,Facebook

圖用於不一樣的行業和領域:

  • GPS系統和谷歌地圖使用圖表來查找從一個目的地到另外一個目的地的最短路徑。
  • 社交網絡使用圖表來表示用戶之間的鏈接。
  • Google搜索算法使用圖 來肯定搜索結果的相關性。
  • 運營研究是一個使用圖 來尋找下降運輸和交付貨物和服務成本的最佳途徑的領域。
  • 甚至化學使用圖 來表示分子!

圖,能夠說是應用最普遍的數據結構之一,真實場景中到處有圖。

7.2 圖的構成

圖表用於表示,查找,分析和優化元素(房屋,機場,位置,用戶,文章等)之間的鏈接。

1. 圖的基本元素

  • 節點:Node,好比地鐵站中某個站/多個村莊中的某個村莊/互聯網中的某臺主機/人際關係中的人.
  • 邊:Edge,好比地鐵站中兩個站點之間的直接連線, 就是一個邊。

2. 符號和術語

  • |V|=圖中頂點(節點)的總數。
  • |E|=圖中的鏈接總數(邊)。

在下面的示例中

|V| = 6 
|E| = 7

3. 有向圖與無向圖

圖根據其邊(鏈接)的特徵進行分類。

1. 有向圖

在有向圖中,邊具備方向。它們從一個節點轉到另外一個節點,而且沒法經過該邊返回到初始節點。

以下圖所示,邊(鏈接)如今具備指向特定方向的箭頭。 將這些邊視爲單行道。您能夠向一個方向前進併到達目的地,可是你沒法經過同一條街道返回,所以您須要找到另外一條路徑。


<center>有向圖</center>

2. 無向圖

在這種類型的圖中,邊是無向的(它們沒有特定的方向)。將無向邊視爲雙向街道。您能夠從一個節點轉到另外一個節點並返回相同的「路徑」。

4. 加權圖

在加權圖中,每條邊都有一個與之相關的值(稱爲權重)。該值用於表示它們鏈接的節點之間的某種可量化關係。例如:

  • 權重能夠表示距離,時間,社交網絡中兩個用戶之間共享的鏈接數。
  • 或者能夠用於描述您正在使用的上下文中的節點之間的鏈接的任何內容。

著名的Dijkstra算法,就是使用這些權重經過查找網絡中節點之間的最短或最優的路徑來優化路由。

5. 稀疏圖與密集圖

當圖中的邊數接近最大邊數時,圖是密集的。


<center>密集圖</center>

當圖中的邊數明顯少於最大邊數時,圖是稀疏的。

<center>稀疏圖</center>

6. 循環

若是你按照圖中的一系列鏈接,可能會找到一條路徑,將你帶回到同一節點。這就像「走在圈子裏」,就像你在城市周圍開車同樣,你走的路能夠帶你回到你的初始位置。🚗

在圖中,這些「圓形」路徑稱爲「循環」。它們是在同一節點上開始和結束的有效路徑。例如,在下圖中,您能夠看到,若是從任何節點開始,您能夠經過跟隨邊緣返回到同一節點。

循環並不老是「孤立的」,由於它們能夠是較大圖的一部分。能夠經過在特定節點上開始搜索並找到將你帶回同一節點的路徑來檢測它們。


<center>循環圖</center>

7.3 圖的實現

咱們將實現具備鄰接列表的有向圖。

class Graph {
  constructor() {
    this.AdjList = new Map();
  }
  
  // 基礎操做方法
  // addVertex(vertex) {}
  // addEdge(vertex, node) {}
  // print() {}
}

1. addVertex:添加頂點

addVertex(vertex) {
  if (!this.AdjList.has(vertex)) {
    this.AdjList.set(vertex, []);
  } else {
    throw 'Already Exist!!!';
  }
}

嘗試建立頂點:

let graph = new Graph();
graph.addVertex('A');
graph.addVertex('B');
graph.addVertex('C');
graph.addVertex('D');

打印後將會發現:

Map {
  'A' => [],
  'B' => [],
  'C' => [],
  'D' => []
}

之因此都爲空數組'[]',是由於數組中須要儲存邊(Edge)的關係。
例以下圖:

該圖的Map將爲:

Map {
  'A' => ['B', 'C', 'D'],
  // B沒有任何指向
  'B' => [],
  'C' => ['B'],
  'D' => ['C']
}

2. addEdge:添加邊(Edge)

addEdge(vertex, node) {
    // 向頂點添加邊以前,必須驗證該頂點是否存在。
    if (this.AdjList.has(vertex)) {
      // 確保添加的邊尚不存在。
      if (this.AdjList.has(node)){
        let arr = this.AdjList.get(vertex);
        // 若是都經過,那麼能夠將邊添加到頂點。
        if(!arr.includes(node)){
          arr.push(node);
        }
      }else {
        throw `Can't add non-existing vertex ->'${node}'`;
      }
    } else {
      throw `You should add '${vertex}' first`;
    }
}

3. print:打印圖(Graph)

print() {
  for (let [key, value] of this.AdjList) {
    console.log(key, value);
  }
}

測試一下

let g = new Graph();
let arr = ['A', 'B', 'C', 'D', 'E', 'F'];
for (let i = 0; i < arr.length; i++) {
  g.addVertex(arr[i]);
}
g.addEdge('A', 'B');
g.addEdge('A', 'D');
g.addEdge('A', 'E');
g.addEdge('B', 'C');
g.addEdge('D', 'E');
g.addEdge('E', 'F');
g.addEdge('E', 'C');
g.addEdge('C', 'F');
g.print();
/* PRINTED */
// A [ 'B', 'D', 'E' ]
// B [ 'C' ]
// C [ 'F' ]
// D [ 'E' ]
// E [ 'F', 'C' ]
// F []

到目前爲止,這就是建立圖所需的。可是,99%的狀況下,會要求你實現另外兩種方法:

  • 廣度優先算法,BFS
  • 深度優先算法,DFS
  • BFS 的重點在於隊列,而 DFS 的重點在於遞歸。這是它們的本質區別。

5. 廣度優先算法實現

廣度優先算法(Breadth-First Search),同廣度優先搜索。

是一種利用隊列實現的搜索算法。簡單來講,其搜索過程和 「湖面丟進一塊石頭激起層層漣漪」 相似。


如上圖所示,從起點出發,對於每次出隊列的點,都要遍歷其四周的點。因此說 BFS 的搜索過程和 「湖面丟進一塊石頭激起層層漣漪」 很類似,此即 「廣度優先搜索算法」 中「廣度」的由來。

該算法的具體步驟爲:

  • BFS將起始節點做爲參數。(例如'A'
  • 初始化一個空對象:visited
  • 初始化一個空數組:q,該數組將用做隊列。
  • 將起始節點標記爲已訪問。(visited = {'A': true})
  • 將起始節點放入隊列中。(q = ['A'])
  • 循環直到隊列爲空

循環內部:

  • 從中獲取元素q並將其存儲在變量中。(let current = q.pop())
  • 打印 當前 current
  • 從圖中獲取current的邊。(let arr = this.AdjList.get(current))
  • 若是未訪問元素,則將每一個元素標記爲已訪問並將其放入隊列中。
visited = {
  'A': true,
  'B': true,
  'D': true,
  'E': true
}
q = ['B', 'D', 'E']

具體實現

createVisitedObject(){
  let arr = {};
  for(let key of this.AdjList.keys()){
    arr[key] = false;
  }
  return arr;
}

bfs(startingNode){
  let visited = this.createVisitedObject();
  let q = [];

  visited[startingNode] = true;
  q.push(startingNode);

  while(q.length){
    let current = q.pop()
    console.log(current);

    let arr = this.AdjList.get(current);

    for(let elem of arr){
      if(!visited[elem]){
        visited[elem] = true;
        q.unshift(elem)
      }
    }

  }
}

6. 深度優先算法實現

深度優先搜索算法(Depth-First-Search,縮寫爲 DFS),是一種利用遞歸實現的搜索算法。簡單來講,其搜索過程和 「不撞南牆不回頭」 相似。

如上圖所示,從起點出發,先把一個方向的點都遍歷完纔會改變方向...... 因此說,DFS 的搜索過程和 「不撞南牆不回頭」 很類似,此即 「深度優先搜索算法」 中「深度」的由來。

該算法的前期步驟和BFS類似,接受起始節點並跟蹤受訪節點,最後執行遞歸的輔助函數。

具體步驟:

  • 接受起點做爲參數dfs(startingNode)
  • 建立訪問對象let visited = this.createVisitedObject()
  • 調用輔助函數遞歸起始節點和訪問對象this.dfsHelper(startingNode, visited)
  • dfsHelper 將其標記爲已訪問並打印出來。
createVisitedObject(){
  let arr = {};
  for(let key of this.AdjList.keys()){
    arr[key] = false;
  }
  return arr;
}

 dfs(startingNode){
    console.log('\nDFS')
    let visited = this.createVisitedObject();
    this.dfsHelper(startingNode, visited);
  }

  dfsHelper(startingNode, visited){
    visited[startingNode] = true;
    console.log(startingNode);

    let arr = this.AdjList.get(startingNode);

    for(let elem of arr){
      if(!visited[elem]){
        this.dfsHelper(elem, visited);
      }
    }
  }

  doesPathExist(firstNode, secondNode){
    let path = [];
    let visited = this.createVisitedObject();
    let q = [];
    visited[firstNode] = true;
    q.push(firstNode);
    while(q.length){
      let node = q.pop();
      path.push(node);
      let elements = this.AdjList.get(node);
      if(elements.includes(secondNode)){
        console.log(path.join('->'))
        return true;
      }else{
        for(let elem of elements){
          if(!visited[elem]){
            visited[elem] = true;
            q.unshift(elem);
          }
        }
      }
    }
    return false;
  }
}

Vans,下一個。

8. 字典樹:Trie

Trie(一般發音爲「try」)是針對特定類型的搜索而優化的樹數據結構。當你想要獲取部分值並返回一組可能的完整值時,可使用Trie。典型的例子是自動完成。

Trie,是一種搜索樹,也稱字典樹或單詞查找樹,此外也稱前綴樹,由於某節點的後代存在共同的前綴。

它的特色:

  • key都爲字符串,能作到高效查詢和插入,時間複雜度爲O(k),k爲字符串長度
  • 缺點是若是大量字符串沒有共同前綴時很耗內存。
  • 它的核心思想就是減小不必的字符比較,使查詢高效率。
  • 即用空間換時間,再利用共同前綴來提升查詢效率。

例如:
搜索前綴「b」的匹配將返回6個值:bebearbellbidbullbuy

搜索前綴「be」的匹配將返回2個值:bear,bell

8.1 字典樹的應用

只要你想要將前綴與可能的完整值匹配,就可使用Trie

現實中多運用在:

  • 自動填充/預先輸入
  • 搜索
  • 輸入法選項
  • 分類

也能夠運用在:

  • IP地址檢索
  • 電話號碼
  • 以及更多...

8.2 字典樹的實現

class PrefixTreeNode {
  constructor(value) {
    this.children = {};
    this.endWord = null;
    this.value = value;
  }
}
class PrefixTree extends PrefixTreeNode {
  constructor() {
    super(null);
  }
  // 基礎操做方法
  // addWord(string) {}
  // predictWord(string) {}
  // logAllWords() {}
}

1. addWord: 建立一個節點

addWord(string) {
    const addWordHelper = (node, str) => {
        if (!node.children[str[0]]) {
            node.children[str[0]] = new PrefixTreeNode(str[0]);
            if (str.length === 1) {
                node.children[str[0]].endWord = 1;
            } else if (str.length > 1) {
                addWordHelper(node.children[str[0]], str.slice(1));
        }
    };
    addWordHelper(this, string);
}

2. predictWord:預測單詞

即:給定一個字符串,返回樹中以該字符串開頭的全部單詞。

predictWord(string) {
    let getRemainingTree = function(string, tree) {
      let node = tree;
      while (string) {
        node = node.children[string[0]];
        string = string.substr(1);
      }
      return node;
    };

    let allWords = [];
    
    let allWordsHelper = function(stringSoFar, tree) {
      for (let k in tree.children) {
        const child = tree.children[k]
        let newString = stringSoFar + child.value;
        if (child.endWord) {
          allWords.push(newString);
        }
        allWordsHelper(newString, child);
      }
    };

    let remainingTree = getRemainingTree(string, this);
    if (remainingTree) {
      allWordsHelper(string, remainingTree);
    }

    return allWords;
  }

3. logAllWords:打印全部的節點

logAllWords() {
    console.log('------ 全部在字典樹中的節點 -----------')
    console.log(this.predictWord(''));
  }

logAllWords,經過在空字符串上調用predictWord來打印Trie中的全部節點。

9. 散列表(哈希表):Hash Tables

使用哈希表能夠進行很是快速的查找操做。可是,哈希表到底是什麼玩意兒?

不少語言的內置數據結構像python中的字典,java中的HashMap,都是基於哈希表實現。但哈希表到底是啥?

9.1 哈希表是什麼?

散列(hashing)是電腦科學中一種對資料的處理方法,經過某種特定的函數/算法(稱爲散列函數/算法)將要檢索的項與用來檢索的索引(稱爲散列,或者散列值)關聯起來,生成一種便於搜索的數據結構(稱爲散列表)。也譯爲散列。舊譯哈希(誤覺得是人名而採用了音譯)。

它也經常使用做一種資訊安全的實做方法,由一串資料中通過散列算法(Hashing algorithms)計算出來的資料指紋(data fingerprint),常常用來識別檔案與資料是否有被竄改,以保證檔案與資料確實是由原創者所提供。 —-Wikipedia

9.2 哈希表的構成

Hash Tables優化了鍵值對的存儲。在最佳狀況下,哈希表的插入,檢索和刪除是恆定時間。哈希表用於存儲大量快速訪問的信息,如密碼。

哈希表能夠概念化爲一個數組,其中包含一系列存儲在對象內部子數組中的元組:

{[[['a',9],['b',88]],[['e',7],['q',8]],[['j',7],['l ',8]]]};
  • 外部數組有多個等於數組最大長度的桶(子數組)。
  • 在桶內,元組或兩個元素數組保持鍵值對。

9.3 哈希表的基礎知識

這裏我就嘗試以大白話形式講清楚基礎的哈希表知識:

散列是一種用於從一組類似對象中惟一標識特定對象的技術。咱們生活中如何使用散列的一些例子包括:

  • 在大學中,每一個學生都會被分配一個惟一的卷號,可用於檢索有關它們的信息。
  • 在圖書館中,每本書都被分配了一個惟一的編號,可用於肯定有關圖書的信息,例如圖書館中的確切位置或已發給圖書的用戶等。

在這兩個例子中,學生和書籍都被分紅了一個惟一的數字。

1. 思考一個問題

假設有一個對象,你想爲其分配一個鍵以便於搜索。要存儲鍵/值對,您可使用一個簡單的數組,如數據結構,其中鍵(整數)能夠直接用做存儲值的索引。

可是,若是密鑰很大而且沒法直接用做索引,此時就應該使用散列。

2, 一個哈希表的誕生

具體步驟以下:

  1. 在散列中,經過使用散列函數將大鍵轉換爲小鍵。
  2. 而後將這些值存儲在稱爲哈希表的數據結構中。
  3. 散列的想法是在數組中統一分配條目(鍵/值對)。爲每一個元素分配一個鍵(轉換鍵)。
  4. 經過使用該鍵,您能夠在O(1)時間內訪問該元素。
  5. 使用密鑰,算法(散列函數)計算一個索引,能夠找到或插入條目的位置。

具體執行分兩步:

  • 經過使用散列函數將元素轉換爲整數。此元素可用做存儲原始元素的索引,該元素屬於哈希表。
  • 該元素存儲在哈希表中,可使用散列鍵快速檢索它。
hash = hashfunc(key)
index = hash % array_size

在此方法中,散列與數組大小無關,而後經過使用運算符(%)將其縮減爲索引(介於0array_size之間的數字 - 1)。

3. 哈希函數

  • 哈希函數是可用於將任意大小的數據集映射到固定大小的數據集的任何函數,該數據集屬於散列表
  • 哈希函數返回的值稱爲哈希值,哈希碼,哈希值或簡單哈希值。

要實現良好的散列機制,須要具備如下基本要求:

  1. 易於計算:它應該易於計算,而且不能成爲算法自己。
  2. 統一分佈:它應該在哈希表中提供統一分佈,不該致使羣集。
  3. 較少的衝突:當元素對映射到相同的哈希值時發生衝突。應該避免這些。
注意:不管散列函數有多健壯,都必然會發生衝突。所以,爲了保持哈希表的性能,經過各類衝突解決技術來管理衝突是很重要的。

4. 良好的哈希函數

假設您必須使用散列技術{「abcdef」,「bcdefa」,「cdefab」,「defabc」}等字符串存儲在散列表中。

首先是創建索引:

  • a,b,c,d,efASCII值分別爲97,98,99,100,101102,總和爲:597
  • 597不是素數,取其附近的素數599,來減小索引不一樣字符串(衝突)的可能性。

哈希函數將爲全部字符串計算相同的索引,而且字符串將如下格式存儲在哈希表中。

因爲全部字符串的索引都相同,此時全部字符串都在同一個「桶」中。

  • 這裏,訪問特定字符串須要O(n)時間(其中n是字符串數)。
  • 這代表該哈希函數不是一個好的哈希函數。

如何優化這個哈希函數?

注意觀察這些字符串的異同

{「abcdef」,「bcdefa」,「cdefab」,「defabc」}
  • 都是由a,b,c,d,ef組成
  • 不一樣點在於組成順序。

來嘗試不一樣的哈希函數。

  • 特定字符串的索引將等於字符的ASCII值之和乘以字符串中它們各自的順序
  • 以後將它與2069(素數)取餘。

字符串哈希函數索引

字符串 索引生成 計算值
abcdef (97 1 + 98 2 + 99 3 + 100 4 + 101 5 + 102 6)%2069 38
bcdefa (98 1 + 99 2 + 100 3 + 101 4 + 102 5 + 97 6)%2069 23
cdefab (99 1 + 100 2 + 101 3 + 102 4 + 97 5 + 98 6)%2069 14
defabc (100 1 + 101 2 + 102 3 + 97 4 + 98 5 + 99 6)%2069 11

在合理的假設下,在哈希表中搜索元素所需的平均時間應是O(1)。

9.4 哈希表的實現

class Node {
    constructor( data ){
        this.data = data;
        this.next = null;
    }
}

class HashTableWithChaining {
    constructor( size = 10 ) {
        this.table = new Array( size );
    }
    
    // 操做方法
    // computeHash( string ) {...}
    // ...
}

1. isPrime:素數判斷

isPrime( num ) {
    for(let i = 2, s = Math.sqrt(num); i <= s; i++)
        if(num % i === 0) return false; 
    return num !== 1;
}

2. computeHash|findPrime:哈希函數生成

computeHash( string ) {
    let H = this.findPrime( this.table.length );
    let total = 0;
    for (let i = 0; i < string.length; ++i) {
          total += H * total + string.charCodeAt(i);
       }
    return total % this.table.length;
}
// 取模
findPrime( num ) {
    while(true) {
        if( this.isPrime(num) ){ break; }
        num += 1
    }
    return num;
}

3. Put:插入值

put( item ) {
    let key = this.computeHash( item );
    let node = new Node(item)
    if ( this.table[key] ) {
        node.next = this.table[key]
    }
    this.table[key] = node
}

4. Remove:刪除值

remove( item ) {
    let key = this.computeHash( item );
    if( this.table[key] ) {
        if( this.table[key].data === item ) {
            this.table[key] = this.table[key].next
        } else {
            let current = this.table[key].next;
            let prev = this.table[key];
            while( current ) {
                if( current.data === item ) {
                    prev.next = current.next
                }
                prev = current
                current = current.next;
            }
        }
    }
}


5. contains:判斷包含

contains(item) {
    for (let i = 0; i < this.table.length; i++) {
        if (this.table[i]) {
            let current = this.table[i];
            while (current) {
                if (current.data === item) {
                    return true;
                }
                current = current.next;
            }
        }
    }
    return false;
}

6. Size & IsEmpty:判斷長度或空

size( item ) {
  let counter = 0
  for(let i=0; i<this.table.length; i++){
    if( this.table[i] ) {
      let current = this.table[i]
      while( current ) {
        counter++
        current = current.next
      }
    }
  }
  return counter
}
isEmpty() {
  return this.size() < 1
}

7. Traverse:遍歷

traverse( fn ) {
  for(let i=0; i<this.table.length; i++){
    if( this.table[i] ) {
      let current = this.table[i];
      while( current ) {
        fn( current );
        current = current.next;
      }
    }
  }
}

10. 爲啥寫這篇

仍是和麪試有關。雖然leetcode上的題刷過一些,但由於缺少對數據結構的總體認知。不少時候被問到或考到,會無所下手。

網上的帖子大多深淺不一,寫這篇的過程當中翻閱了大量的資料和示例。在下的文章都是學習過程當中的總結,若是發現錯誤,歡迎留言指出。

參考:

  1. DS with JS - Hash Tables— I
  2. Joseph Crick - Practical Data Structures for Frontend Applications: When to use Tries
  3. [Thon Ly - Data Structures in JavaScript

](https://medium.com/siliconwat...

  1. Graphs — A Visual Introduction for Beginners
  2. Graph Data Structure in JavaScript
  3. Trie (Keyword Tree)
相關文章
相關標籤/搜索