Js中常用的数据结构与算法(学习)

对任何专业技术人员来说,理解数据结构都非常重要。作为软件开发者,我们要能够用编程语言和数据结构来解决问题。编程语言和数据结构是这些问题解决方案中不可或缺的一部分。如果选择了不恰当的数据结构,可能会影响所写程序的性能。因此,了解不同数据结构和它们的适用范围十分重要。


复杂度

数据结构

  1. 栈:一种遵从先进后出 (LIFO) 原则的有序集合;新添加的或待删除的元素都保存在栈的末尾,称作栈顶,另一端为栈底。在栈里,新元素都靠近栈顶,旧元素都接近栈底。
  2. 队列:与上相反,一种遵循先进先出 (FIFO / First In First Out) 原则的一组有序的项;队列在尾部添加新元素,并从头部移除元素。最新添加的元素必须排在队列的末尾。
  3. 链表:存储有序的元素集合,但不同于数组,链表中的元素在内存中并不是连续放置的;每个元素由一个存储元素本身的节点和一个指向下一个元素的引用(指针/链接)组成。
  4. 集合:由一组无序且唯一(即不能重复)的项组成;这个数据结构使用了与有限集合相同的数学概念,但应用在计算机科学的数据结构中。
  5. 字典:以 [键,值] 对为数据形态的数据结构,其中键名用来查询特定元素,类似于 Javascript 中的Object。
  6. 散列:根据关键码值(Key value)直接进行访问的数据结构;它通过把关键码值映射到表中一个位置来访问记录,以加快查找的速度;这个映射函数叫做散列函数,存放记录的数组叫做散列表。
  7. 树:由 n(n>=1)个有限节点组成一个具有层次关系的集合;把它叫做“树”是因为它看起来像一棵倒挂的树,也就是说它是根朝上,而叶朝下的,基本呈一对多关系,树也可以看做是图的特殊形式。
  8. 图:图是网络结构的抽象模型;图是一组由边连接的节点(顶点);任何二元关系都可以用图来表示,常见的比如:道路图、关系图,呈多对多关系。

算法

排序算法

  1. 冒泡排序:比较任何两个相邻的项,如果第一个比第二个大,则交换它们;元素项向上移动至正确的顺序,好似气泡上升至表面一般,因此得名。
  2. 选择排序:每一次从待排序的数据元素中选出最小(或最大)的一个元素,存放在序列的起始位置,以此循环,直至排序完毕。
  3. 插入排序:将一个数据插入到已经排好序的有序数据中,从而得到一个新的、个数加一的有序数据,此算法适用于少量数据的排序,时间复杂度为 O(n^2)。
  4. 归并排序:将原始序列切分成较小的序列,只到每个小序列无法再切分,然后执行合并,即将小序列归并成大的序列,合并过程进行比较排序,只到最后只有一个排序完毕的大序列,时间复杂度为 O(n log n)。
  5. 快速排序:通过一趟排序将要排序的数据分割成独立的两部分,其中一部分的所有数据都比另外一部分的所有数据都要小,然后再按此方法对这两部分数据分别进行上述递归排序,以此达到整个数据变成有序序列,时间复杂度为 O(n log n)。

搜索算法

  1. 顺序搜索:让目标元素与列表中的每一个元素逐个比较,直到找出与给定元素相同的元素为止,缺点是效率低下。
  2. 二分搜索:在一个有序列表,以中间值为基准拆分为两个子列表,拿目标元素与中间值作比较从而再在目标的子列表中递归此方法,直至找到目标元素。

其他

  1. 贪心算法:在对问题求解时,不考虑全局,总是做出局部最优解的方法。

  2. 动态规划:在对问题求解时,由以求出的局部最优解来推导全局最优解。

复杂度概念:一个方法在执行的整个生命周期,所需要占用的资源,主要包括:时间资源、空间资源。

数据结构

数组

数组是可以再内存中连续存储多个元素的结构,在内存中的分配也是连续的,数组中的元素通过数组下标进行访问,数组下标从0开始。

优点

1、按照索引查询元素速度快
2、按照索引遍历数组方便

缺点

1、数组的大小固定后就无法扩容了
2、数组只能存储一种类型的数据
3、添加,删除的操作慢,因为要移动其他的元素。

适用场景:

频繁查询,对存储空间要求不大,很少增加和删除的情况。

链表

要存储多个元素,数组(或列表)可能是最常用的数据结构。
链表是物理存储单元上非连续的、非顺序的存储结构,数据元素的逻辑顺序是通过链表的指针地址实现,每个元素包含两个结点,一个是存储元素的数据域 (内存空间),另一个是指向下一个结点地址的指针域。根据指针的指向,链表能形成不同的结构,例如单链表,双向链表,循环链表等。

链表的优点(添加删除很快)

链表是很常用的一种数据结构,不需要初始化容量,可以任意加减元素;
添加或者删除元素时只需要改变前后两个元素结点的指针域指向地址即可,所以添加,删除很快;

缺点

因为含有大量的指针域,占用空间较大;
查找元素需要遍历链表来查找,非常耗时。

适用场景:数据量较小,需要频繁增加,删除操作的场景
链表

树是一种数据结构,它是由n(n>=1)个有限节点组成一个具有层次关系的集合。把它叫做 “树” 是因为它看起来像一棵倒挂的树,也就是说它是根朝上,而叶朝下的。它具有以下的特点:

  1. 每个节点有零个或多个子节点;
  2. 没有父节点的节点称为根节点;
  3. 每一个非根节点有且只有一个父节点;
  4. 除了根节点外,每个子节点可以分为多个不相交的子树;

