Js 一些常见的数据结构和算法

1,390 阅读7分钟

Stacks(栈)

栈是一种先进后出的数据结构,例如:浏览器的历史记录

JS 中的数组(Array)已经实现了栈的所有功能

例子:判断是否回文字符串

let letters = [] // 栈
let word = 'racecar'
let rword = ''

for(let i = 0; i < word.length; i++) {
  letters.push(word[i])
}
for(let i = 0; i < word.length; i++) {
  rword += letters.pop()
}
if (word === rword) {
  console.log(`${word} 是回文字符串`)
}

简单模拟栈的实现

class Stack {
  count = 0
  storage = {}
	// 推入数据
  push(value) {
    this.storage[this.count] = value;
    this.count++;
  }
	// 推出最后推入的数据
  pop() {
    if (this.count === 0) {
      return undefined;
    }

    this.count--;
    const result = this.storage[this.count];
    delete this.storage[this.count]
    return result;
  }
  // 栈中元素个数
  size() {
    return this.count;
  }
	// 查看栈中最后一个元素
  peek() {
    return this.storage[this.count - 1];
  }
}

测试:

let myStack = new Stack();
myStack.push(1);
myStack.push(2);
console.log(myStack.peek())
console.log(myStack.pop())
console.log(myStack.peek())
myStack.push('abcd')
console.log(myStack.size())
console.log(myStack.peek())
console.log(myStack.pop())
console.log(myStack.peek())

Set

Set 中的元素永不重复, 而且没有排列顺序

ES6已经原生支持Set了

简单模拟Set的实现

class MySet { // 和原生Set区分
  collection = []

  has(element) {
    return this.collection.indexOf(element) !== -1
  }

  values() {
    return this.collection
  }

  add(element) {
    if (!this.has(element)) {
      this.collection.push(element);
      return true;
    }
    return false;
  }

  remove(element) {
    if (this.has(element)) {
      const index = this.collection.indexOf(element);
      this.collection.splice(index, 1);
      return true;
    }
    return false;
  }

  size() {
    return this.collection.length
  }

  // 返回2个Set的并集
  union(otherSet) {
    let unionSet = new MySet();
    let firstSet = this.values()
    let secondSet = otherSet.values()

    firstSet.forEach((e) => unionSet.add(e))
    secondSet.forEach((e) => unionSet.add(e))

    return unionSet
  }

  // 返回2个Set的交集
  intersection(otherSet) {
    let intersectionSet = new MySet();
    let firstSet = this.values()

    firstSet.forEach((e) => {
      if (otherSet.has(e)) {
        intersectionSet.add(e)
      }
    })

    return intersectionSet
  }

  // 返回一个新的Set: 里面的元素在当前Set中,但不在otherSet中
  difference(otherSet) {
    let differenceSet = new MySet();
    let firstSet = this.values()

    firstSet.forEach((e) => {
      if (!otherSet.has(e)) {
        differenceSet.add(e)
      }
    })

    return differenceSet
  }

  // 检查当前Set是否属于otherSet的子集
  subset(otherSet) {
    let firstSet = this.values()

    return firstSet.every(value => {
      return otherSet.has(value)
    })
  }
}

测试

let setA = new MySet();
let setB = new MySet();
setA.add('a')
setB.add('b')
setB.add('c')
setB.add('a')
setB.add('d')
console.log(setA.subset(setB))
console.log(setA.intersection(setB).values())

Queue(队列)

先进先出, 比如买东西排队

JS中也能用数组实现队列, 但是如果想精确点, 也得自己实现

class Queue {
  collection = []

  print() {
    console.log(this.collection)
  }
  // 把元素推入队列尾部
  enqueue(element) {
    this.collection.push(element)
  }
  // 把第一个元素推出队列, 并返回推出的元素
  dequeue() {
    return this.collection.shift()
  }

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

  size() {
    return this.collection.length
  }

  isEmpty() {
    return this.collection === 0
  }
}

测试

let queue = new Queue()
queue.enqueue('a')
queue.enqueue('b')
queue.enqueue('c')
queue.print()
queue.dequeue()
queue.front()
queue.print()

PriorityQueue(优先队列)

除了传入元素, 还需要传入对应的优先级:

