JavaScript数据结构与算法(下)
前言:在上一篇文章中我们认识了很多的数据结构,包括栈、队列、字典、还有链表等。希望你能够一一吸收,我们也一起共同进步,那在这篇文章中,我们将一起探索树和图的奥秘,并且我还会介绍几种算法,武装我们的算法体系。开始学起来吧!
七、树
(1)普通树
- 概念:什么是树,树是用来描述我们生活中形似于树的一种数据结构,由节点、边构成。我们生活中很多场景都可以用树来进行描述,比如公司的职位架构,家族关系谱,食物链等。因此研究树的特点和学习树对于我们来讲至关重要。
- 上面的图就可以很好的表示一个树的结构,我们需要认识几个概念,形如A就为树的
根节点
,因为A有三个子节点,因此A也可称之为一个3度
节点,那么你可以在心里默默的告诉我D,C,B都是几度节点呢?并且最下面有很多的B,所有的B其实本质上都是叶节点
! - 这个就是一颗普普通通的树,有几点和边组成,每个节点有属于自己的值,也保存着指向其他节点的引用。
(2)二叉树
- 那什么是
二叉树
呢,其实二叉树就是每一个节点都至多是二度节点的树,也就是说任意一个节点,只要他的子节点个数不超过二个节点,那么这样的树,就可以称为一个二叉树,并且任意一颗树通过一定的操作都可以将其视为或者转换成为一颗二叉树。 - 二叉树相较于普通树而言有一些特定的规律,因此研究二叉树其实是非常重要的一项课题!
- 上面便是一个标准的二叉树,并且是一颗完美的二叉树,可以看到每一个节点都是不超过二个节点的,既然如此我们也可以进行分类,有的可能分支不完全对称的二叉树称为完全二叉树,剩下的就称为普通二叉树
(3)二叉搜索树
- 下面我们要认识一个重要的话题,二叉搜索树
- 什么是二叉搜索树
- 在符合二叉树的原则上再符合以下几点要求就可以了
1.若它的左子树不为空,则左子树上所有节点的值都小于根节点的值
2.若它的右子树不为空,则右子树上所有节点的值都大于根节点的值
3.它的左右子树也分别为二叉搜索树
- 看上面的例子,根节点左侧的所有节点的数字都比根节点小,右侧都比根节点大。并且完全符合以上三点的要求,因此我们就可以非常自信的称上面这棵树为二叉搜索树。
- 但是,问题是他有什么用呢!
- 答:它的查找效率是非常高的,我们等会儿会对其进行封装。
- 大家可以发现,二叉树是有序的,因此当我们想要查找一个数据的时候,其实不必一个一个进行比较遍历,先将其与根节点进行比较,如果比根节点大,那么目标一定在右侧,否则,一定在左侧,这样一下就排除了一半的数据,因此查找的效率大大的提高了。
- 并且一颗二叉搜索树越平均,层级越少,搜索效率是越高的,因此在创建二叉搜索树的时候我们也要将二叉搜索树尽量的平均分布!这也是一个重要的课题。
- 好了说了这么多我们接下来就来正式封装一下二叉搜索树吧!
// 封装节点
class TreeNode {
constructor(data, left, right) {
this.data = data
this.left = null
this.right = null
if (left && left < data) {
this.left = left
}
if (right && right > data) {
this.right = right
}
}
}
//封装二叉搜索树
class BinarySearchTree {
constructor(root) {
this.root = root || null
this.size = 0
}
whitchIn(node, newNode) {
if (newNode.data > node.data) {
// 说明更加的大,应该插入右边
if (node.right === null) {
node.right = newNode
} else {
this.whitchIn(node.right, newNode)
}
} else {
if (node.left === null) {
node.left = newNode
} else {
this.whitchIn(node.left, newNode)
}
}
}
insert(data) {
if (this.root == null) {
const newNode = new TreeNode(data)
this.root = newNode
} else {
// 调用递归函数
const newNode = new TreeNode(data)
this.whitchIn(this.root, newNode)
}
this.size++
return this
}
// 深度优先遍历
foreach(handler, type = 'first') {
this.forFun(this.root, handler, type)
}
forFun(node, handler, type) {
if (node !== null) {
if (type === 'first') {
handler(node.data)
}
this.forFun(node.left, handler, type)
if (type === 'middle') {
handler(node.data)
}
this.forFun(node.right, handler, type)
if (type === 'last') {
handler(node.data)
}
}
}
min(current = this.root, returnType = 'data') {
while (current.left !== null) {
current = current.left
}
if (returnType === 'data') {
return current.data
} else {
return current
}
}
max(current = this.root, returnType = 'data') {
let parentNode = null
while (current.right !== null) {
parentNode = current
current = current.right
}
if (returnType === 'data') {
return current.data
} else {
return [current, parentNode]
}
}
search(data) {
let node = this.root
while (node !== null) {
if (data > node.data) {
node = node.right
} else if (data < node.data) {
node = node.left
} else {
return true
}
}
return false
}
remove(data) {
let parentNode = null
let isLeft = true
let current = this.root
while (current != null) {
if (data > current.data) {
isLeft = false
parentNode = current
current = current.right
} else if (data < current.data) {
isLeft = true
parentNode = current
current = current.left
}
}
if (current.left === null && current.right === null) {
if (current === this.root) {
this.root = null
} else {
if (isLeft) {
parentNode.left = null
} else {
parentNode.right = null
}
}
} else if (current.left === null) {
if (isLeft) {
parentNode.left = current.right
} else {
parentNode.right = current.right
}
} else if (current.right = null) {
if (isLeft) {
parentNode.left = current.left
} else {
parentNode.right = current.left
}
} else {
// 这就是著名的第三种情况,两边都不为零
let [current = replaceNode, parentNode = replaceParentNode] = this.max(current.left, 'node')
let replaceLeft = replaceNode.left
if (replaceNode.left !== null) {
replaceParentNode.right = replaceLeft
}
replaceNode.left = current.left
replaceNode.right = current.right
if (isLeft) {
parentNode.left = replaceNode
} else {
parentNode.right = replaceNode
}
}
}
}
-
在封装的过程中其实大部分都还是比较容易理解的,但是删除操作还是比较麻烦的,考虑的情况比较多,我来理一下思路!
-
首先我们会得到一个需要删除的点,通过一顿循环操作我们可以拿到
要删除的节点
,要删除节点的父节点
,要删除的节点是否是父节点的左侧节点
。 -
然后会有几种大的情况
-
1.如果要删除的节点是一个叶节点,
-
2.如果要删除的节点只有一个左子节点
-
3.如果要删除的节点只有一个右子节点
-
4.如果要删除的节点有两个子节点
-
根据以上的不同情况进行一个判断处理,保证删除某一个节点后依然是一个二叉搜索树就可以了。
(4)红黑树
- 上面这个东西其实就是红黑树
- 我们先来回答一下,什么是红黑树,为什么会出现红黑树!
- 红黑树本质上来说就是一种二叉搜索树
- 它的出现实际上是用来解决一个问题
- 二叉搜索树在插入数据时,有的时候会导致插入形成的树失去平衡,从而使得二叉搜索树层侧非常的深
- 因为我们都知道,二叉搜索树的效率和其中的层级是非常相关的,因此我必须想办法是在插入的过程中,能够不断的调整,使得二叉搜索树,变得相对平衡和稳定
- 而红黑树就是这样的一种相对平衡和分布均匀的一颗二叉搜索树!
- 下面是一个不太平衡的二叉搜索树
-
可以看到如果我们不进行调整的话,那么只要插入比连续插入递增的较大的数字会形成一种很深的结构,这样的话二叉搜索树就失去它原本的意义了。
-
红黑树就是一种解决方案!
-
也就是说红黑树只要符合以下的规则,就能够保证,这个红黑树是一颗分布相对均匀查找效率也能接近最佳的二叉搜索树
-
每一个节
-
点不是黑色就
-
是红色
* 根节点总是黑色的 * 如果节点是红色的,则它的子节点必须是黑色的(反之则不一定) * 从根到叶节点的每条路径 * 包含的黑色节点数目必须相同
因为红黑树的内容还是比较负责的,所以我们暂时将红黑树就介绍到这里,后面我会专门写一篇博客进行详细介绍的。
八、图
-
接下来我们要认识一个重要的数据结构,图,在数学中甚至都有一套专门的理论去研究它!
-
而今天我们断章取义只学习对我们开发有帮助的部分。
-
在图这种数据结构中,由节点和边构成,为什么会出现图呢!
-
实际上是由于生活中其实很多问题我们可以将其抽象成为图进行研究,这样可以方便研究,可以借助数学的工具帮助我们去分析它。
- 会发现图和二叉搜索树是很相似的,因为二叉搜索树其实就是一种图,图由节点和边构成,任意节点可以由边指向任意节点
- 生活中我们会遇到最短路径问题,最小开销问题,都可以借助图来帮助我们分析。
- 首先我们先将图这种数据结构进行封装!
class Graph {
constructor() {
this.vertexes = []
this.edges = {}
this.size = 0
this.state = {}
}
addVertex(vertex) {
let list = new List() // 此处的List就是上一篇文章的封装的list
this.vertexes.push(vertex)
this.edges[[vertex]] = list
this.size++
this.state[vertex] = false
}
addEdge(vertex1, vertex2) {
// 判断如果不存在
this.edges[vertex1].append(vertex2)
this.edges[vertex2].append(vertex1)
return this
}
toString() {
let result = ''
for (var key in this.edges) {
result += key + '=>' + this.edges[key].toString() + '\n'
}
console.log(result);
return result
}
}
九、遍历算法
- 为了对图这种结构进行遍历,我们可以有两种策略,第一种
- 从开始节点开始遍历,遍历时将其子节点一一遍历,再孙节点,直到将整个图中的节点遍历完成,这种方式叫做广度优先搜索。
- 第二种,就是从开始节点开始遍历,遍历其第一个子节点,再遍历第一个子节点第一个子节点,等这一分支遍历完的之后再遍历开始节点的第二个子节点,一直遍历完整个图,这样的方式称为深度优先搜索,我们也将方法写上。
- 直接加到上一个类中就行!
// BFS 广度优先搜索
bfs(initV, handler) {
let queue = []
queue.push(initV)
// [ 'A','B','C','D'... ]
while (queue.length > 0) {
let targetV = queue.shift()
if (!this.state[targetV]) {
this.edges[targetV].foreach((item) => {
if (!this.state[targetV]) {
queue.push(item)
}
})
handler(targetV)
this.state[targetV] = true
}
}
}
// DFS 深度优先搜索
dfs(initV, handler) {
let handleNode = (initV, handler) => {
if (!this.state[initV]) {
handler(initV)
this.state[initV] = true
this.edges[initV].foreach(item => {
if (!this.state[item]) {
handleNode(item, handler)
}
})
}
}
handleNode(initV, handler)
}
十、排序算法
- 接下来我们来介绍几种排序算法,每一种排序算法都有自己的特点!
- 为什么要了解排序算法!
- 因为让一组数据从无序变为有序是极为重要的,无论对于今后对这些数据进行管理还是使用这些数据都有着举足轻重的作用,因此我们就来好好学习一下排序算法!
(1)冒泡排序
- 首先我先来解释一下冒泡排序,它的核心就是当有一组数据时,先将第一个与第二个进行比较,遵循一个原则就是如果谁更大,就将谁换到前面来!然后再将第二个与第三个进行比较!这个第一轮循环结束的时候,这组数据的最后面一定是一个最大的数,然后再用循环控制到哪里结束子循环,外层循环应该递减,因为越到最后需要比较的元素就越少!最后便有了一个排好序的数据了!
class ArrayList {
constructor(arr = []) {
this.arr = arr
this.size = arr.length
}
insert(ele) {
this.arr.push(ele)
this.size++
}
toString() {
let result = ''
this.arr.forEach((item) => {
result += item + " "
})
return result
}
// 交换位置
exchange(position1, position2) {
let temp = this.arr[position1]
this.arr[position1] = this.arr[position2]
this.arr[position2] = temp
}
// 冒泡排序 O(n**2)
bubbleSort() {
for (var j = this.size - 1; j > 0; j--) {
for (var i = 0; i < j; i++) {
if (this.arr[i] > this.arr[i + 1]) {
this.exchange(i, i + 1)
}
}
}
}
}
(2)选择排序
- 选择排序实际上是对冒泡排序的一种优化,主要优化的是交换的次数,大家可以在自己的脑海中设想一下冒泡排序,首先冒泡排序的内层循环会找出最大的那个值,放到最后,但这个放到最后的结果是由非常多次的交换形成的,如果那个最大的树在数据列的最头部的话,那得
交换n-1次(n泛指列表的长度)
才能让最大值放到最后面,选择排序的优化思路是,既然最大值是比较出来的,那我们为什么不先找出限定范围的最大值,找到之后,只需要交换一次,这一次直接把最大值放到最后呢!这么做是不是比冒泡排序的交换次数要少很多呢!是的,我们来写一下!
class ArrayList {
constructor(arr = []) {
this.arr = arr
this.size = arr.length
}
insert(ele) {
this.arr.push(ele)
this.size++
}
toString() {
let result = ''
this.arr.forEach((item) => {
result += item + " "
})
return result
}
// 交换位置
exchange(position1, position2) {
let temp = this.arr[position1]
this.arr[position1] = this.arr[position2]
this.arr[position2] = temp
}
// 选择排序 O(n**2)
chooseSort() {
for (var j = 0; j < this.size; j++) {
let min = j
for (var i = j + 1; i < this.size; i++) {
if (this.arr[min] > this.arr[i]) {
min = i
}
}
this.exchange(min, j)
}
}
}
- 发现了么,第一层循环没结束一次,将有一个最大值得位置诞生,然后做一次交换就好,
(3)插入排序
- 所谓的插入排序,则是真正对循环次数进行的优化,怎么优化呢,我们来细细解释一下插入排序
- 插入排序的思路是将一组无序数列中的左侧始终看成有序的,第一次是首位,因为只有一位因此我们将其视为有序的。
- 第二次则将第二位取出来插入到左侧的有序数列,然后遍历这个有序数列,因为是有序的,所以只要找到比取出来数小的第一个数,将将其插入到该数的前面,而不需要遍历完整个数列,因此在循环次数上是可以比上两种算法要少一些的,因此直到最后就可以将整个数列排好序了。
- 好了,我们开始书写吧!
class ArrayList {
constructor(arr = []) {
this.arr = arr
this.size = arr.length
}
insert(ele) {
this.arr.push(ele)
this.size++
}
toString() {
let result = ''
this.arr.forEach((item) => {
result += item + " "
})
return result
}
// 交换位置
exchange(position1, position2) {
let temp = this.arr[position1]
this.arr[position1] = this.arr[position2]
this.arr[position2] = temp
}
// 插入排序 O(n**2)
insertSort() {
for (var i = 1; i < this.size; i++) {
let target = this.arr[i]
for (var j = 0; j < i; j++) {
if (target < this.arr[j]) {
this.exchange(i, j)
break
}
}
}
}
- 其实第二个循环建议写成while循环,这样就不用强制break结束循环了
(4)快速排序
- 接下来可以小小激动一下啦!快速排序被称为是20世纪10大算法之一。
- 快速排序是什么思路呢!它是这样的!
- 从一组无序数列中任选一位尽量中位的数(也就是这个数在这组数列中大小差不多处于中间的位置)然后将所有比这个树大的放在右侧,比这个数小的放在左侧
- 然后对其左右侧的无序数列做相同的递归操作
- 递归结束后便是一个有序的数列了
- 我们来看一下代码!
class ArrayList {
constructor(arr = []) {
this.arr = arr
this.size = arr.length
}
insert(ele) {
this.arr.push(ele)
this.size++
}
toString() {
let result = ''
this.arr.forEach((item) => {
result += item + " "
})
return result
}
// 快速排序
quickSort(arr = this.arr) {
if (arr.length == 1 || !arr.length) {
return arr
} else {
let middle = arr[0]
let less = arr.filter(item => item < middle)
let more = arr.filter(item => item > middle)
return [...this.quickSort(less), middle, ...this.quickSort(more)]
}
}
}
- 代码看到之后是不是就比较容易理解啦!其实除了这四种排序算法以外,还有一种希尔排序有兴趣的同学也可以了解一下!由于篇幅原因,在这里就不介绍啦!