在日常的应用中,我们讨论和用的更多的是树的其中一种结构,就是二叉树。

二叉树是树的特殊一种,具有如下特点:

1、每个结点最多有两颗子树,结点的度最大为2。
2、左子树和右子树是有顺序的,次序不能颠倒。
3、即使某结点只有一个子树,也要区分左右子树。

二叉树是一种比较有用的折中方案,它添加,删除元素都很快,并且在查找方面也有很多的算法优化,所以,二叉树既有链表的好处,也有数组的好处,是两者的优化方案,在处理大批量的动态数据方面非常有用。

扩展:
二叉树有很多扩展的数据结构,包括平衡二叉树、红黑树、B+树等,这些数据结构二叉树的基础上衍生了很多的功能,在实际应用中广泛用到,例如mysql的数据库索引结构用的就是B+树,还有HashMap的底层源码中用到了红黑树。这些二叉树的功能强大,但算法上比较复杂,想学习的话还是需要花时间去深入的。

散列表

散列表,也叫哈希表,是根据关键码和值 (key和value) 直接进行访问的数据结构,通过key和value来映射到集合中的一个位置,这样就可以很快找到集合中的对应元素。

记录的存储位置=f(key)

这里的对应关系 f 成为散列函数,又称为哈希 (hash函数),而散列表就是把Key通过一个固定的算法函数既所谓的哈希函数转换成一个整型数字,然后就将该数字对数组长度进行取余,取余结果就当作数组的下标,将value存储在以该数字为下标的数组空间里,这种存储空间可以充分利用数组的查找优势来查找元素,所以查找的速度很快。
哈希表在应用中也是比较常见的,就如Java中有些集合类就是借鉴了哈希原理构造的,例如HashMap,HashTable等,利用hash表的优势,对于集合的查找元素时非常方便的,然而,因为哈希表是基于数组衍生的数据结构,在添加删除元素方面是比较慢的,所以很多时候需要用到一种数组链表来做,也就是拉链法。拉链法是数组结合链表的一种结构,较早前的hashMap底层的存储就是采用这种结构,直到jdk1.8之后才换成了数组加红黑树的结构,其示例图如下:
哈希表
从图中可以看出,左边很明显是个数组,数组的每个成员包括一个指针,指向一个链表的头,当然这个链表可能为空,也可能元素很多。我们根据元素的一些特征把元素分配到不同的链表中去,也是根据这些特征,找到正确的链表,再从链表中找出这个元素。
哈希表的应用场景很多,当然也有很多问题要考虑,比如哈希冲突的问题,如果处理的不好会浪费大量的时间,导致应用崩溃。

栈是一种遵从先进后出 (LIFO) 原则的有序集合;新添加的或待删除的元素都保存在栈的末尾,称作栈顶,另一端为栈底。在栈里,新元素都靠近栈顶,旧元素都接近栈底。

栈的结构就像一个集装箱,越先放进去的东西越晚才能拿出来,所以,栈常应用于实现递归功能方面的场景,例如斐波那契数列。

通俗来讲,一摞叠起来的书或盘子都可以看做一个栈,我们想要拿出最底下的书或盘子,一定要现将上面的移走才可以。

栈也被用在编程语言的编译器和内存中保存变变量、方法调用。
在 Javascript 中我们可以使用数组的原生方法实现一个栈/队列的功能,鉴于学习目的,我们使用类来实现一个栈。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
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())
}
}

使用栈类:

1
2
3
4
5
6
7
8
9
10
11
12
13
// 实例化一个栈
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 中实现一个队列类。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
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())
}
}

使用队列类:

1
2
3
4
5
6
7
8
9
10
11
12
13

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'

优先队列

队列大量应用在计算机科学以及我们的生活中,我们在之前话题中实现的默认队列也有一些修改版本。
其中一个修改版就是优先队列。元素的添加和移除是基于优先级的。一个现实的例子就是机场登机的顺序。头等舱和商务舱乘客的优先级要高于经济舱乘客。在有些国家,老年人和孕妇(或 带小孩的妇女)登机时也享有高于其他乘客的优先级。
另一个现实中的例子是医院的(急诊科)候诊室。医生会优先处理病情比较严重的患者。通常,护士会鉴别分类,根据患者病情的严重程度放号。

实现一个优先队列,有两种选项:
设置优先级,然后在正确的位置添加元素;
或者用入列操作添加元素,然后按照优先级移除它们。

在下面示例中,我们将会在正确的位置添加元素,因此可以对它们使用默认的出列操作:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
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)
}
}

优先队列的使用:

1
2
3
4
5
6
7
8
9
10
11
12

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)。这种循环队列可以以单链表、队列的方式来在实际编程应用中来实现。
下面我们基于首次实现的队列类,简单实现一个循环引用的示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
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
}
}

访问一个循环队列:

1
2
3
4
5
6
7
8
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'

堆是一种比较特殊的数据结构,可以被看做一棵树的数组对象,具有以下的性质:

  1. 堆中某个节点的值总是不大于或不小于其父节点的值;
  2. 堆总是一棵完全二叉树。

图是由结点的有穷集合V和边的集合E组成。其中,为了与树形结构加以区别,在图结构中常常将结点称为顶点,边是顶点的有序偶对,若两个顶点之间存在一条边,就表示这两个顶点具有相邻关系。

参考文章

在 JavaScript 中学习数据结构与算法
数据结构:八大数据结构分类