如果所有元素的优先级相同, 就和普通队列一样; 但是如果不同, 高优先级的元素会被插入到队列的开始

class PriorityQueue extends Queue {
  enqueue(element) {
    const { collection } = this;

    if (this.isEmpty()) {
      collection.push(element)
    } else {
      let added = false;
      for(let i = 0; i < collection.length; i++) {
        if (element[i] < collection[i][1]) {
          collection.splice(i, 0, element)
          added = true;
          break;
        }
      }
      if (!added) {
        collection.push(element)
      }
    }
  }

  dequeue() {
    const value = this.collection.shift()
    return value[0]
  }
}

测试

const pq = new PriorityQueue();
pq.enqueue(['bbb', 2])
pq.enqueue(['ccc', 3])
pq.enqueue(['aaa', 1])
pq.print()
pq.dequeue()
pq.front()
pq.print()

Binary Search Tree(二叉搜索树)

树的特点是: 保存的数据看起来很像自然界中的树, 下面就是树的图形化表示, 其中保存数据的圆圈叫做节点, 最上面的节点A叫做根节点, 叶节点指在树的最末端, 而且一定没有子节点。

树有很多种类型, 下面我们介绍其中一种: 二叉搜索树。

理论上, 树可以有任意个数的分支, 比如上图的C节点有F, G, H 3个子节点, 而二叉树, 一个节点就只有2个分支, 如下图就是一个二叉树, 每个节点有且只有2个分支:

二叉树是有序的数据结构, 每个左子节点一定小于父节点, 每个右子节点一定大于父节点(一般不需要等于), 因为这种特性, 搜索次数可以一直减半, 所以这个算法的插入/删除(本质是查找)的时间复杂度是: O(logn), 它比线性复杂度(遍历)要好得多, 但是比哈希表要慢。

下面我们看看JS的实现:

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

class BST {
  constructor() {
    this.root = null
  }

  add(data) {
    const node = this.root

    if (node === null) {
      this.root = new Node(data)
      return
    } else {
      const searchTree = (node) => {
        if (data < node.data) {
          if (node.left === null) {
            node.left = new Node(data)
            return
          } else {
            return searchTree(node.left)
          }
        } else if (data > node.data) {
          if (node.right === null) {
            node.right = new Node(data)
            return
          } else {
            return searchTree(node.right)
          }
        } else {
          return null;
        }
      }

      return searchTree(node)
    }
  }

  findMin() {
    let current = this.root;
    while(current.left !== null) {
      current = current.left;
    }
    return current.data
  }

  findMax() {
    let current = this.root;
    while (current.right !== null) {
      current = current.right;
    }
    return current.data
  }

  find(data) {
    let current = this.root;

    while (current.data !== data) {
      if (data < current.data) {
        current = current.left;
      } else {
        current = current.right;
      }
      if (current === null) {
        return null;
      }
    }

    return current
  }
  // 是否包含该数据
  isPresent(data) {
    let current = this.root;

    while(current) {
      if (data === current.data) {
        return true;
      }
      if (data < current.data) {
        current = current.left
      } else {
        current = current.right
      }
    }

    return false;
  }

  remove(data) {
    const removeNode = (node, data) => {
      if (node === null) {
        return null;
      }
      if (data === node.data) {
        // 没有子节点
        if (node.left === null && node.right === null) {
          return null
        }
        // 没有左子节点
        if (node.left === null) {
          return node.right
        }
        // 没有右子节点
        if (node.right === null) {
          return node.left
        }
        // 左右子节点都存在
        // 如下图, 要想移除3节点, 就得用3的右节点的最左节点(4)替换, 就会变成右边的样子
        let tempNode = node.right;
        while (tempNode.left !== null) {
          tempNode = tempNode.left;
        }
        node.data = tempNode.data;
        node.right = removeNode(node.right, tempNode.data);
        return node;
      } else if(data < node.data) {
        node.left = removeNode(node.left, data)
        return node;
      } else {
        node.right = removeNode(node.right, data)
        return node;
      }
    }

    // 传入了根节点和要删除的数据
    this.root = removeNode(this.root, data)
  }
}

测试

const bst = new BST ()
bst.add(4)
bst.add(2)
bst.add(6)
bst.add(1)
bst.add(3)
bst.add(5)
bst.add(7)
bst.remove(4)
console.log(bst.findMin())
console.log(bst.findMax())
bst.remove(7)
console.log(bst.findMax())
console.log(bst.isPresent(4))

二叉搜索树的遍历和高度

BST的高度就是根节点到某个叶节点的距离, 比如下图中, 相对于9, 9的高度是0, 而4和17节点的高度是1, 5, 7, 20的高度是3

对任意BST一定有最小高度和最大高度, 如果 最大高度 - 最小高度 不大于1 , 那么BST平衡。

如上图, 这个BST的最小高度就是从根节点到第1个(没有2个子节点的)叶节点17的高度1, 最大高度就是从根节点开始到任意最底部的(没有子节点的)叶节点的高度。 很明显这个BST的最大高度是3, 那么这个树不平衡. 这个树之所以不平衡, 就是17少了个左节点。

如果我们加一个10节点(即: 17的左节点), 此时最小高度是2, 最大高度是3, 此时树就平衡了。

为什么要树平衡? 因为平衡树的查询效率更高, 这里不作展开

我们可以实现一种BST, 在增加, 删除节点的时候自动平衡自身, 这会提高BST的查询效率

JS实现二叉搜索树的遍历和高度:

class THBST extends BST {
  // 是否平衡
  isBalanced() {
    return this.findMinHeight() >= this.findMaxHeight() - 1
  }
  // 最小高度
  findMinHeight(node = this.root) {
    if (node === null) {
      return -1
    }
    // left 和 right 到最后一定会返回 -1
    let left = this.findMinHeight(node.left)
    let right = this.findMinHeight(node.right)
    // 如果左树最小高度小,那么整个树的最小高度就是左树最小高度+1
    if (left < right) {
      return left + 1
    } else { // 反之,整个树的最小高度就是右树最小高度+1
      return right + 1
    }
  }
  // 最大高度
  findMaxHeight(node = this.root) {
    if (node === null) {
      return -1
    }

    let left = this.findMaxHeight(node.left)
    let right = this.findMaxHeight(node.right)

    if (left > right) {
      return left + 1
    } else {
      return right + 1
    }
  }
  // 顺序遍历: 从最左节点开始(按大小顺序)直到最右节点
  inOrder() {
    if (this.root === null) {
      return null;
    } else {
      let result = [];
      function traverseInOrder(node) {
        node.left && traverseInOrder(node.left)
        result.push(node.data)
        node.right && traverseInOrder(node.right)
      }
      traverseInOrder(this.root)
      return result;
    }
  }
  // 前序遍历: 从根节点开始, 深度优先遍历(先遍历根节点, 然后往叶节点延伸)
  preOrder() {
    if (this.root === null) {
      return null;
    } else {
      let result = [];
      function traversePreOrder(node) {
        result.push(node.data)
        node.left && traversePreOrder(node.left)
        node.right && traversePreOrder(node.right)
      }
      traversePreOrder(this.root)
      return result;
    }
  }
  // 后续遍历: 在遍历根节点之前, 先从最左开始遍历叶节点(先递归左侧, 再递归右侧)
  postOrder() {
    if (this.root === null) {
      return null;
    } else {
      let result = [];
      function traversePostOrder(node) {
        node.left && traversePostOrder(node.left)
        node.right && traversePostOrder(node.right)
        result.push(node.data)
      }
      traversePostOrder(this.root)
      return result;
    }
  }
  // 分级遍历: 广度优先搜索, 在查找下一个级别前, 先把本级别元素都遍历到
  levelOrder() {
    let result = []
    let queue = []

    if (this.root !== null) {
      queue.push(this.root)
      while(queue.length > 0) {
        let node = queue.shift()
        result.push(node.data)

        if (node.left !== null) {
          queue.push(node.left)
        }
        if (node.right !== null) {
          queue.push(node.right)
        }
      }
      return result;
    } else {
      return null;
    }
  }
}

依照下图构造数据并测试:

const thbst = new THBST()
thbst.add(9)
thbst.add(4)
thbst.add(17)
thbst.add(3)
thbst.add(6)
thbst.add(21)
thbst.add(5)
thbst.add(7)
thbst.add(20)

console.log(thbst.findMinHeight())
console.log(thbst.findMaxHeight())
console.log(thbst.isBalanced())
thbst.add(10)
console.log(thbst.findMinHeight())
console.log(thbst.findMaxHeight())
console.log(thbst.isBalanced())

console.log(thbst.inOrder()) // [3, 4, 5, 6, 7, 9, 10, 17, 20, 21]
console.log(thbst.preOrder()) // [9, 4, 3, 6, 5, 7, 17, 10, 21, 20]
console.log(thbst.postOrder()) // [3, 5, 7, 6, 4, 10, 20, 21, 17, 9]
console.log(thbst.levelOrder()) // [9, 4, 17, 3, 6, 10, 21, 5, 7, 20]

Hash Tables(哈希表)

哈希表是用于实现关联数组或者是键值对映射的一种数据结构, 由于其效率较高, 常用于 map 、 object 等数据结构的实现。

每次(查找、插入、删除)操作的平均复杂度和哈希表中的元素个数无关, 时间复杂度为O (1), 所以速度非常快。

绝大多数的开发语言都原生自带这个功能, 比如 JavaScript 的 object 就是哈希表的实现, 下面就看看具体细节:

// 生成索引
function hash(str, max) {
  let hash = 0
  for(let i = 0; i < str.length; i++) {
    hash += str.charCodeAt(i)
  }
  return hash % max;
}

class HashTable {
  constructor(limit) {
    this.storage = []
    this.limit = limit
  }

  print() {
    console.log(this.storage)
  }

  add(key, value) {
    const { storage } = this
    // 调用hash函数得出哈希表中存储该value的索引
    let index = hash(key, this.limit)

    if (storage[index] === undefined) {
      storage[index] = [ [key, value] ]
    } else {
      let inserted = false;
      for(let i = 0; i < storage[index][length]; i++) {
        if (storage[index][i][0] === key) { // 更新
          storage[index][i][1] = value
          inserted = true
        }
      }
      if (inserted === false) { // 添加
        storage[index].push([ key, value ])
      }
    }
  }

  remove(key) {
    const { storage } = this
    let index = hash(key, this.limit)
    // 对应的可能不止一个
    if (storage[index].length === 1 && storage[index][0][0] === key) {
      delete storage[index]
    } else {
      for (let i = 0; i < storage[index].length; i++) {
        if (storage[index][i][0] === key) {
          delete storage[index][i]
        }
      }
    }
  }
  // 查找数据
  lookup(key) {
    const { storage } = this
    let index = hash(key, this.limit)

    if (storage[index] === undefined) {
      return undefined
    } else {
      for (let i = 0; i < storage[index].length; i++) {
        if (storage[index][i][0] === key) {
          return storage[index][i][1]
        }
      }
    }
  }
}

测试

let ht = new HashTable(10)
ht.add('aaa', '111')
ht.add('bbb', '222')
ht.add('ccc', '333')
ht.add('ddd', '444')
console.log(ht.lookup('ccc')) // 333
ht.print()

Linked List(链表)

参考

可以认为链表的每个节点存储了一段数据, 链表每个节点都有两部分: 数据部分和地址部分。

链表有不同的形态, 主要分为三种: 单向链表、双向链表、循环链表。 如下图就是一个单向链表:

单向链表和数组的对比

查找性能

单向链表的查找操作通常是这样的:

  1. 从头节点进入,开始比对节点的值,如果不同则通过指针进入下一个节点
  2. 重复上面的动作,直到找到相同的值,或者节点的指针指向null

链表的查找性能与数组一样,都是时间复杂度为O(n).

插入删除性能

链表与数组最大的不同就在于删除、插入的性能优势,由于链表是非连续的内存,所以不用像数组一样在插入、删除操作的时候需要进行大面积的成员位移,比如在a、b节点之间插入一个新节点c, 链表只需要:

  1. a断开指向b的指针,将指针指向c
  2. c节点将指针指向b,完毕

这个插入操作仅仅需要移动一下指针即可,插入、删除的时间复杂度只有O(1).

读取性能

链表相比之下也有劣势,那就是读取操作远不如数组,数组的读取操作之所以高效,是因为它是一块连续内存,数组的读取可以通过寻址公式快速定位,而链表由于非连续内存,所以必须通过指针一个一个节点遍历.

比如,对于一个数组,我们要读取第三个成员,我们仅需要arr[2]就能快速获取成员,而链表则需要从头部节点进入,在通过指针进入后续节点才能读取.

JS实现单链表

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

class LinkedList {
  length = 0;
  head = null;

  size() {
    return this.length;
  }
  // 返回链表第1个元素
  head() {
    return this.head;
  }
  // 在链表尾部添加元素
  add(element) {
    const node = new Node(element)
    if (this.head === null) {
      this.head = node
    } else {
      let currentNode = this.head;
      while(currentNode.next) {
        currentNode = currentNode.next;
      }
      currentNode.next = node;
    }
    this.length++
  }

  remove(element) {
    let currentNode = this.head
    let prevNode;

    if (currentNode.element === element) {
      this.head = currentNode.next
    } else {
      while (currentNode.element !== element) {
        prevNode = currentNode
        currentNode = currentNode.next
      }
      prevNode.next = currentNode.next
    }

    this.length--; // 这里不是很严谨
  }

  isEmpty() {
    return this.length === 0
  }

  indexOf(element) {
    let currentNode = this.head;
    let index = -1;

    while (currentNode) {
      index++;
      if (currentNode.element === element) {
        return index;
      }
      currentNode = currentNode.next;
    }

    return -1;
  }
  // 获取索引对应的元素
  elementAt(index) {
    let currentNode = this.head;
    let count = 0;

    while (count < index) {
      count++;
      currentNode = currentNode.next;
    }

    return currentNode.element
  }
  // 在链表某个具体位置添加元素
  addAt(index, element) {
    let node = new Node(element)
    let currentNode = this.head;
    let prevNode;
    let currentIndex = 0;

    if (index > this.length) {
      return false;
    }

    if (index === 0) {
      node.next = currentNode;
      this.head = node;
    } else {
      while (currentIndex < index) {
        currentIndex++;
        prevNode = currentNode
        currentNode = currentNode.next
      }
      node.next = currentNode
      prevNode.next = node;
    }
    this.length++;
  }
  // 和addAt方法类似
  removeAt(index) {
    let currentNode = this.head;
    let prevNode;
    let currentIndex = 0;

    if (index < 0 || index >= this.length) {
      return null;
    }

    if (index === 0) {
      this.head = currentIndex.next;
    } else {
      while (currentIndex < index) {
        currentIndex++;
        prevNode = currentNode
        currentNode = currentNode.next
      }
      prevNode.next = currentNode.next;
    }
    this.length--;
    return currentNode.element
  }
}

测试

const list = new LinkedList();
list.add('aaa')
list.add('bbb')
list.add('ccc')
list.add('ddd')
list.add('eee')
console.log(list.size()) // 5 
console.log(list.removeAt(3)) // ddd
console.log(list.size()) // 4

Trie(字典树)

应用场景

也叫前缀树, 是树的一种分支, 用于存储关联数据的数据结构。

trie 也有分支, 一个分支能存储一个完整的单词, 所以 trie 结构常用于存储单词/字符串, 比如: 检查某个单词/字符串是否存在于某个数据集合中。

在 trie 中,每个节点都代表一个字母/字符, 如下图:

星标志着这个位置是一个单词的终点节点, 即到星标志的位置,就存在一个完整的单词

JS代码实现:

class Node {
  constructor() {
    // 如上图, 对于根节点, 它的keys就是 new Map(['b', new Node()], ['d', new Node()], ['s', new Node()])
    this.keys = new Map();
    this.end = false;
  }
  setEnd() {
    this.end = true;
  }
  isEnd() {
    return this.end;
  }
}

class Trie {
  constructor() {
    this.root = new Node()
  }
  add(str, node = this.root) {
    if (str.length === 0) {
      node.setEnd()
      return;
    } else if (!node.keys.has(str[0])) {
      node.keys.set(str[0], new Node())
      return this.add(str.substr(1), node.keys.get(str[0]))
    } else {
      return this.add(str.substr(1), node.keys.get(str[0]))
    }
  }
  // word 是否在 Trie 中
  isWord(word) {
    let node = this.root;

    while (word.length > 1) { // 大于1
      if (!node.keys.has(word[0])) {
        return false;
      } else {
        node = node.keys.get(word[0])
        word = word.substr(1)
      }
    }
    // while中是大于1, 所以这里是判断最后一位是否符合要求
    return (node.keys.has(word) && node.keys.get(word).isEnd()) ? true : false;
  }
  // 打印出所有单词(分支)
  print() {
    let words = []
    let search = (node, str) => {
      if (node.keys.size !== 0) {
        for (let letter of node.keys.keys()) {
          search(node.keys.get(letter), str.concat(letter))
        }
        if (node.isEnd()) {
          words.push(str)
        }
      } else {
        str.length > 0 ? words.push(str): undefined;
        return;
      }
    }
    search(this.root, '')
    return words.length > 0 ? words : null
  }
}

测试

const myTrie = new Trie();
myTrie.add('ball')
myTrie.add('bat')
myTrie.add('doll')
myTrie.add('dork')
myTrie.add('do')
myTrie.add('dorm')
myTrie.add('send')
myTrie.add('sense')
console.log(myTrie.isWord('doll')) // true
console.log(myTrie.isWord('dor')) // false
console.log(myTrie.isWord('dora')) // false
console.log(myTrie.print()) // ['ball', 'bat', 'doll', 'dork', 'dorm', 'do', 'send', 'sense']

Graphs(图)

应用场景

图的存储方式有很多种: 邻接矩阵, 邻接表, 十字链表, 邻接多重表等.

最常用的2种: 邻接矩阵和邻接表,这里说明一下邻接表。

邻接表结构:

  1. 存储图中顶点 ----> 数组/对象

  2. 边的指向关系 ----> 链表/数组

如下图:

领接表的代码实现:

class Graph {
  constructor(points) {
    // 点
    this.points = points
    // 边的数量
    this.sideNum = 0;
    // 每个点都对应一个链表/数组
    this.adjArray = Array.from({ length: points.length }, () => [])
  }
  addPoint(id, point) {
    let index1 = this.points.findIndex(v => v === id)
    this.adjArray[index1].push(point)

    /* 
      对于无向图, 假设A和B相连:
        如果你只想添加1次A和B(A-B或B-A)的相连关系, 就需要下面语句, 
        但是如果你添加了2次(A-B和B-A)相连关系, 就不需要下面语句了
    */
    // let index2 = this.points.findIndex(v => v === point)
    // this.adjArray[index2].push(id) 

    this.sideNum++
  }
  addPoints(id, points) {
    points.forEach(item => {
      this.addPoint(id, item)
    })
  }

  getPoints(id) {
    let index = this.points.findIndex(v => v === id)
    return this.adjArray[index]
  }
  // 打印格式化数据
  print() {
    let str = ''

    this.points.forEach((item, index) => {
      str += item + ' ---> [' + this.adjArray[index].join(', ') + '] \n'
    })

    return str
  }
  getMap(points) {
    let arr = points.reduce((arr, item) => {
      return arr.concat(this.getPoints(item))
    }, [])

    return arr.reduce((map, item) => {
      if (!map[item]) {
        map[item] = 1
      } else {
        map[item]++
      }
      return map
    }, {})
  }
  // 交集
  getUnions(points) {
    let map = this.getMap(points)
    let len = points.length

    return Object.keys(map).filter(key => map[key] >= len)
  }
  // 并集
  getCollect(points) {
    let map = this.getMap(points)
    return Object.keys(map).filter(key => map[key])
  }
}

测试,实现如下图结构:

image.png

const demo = new Graph(['v0', 'v1', 'v2', 'v3', 'v4'])

// 注册点
demo.addPoints('v0', ['v2', 'v3']);
demo.addPoints('v1', ['v3', 'v4']);
demo.addPoints('v2', ['v0', 'v3', 'v4']);
demo.addPoints('v3', ['v0', 'v1', 'v2']);
demo.addPoints('v4', ['v1', 'v2']);

// -----------------------------------
// console.log(demo.getPoints('v0'))
// console.log(demo.getPoints('v1'))
// console.log(demo.getPoints('v2'))
// console.log(demo.getPoints('v3'))
// console.log(demo.getPoints('v4'))

console.log(demo.print())

若有收获,就点个赞吧