JavaScript数据结构与算法笔记
- 1. 数据结构的重要性
- 2. 线性结构
- 3. 集合结构
- 4. 字典结构
- 5. 哈希表
- 6. 树结构
- 7. 红黑树
- 8. 图结构
- 9. 排序算法
- 10. 总结
1. 数据结构的重要性
1.1 什么是数据结构
1.1.1 什么是数据结构和算法
定义
- 数据结构就是在计算机中,
存储和组织数据
的方式,精心选择的数据结构可以带来最优效率的算法
- 计算机中数据量非常庞大,如何以
高效
的方式组织和存储
呢?- 比如一个图书馆中存储了大量的书籍,我们不仅仅要把书放进去,还应该在合适的时候能取出来,图书摆放要使得两个
相关操作
方便实现:- 操作1:新书怎么插入?
- 操作2:怎么找到某本指定的书?
- 方法:把书架划分成几块区域,按照类别存放,类别中按照字母顺序
- 操作1:先定类别,二分查找确定位置,移出空位
- 操作2:先定类别,再二分查找
- 比如一个图书馆中存储了大量的书籍,我们不仅仅要把书放进去,还应该在合适的时候能取出来,图书摆放要使得两个
- 数据结构就是在计算机中,
结论
- 解决问题方法的
效率
,跟数据的组织方式
有关 - 计算机中存储的数据量相对于图书馆的书籍来说数据量更大,数据种类更多
- 以什么样的方式,来存储和组织我们的数据才能在使用数据时更加方便呢?
- 这就是数据结构需要考虑的问题
- 解决问题方法的
1.1.2 常见的数据结构
- 常见的数据结构较多
- 每一种都有其对应的应用场景,
不同的数据结构
的不同操作
性能是不同的 - 有的查询性能很快,有的插入速度很快,有的是插入头和尾速度很快
- 有的做范围查找很快,有的允许元素重复,有的不允许重复等等
- 在开发中如何选择,要根据具体的需求来选择
- 每一种都有其对应的应用场景,
- 注意:数据结构和语言无关,常见的编程语言都有
直接或者间接
的使用上述常见的数据结构
1.2 什么是算法(Algorithm)
1.2.1 算法的认识
- 不同的
算法
,执行效率
是不一样的 - 在解决问题的过程中,不仅仅
数据的存储方式
会影响效率,算法的优劣
也会影响着效率
1.2.2 算法的定义
- 一个有限指令集,每条指令的描述不依赖于语言
- 接受一些输入(有些情况下不需要输入)
- 产生输出
- 一定在有限步骤之后终止
1.2.3 算法的通俗理解
- Algorithm这个单词本意就是
解决问题的办法/步骤逻辑
- 数据结构的实现,离不开算法
- 好的算法对比于差的算法,效率天壤之别
1.3 数据结构和算法的重要性
- 如果只是想了解某一门语言的
应用层面
,那么数据结构和算法显得没有那么重要 - 但是如果希望了解语言的
设计层面
,那么数据结构和算法就非常重要
2. 线性结构
2.1 数组
- 几乎所有的编程语言都原生支持数组类型,因为数组是最简单的内存数据结构
- 数组通常情况下用于存储一系列同一种数据类型的值
- 但在JavaScript里,也可以在数组中保存不同类型的值
2.1.1 数组的基本使用
为什么使用数组?
- 需求:保存多个名字
// 使用数组来保存名字
var names = ['Tom','zx','zs','ls']
创建和初始化数组
- 用JavaScript声明,创建和初始化数组很简单
// 创建和初始化数组
var daysOfWeek = new Array()
var daysOfWeek = new Array(7)
var daysOfWeek = new Array('Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday')
代码解析:
1. 使用new关键字,就能简单地声明并初始化一个数组
2. 还可以使用这种方式创建一个指定长度的数组
3. 也可以直接将数组元素作为参数传递给它的构造器
4. 用new创建数组并不是最好的方式,在JavaScript中创建一个数组,只用中括号`[]`的形式就行
// 使用中括号`[]`创建数组
var daysOfWeek = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
数组长度和遍历数组
- 如果希望获取数组的长度,有一个
length
属性
- 如果希望获取数组的长度,有一个
// 获取数组的长度
alert(daysOfWeek.length)
// 也可以通过下标值来遍历数组
// 普通for方式遍历数组
for (var i = 0; i < daysOfWeek.length; i++) {
alert(daysOfWeek[i])
}
// 通过foreach遍历数组
daysOfWeek.forEach(function (value) {
alert(value)
})
练习:
1. 求斐波那契数列的前20个数字,并且放在数组中
2. 斐波那契数列(数列第一个数字是1,第二个数字也是1,第三项是前两项的和)
// 求斐波那契数列的前20个数字
var fibonacci = []
fibonacci[0] = 1
fibonacci[1] = 1
for (var i = 2; i < 20; i++) {
fibonacci[i] = fibonacci[i - 1] + fibonacci[i - 2]
}
alert(fibonacci)
2.1.2 数组的常见操作
添加元素
- 假如有一个数组:numbers,初始化0-9
// 初始化一个数组
var numbers = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
===============================================================
- 添加一个元素到数组的最后位置
// 添加一个元素到数组的最后位置
// 方式一:
numbers[numbers.length] = 10
// 方式二:
numbers.push(11)
numbers.push(12, 13)
alert(numbers)
===============================================================
- 在数组首位插入一个元素
// 在数组首位插入一个元素
for (var i = numbers.length; i > 0; i--) {
numbers[i] = numbers[i-1]
}
number[0] = -1
alert(numbers)// -1,0,,1,2,3,4,5,6,7,8,9,10,11,12,13
===============================================================
- 在数组首位插入数据可以直接使用unshift方法
// 通过unshift在首位插入数据
numbers.unshift(-2)
numbers.unshift(-4, -3)
alert(numbers) // -4,-3,-2,-1,0,1,2,3,4,5,6,7,8,9,10,11,12,13
- 性能问题
- 性能并不算非常高
- 这也是数组和链表相对比的一个劣势:在中间位置插入元素的效率比链表低
- 实现原理
删除元素
- 如果希望删除数组最后的元素,可以使用`pop()`方法
// 删除最后的元素
numbers.pop()
alert(numbers) // -4,-3,-2,-1,0,1,2,3,4,5,6,7,8,9,10,11,12
===============================================================
- 如果希望移除的首位元素
// 删除首位的元素
for (var i = 0; i < numbers.length; i++) {
numbers[i] = numbers[i+1]
}
numbers.pop()
alert(numbers)
===============================================================
- 可以直接使用shift方法来实现
numbers.shift()
alert(numbers)
任意位置
- 前面学习的主要是在数组开头和结尾处添加和删除数据
- 那如果希望在数组的中间位置进行一些操作应该怎么办呢?
===============================================================
- 通过splice删除数据
// 删除指定位置的几个元素
numbers.splice(5, 3)
alert(numbers) // -4,-3,-2,-1,0,4,5,6,7,8,9,10,11,12,13
- 代码解析
- 上面的代码会删除索引为5, 6, 7位置的元素
- 第一个参数表示索引起始的位置为5(其实是第6个元素, 因为索引从0开始的), 删除3个元素
===============================================================
- 如果希望使用splice来插入数据呢?
// 插入指定位置元素
numbers.splice(5, 0, 3, 2, 1)
alert(numbers) // -4,-3,-2,-1,0,3,2,1,4,5,6,7,8,9,10,11,12,13
- 代码解析
- 上面的代码会从索引为5的位置开始插入数据,其他数据依次向后位移
- 第一个参数依然是索引值为5(第六个位置)
- 第二个参数为0时表示不是删除数据, 而是插入数据.
- 后面紧跟的是在这个位置要插入的数据, 可以是其他类型, 比如"a", "b", "c"
===============================================================
- 如果希望使用splice来修改数据呢?
// 修改指定位置的元素
numbers.splice(5, 3, "a", "b", "c")
alert(numbers) // -4,-3,-2,-1,0,a,b,c,4,5,6,7,8,9,10,11,12,13
- 代码解析
- 上面的代码会从索引5的位置开始修改数据, 修改多少个呢? 第二个参数来决定的
- 第一个参数依然是索引的位置为5(第六个位置)
- 第二个参数是要将数组中多少个元素给替换掉, 我们这里是3个
- 后面跟着的就是要替换的元素
2.1.3 数组的其他操作
常见方法
方法名 | 方法描述 |
---|---|
concat | 连接2个或更多数组,并返回结果 |
every | 对数组中的每一项运行给定函数,如果该函数对每一项都返回 true,则返回 true,否则返回 false |
filter | 对数组中的每一项运行给定函数,返回该函数会返回 true 的项组成的数组 |
forEach | 对数组中的每一项运行给定函数,这个方法没有返回值 |
join | 将所有的数组元素连接成一个字符串 |
indexOf | 返回第一个与给定参数相等的数组元素的索引,没有找到则返回-1 |
lastIndexOf | 返回在数组中搜索到的与给定参数相等的元素的索引里最大的值 |
map | 对数组中的每一项运行给定函数,返回每次函数调用的结果组成的数组 |
reverse | 颠倒数组中元素的顺序,原先第一个元素现在变成最后一个,同样原先的最后一个元素变成了现在的第一个 |
slice | 传入索引值,将数组里对应索引范围内的元素作为新数组返回 |
some | 对数组中的每一项运行给定函数,如果任一项返回 true,则结果为true, 并且迭代结束 |
sort | 按照字母顺序对数组排序,支持传入指定排序方法的函数作为参数 |
toString | 将数组作为字符串返回 |
valueOf | 和 toString 类似,将数组作为字符串返回 |
数组合并
- 数组的合并非常简单,使用concat即可(也可以直接+进行合并)
// 数组的合并
var nums1 = [1, 2, 3]
var nums2 = [100, 200, 300]
var newNums = nums1.concat(nums2)
alert(newNums) // 1,2,3,100,200,300
newNums = nums1 + nums2
alert(newNums) // 1,2,3,100,200,300
迭代方法
- every()方法
- every()方法是将数组中每一个元素传入到一个函数中,该函数返回 true/false
- 如果函数中每一个元素都返回 true,那么结果为 true,有一个为 false,那么结果为 false
- every()练习
- 判断一组元素中是否都包含某一个字符
// 定义数组
var names = ['abc', 'cb', 'mba', 'dna']
// 判断数组的元素是否都包含a字符
var flag = names.every(function (t) {
return t.indexOf('a') != -1
})
alert(flag)
========================================================================================
- some()方法
- some()方法是将数组中每一个元素传入到一个函数中,该函数返回 true/false
- 但是和 every 不同的是,一旦有一次函数返回了 true ,那么迭代就会结束,并且结果为 true
- some()练习
// 定义数组
var names = ['abc', 'cb', 'mba', 'dna']
// 判断数组中是否包含有a字符的字符
var flag = names.some(function (t) {
alert(t)
return t.indexOf('a') != -1
})
alert(flag)
========================================================================================
- forEach()方法
- forEach()方法仅仅是一种快速迭代数组的方式
- 该方法不需要返回值
- forEach 的使用
// 定义数组
var names = ['abc', 'cb', 'mba', 'dna']
// forEach的使用
names.forEach(function (t) {
alert(t)
})
========================================================================================
- filter()方法
- filter()方法是一种过滤的函数
- 首先会遍历数组中每一个元素传入到函数中
- 函数的结果返回 true,那么这个元素会被添加到最新的数组中,返回 false,则忽略该元素
- 最终会形成一个新的数组,该数组就是 filter() 方法的返回值
- filter()的练习
// 定义数组
var name = ['abc', 'cb', 'mba', 'dna']
// 获取names中所有包含'a'字符的元素
var newNames = names.filter(function (t) {
return t.indexOf('a') != -1
})
alert(newNames)
========================================================================================
- map()方法
- map()方法提供的是一种映射函数
- 首先会遍历数组中每一个元素传入到函数中
- 元素会经过函数中的指令进行各种变换, 生成新的元素, 并且将新的元素返回
- 最终会将返回的所有元素形成一个新的数组, 该数组就是map()方法的返回值
- map()练习
// 定义数组
var names = ["abc", "cb", "mba", "dna"]
// 在names中所有的元素后面拼接-abc
var newNames = names.map(function (t) {
return t + "-abc"
})
alert(newNames)
reduce方法
arr.reduce(callback[, initialValue])
- 参数
- callback(一个在数组中每一项上调用的函数,接受四个函数:)
- previousValue(上一次调用回调函数时的返回值,或者初始值)
- currentValue(当前正在处理的数组元素)
- currentIndex(当前正在处理的数组元素下标)
- array(调用reduce()方法的数组)
- initialValue(可选的初始值。作为第一次调用回调函数时传给previousValue的值)
========================================================================================
- reduce 练习
- 求一个数字中数字的累加和
- 使用 for 实现:
// 1.定义数组
var numbers = [1, 2, 3, 4]
// 2.for实现累加
var total = 0
for (var i = 0; i < numbers.length; i++) {
total += numbers[i]
}
alert(total) // 10
========================================================================================
- 使用forEach简化for循环
- 相对于for循环, forEach更符合我们的思维(遍历数组中的元素)
// 3.使用forEach
var total = 0
numbers.forEach(function (t) {
total += t
})
alert(total)
========================================================================================
- 使用reduce方法实现
// 4.使用reduce方法
var total = numbers.reduce(function (pre, cur) {
return pre + cur
})
alert(total)
- 代码解析:
- pre中每次传入的参数是不固定的, 而是上次执行函数时的结果保存在了pre中
- 第一次执行时, pre为0, cur为1
- 第二次执行时, pre为1 (0+1, 上次函数执行的结果), cur为2
- 第三次执行时, pre为3 (1+2, 上次函数执行的结果), cur为3
- 第四次执行时, pre为6 (3+3, 上次函数执行的结果), cur为4
- 当cur为4时, 数组中的元素遍历完了, 就直接将第四次的结果, 作为reduce函数的返回值进行返回.
- reduce优势
- 通过代码会发现, 不需要在调用函数前先定义一个变量, 只需要一个变量来接收方法最终的参数即可.
- 优势在于reduce方法有返回值, 而forEach没有.
- 如果reduce方法有返回值, 那么reduce方法本身就可以作为参数直接传递给另外一个需要reduce返回值的作为参数的函数. 而forEach中只能先将每次函数的结果保存在一个变量, 最后再将变量传入到参数中.
- 这就是函数式编程. 几乎每个可以使用函数式编程的语言都有reduce这个方法
2.1.4 数组的补充
- 普通语言的数组封装(比如
Java
的ArrayList
)- 常见语言的数组
不能
存放不同的数据类型
,因此所有在封装时通常存放在数组中的是Object
类型 - 常见语言的数组
容量不会自动改变
(需要进行扩容
操作) - 常见语言的数组进行中间
插入和删除
操作性能比较低
- 常见语言的数组
2.2 栈
2.2.1 认识栈结构
-
栈也是一种
非常常见
的数据结构,并且在程序中的应用非常广泛
-
数组
- 数组是一种
线性结构
, 并且可以在数组的任意位置
插入和删除数据. - 但是为了实现某些功能, 必须对这种
任意性
加以限制
- 而
栈和队列
就是比较常见的受限的线性结构
- 数组是一种
-
栈的结构示意图
-
栈(
stack
),它是一种运算受限的线性表,后进先出(LIFO)
LIFO(last in first out)
表示就是后进入的元素, 第一个弹出栈空间- 其限制是仅允许在
表的一端
进行插入和删除运算。这一端被称为栈顶
,相对地,把另一端称为栈底
- 向一个栈插入新元素又称作
进栈、入栈
或压栈
,它是把新元素放到栈顶元素的上面,使之成为新的栈顶元素 - 从一个栈删除元素又称作
出栈或退栈
,它是把栈顶元素删除掉,使其相邻的元素成为新的栈顶元素
2.2.2 栈的应用
-
函数调用栈
- 函数之间和相互调用: A调用B, B中又调用C, C中又调用D.
- 在执行的过程中, 会先将A压入栈, A没有执行完, 所有不会弹出栈.
- 在A执行的过程中调用了B, 会将B压入到栈, 这个时候B在栈顶, A在栈底.
- 如果这个时候B可以执行完, 那么B会弹出栈. 但是B有执行完吗? 没有, 它调用了C.
- 所以C会压栈, 并且在栈顶. 而C调用了D, D会压入到栈顶.
- 所以当前的栈顺序是:
栈底A->B->C->D栈顶
- D执行完, 弹出栈. C/B/A依次弹出栈.
- 所以函数调用栈来自于它们内部的实现机制. (通过栈来实现的)
- 函数之间和相互调用: A调用B, B中又调用C, C中又调用D.
-
函数调用栈图解
2.2.3 栈结构面试题
- 面试题目
- 答案:C
- A:65进栈, 5出栈, 4进栈出栈, 3进栈出栈, 6出栈, 21进栈,1出栈, 2出栈
- B:654进栈, 4出栈, 5出栈, 3进栈出栈, 2进栈出栈, 1进栈出栈, 6出栈
- C:65432进栈, 2出栈, 3出栈, 4出栈, 1进栈出栈, 5出栈, 6出栈
2.2.4 栈结构的实现
- 实现栈结构有两种比较常见的方式
- 基于
数组
实现 - 基于
链表
实现
- 基于
2.2.5 栈的封装
栈的创建
- 先创建一个栈的类,用于封装栈相关操作
// 栈类
function Stack() {
// 1. 栈中的属性
var items = []
// 2. 栈的相关操作
}
- 代码解析:
- 创建了一个Stack构造函数, 用户创建栈的类.
- 在构造函数中, 定义了一个变量, 这个变量可以用于保存当前栈对象中所有的元素.
- 这个变量是一个数组类型. 无论是压栈操作还是出栈操作, 都是从数组中添加和删除元素.
- 栈有一些相关的操作方法, 通常无论是什么语言, 操作都是比较类似的.
栈的常见操作
push(element)
: 添加一个新元素到栈顶位置.pop()
:移除栈顶的元素,同时返回被移除的元素。peek()
:返回栈顶的元素,不对栈做任何修改(这个方法不会移除栈顶的元素,仅仅返回它)。isEmpty()
:如果栈里没有任何元素就返回true,否则返回false。clear()
:移除栈里的所有元素。size()
:返回栈里的元素个数。这个方法和数组的length属性很类似。
2.1 将元素压入栈(push方法)
- 注意: 我们的实现是将最新的元素放在了数组的末尾, 那么数组末尾的元素就是我们的栈顶元素
方式一 给某一个对象的实例添加了一个方法(不推荐)
this.push = function (element) {}
方式二 给整个类添加了一个方法(更加节省内存,性能更高)
Stack.prototype.push = function (element) {
this.items.push(element)
}
=================================================================
2.2 从栈中取出元素(pop方法)
- 注意: 出栈操作应该是将栈顶的元素删除, 并且返回.
- 因此, 我们这里直接从数组中删除最后一个元素, 并且将该元素返回就可以了
Stack.prototype.pop = function () {
return this.items.pop()
}
=================================================================
2.3 查看一下栈顶元素(peek方法)
- peek方法是一个比较常见的方法, 主要目的是看一眼栈顶的元素.
- 注意: 和pop不同, peek仅仅的瞥一眼栈顶的元素, 并不需要将这个元素从栈顶弹出.
Stack.prototype.peek = function () {
return this.items[this.items.length -1]
}
=================================================================
2.4 判断栈是否为空(isEmpty方法)
- isEmpty方法用户判断栈中是否有元素.
- 实现起来非常简单, 直接判断数组中的元素个数是为0, 为0返回true, 否则返回false
Stack.prototype.isEmpty = function () {
return this.items.length == 0
}
=================================================================
2.5 获取栈中元素的个数(size方法)
- size方法是获取栈中元素的个数.
- 因为我们使用的是数组来作为栈的底层实现的, 所以直接获取数组的长度即可.(也可以使用链表作为栈的顶层实现)
Stack.prototype.size = function () {
return this.items.length
}
=================================================================
2.6 toString方法
Stack.prototype.toString = function () {
var resultString = ''
for (var i = 0; i < this.items.length; i++) {
resultString += this.items[i] + ' '
}
return resultString
}
}
=================================================================
// 栈的使用
var s = new Stack()
s.push(20)
s.push(10)
s.push(30)
s.push(50)
alert(s) // 20 10 30 50
s.pop()
s.pop()
alert(s) // 20 10
alert(s.peek()) // 10
alert(s.isEmpty()) // false
alert(s.size()) // 2
2.2.6 十进制转二进制
为什么需要进制转换
- 在计算科学中,二进制非常重要
- 计算机里的所有内容都是用二进制数字表示的(0和1)
- 没有十进制和二进制相互转化的能力,与计算机交流就很困难
如何实现进制转换
- 要把十进制转化成二进制,可以将该十进制数字和2整除(二进制是满二进一),直到结果是0为止
举例
:
代码实现
function Stack() {
this.items = [];
Stack.prototype.push = function(element) {
this.items.push(element)
}
Stack.prototype.pop = function(element) {
return this.items.pop()
}
Stack.prototype.isEmpty = function() {
return this.items.length == 0
}
}
// 函数:将十进制转换二进制
function dec2bin(decNumber) {
// 1. 定义栈对象
var stack = new Stack()
// 2. 循环操作
// 从最开始的decNumber除2,所得余数压入栈中,所得正数结果用于下一次计算
// 当被除数小于等于0时,循环终止
// 不确定循环次数,用while循环
while (decNumber > 0) {
// 2.1 获取余数,并放入到栈中
stack.push(decNumber % 2)
// 2.2 获取整除后的结果,作为下一次运行的数字
decNumber = Math.floor(decNumber / 2)
}
// 3. 从栈中取出0和1
var binaryString = ''
while (!stack.isEmpty()) {
binaryString += stack.pop()
}
return binaryString
}
// 测试十进制转二进制函数
console.log(dec2bin(100));
2.3 队列
2.3.1 认识队列
队列结构
- 队列(Queue),它是一种运算受限的线性表,
先进先出
(FIFO First In First Out)- 队列是一种
受限的线性结构
- 受限之处在于它只允许在表的
前端
(front)进行删除操作,而在表的后端
(rear)进行插入操作
- 队列是一种
- 队列(Queue),它是一种运算受限的线性表,
队列图解
队列的应用
打印队列
- 有五份文档需要打印, 这些文档会按照次序放入到打印队列中
- 打印机会依次从队列中取出文档, 优先放入的文档, 优先被取出, 并且对该文档进行打印
- 以此类推, 直到队列中不再有新的文档
线程队列
- 在进行多线程开发时, 我们不可能无限制的开启新的线程
- 这个时候, 如果有需要开启线程处理任务的情况, 我们就会使用线程队列
- 线程队列会依照次序来启动线程, 并且处理对应的任务
2.3.2 队列的实现
队列类的创建
- 基于数组实现
- 基于链表实现
// 封装队列类
function Queue() {
// 属性
this.items = []
// 方法
}
- 代码解析:
- 创建一个Queue构造函数, 用户创建队列的类.
- 在构造函数中, 定义了一个变量, 这个变量可以用于保存当前队列对象中所有的元素.
- 这个变量是一个数组类型. 之后在队列中添加元素或者删除元素, 都是在这个数组中完成的.
- 队列和栈一样, 有一些相关的操作方法, 通常无论是什么语言, 操作都是比较类似的.
2.3.3 队列常见操作
队列常见操作
enqueue(element)
:向队列尾部添加一个(或多个)新的项dequeue()
:移除队列的第一(即排在队列最前面的)项,并返回被移除的元素front()
:返回队列中第一个元素——最先被添加,也将是最先被移除的元素。队列不做任何变动(不移除元素,只返回元素信息——与Stack类的peek方法非常类似)isEmpty()
:如果队列中不包含任何元素,返回true,否则返回falsesize()
:返回队列包含的元素个数,与数组的length属性类似
// 方法
// 1. 将元素加入到队列中
Queue.prototype.enqueue = function (element) {
this.items.push(element)
}
// 2. 从队列中删除前端元素
Queue.prototype.dequeue = function () {
return this.items.shift()
}
// 3. 查看前端的元素
Queue.prototype.front = function () {
return this.items[0]
}
// 4. 查看队列是否为空
Queue.prototype.isEmpty = function () {
return this.items.length == 0
}
// 5. 查看队列中元素的个数
Queue.prototype.size = function () {
return this.items.length
}
// 6. toString方法
Queue.prototype.toString = function () {
var resultString = ''
for (var i = 0; i < this.items.length; i++) {
resultString += this.items[i] + ''
}
return resultString
}
队列的使用
// 使用队列
var queue = new Queue()
// 在队列中添加元素
queue.enqueue("abc")
queue.enqueue("cba")
queue.enqueue("nba")
// 查看一下队列前端元素
alert(queue.front())
// 查看队列是否为空和元素个数
alert(queue.isEmpty())
alert(queue.size())
// 从队列中删除元素
alert(queue.dequeue())
alert(queue.dequeue())
alert(queue.dequeue())
2.3.4 击鼓传花问题
击鼓传花规则
- 几个朋友一起玩一个游戏, 围成一圈, 开始数数, 数到某个数字的人自动淘汰.
- 最后剩下的这个人会获得胜利, 请问最后剩下的是原来在哪一个位置上的人?
代码实现
// 面试题:击鼓传花
function passGame(nameList, num) {
// 1. 创建一个队列结构
var queue = new Queue()
// 2. 将所有人依次加入到队列中
for (var i = 0; i < nameList.length; i++) {
queue.enqueue(nameList[i])
}
// 3. 开始数数字
while (queue.size() > 1) {
// 不是num的时候,重新加入到队列的末尾
// 是num这个数字的时候,将其从队列中删除
// 3.1 num数字之前的人重新放入到队列的末尾
for (var i = 0; i < num - 1; i++) {
queue.enqueue(queue.dequeue())
}
// 3.2 num对应这个人,直接从队列中删除掉
queue.dequeue()
}
// 4. 获取剩下的那个人
alert(queue.size())
var endName = queue.front()
alert('最终剩下的人:' + endName)
return nameList.indexOf(endName)
}
// 测试击鼓传花
var names = ['John','Jack','Camila','Ingrid','Carl'];
var index = passGame(names, 7) // 数到8的人淘汰
alert("最终位置:" + index)
2.3.5 优先级队列
特点
- 优先级队列, 在插入一个元素的时候会考虑
该数据的优先级
- 和其他数据优先级进行
比较
- 比较完成后, 可以得出这个元素在队列中
正确的位置
- 其他处理方式, 和队列的处理方式一样
- 优先级队列, 在插入一个元素的时候会考虑
主要考虑问题
- 每个元素不再只是一个数据,而是包含数据的优先级
- 在添加方式中,根据优先级放入正确的位置
优先级队列的实现
- 两方面考虑
- 封装元素和优先级放在一起(可以封装一个新的构造函数)
- 添加元素时, 将当前的优先级和队列中已经存在的元素优先级进行比较, 以获得自己正确的位置
- 两方面考虑
代码实现
// 封装优先级队列
function PriorityQueue() {
// 在PriorityQueue重新创建了一个类:可以理解成内部类
function QueueElement(element, priority) {
this.element = element
this.priority = priority
}
// 封装属性
this.items = []
// 实现插入方法
PriorityQueue.prototype.enqueue = function (element, priority) {
// 1. 创建QueueElement对象
var queueElement = new QueueElement(element, priority)
// 2. 判断队列是否为空
if (this.items.length == 0) {
this.items.push(queueElement)
} else {
var added = false
for (var i = 0; i < this.items.length; i++) {
if(queueElement.priority < this.items[i].priority) {
this.items.splice(i, 0, queueElement)
added = true
break
}
}
if (!added) {
this.items.push(queueElement)
}
}
}
// 2. 从队列中删除前端元素
PriorityQueue.prototype.dequeue = function () {
return this.items.shift()
}
// 3. 查看前端的元素
PriorityQueue.prototype.front = function () {
return this.items[0]
}
// 4. 查看队列是否为空
PriorityQueue.prototype.isEmpty = function () {
return this.items.length == 0
}
// 5. 查看队列中元素的个数
PriorityQueue.prototype.size = function () {
return this.items.length
}
// 6. toString方法
PriorityQueue.prototype.toString = function () {
var resultString = ''
for (var i = 0; i < this.items.length; i++) {
resultString += this.items[i].element + '-' + this.items[i].priority + ' '
}
return resultString
}
}
// 测试代码
var pq = new PriorityQueue()
// 添加元素
pq.enqueue("abc", 10)
pq.enqueue("cba", 5)
pq.enqueue("nba", 12)
pq.enqueue("mba", 3)
// 遍历所有的元素
var size = pq.size()
for (var i = 0; i < size; i++) {
var item = pq.dequeue()
alert(item.element + "-" + item.priority)
}
代码解析
- 封装了一个QueueElement, 将element和priority封装在一起
- 在插入新的元素时, 有如下情况下考虑:
- 根据新的元素先创建一个新的QueueElement对象
- 如果元素是第一个被加进来的, 直接加入数组中即可
- 如果是后面加进来的元素, 需要和前面加进来的元素依次对比优先级
- 一旦优先级, 大于某个元素, 就将该元素插入到元素这个元素的位置. 其他元素会依次向后移动
- 如果遍历了所有的元素, 没有找到某个元素被这个新元素的优先级低, 直接放在最后即可
2.4 链表
2.4.1 链表与数组
数组
- 要存储多个元素,数组(或列表)可能是
最常用
的数据结构 - 几乎每一种编程语言都有默认实现
数组结构
, 这种数据结构非常方便,提供了一个便利的[]
语法来访问它的元素
- 要存储多个元素,数组(或列表)可能是
数组的缺点
- 数组的创建通常需要申请一段
连续的内存空间
(一整块的内存), 并且大小是固定的(大多数编程语言数组都是固定的), 所以当当前数组不能满足容量需求
时, 需要扩容
. (一般情况下是申请一个更大的数组, 比如2倍. 然后将原数组中的元素复制过去) - 在数组开头或中间位置插入数据的成本很高, 需要进行大量元素的位移.(尽管JavaScript的
Array
类方法可以做这些事,但背后的原理依然是这样)
- 数组的创建通常需要申请一段
链表
- 要存储多个元素, 另外一个选择就是使用
链表
- 但不同于数组, 链表中的元素在内存中
不必是连续的空间
链表的每个元素由一个存储元素本身的节点
和一个指向下一个元素的引用
(有些语言称为指针或者链接)组成.
- 要存储多个元素, 另外一个选择就是使用
链表的优点
- 内存空间不是必须是连续的. 可以充分利用计算机的内存. 实现灵活的
内存动态管理
- 链表不必在创建时就
确定大小
, 并且大小可以无限的延伸
下去 - 链表在
插入和删除
数据时,时间复杂度
可以达到O(1). 相对数组效率高很多
- 内存空间不是必须是连续的. 可以充分利用计算机的内存. 实现灵活的
链表的缺点
- 链表访问任何一个位置的元素时, 都需要
从头开始访问
.(无法跳过第一个元素访问任何一个元素). - 无法通过下标直接访问元素, 需要从头一个个访问, 直到找到对应的问题.
- 链表访问任何一个位置的元素时, 都需要
2.4.2 什么是链表
2.4.3 链表结构的封装
创建链表类
// 封装链表类
function LinkedList() {
// 内部的类:节点类
function Node(data) {
this.data = data
this.next = null
}
// 属性
this.head = null
this.length = 0
}
代码解析
- 封装LinkedList的类, 用于表示链表结构. (和Java中的链表同名, 不同Java中的这个类是一个双向链表)
- 在LinkedList类中有一个Node类, 用于封装每一个节点上的信息.(和优先级队列的封装一样)
- 链表中保存两个属性, 一个是链表的长度, 一个是链表中第一个节点
2.4.4 链表常见操作
常见操作
append(element)
:向列表尾部添加一个新的项insert(position, element)
:向列表的特定位置插入一个新的项get(position)
:获取对应位置的元素indexOf(element)
:返回元素在列表中的索引。如果列表中没有该元素则返回-1update(position, element)
:修改某个位置的元素remove(element)
:从列表中移除一项removeAt(position)
:从列表的特定位置移除一项isEmpty()
:如果链表中不包含任何元素,返回true,如果链表长度大于0则返回falsesize()
:返回链表包含的元素个数。与数组的length属性类似toString()
:由于列表项使用了Node类,就需要重写继承自JavaScript对象默认的toString方法,让其只输出元素的值
2.4.5 链表操作实现
append()方法
- 尾部追加数据的两种情况:
- 链表本身为空, 新添加的数据时唯一的节点
- 链表不为空, 需要向其他节点后面追加节点
代码分析
- 将data传入方法, 并根据data创建一个Node节点
场景一
- 链表本身为空, 这种情况下插入一个15作为元素
- 链表本身为空, 这种情况下插入一个15作为元素
场景二
- 链表中已经有元素, 需要向最后的节点的next中添加节点
- 首先需要找到这个尾部元素
- 只有第一个元素的引用, 因此需要循环访问链表, 直接找到最后一个项
- 找到最后一项后, 最后一项的next为null, 这时不让其为null, 而是指向新创建的节点即可
- 链表中已经有元素, 需要向最后的节点的next中添加节点
- 最后将链表的length+1
- 尾部追加数据的两种情况:
// 1. 追加方法
LinkedList.prototype.append = function (data) {
// 1. 创建新的节点
var newNode = new Node(data)
// 2. 判断是否添加的是第一个节点
if (this.length == 0) { // 2.1 是第一个节点
this.head = newNode
} else { // 2.2 不是第一个节点
// 找到最后一个节点
var current = this.head
while (current.next) {
current = current.next
}
// 最后节点的next指向新的节点
current.next = newNode
}
// 3. length+1
this.length += 1
}
toString()方法
- 从head开头, 因为获取链表的任何元素都必须从第一个节点开头
- 循环遍历每一个节点, 并且取出其中的data, 拼接成字符串
- 将最终字符串返回
- 从head开头, 因为获取链表的任何元素都必须从第一个节点开头
代码分析
- 该方法比较简单, 主要是获取每一个元素
- 还是从head开头, 因为获取链表的任何元素都必须从第一个节点开头
- 循环遍历每一个节点, 并且取出其中的data, 拼接成字符串
- 将最终字符串返回
// 2. toString
LinkedList.prototype.toString = function () {
// 1. 定义变量
var current = this.head
var listString = ''
// 2. 循环获取一个个的节点
while (current) {
listString += current.data + ' '
current = current.next
}
return listString
}
insert()方法
- 在任意位置插入数据
代码分析
- 首先处理越界问题, 基本传入位置信息时, 都需要进行越界的判断
- 如果越界, 返回false, 表示数据添加失败. (位置信息是错误的, 数据肯定是添加失败的)
- 然后定义了一些变量, 后续需要使用它们来保存信息
- 对数据插入的位置进行判断(因为添加到第一个位置和其他位置是不同的)
- 添加到第一个位置
- 添加到第一个位置, 表示新添加的节点是头, 就需要将原来的头节点, 作为新节点的next
- 另外这个时候的head应该指向新节点
- 添加到其他位置:
- 如果是添加到其他位置, 就需要先找到这个节点位置了
- 通过while循环, 一点点向下找. 并且在这个过程中保存上一个节点和下一个节点
- 找到正确的位置后, 将新节点的next指向下一个节点, 将上一个节点的next指向新的节点
- 添加到第一个位置
- 最后length+1
- 返回true, 表示元素插入成功
- 首先处理越界问题, 基本传入位置信息时, 都需要进行越界的判断
// 3. insert方法----任意位置插入
LinkedList.prototype.insert = function (position, data) {
// 1. 对 position 进行越界判断
if(position < 0 || position > this.length) return false
// 2. 根据 data 创建 newNode
var newNode = new Node(data)
// 3. 判断插入的位置是否是第一个
if (position == 0) {
newNode.next = this.head
this.head = newNode
} else {
var index = 0
var current = this.head
var previous = null
while (index++ < position) {
previous = current
current = current.next
}
newNode.next = current
previous.next = newNode
}
// 4. length + 1
this.length += 1
return true
}
get()方法
- 获取对应位置的元素
// 4. get方法
LinkedList.prototype.get = function (position) {
// 1. 越界判断
if (position < 0 || position >= this.length) return null
// 2. 获取对应的data
var current = this.head
var index = 0
while (index++ < position) {
current = current.next
}
return current.data
}
indexOf()方法
- 根据元素获取它在链表中的位置
代码解析
- 定义需要的变量
- 通过while循环获取节点
- 通过节点获取元素和data进行对比, 如果和传入data相同, 表示找到, 直接返回index即可
- 如果没有找到, index++, 并且指向下一个节点
- 到最后都没有找到, 说明链表中没有对应的元素, 那么返回-1即可
// 5. indexOf方法
LinkedList.prototype.indexOf = function (data) {
// 1. 定义变量
var current = this.head
var index = 0
// 2. 开始查找
while (current) {
if (current.data == data) {
return index
}
current = current.next
index += 1
}
// 3. 找到最后没有找到,返回 -1
return -1
}
update()方法
- 修改某个位置的元素
// 6. update方法
LinkedList.prototype.update = function (position, newData) {
// 1. 越界判断
if (position < 0 || positon >= this.length) return false
// 2. 查找正确的节点
var current = this.head
var index = 0
while (index++ < positon) {
current = current.next
}
// 3. 将 position 位置的 node 的 data 修改成 newData
current.data = newData
return true
}
removeAt()方法
- 移除数据的两种常见的方式
- 根据位置移除对应的数据
- 根据数据, 先找到对应的位置, 再移除数据
- 移除数据的两种常见的方式
代码分析
- 越界判断(注意: 这里越界判断中的等于length也是越界的, 因为下标值是从0开始的)
- 定义变量, 用于保存临时信息
- 进行判断, 因为移除第一项和其他项的方式是不同的
- 移除第一项的信息
- 移除第一项时, 直接让head指向第二项信息就可以
- 那么第一项信息没有引用指向, 就在链表中不再有效, 后面会被回收掉
- 移除其他项的信息
- 通过while循环, 找到正确的位置
- 找到正确位置后, 就可以直接将上一项的next指向current项的next, 这样中间的项就没有引用指向它, 也就不再存在于链表后, 会面会被回收掉
- 移除第一项的信息
// 根据位置移除节点
LinkedList.prototype.removeAt = function (position) {
// 1.检测越界问题: 越界移除失败, 返回null
if (position < 0 || position >= this.length) return null
// 2.定义变量, 保存信息
var current = this.head
var previous = null
var index = 0
// 3.判断是否是移除第一项
if (position === 0) {
this.head = current.next
} else {
while (index++ < position) {
previous = current
current = current.next
}
previous.next = current.next
}
// 4.length-1
this.length--
// 5.返回移除的数据
return current.data
}
remove()方法
- 根据元素来删除信息
代码分析
- 获取元素所在位置(已经封装好), 根据位置移除元素(已经封装好)
// 8. remove方法
LinkedList.prototype.remove = function (data) {
// 1. 获取data在列表中的位置
var position = this.indexOf(data)
// 2. 根据位置信息,删除节点
return this.removeAt(position)
}
isEmpty()方法
- 判断链表是否为空
// 9. isEmpty方法
LinkedList.prototype.isEmpty = function () {
return this.length == 0
}
size()方法
- 获取链表的长度
// 10. size()方法
LinkedList.prototype.size = function () {
return this.length
}
2.5 双向链表
2.5.1 认识双向链表
单向链表
- 只能
从头遍历到尾
或者从尾遍历到头
(一般从头到尾) - 链表相连的过程是
单向
的 - 实现的原理是上一个链表中有一个指向下一个的
引用
- 只能
缺点
- 可以轻松的到达
下一个节点
, 但是回到前一个节点
是很难的 - 在实际开发中, 经常会遇到需要回到上一个节点的情况
- 可以轻松的到达
双向链表
- 既可以
从头遍历到尾
, 又可以从尾遍历到头
- 链表相连的过程是
双向
的 - 一个节点既有
向前连接的引用
, 也有一个向后连接的引用
- 既可以
缺点
- 每次在插入或删除某个节点时, 需要
处理四个节点
的引用, 而不是两个 - 相比于单向链表, 必然
占用内存空间更大
一些.
- 每次在插入或删除某个节点时, 需要
双向连接图解
双向链表特点
- 可以使用一个head和一个tail分别指向头部和尾部的节点
- 每个节点都由三部分组成:前一个节点的指针(prev)/保存的元素(item)/后一个节点的指针(next)
- 双向链表的第一个节点的prev是null
- 双向链表的最后的节点是next是null
2.5.2 封装双向链表
代码分析
- 基本思路和单向链表比较相似, 都是创建节点结构函数以及定义一些属性和方法
- 只是Node中添加了一个this.prev属性, 该属性用于指向上一个节点
- 另外属性中添加了一个this.tail属性, 该属性指向末尾的节点
// 封装双向链表
function DoublyLinkedList () {
// 内部类:节点类
function Node(data) {
this.data = data
this.prev = null
this.next = null
}
// 属性
this.head = null
this.tail = null
this.length = 0
}
2.5.3 操作双向链表
双向链表常见操作
append(element)
: 向列表尾部添加一个新的项insert(position, element)
: 向列表的特定位置插入一个新的项get(position)
: 获取对应位置的元素indexOf(element)
: 返回元素在列表中的索引。如果列表中没有该元素则返回-1update(position, element)
: 修改某个位置的元素removeAt(position)
: 从列表的特定位置移除一项remove(element)
: 从列表中移除一项isEmpty()
: 如果链表中不包含任何元素,返回true,如果链表长度大于0则返回falsesize()
: 返回链表包含的元素个数,与数组的length属性类似toString()
: 由于列表项使用了Node类,就需要重写继承自JS对象默认的toString方法,让其只输出元素的值forwardString()
: 返回正向遍历的节点字符串形式backwordString()
: 返回反向遍历的节点字符串形式
2.5.4 双向链表操作实现
append()方法
- 尾部追加数据
代码分析
- 通过元素创建新的节点
- 判断列表是否为空列表的两种情况
- 链表原来为空
- 链表中原来如果没有数据, 那么直接让head和tail指向这个新的节点即可
- 链表中已经存在数据
- 要将数据默认追加到尾部
- 首先tail中的next之前指向的是null. 现在应该指向新的节点
newNode: this.tail.next = newNode
- 因为是双向链表, 新节点的next/tail目前都是null. 但是作为最后一个节点, 需要有一个指向前一个节点的引用. 所以这里需要
newNode.prev = this.tail
- 因为目前newNod已经变成了最后的节点, 所以this.tail属性的引用应该指向最后:
this.tail = newNode
即可
- 链表原来为空
- length需要+1
// 常见的操作:方法
// 1. append方法
DoublyLinkedList.prototype.append = function (data) {
// 1. 根据data创建节点
var newNode = new Node(data)
// 2. 判断添加的是否是第一个节点
if (this.length == 0) {
this.head = newNode
this.tail = newNode
} else {
newNode.prev = this.tail
this.tail.next = newNode
this.tail = newNode
}
// 3. length+1
this.length += 1
}
toString()方法
- 正向遍历转成字符串
forwardString()方法
- 向前遍历(反向遍历)转成字符串
backwardString()方法
- 向后遍历(正向遍历)转成字符串
// 2. 将链表转成字符串形式
// 2.1 toString方法
DoublyLinkedList.prototype.toString = function () {
return this.backwardString()
}
// 2.2 forwardString方法
DoublyLinkedList.prototype.forwardString = function () {
// 1. 定义变量
var current = this.tail
var resultString = ""
// 2. 依次向前遍历,获取每一个节点
while (current) {
resultString += current.data + " "
current = current.prev
}
return resultString
}
// 2.3 backwardString方法
DoublyLinkedList.prototype.backwardString = function () {
// 1. 定义变量
var current = this.head
var resultString = ""
// 2. 依次向后遍历,获取每一个节点
while (current) {
resultString += current.data + " "
current = current.next
}
return resultString
}
insert()方法
- 向双向链表的任意位置插入数据
代码分析
情况一
: 将元素插入到头部
(position === 0)- 列表为空
- 直接让head/tail指向newNode即可
- 列表不为空
- 修改原来head的prev指向新节点
- 新节点的next指向原来的head
- 并且head现在要指向newNode
- 列表为空
情况二
: 将元素插入到尾部
(position === length)- 注意: 这里不需要判断元素为空的情况, 因为在
position === 0
的时候,已经处理过了. 所以到这里的时候, 肯定不为空
- 注意: 这里不需要判断元素为空的情况, 因为在
情况三
: 将元素插入到中间位置- 首先通过while循环找到正确的插入位置
- 查找正确的位置后, 需要进行插入操作
- newNode的next/prev必然要指向前后的节点, 也就是current和previous
- 而current的prev需要指向newNode, 而previous的next需要指向newNode.
// 3. insert方法
DoublyLinkedList.prototype.insert = function (position, data) {
// 1. 越界判断
if (position < 0 || position > this.length) return false
// 2. 根据data创建新的节点
var newNode = new Node(data)
// 3. 判断原来的列表是否为空
if (this.length = 0) {
this.head = newNode
this.tail = newNode
} else {
if (position == 0) { // 3.1 判断position是否为0
this.head.prev = newNode
newNode.next = this.head
this.head = newNode
} else if (position == this.length) { // 3.2 position == length
newNode.prev = this.tail
this.tail.next = newNode
this.tail = newNode
} else { // 3.3 其他情况
var current = this.head
var index = 0
while (index++ < position) {
current = current.next
}
// 修改指针
newNode.next = current
newNode.prev = current.prev
current.prev.next = newNode
current.prev = newNode
}
}
// 4. length+1
this.length += 1
return true
}
get()方法
- 获取对应位置的元素
// 4. get方法
DoublyLinkedList.prototype.get = function (position) {
// 1. 越界判断
if (position < 0 || position >= this.length) return null
// 2. 获取元素
var current = this.head
var index = 0
while (index++ < position) {
current = current.next
}
return current.data
}
indexOf()方法
- 根据元素获取再链表中的位置
// 5. indexOf方法
DoublyLinkedList.prototype.indexOf = function (data) {
// 1. 定义变量
var current = this.head
var index = 0
// 2. 查找和data相同的节点
while (current) {
if (current.data == data) {
return index
}
current = current.next
index += 1
}
return -1
}
update()方法
- 修改某个位置的元素
// 6. update方法
DoublyLinkedList.prototype.update = function (position, newData) {
// 1. 越界的判断
if (position < 0 || position >= this.length) return false
// 2. 寻找正确的节点
var current = this.head
var index = 0
while (index++ < position) {
current = current.next
}
// 3. 修改找到的节点的data信息
current.data = newData
return true
}
removeAt()方法
- 通过下标值删除某个元素
代码分析
情况一
: 删除头部的元素- 链表只有一个元素, 那么将head/tail直接设置为null即可
- 链表有多个元素, 这个时候删除头部的元素.
head = head.next. head.prev = null
情况二
: 删除尾部的元素- 将tail设置为tail的prev. tail的next设置为null即可
- 将tail设置为tail的prev. tail的next设置为null即可
情况三
: 删除中间位置的元素- 需要先找到正确的位置, 使用while循环.
- 将previous的next直接设置成current的next, 将current.next的prev设置成previous即可
// 7. removeAt方法
DoublyLinkedList.prototype.removeAt = function (position) {
// 1. 越界判断
if (position < 0 || position >= this.length) return null
// 2. 判断是否只有一个节点
var current = this.head
if (this.length == 1) {
this.head = null
this.tail = null
} else {
if (position == 0) { // 判断是否删除的是第一个节点
this.head.next.prev = null
this.head = this.head.next
} else if (position == this.length - 1) { // 最后节点
current = this.tail
this.tail.prev.next = null
this.tail = this.tail.prev
} else {
var index = 0
while (index++ < position) {
current = current.next
}
current.prev.next = current.next
current.next.prev = current.prev
}
}
// 3. length-1
this.length -= 1
return current.data
}
remove()方法
- 根据元素来删除信息
// 8. remove方法
DoublyLinkedList.prototype.remove = function (data) {
// 1.根据data获取下标值
var index = this.indexOf(data)
// 2.根据index删除对应位置的节点
return this.removeAt(index)
}
其他方法
// 9.其他方法
// isEmpty方法 判断是否为空
DoublyLinkedList.prototype.isEmpty = function () {
return this.length == 0
}
// size方法 获取链表长度
DoublyLinkedList.prototype.size = function () {
return this.length
}
// 获取链表的第一个元素
DoublyLinkedList.prototype.getHead = function () {
return this.head.data
}
// 获取链表的最后一个元素
DoublyLinkedList.prototype.getTail = function () {
return this.tail.data
}
3. 集合结构
3.1 集合介绍
集合特点
- 集合通常是由一组无序的, 不能重复的元素构成
- 可以将集合看成一种特殊的数组
- 特殊之处在于里面的元素没有顺序, 也不能重复.
- 没有顺序意味着不能通过下标值进行访问, 不能重复意味着相同的对象在集合中只会存在一份
3.2 封装集合
创建集合类
- 代码解析:
- 封装了一个集合的构造函数
- 在集合中, 添加了一个items属性, 用于保存之后添加到集合中的元素. 因为Object的keys本身就是一个集合
- 给集合添加对应的操作方法
- 代码解析:
// 封装集合类
function Set() {
// 属性
this.items = {}
// 集合的操作方法
}
3.3 集合的操作方法
集合常用的操作方法
add(value)
:向集合添加一个新的项remove(value)
:从集合移除一个值has(value)
:如果值在集合中,返回true,否则返回falseclear()
:移除集合中的所有项size()
:返回集合所包含元素的数量。与数组的length属性类似values()
:返回一个包含集合中所有值的数组
3.4 集合操作方法实现
has()方法
- 判断集合中是否有某个元素
// has方法
Set.prototype.has = function (value) {
return this.items.hasOwnProperty(value)
}
add()方法
- 向集合中添加元素
// add方法
Set.prototype.add = function (value) {
// 判断当前集合中是否已经包含了该元素
if (this.has(value)) {
return false
}
// 将元素添加到集合中
this.items[value] = value
return true
}
remove()方法
- 从集合中删除某个元素
// remove方法
Set.prototype.remove = function (value) {
// 1. 判断该集合中是否包含该元素
if (!this.has(value)) {
return false
}
// 2. 将元素从属性中删除
delete this.items[value]
return true
}
clear()方法
- 清空集合中所有的元素
// clear方法
Set.prototype.clear = function () {
this.items = {}
}
size()方法
- 获取集合的大小
// size方法
Set.prototype.size = function () {
return Object.keys(this.items).length
}
values()方法
- 获取集合中所有的值
Set.prototype.values = function () {
return Object.keys(this.items)
}
3.5 集合间操作
- 集合间通常有如下操作:
并集
- 对于给定的两个集合,返回一个包含两个集合中所有元素的新集合
交集
- 对于给定的两个集合,返回一个包含两个集合中共有元素的新集合
差集
- 对于给定的两个集合,返回一个包含所有存在于第一个集合且不存在于第二个集合的元素的新集合
子集
- 验证一个给定集合是否是另一集合的子集
3.6 集合间操作实现
3.6.1 并集实现
并集
- 集合A和B的并集,表示为AUB,定义如下:
A∪B={x|x∈A,或x∈B}
- x元素存在于A中,或x存在于B中
- 集合A和B的并集,表示为AUB,定义如下:
代码解析
- 首先需要创建一个新的集合,代表两个集合的并集
- 遍历集合1中所有的值,并且添加到新集合中
- 遍历集合2中所有的值,并且添加到新集合中
- 将最终的新集合返回
// 并集
Set.prototype.union = function (otherSet) {
// this: 集合对象A
// otherSet: 集合对象B
// 1. 创建新的集合
var unionSet = new Set()
// 2. 将A集合中所有的元素添加到新集合中
var values = this.values()
for (var i = 0; i < values.length; i++) {
unionSet.add(values[i])
}
// 3. 取出B集合中的元素,判断是否需要加到新集合
values = otherSet.values()
for (var i = 0; i < values.length; i++) {
unionSet.add(values[i])
}
return unionSet
}
3.6.2 交集实现
交集
- 集合A和B的交集,表示为A∩B,定义如下:
A∩B= {x|x∈A∧x∈B}
- x元素存在于A中,且x存在于B中
- 集合A和B的交集,表示为A∩B,定义如下:
代码解析
- 创建一个新的集合
- 遍历集合1中所有的元素,判断是否该元素在集合2中
- 同时在集合2中,将该元素加入到新集合中
- 将最终的新集合返回
// 交集
Set.prototype.intersection = function (otherSet) {
// this: 集合A
// otherSet: 集合B
// 1. 创建新的集合
var intersectionSet = new Set()
// 2. 从A中取出一个个元素,判断是否同时存在于集合B中,存在放入新集合中
var values = this.values()
for (var i = 0; i < values.length; i++) {
var item = values[i]
if (otherSet.has(item)) {
intersectionSet.add(item)
}
}
return intersectionSet
}
3.6.3 差集实现
差集
- 集合A和B的差集,表示为A-B,定义如下:
A-B= {x|x∈A∧x∉B}
- x元素存在于A中,且x不存在于B中
- 集合A和B的差集,表示为A-B,定义如下:
代码解析
- 判断
- 遍历集合1中所有的元素,判断是否在集合2中
- 不存在于集合2中,将该元素加入到新集合中
- 将最终的新集合返回
// 差集
Set.prototype.difference = function (otherSet) {
// this: 集合A
// otherSet: 集合B
// 1. 创建新的集合
var differenceSet = new Set()
// 2. 取出A集合一个个元素,判断是否同时存在于B中,不存在B中,则添加到新集合中
var values = this.values()
for (var i = 0; i < values.length; i++) {
var item = values[i]
if (!otherSet.has(item)) {
differenceSet.add(item)
}
}
return differenceSet
}
3.6.4 子集实现
子集
- 集合A是B的子集,表示为A⊆B,定义如下:
∀a∈A有a∈B,则A⊆B
- 集合A中的每一个x元素,也需要存在于B中
- 集合A是B的子集,表示为A⊆B,定义如下:
代码解析
- 判断集合1是否大于集合2,如果大于,那么肯定不是集合2的子集
- 不大于的情况下:
- 判断集合1中的元素是否都在集合2中存在
- 存在,那么是集合2的子集
- 有一个不存在,那么不是集合2的子集
// 子集
Set.prototype.subset = function (otherSet) {
// this: 集合A
// otherSet: 集合B
// 遍历集合A中所有的元素,如果发现,集合A中的元素,在集合B中不存在,那么false
// 如果遍历完了整个集合,依然没有返回false,那么返回true即可
var values = this.values()
for (var i = 0; i < values.length; i++) {
var item = values[i]
if (!otherSet.has(item)) {
return false
}
}
return true
}
4. 字典结构
4.1 认识字典
4.1.1 字典的特点
- 字典的主要特点是
一一对应
的关系- 比如保存一个人的信息, 在合适的情况下取出这些信息.
- 使用
数组
的方式: [18, “zs”, 1.88]. 可以通过下标值取出信息. - 使用
字典
的方式: {“age” : 18, “name” : “zs”, “height”: 1.88}. 可以通过key取出value
- 使用
- 比如保存一个人的信息, 在合适的情况下取出这些信息.
4.1.2 字典的映射关系
- 有些编程语言中称这种
映射
关系为字典
(比如Swift中Dictionary
,Python中的dict
) - 有些编程语言中称这种
映射
关系为Map
(比如Java中就有HashMap&TreeMap
等)
4.1.3 字典和数组
- 字典可以非常方便的通过key来搜索对应的value
- key可以包含特殊含义
4.1.4 字典和对象
- 对象通常是一种在编译期就确定下来的结构, 不可以动态的添加或者删除属性.
- 字典通常会使用类似于哈希表的数据结构去实现一种可以动态的添加数据的结构.
- 但是在JavaScript中, 似乎对象本身就是一种字典. 所有在早期的JavaScript中, 没有字典这种数据类型, 因为完全可以使用对象去代替.
4.2 操作字典
4.2.1 创建字典类
- 封装一个字典的构造函数
代码解析
- 创建一个Dictionary的构造函数, 用于字典的封装
- 在字典中, 使用了一个items属性, 该属性是一个Object对象
- 字典是基于Object封装的
// 创建字典的构造函数
function Dictionay() {
// 字典属性
this.items = {}
// 字典操作方法
}
4.2.2 字典常用操作
- 字典常见的操作
set(key,value)
:向字典中添加新元素remove(key)
:通过使用键值来从字典中移除键值对应的数据值has(key)
:如果某个键值存在于这个字典中,则返回true,反之则返回falseget(key)
:通过键值查找特定的数值并返回clear()
:将这个字典中的所有元素全部删除size()
:返回字典所包含元素的数量。与数组的length属性类似keys()
:将字典所包含的所有键名以数组形式返回values()
:将字典所包含的所有数值以数组形式返回
4.2.3 字典操作实现
// 创建字典的构造函数
function Dictionay() {
// 字典属性
this.items = {}
// 字典操作方法
// 在字典中添加键值对
Dictionay.prototype.set = function (key, value) {
this.items[key] = value
}
// 判断字典中是否有某个key
Dictionay.prototype.has = function (key) {
return this.items.hasOwnProperty(key)
}
// 从字典中移除元素
Dictionay.prototype.remove = function (key) {
// 1.判断字典中是否有这个key
if (!this.has(key)) return false
// 2.从字典中删除key
delete this.items[key]
return true
}
// 根据key去获取value
Dictionay.prototype.get = function (key) {
return this.has(key) ? this.items[key] : undefined
}
// 获取所有的keys
Dictionay.prototype.keys = function () {
return Object.keys(this.items)
}
// 获取所有的value
Dictionay.prototype.values = function () {
return Object.values(this.items)
}
// size方法
Dictionay.prototype.size = function () {
return this.keys().length
}
// clear方法
Dictionay.prototype.clear = function () {
this.items = {}
}
}
5. 哈希表
5.1 认识哈希表
5.1.1 哈希表介绍
- 哈希表通常是
基于数组
进行实现的 优势
- 可以提供非常快速的
插入-删除-查找
操作 - 无论多少数据, 插入和删除值需要接近常量的时间: 即O(1)的时间级. 只需要几个机器指令即可
- 哈希表的速度比树还要快, 基本可以瞬间查找到想要的元素
- 哈希表相对于树来说编码要容易很多
- 可以提供非常快速的
缺点
- 哈希表中的数据是
没有顺序
的 - 不能以一种
固定
的方式(比如从小到大)来遍历其中的元素 - 通常情况下, 哈希表中的key是
不允许重复
的, 不能放置相同的key, 用于保存不同的元素
- 哈希表中的数据是
什么是哈希表
- 哈希表的结构就是数组, 是对下标值的一种变换, 这种变换可以称之为
哈希函数
, 通过哈希函数可以获取到HashCode
- 哈希表的结构就是数组, 是对下标值的一种变换, 这种变换可以称之为
5.1.2 哈希表应用案例
-
案例一
员工信息
- 假如一家公司有1000个员工, 现在需要将这些员工的信息使用某种数据结构来保存起来
方案
- 按照顺序将所有的员工依次存入一个长度为1000的数组中. 每个员工的信息都保存在数组的某个位置上
- 数组最大的优势是可以通过下标值去获取信息
- 所以为了可以通过数组快速定位到某个员工, 最好给员工信息中添加一个员工编号, 而编号对应的就是员工的下标值
- 当查找某个员工的信息时, 通过员工编号可以快速定位到员工的信息位置
缺点
- 每查找一个员工就问一下员工编号嘛?
解决方案
- 使用哈希函数, 让某个key的信息和索引值对应起来
- 比如通过具体的员工名字, 获取到索引值, 再通过索引值就能获取到员工的信息
-
案例二
联系人电话
- 设计一个数据结构, 保存联系人和电话
方案
- 将联系人和数组的下标值对应
- 可以让联系人的名字作为下标值, 来获取这个联系人对应的电话
缺点
- 联系人的名字(字符串)不可以作为下标值
解决方案
- 将字符串转成下标值
-
案例三
单词信息
- 使用一种数据结构存储单词信息, 比如有50000个单词. 找到单词后每个单词有自己的翻译&读音&应用等等
方案
- 将单词转成数组的下标值
- 如果单词转成数组的下标, 那么以后要查找某个单词的信息, 直接按照下标值一步即可访问到想要的元素
-
案例四
高级语言的编辑器
- 哈希表还有另外一个非常重要的应用场景, 就是高级语言的编译器
- 通常用哈希表来保留符号表
- 符号表记录了程序员声明的所有变量和函数名, 以及它们在内存中的地址
- 程序需要快速的访问这些名字, 所以哈希表是理想的实现方式
5.2 字符串转下标值
如何将字符串转成数组的下标值
- 在计算机中有很多的编码方案就是用数字代替单词的字符
- 比如ASCII编码: a是97, b是98, 依次类推122代表z
- 也可以设计一个自己的编码系统, 比如a是1, b是2, c是3, 依次类推, z是26. 加上空格用0代替, 就是27个字符(不考虑大写问题)
- 有了编码系统后, 一个单词如何转成数字呢?
5.2.1 数字相加
- 把单词每个字符的编码
求和
- 例如单词cats转成数字: 3+1+20+19=43, 那么43就作为cats单词的下标存在数组中
问题
- 会有很多单词最终的下标可能都是43
- 比如was/tin/give/tend/moan/tick等等
- 数组中一个下标值位置只能存储一个数据,
- 如果存入后来的数据, 必然会造成数据的覆盖.
- 一个下标存储这么多单词显然是不合理的
5.2.2 幂的连乘
- 可以用
幂的连乘
来表示唯一性
- 比如cats = 327³+127²+20*27+19= 60337
- 这样数字可以几乎保证它的唯一性, 不会和别的单词重复
问题
- 如果一个单词特别复杂(一般英文单词不会超过10个字符). 得到的数字非常大.
- 数组可以表示这么大的下标值吗?
- 事实上有很多是无效的单词
- 创建这么大的数组是没有意义的
总结
- 数字相加求和方法产生的数组下标太少
- 幂的连乘求和方法产生的数组下标又太多
5.3 认识哈希化
-
压缩方法
- 把幂的连乘方案系统中得到的巨大整数范围压缩到可接受的数组范围中
-
取余操作符
- 得到一个数被另外一个数整除后的余数…
-
取余操作的实现
- 假设把从0~199的数字, 比如使用largeNumber代表, 压缩为从0到9的数字, 比如使用smallRange代表.
- 下标值的结果:
index = largeNumber % smallRange
- 当一个数被10整除时, 余数一定在0~9之间(比如13%10=3, 157%10=7)
- 这中间还是会有重复, 不过重复的数量明显变小了
-
哈希化
- 将大数字转化成数组范围内下标的过程, 称之为哈希化
-
哈希函数
- 通常会将单词转成大数字, 大数字在进行哈希化的代码实现放在一个函数中, 这个函数我们成为哈希函数
-
哈希表
- 最终将数据插入到的这个数组, 称之为是一个哈希表
5.4 地址的冲突
5.4.1 什么是冲突
- 尽管压缩过后,仍然会出现
下标值重合
的情况- 比如melioration这个单词, 通过哈希函数得到它数组的下标值后, 发现那个位置上已经存在一个单词demystify, 因为它经过哈希化后和melioration得到的下标值是相同的
- 最好是
每个下标对应一个数据项
,但是通常情况下不可能
解决方案
- 链地址法
- 开放地址法
5.4.2 链地址法
-
图解链地址法(拉链法)
-
方法解析
- 从图片中可以看出, 链地址法解决冲突的办法是每个数组单元中存储的不再是单个数据, 而是一个链条(常见的是数组或者链表)
- 比如是链表, 也就是每个数组单元中存储着一个链表. 一旦发现重复, 将重复的元素插入到链表的首端或者末端即可
- 当查询时, 先根据哈希化后的下标值找到对应的位置, 再取出链表, 依次查询找寻找的数据
-
如何选择
- 根据哈希化的index找出这个数组或者链表时, 通常就会使用
线性查找
, 这个时候数组和链表的效率是差不多的 - 在某些业务中, 会将新插入的数据放在数组或者链表的最前面, 因为可能新插入的数据用于取出的可能性更大
- 这种情况最好采用链表, 因为数组在首位插入数据是需要所有其他项后移的, 链表就没有这样的问题
- 具体问题具体分析
- 根据哈希化的index找出这个数组或者链表时, 通常就会使用
5.4.3 开放地址法
- 开放地址法的主要工作方式是
寻找空白的单元格来添加重复的数据
图解开放地址法
问题
- 新插入32放在什么位置?
开放地址法解决方案
- 新插入的32本来应该插入到82的位置,但是该位置已经包含数据
- 但是3,5,9的位置是没有任何内容的
- 这时候就可以寻找到对应的空白位置来放这个数据
- 比如可以把32放在3的位置,把62放在5的位置
- 探索位置的方式不同,有三种方法
线性探测
二次探测
再哈希法
5.4.4 线性探测
线性探测: 线性的查找空白的单元
插入32
- 经过哈希化得到的
index=2
- 在插入的时候, 发现该位置已经有了82
- 线性探测就是从
index位置+1
开始一点点查找合适的位置来放置32 - 空的位置就是合适的位置
index=3
的位置为空, 32就会放在该位置
- 经过哈希化得到的
查询32
- 经过哈希化得到
index=2
- 比较2的位置结果和查询的数值是否相同, 相同那么就直接返回
- 不相同就线性查找, 从
index位置+1
开始查找和32一样的 注意:查询到空位置, 就停止
- 因为查询到有空位置, 32之前不可能跳过空位置去其他的位置
- 经过哈希化得到
删除32
- 经过哈希化得到
index=2
- 比较2的位置结果和查询的数值是否相同, 相同那么就直接删除
- 不相同就线性查找, 从
index位置+1
开始查找和32一样的 注意: 删除操作一个数据项时, 不可以将这个位置下标的内容设置为null
- 因为设置为null可能会影响之后查询其他操作
- 通常删除一个位置的数据项时, 可以将它进行特殊处理(比如设置为-1)
- 当看到-1位置的数据项时, 继续查询, 但是插入时这个位置可以放置数据
- 经过哈希化得到
线性探测的问题
- 线性探测有一个比较严重的问题, 就是
聚集
聚集
- 在没有任何数据的时候, 插入的是22-23-24-25-26
- 那么意味着下标值:2-3-4-5-6的位置都有元素
- 这种一连串填充单元就叫做聚集
- 聚集会影响哈希表的性能, 无论是插入/查询/删除都会影响
- 比如插入一个32, 会发现连续的单元都不允许放置数据, 并且在这个过程中需要探索多次
- 线性探测有一个比较严重的问题, 就是
5.4.5 二次探测
- 二次探测在线性探测的基础上进行了
优化
: - 二次探测主要优化的是探测时的
步长
- 线性探测, 可以看成是
步长为1
的探测- 比如
从下标值x开始
, 那么线性测试就是x+1, x+2, x+3
依次探测
- 比如
- 二次探测, 对步长做了优化
从下标值x开始, x+1², x+2², x+3²
- 这样就可以一次性探测比较长的距离, 比避免那些聚集带来的影响
- 线性探测, 可以看成是
二次探测的问题
- 二次探测依然存在问题
- 比如连续插入的是32-112-82-2-192, 那么它们依次累加的时候步长的相同的
- 也就是这种情况下会造成
步长不一
的一种聚集
. 还是会影响效率
- 二次探测依然存在问题
5.4.6 再哈希法
- 为了消除线性探测和二次探测中无论
步长+1
还是步长+平法
中存在的问题 - 还有一种最常用的解决方案:
再哈希法
再哈希法
- 二次探测的算法产生的探测序列
步长是固定
的: 1, 4, 9, 16, 依次类推. - 现在需要一种方法: 产生一种
依赖关键字
的探测序列, 而不是每个关键字都一样 - 不同的关键字即使映射到相同的数组下标, 也可以使用不同的探测序列
- 二次探测的算法产生的探测序列
再哈希法的做法
- 把关键字用另外一个哈希函数,
再做一次哈希化
, 用这次哈希化的结果作为步长 - 对于指定的关键字, 步长在整个探测中是
不变
的, 不过不同的关键字使用不同的步长
- 把关键字用另外一个哈希函数,
第二次哈希化特点
和第一个哈希函数不同
- 不能再使用上一次的哈希函数了, 不然结果还是原来的位置
不能输出为0
- 否则, 将没有步长. 每次探测都是原地踏步, 算法就进入了死循环
哈希函数
stepSize = constant - (key - constant)
- 其中
constant是质数
, 且小于数组的容量 - 例如:
stepSize = 5 - (key % 5)
, 满足需求, 并且结果不可能为0
5.5 哈希化效率
- 哈希表中执行插入和搜索操作可以达到
O(1)
的时间级,效率非常高
- 如果
没有发生冲突
- 只需要使用一次哈希函数和数组的引用
- 就可以插入一个新数据项或找到一个已经存在的数据项
- 效率更高
- 如果发生冲突,存取时间就依赖后来的探测长度
- 一个单独的查找或插入时间与探测的长度
成正比
- 这里还要加上哈希函数的
常量时间
- 一个单独的查找或插入时间与探测的长度
- 平均探测长度以及平均存取时间,取决于
填装因子
填装因子变大,探测长度也越来越长
- 随着填装因子变大,效率下降的情况
- 在不同开放地址法方案中比链地址法更严重, 所以对比一下他们的效率, 再决定选取的方案
- 如果
5.5.1 装填因子
装填因子
- 装填因子表示当前哈希表中已经包含的
数据项
和整个哈希表长度
的比值
装填因子 = 总数据项 / 哈希表长度
- 装填因子表示当前哈希表中已经包含的
- 开放地址法的装填因子最大是多少呢?
- 1
- 因为它必须寻找到空白的单元才能将元素放入
- 链地址法的装填因子呢?
- 可以大于1
- 因为拉链法可以
无限的延伸
下去(后面效率就变低了)
5.5.2 开放地址法效率
线性探测
- 线性探测时,
探测序列(P)
和填装因子(L)
的关系- 对
成功
的查找:P = (1+1/(1-L))/2
- 对
不成功
的查找:P=(1+1/(1-L)^2)/2
- 对
- 线性探测时,
图解算法效率
图片解析
- 当
填装因子是1/2
时- 成功的搜索需要
1.5
次比较 - 不成功的搜索需要
2.5
次
- 成功的搜索需要
- 当
填装因子为2/3
时- 成功的搜索需要
2.0
次比较 - 不成功的搜索需要
5.0
次
- 成功的搜索需要
如果填装因子更大,比较次数会非常大
- 应该使填装因子保持在2/3以下,最好在1/2以下
- 填装因子越低,对于给定数量的数据项,就需要越多的空间
- 实际情况中,最好的填装因子取决于
存储效率
和速度
之间的平衡
- 随着
填装因子变小,存储效率下降,而速度上升
- 当
===========================================================
二次探测和再哈希
- 二次探测和再哈希法的性能相当,它们的性能比线性探测略好
- 对
成功
的搜索,公式是:-log2(1 - loadFactor) / loadFactor
- 对于
不成功
的搜搜, 公式是:1 / (1-loadFactor)
- 对
- 二次探测和再哈希法的性能相当,它们的性能比线性探测略好
图解算法效率
图片解析
- 当
填装因子是0.5
时- 成功和不成功的查找平均需要
2次比较
- 成功和不成功的查找平均需要
- 当
填装因子为2/3
时- 分别需要
2.37
和3.0
次比较
- 分别需要
- 当
填装因子为0.8
时- 分别需要
2.9
和5.0
次
- 分别需要
- 因此对于
较高
的填装因子,对比线性探测,二次探测和再哈希法还是可以忍受的
- 当
5.5.3 链地址法效率
- 假如哈希表包含
arraySize
个数据项, 每个数据项有一个链表, 在表中一共包含N
个数据项 - 平均起来每个链表有多少个数据项呢?
N / arraySize
- 这个公式其实就是
装填因子
- 求出查找成功和不成功的次数
成功
可能只需要查找链表的一半
即可:1 + loadFactor/2
不成功
可能需要将整个链表查询完才知道不成功:1 + loadFactor
图解算法效率
5.6 效率的结论
- 链地址法相对来说效率是好于开放地址法的
- 在真实开发中, 使用链地址法的情况较多
- 因为它不会因为添加了某元素后性能急剧下降
- 比如在
Java的HashMap
中使用的就是链地址法
5.7 哈希表实现
5.7.1 哈希函数
-
快速的计算
- 哈希函数应该尽可能让计算的过程变得
简单
, 应该可以快速计算出结果
哈希表的主要优点
速度
- 提高速度的办法
尽量少的有乘法和除法
乘除的性能是比较低
- 前面计算哈希值的时候使用的方式
cats = 3*27³+1*27²+20*27+19= 60337
- 这种方式是
直观的计算结果
, 可能不止4项, 可能有更多项 - 这个表达式其实是一个多项式:
a(n)xn+a(n-1)x(n-1)+…+a(1)x+a(0)
- 现在问题就变成了多项式有多少次乘法和加法:
- 乘法次数:
n+(n-1)+…+1=n(n+1)/2
- 加法次数:
n次
- 乘法次数:
- 多项式的优化:
霍纳法则
- 通过如下变换得到一种算法
Pn(x)= anx n+a(n-1)x(n-1)+…+a1x+a0=((…(((anx +an-1)x+an-2)x+ an-3)…)x+a1)x+a0
- 变换后
- 乘法次数:
N次
- 加法次数:
N次
- 乘法次数:
- 如果使用大O表示时间复杂度的话, 直接从O(N²)降到了O(N)
- 通过如下变换得到一种算法
- 哈希函数应该尽可能让计算的过程变得
-
均匀分布
均匀的分布
- 在设计哈希表时, 有办法处理映射到相同下标值的情况
链地址法
开放地址法
- 为了提供效率, 最好的情况还是让数据在哈希表中均匀分布
- 因此, 需要在使用常量的地方, 尽量使用质数
- 在设计哈希表时, 有办法处理映射到相同下标值的情况
-
质数的使用
哈希表的长度
N次幂的底数
-
哈希表的长度使用质数
再哈希法中质数的重要性
- 假设表的容量
不是质数
, 例如: 表长为15(下标值0~14) - 有一个特定关键字
映射到0, 步长为5
探测序列是多少呢? - 0 - 5 - 10 - 0 - 5 - 10,
依次类推, 循环下去
- 算法只尝试着三个单元, 如果这三个单元已经有了数据, 那么会
一直循环
下去, 直到程序崩溃 - 如果容量
是一个质数
, 比如13. 探测序列是多少呢? - 0 - 5 - 10 - 2 - 7 - 12 - 4 - 9 - 1 - 6 - 11 - 3, 一直这样下去.
- 不仅
不会产生循环
, 而且可以让数据在哈希表中更加均匀
的分布
- 假设表的容量
- 链地址法中质数
没有那么重要
- Java中的哈希表采用的是
链地址法
HashMap
的初始长度是16, 每次自动扩展
, 长度必须是2的次幂
- 这是为了服务于
从Key映射到index
的算法 HashMap
中为了提高效率
, 采用了位运算
的方式- HashMap中index的计算公式:
index = HashCode(Key) & (Length - 1)
- 比如计算book的
hashcode
,结果为十进制的3029737
,二进制的101110001110101110 1001
- 假定HashMap长度是默认的16,计算Length-1的结果为十进制的15,二进制的1111
- 把以上两个结果做
与运算
,101110001110101110 1001 & 1111 = 1001
,十进制是9
,所以index=9
- 这样的方式相对于取模来说性能是高的, 因为计算机更快运算二进制的数据
- HashMap中index的计算公式:
- 但是JavaScript中进行
较大数据的位运算
时会出问题, 所以代码实现中还是使用了取模
- Java中的哈希表采用的是
N次幂的底数, 使用质数
- 采用质数的原因是
为了产生的数据不按照某种规律递增
- 比如有一组数据是
按照4进行递增
: 0 4 8 12 16, 将其映射到程度为8的哈希表中 - 它们的位置是 0 - 4 - 0 - 4, 依次类推
- 如果哈希表
本身不是质数
, 而递增的数量可以使用质数
, 比如5, 那么 0 5 10 15 20 - 它们的位置是 0 - 5 - 2 - 7 - 4, 依次类推. 也可以尽量让数据均匀的分布.
- 采用质数的原因是
5.7.2 哈希函数的实现
代码实现
// 设计哈希函数
// 1. 将字符串转成比较大的数字: hashCode
// 2. 将大的数字 hashCode 压缩到数组范围(大小)之内
function hashFunc(str, size) {
// 1. 定义 hashCode
var hashCode = 0
// 2. 霍纳算法,来计算 hashCode 的值
// cats -> Unicode编码
for (var i = 0; i < str.length; i++) {
hashCode = 37 * hashCode + str.charCodeAt(i)
}
// 取余操作
var index = hashCode % size
return index
}
// 测试哈希函数
alert(hashFunc('abc', 7)) // 4
alert(hashFunc('cba', 7)) // 3
alert(hashFunc('nba', 7)) // 5
alert(hashFunc('mba', 7)) // 1
5.7.3 创建哈希表
- 这里采用
链地址法
来实现哈希表- 实现的哈希表(
基于storage的数组
)每个index
对应的是一个数组(bucket
) bucket
中存放key
和value
, 继续使用一个数组
- 最终的哈希表的数据格式是这样:
[[ [k,v], [k,v], [k,v] ] , [ [k,v], [k,v] ], [ [k,v] ] ]
- 实现的哈希表(
创建哈希表
- 先创建一个哈希表的类:
HashTable
- 先创建一个哈希表的类:
// 封装哈希表类
function HashTable() {
// 属性
this.storage = []
this.count = 0
this.limit = 7
// 方法
// 哈希函数
HashTable.prototype.hashFunc = function (str, size) {
// 1. 定义 hashCode 变量
var hashCode = 0
// 2. 霍纳算法,来计算 hashCode 的值
// cats -> Unicode 编码
for (var i = 0; i < str.length; i++) {
hashCode = 37 * hashCode + str.charCodeAt(i)
}
// 3. 取模操作
var index = hashCode % size
return index
}
}
代码解析
- 定义了三个属性:
storage
作为数组
, 数组中存放相关的元素count
表示当前已经存在了多少数据
limit
用于标记数组中一共可以存放多少个元素
- 另外, 直接将哈希函数定义在了
HashTable
中
- 定义了三个属性:
5.7.4 插入&修改数据
HashTable.prototype.put = function (key, value) {
// 1. 根据 key 获取对应的 index
var index = this.hashFunc(key, this.limit)
// 2. 根据 index 取出对应的 bucket
var bucket = this.storage[index]
// 3. 判断该 bucket 是否为 null
if (bucket == null) {
bucket = []
this.storage[index] = bucket
}
// 4. 判断是否是修改数据
for (var i = 0; i < bucket.length; i++) {
var tuple = bucket[i]
if (tuple[0] == key) {
tuple[1] == value
return
}
}
// 5. 进行添加操作
bucket.push([key, value])
this.count += 1
}
代码解析
- 根据传入的
key
获取对应的hashCode
, 也就是数组的index
- 从哈希表的
index
位置中取出另外一个数组 - 查看上一步的
bucket
是否为null
- 为
null
, 表示之前在该位置没有放置过任何的内容, 那么就新建一个数组[]
- 为
- 查看是否之前已经放置过
key
对应的value
- 如果放置过, 那么就是
依次替换
操作, 而不是插入新的数据 - 使用一个
变量override
来记录是否是修改操作
- 如果放置过, 那么就是
- 如果不是修改操作, 那么
插入新的数据
- 在
bucket
中push
新的[key, value]
即可 - 注意: 这里需要将
count+1
, 因为数据增加了一项
- 在
- 根据传入的
5.7.5 获取数据
- 有插入和修改数据, 就应该有
根据key获取value
HashTable.prototype.get = function (key) {
// 1. 根据 key 获取对应的 index
var index = this.hashFunc(key, this.limit)
// 2. 根据 index 获取对应的 bucket
var bucket = this.storage[index]
// 3. 判断 bucket 是否为 null
if (bucket == null) {
return null
}
// 4. 有 bucket,那么就进行线性查找
for (var i = 0; i < bucket.length; i++) {
var tuple = bucket[i]
if (tuple[0] === key) {
return tuple[1]
}
}
// 5. 依然没有找到,那么返回 null
return null
}
代码解析
- 根据
key
获取hashCode
(也就是index
) - 根据
index
取出bucket
- 因为如果
bucket
都是null
, 那么说明这个位置之前并没有插入过数据
- 有了
bucket
, 就遍历
, 并且如果找到, 就将对应的value
返回即可 - 没有找到, 返回
null
- 根据
5.7.6 删除数据
- 根据对应的
key
, 删除对应的key/value
HashTable.prototype.remove = function (key) {
// 1. 根据 key 获取对应的 index
var index = this.hashFunc(key, this.limit)
// 2. 根据 index 获取对应的 bucket
var bucket = this.storage[index]
// 3. 判断 bucket 是否为 null
if (bucket == null) return null
// 4. 有 bucket,那么就进行线性查找,并且删除
for (var i = 0; i < bucket.length; i++) {
var tuple = bucket[i]
if (tuple[0] == key) {
bucket.splice(i, 1)
this.count--
return tuple[1]
}
}
// 5. 依然没有找到,那么返回 null
return null
}
代码思路
- 根据
key
获取对应的index
- 根据
index
获取bucket
- 判断
bucket
是否存在,如果不存在,那么直接返回null
- 线性查找
bucket
,寻找对应的数据,并且删除 - 依然没有找到,那么返回
null
- 根据
5.8 哈希表扩容
5.8.1 哈希表扩容的思想
-
为什么需要扩容
- 目前所有的数据项放在长度为8的数组中的
- 因为使用的是
链地址法
,loadFactor
可以大于1, 所以这个哈希表可以无限制
的插入新数据 - 但是, 随着
数据量的增多
, 每一个index
对应的bucket
会越来越长, 也就造成效率的降低
- 所以, 在合适的情况对数组进行
扩容
. 比如扩容两倍
-
如何进行扩容
- 扩容可以简单的
将容量增加大两倍
- 但是这种情况下, 所有的数据项一定要
同时进行修改
(重新哈希化, 来获取到不同的位置) - 比如hashCode=12的数据项, 在length=8的时候, index=4. 在长度为16的时候呢? index=12
- 这是一个
耗时
的过程, 但是如果数组需要扩容, 那么这个过程是必要的
- 扩容可以简单的
-
什么情况下扩容呢
- 比较常见的情况是
loadFactor>0.75
的时候进行扩容 - 比如Java的哈希表就是在装填因子大于0.75的时候, 对哈希表进行扩容
- 比较常见的情况是
5.8.2 哈希表扩容的实现
// 哈希表扩容/缩容
HashTable.prototype.resize = function (newLimit) {
// 1. 保存旧的数组内存
var oldStorage = this.storage
// 2. 重置所有的属性
this.storage = []
this.count = 0
this.limit = newLimit
// 3. 遍历 oldStorage 中所有的 bucket
for (var i = 0; i < oldStorage.length; i++) {
// 3.1 取出对应的 bucket
var bucket = oldStorage[i]
// 3.2 判断 bucket 是否为 null
if (bucket == null) {
continue
}
// 3.3 bucket 中有数据,那么取出数据,重新插入
for (var i = 0; i < bucket.length; i++) {
var tuple = bucket[i]
this.put(tuple[0], tuple[i])
}
}
}
-
代码解析
- 先将之前数组保存起来, 因为会将
storeage = []
- 之前的属性值需要
重置
- 遍历所有的数据项,
重新插入
到哈希表中
- 先将之前数组保存起来, 因为会将
-
在什么时候
调用
扩容方法- 在每次添加完新的数据时, 都进行
判断
- 在每次添加完新的数据时, 都进行
-
修改put方法
HashTable.prototype.put = function (key, value) {
// 1. 根据 key 获取对应的 index
var index = this.hashFunc(key, this.limit)
// 2. 根据 index 取出对应的 bucket
var bucket = this.storage[index]
// 3. 判断该 bucket 是否为 null
if (bucket == null) {
bucket = []
this.storage[index] = bucket
}
// 4. 判断是否是修改数据
for (var i = 0; i < bucket.length; i++) {
var tuple = bucket[i]
if (tuple[0] == key) {
tuple[1] = value
return
}
}
// 5. 进行添加操作
bucket.push([key, value])
this.count += 1
// 6. 判断是否需要扩容操作
if (this.count > this.limit * 0.75) {
this.resize(this.limit * 2)
}
}
- 如果
不断的删除数据
- 当
loadFactor < 0.25
的时候, 最好将数量限制在一半
- 当
修改remove方法
HashTable.prototype.remove = function (key) {
// 1. 根据 key 获取对应的 index
var index = this.hashFunc(key, this.limit)
// 2. 根据 index 获取对应的 bucket
var bucket = this.storage[index]
// 3. 判断 bucket 是否为 null
if (bucket == null) return null
// 4. 有 bucket,那么就进行线性查找,并且删除
for (var i = 0; i < bucket.length; i++) {
var tuple = bucket[i]
if (tuple[0] == key) {
bucket.splice(i, 1)
this.count--
return tuple[1]
// 缩小容量
if (this.limit > 7 && this.count < this.limit * 0.25) {
this.resize(Math.floor(this.limit / 2))
}
}
}
5.9 容量质数
5.9.1 判断质数
质数的特点
- 质数也称为素数
- 质数表示大于1的自然数中, 只能被1和自己整除的数
function isPrime(num) {
for (var i = 2; i < num; i++) {
if (num % i == 0) {
return false
}
}
return true
}
// 测试
alert(isPrime(3)) // true
alert(isPrime(32)) // false
alert(isPrime(37)) // true
- 为什么
效率不高
- 对于每个数n,其实并不需要从2判断到n-1
- 一个数若可以进行因数分解,那么分解时得到的两个数一定是一个小于等于sqrt(n),一个大于等于sqrt(n)
- 比如16可以被分离. 那么是28, 2小于sqrt(16), 也就是4, 8大于4. 而44都是等于sqrt(n)
- 所以其实遍历到等于sqrt(n)即可
function isPrime(num) {
// 1.获取平方根
var temp = parseInt(Math.sqrt(num))
// 2.循环判断
for (var i = 2; i <= temp; i++) {
if (num % i == 0) {
return false
}
}
return true
}
5.9.2 扩容的质数
- 首先, 将初始的limit为8, 改成7
- 前面对容量进行扩展
- 方式是:
原来的容量 x 2
- 比如之前的容量是7, 那么扩容后就是14. 14不是质数
- 所以还需要一个方法, 来实现一个新的容量为质数的算法
- 方式是:
封装获取新的容量的代码(质数)
// 判断某个数字是否是质数
HashTable.prototype.isPrime = function (num) {
// 1. 获取 num 的平方根
var temp = parseInt(Math.sqrt(num))
// 2. 循环判断
for (var i = 2; i < temp; i++) {
if (num % i == 0) {
return false
}
}
return true
}
// 获取质数的方法
HashTable.prototype.getPrime = function (num) {
while (!this.isPrime(num)) {
num++
}
return num
}
修改插入数据的代码
// 6. 判断是否需要扩容操作
if (this.count > this.limit * 0.75) {
var newSize = this.limit * 2
var newPrime = this.getPrime(newSize)
this.resize(newPrime)
}
修改删除数据的代码
// 缩小容量
if (this.limit > 7 && this.count < this.limit * 0.25) {
var newSize = Math.floor(this.limit / 2)
var newPrime = this.getPrime(newSize)
this.resize(newPrime))
}
6. 树结构
6.1 树相关的概念
简介
- 树也是一种非常常用的数据结构, 特别是二叉树
- 二叉树是程序中一种非常重要的数据结构, 它的优势是前面介绍的数据结构所没有的
树结构图
树的定义
- 树(Tree): n(n≥0)个结点构成的有限集合。当n=0时,称为空树
- 对于任一棵非空树(n> 0),它具备以下性质:
- 树中有一个称为“根(Root)”的特殊结点,用 r 表示
- 其余结点可分为m(m>0)个互不相交的有限集T1,T2,… ,Tm,其中每个集合本身又是一棵树,称为原来树的“子树(SubTree)”
注意
:- 子树之间不可以相交
- 除了根结点外,每个结点有且仅有一个父结点
- 一棵N个结点的树有N-1条边
6.2 树与其他数据结构对比
-
数组
优点
- 根据下标值访问效率高
- 但是如果根据元素来查找对应的位置呢?
- 比较好的方式是先对数组进行排序, 再进行二分查找
缺点
- 需要先对数组进行排序, 生成有序数组, 才能提高查找效率
- 另外数组在插入和删除数据时, 需要有大量的位移操作(插入到首位或者中间位置的时候), 效率很低
-
链表
优点
- 链表的插入和删除操作效率都很高
缺点
- 查找效率很低, 需要从头开始依次访问链表中的每个数据项, 直到找到
- 而且即使插入和删除操作效率很高, 但是如果要插入和删除中间位置的数据, 还是需要重头先找到对应的数据
-
哈希表
优点
- 哈希表的插入/查询/删除效率都是非常高
缺点
- 空间利用率不高, 底层使用的是数组, 并且某些单元是没有被利用的
- 哈希表中的元素是无序的, 不能按照固定的顺序来遍历哈希表中的元素
- 不能快速的找出哈希表中的最大值或者最小值这些特殊的值
-
树结构
- 树综合了上面的数据结构的优点(当然优点不足于盖过其他数据结构, 比如效率一般情况下没有哈希表高), 并且也弥补了上面数据结构的缺点
- 而且为了模拟某些场景, 使用树结构会更加方便. 比如文件的目录结构
6.3 树的术语
树的术语
结点的度(Degree)
:结点的子树个数树的度
:树的所有结点中最大的度数. (树的度通常为结点的个数N-1)叶结点(Leaf)
:度为0的结点. (也称为叶子结点)父结点(Parent)
:有子树的结点是其子树的根结点的父结点子结点(Child)
:若A结点是B结点的父结点,则称B结点是A结点的子结点;子结点也称孩子结点兄弟结点(Sibling)
:具有同一父结点的各结点彼此是兄弟结点路径和路径长度
:从结点n1到nk的路径为一个结点序列n1 , n2,… , nk, ni是 ni+1的父结点。路径所包含边的个数为路径的长度结点的层次(Level)
:规定根结点在1层,其它任一结点的层数是其父结点的层数加1树的深度(Depth)
:树中所有结点中的最大层次是这棵树的深度
6.4 树的表示
-
树可以有多种表示的方式
-
所有的树本质上都可以使用
二叉树
模拟出来 -
所以在学习树的过程中, 二叉树非常重要
-
普通
:
-
儿子-兄弟表示法
-
儿子-兄弟表示法旋转
6.5 二叉树
- 如果树中每个节点
最多只能有两个子节点
, 这样的树就成为"二叉树
"
6.5.1 二叉树的定义
二叉树的定义
- 二叉树可以为空, 也就是没有结点
- 若不为空,则它是由根结点和称为其左子树TL和右子树TR的两个不相交的二叉树组成
二叉树的五种形态
- 注意c和d是不同的二叉树, 因为二叉树是有左右之分的
- 注意c和d是不同的二叉树, 因为二叉树是有左右之分的
6.5.2 二叉树的特性
- 二叉树有几个比较重要的特性, 在笔试题中比较常见:
- 一个二叉树第 i 层的最大结点数为:
2^(i-1), i >= 1
; - 深度为k的二叉树有最大结点总数为:
2^k - 1, k >= 1
; - 对任何非空二叉树 T,若n0表示叶结点的个数、n2是度为2的非叶结点个数,那么两者满足关系
n0 = n2 + 1
- 一个二叉树第 i 层的最大结点数为:
6.5.3 特殊二叉树
完美二叉树(Perfect Binary Tree)
, 也称为满二叉树(Full Binary Tree)
- 在二叉树中, 除了最下一层的叶结点外, 每层节点都有2个子结点, 就构成了满二叉树
- 在二叉树中, 除了最下一层的叶结点外, 每层节点都有2个子结点, 就构成了满二叉树
完全二叉树(Complete Binary Tree)
- 除二叉树最后一层外, 其他各层的节点数都达到最大个数
- 且最后一层从左向右的叶结点连续存在, 只缺右侧若干节点
- 完美二叉树是特殊的完全二叉树
- 下面不是完全二叉树, 因为D节点还没有右结点, 但是E节点就有了左右节点
6.5.4 二叉树的存储
-
二叉树的存储常见的方式是
数组
和链表
-
数组存储
- 完全二叉树: 按从上至下、从左到右顺序存储
- 完全二叉树: 按从上至下、从左到右顺序存储
-
非完全二叉树
- 非完全二叉树要转成完全二叉树才可以按照上面的方案存储
- 但是会造成很大的空间浪费
-
链表存储
- 二叉树最常见的方式还是使用链表存储.
- 每个结点封装成一个Node, Node中包含存储的数据, 左结点的引用, 右结点的引用
6.6 二叉搜索树
6.6.1 二叉搜索树的概念
什么是二叉搜索树?
- 二叉搜索树(
BST,Binary Search Tree
),也称二叉排序树或二叉查找树 - 二叉搜索树是一颗二叉树, 可以为空;如果不为空,满足以下性质:
- 非空左子树的所有键值小于其根结点的键值
- 非空右子树的所有键值大于其根结点的键值
- 左、右子树本身也都是二叉搜索树
- 下面哪些是二叉搜索树, 哪些不是?
- 二叉搜索树(
二叉搜索树的特点
- 二叉搜索树的特点就是相对较小的值总是保存在左结点上, 相对较大的值总是保存在右结点上
- 利用这个特点, 查找效率非常高, 这也是二叉搜索树中, 搜索的来源
6.6.2 二叉搜索树的操作
- 二叉搜索树常见操作
insert(key)
:向树中插入一个新的键search(key)
:在树中查找一个键,如果结点存在,则返回true;如果不存在,则返回falseinOrderTraverse
:通过中序遍历方式遍历所有结点preOrderTraverse
:通过先序遍历方式遍历所有结点postOrderTraverse
:通过后序遍历方式遍历所有结点min
:返回树中最小的值/键max
:返回树中最大的值/键remove(key)
:从树中移除某个键
6.7 二叉搜索树的实现
6.7.1 创建二叉搜索树
-
代码解析
- 封装
BinarySerachTree
的构造函数 - 还需要封装一个用于保存每一个结点的类Node
- 该类包含三个属性: 结点对应的key, 指向的左子树, 指向的右子树
- 对于BinarySearchTree来说, 只需要保存根结点即可, 因为其他结点都可以通过根结点找到
- 封装
-
代码实现
// 创建 BinarySerachTree
function BinarySerachTree() {
// 创建结点构造函数
function Node(key) {
this.key = key
this.left = null
this.right = null
}
// 保存根的属性
this.root = null
// 二叉搜索树相关的操作方法
}
6.7.2 插入数据
外界调用的 insert 方法
// 插入数据
BinarySerachTree.prototype.insert = function (key) {
// 1. 根据 key 创建对应的 node
var newNode = new Node(key)
// 2. 判断根结点是否有值
if(this.root === null) {
this.root = newNode
} else {
this.insertNode(this.root, newNode)
}
}
-
代码解析
- 首先, 根据传入的key, 创建对应的Node
- 其次, 向树中插入数据需要分成两种情况:
- 第一次插入, 直接修改根结点即可
- 其他次插入, 需要进行相关的比较决定插入的位置
-
插入非根结点
BinarySerachTree.prototype.insertNode = function (node, newNode) {
if (newNode.key < node.key) { // 1. 准备向左子树插入数据
if (node.left === null) { // 1.1 node 的左子树上没有内容
node.left = newNode // 数据插入的结点位置
} else { // 1.2 node 的左子树上已经有了内容
this.insertNode(node.left, newNode) // 递归调用
}
} else { // 2. 准备向右子树插入数据
if (node.right === null) { // 2.1 node 的右子树上没有内容
node.right = newNode // 数据插入的结点位置
} else { // 2.2 node 的右子树上有内容
this.insertNode(node.right, newNode) // 递归调用
}
}
}
代码解析
- 插入其他节点时, 需要判断该值到底是插入到左边还是插入到右边
- 判断的依据来自于新节点的key和原来节点的key值的比较
- 如果新节点的newKey小于原节点的oldKey, 那么就向左边插入
- 如果新节点的newKey大于原节点的oldKey, 那么就向右边插入
- 向左子树插入数据,本身又分成两种情况
- 情况一: 左子树上原来没有内容, 那么直接插入即可
- 情况二: 左子树上已经有了内容, 那么就一次向下继续查找新的走向, 所以使用递归调用即可
- 向右子树插入数据,也分两种情况
- 情况一: 左右树上原来没有内容, 那么直接插入即可
- 情况二: 右子树上已经有了内容, 那么就一次向下继续查找新的走向, 所以使用递归调用即可
6.8 遍历二叉搜索树
树的遍历
- 遍历一棵树是指访问树的每个结点(也可以对每个结点进行某些操作)
- 但是树和线性结构不太一样, 线性结构通常按照从前到后的顺序遍历, 但是树呢?
- 应该从树的顶端还是底端开始呢? 从左开始还是从右开始呢?
- 二叉树的遍历常见的有三种方式:
先序遍历/中序遍历/后序遍历
. (还有层序遍历, 使用较少, 可以使用队列来完成)
6.8.1 先序遍历
遍历过程
- 访问根结点
- 先序遍历其左子树
- 先序遍历其右子树
代码实现
BinarySerachTree.prototype.preOrderTraversal = function (handler) {
this.preOrderTranversalNode(this.root, handler)
}
BinarySerachTree.prototype.preOrderTranversalNode = function (node, handler) {
if (node !== null) {
// 1. 打印当前经过的节点
handler(node.key)
// 2. 遍历所有的左子树
this.preOrderTranversalNode(node.left, handler)
// 3. 遍历所有的右子树
this.preOrderTranversalNode(node.right, handler)
}
}
// 测试代码
var resulting = ""
bst.preOrderTraversal(function (key) {
resulting += key + ""
})
alert(resulting)
-
代码解析
- 遍历树最好用的办法就是递归, 因为每个节点都可能有自己的子节点, 所以递归调用是最好的方式
- 在先序遍历中, 在经过节点的时候, 会先将该节点打印出来
- 然后, 遍历节点的左子树, 再然后遍历节点的右子树
-
先序遍历图解
6.8.2 中序遍历
遍历过程
- 中序遍历其左子树
- 访问根结点
- 中序遍历其右子树
代码实现
// 中序遍历
BinarySerachTree.prototype.midOrderTraversal = function (handler) {
this.midOrderTraversalNode(this.root, handler)
}
BinarySerachTree.prototype.midOrderTraversalNode = function (node, handler) {
if (node != null) {
// 1. 处理左子树中的节点
this.midOrderTraversalNode(node.left, handler)
// 2. 处理节点
handler(node.key)
// 3. 处理右子树中的节点
this.midOrderTraversalNode(node.right, handler)
}
}
-
代码解析
- 先从最左边开始, 进行中序遍历
- 依次向右移动, 最后遍历最右边
-
中序遍历图解
6.8.3 后序遍历
- 遍历过程
- 后序遍历其左子树
- 后序遍历其右子树
- 访问根结点
代码实现
// 后序遍历
BinarySerachTree.prototype.postOrderTraversal = function (handler) {
this.postOrderTraversalNode(this.root, handler)
}
BinarySerachTree.prototype.postOrderTraversalNode = function (node, handler) {
if (node != null) {
// 1. 查看左子树中的节点
this.postOrderTraversalNode(node.left, handler)
// 2. 查找右子树中节点
this.postOrderTraversalNode(node.right, handler)
// 3. 处理节点
handler(node.key)
}
}
-
代码解析
- 先遍历左子树上的节点
- 再遍历右子树上的节点
- 最后遍历根节点
-
后序遍历的图解
6.9 特殊的值
6.9.1 最大值&最小值
代码实现
// 获取最值
// 最大值
BinarySerachTree.prototype.max = function () {
// 1. 获取根节点
var node = this.root
// 2. 依次向右不断的查找,直到节点为null
var key = null
while (node !== null) {
key = node.key
node = node.right
}
return key
}
// 最小值
BinarySerachTree.prototype.min = function () {
// 1. 获取根节点
var node = this.root
// 2. 依次向左不断的查找,直到节点为null
var key = null
while (node != null) {
key = node.key
node = node.left
}
return key
}
代码解析
- 代码依次向左找到最左边的结点就是最小值
- 代码依次向右找到最右边的结点就是最大值
6.9.2 搜索特定的值
- 二叉搜索树不仅仅获取最值效率非常高, 搜索特定的值效率也非常高
非递归代码实现
// 3. 搜索某一个 key
BinarySerachTree.prototype.search = function (key) {
// 1. 获取根节点
var node = this.root
// 2. 循环搜索 key
while (node != null) {
if (key < node.key) {
node = node.left
} else if (key > node.key) {
node = node.right
} else {
return true
}
}
return false
}
递归代码实现
// 搜索特定的值
// 递归方法
BinarySerachTree.prototype.search = function (key) {
return this.searchNode(this.root, key)
}
BinaryserachTree.prptotype.searchNode = function (node, key) {
// 1. 如果传入的 node 为 null,那么就退出递归
if (node === null) {
return false
}
// 2. 判断 node 节点的值和传入的 key 大小
if (node.key > key) { // 2.1 传入的 key 较小,向左边继续查找
return this.searchNode(node.left, key)
} else if (node.key < key) { // 2.2 传入的 key 较大,向右边继续查找
return this.searchNode(node.right, key)
} else { // 2.3 相同,说明找到了 key
return true
}
}
代码解析
- 使用递归的方式
- 递归必须有
退出条件
, 这里是两种情况下退出node === null
, 也就是后面不再有节点的时候- 找到对应的key, 也就是
node.key === key
的时候
- 在其他情况下, 根据node.的key和传入的key进行比较来决定向左还是向右查找
- 如果
node.key > key
, 那么说明传入的值更小, 需要向左查找 - 如果
node.key < key
, 那么说明传入的值更大, 需要向右查找
- 如果
递归or循环?
- 其实递归和循环之间可以相互转换
- 大多数情况下, 递归调用可以
简化代码
, 但是也会增加空间的复杂度
- 循环空间复杂度较低, 但是代码会相对复杂
- 可以根据实际的情况自行选择, 不需要套死必须使用某种方式
6.10 二叉搜索树的删除
6.10.1 删除节点的思路
- 删除节点要从查找要删的节点开始, 找到节点后, 需要考虑
三种情况
:- 该节点是
叶结点
(没有字节点, 比较简单) - 该节点有
一个子节点
(也相对简单) - 该节点有
两个子节点
.(情况比较复杂)
- 该节点是
先从查找要删除的节点
// 二叉树的删除
BinarySerachTree.prototype.remove = function (key) {
// 寻找要删除的节点
// 1. 定义变量,保存一些信息
var current = this.root
var parent = null
var isLeftChild = true
// 2. 开始寻找删除的节点
while (current.key != key) {
parent = current
if (key < current.key) {
isLeftChild = true
current = current.left
} else {
isLeftChild = false
current = current.right
}
// 某种情况: 已经找到了最后的节点,依然没有找到 ==key
if (current == null) return false
}
// 2. 根据对应的情况删除节点
}
代码解析
-
先保存了一些临时变量
current
: 用于一会儿找到的要删除的节点对应的nodeparent
: 用于保存current节点的父节点. 因为如果current有子节点, 那么在删除current节点的时候, 必然需要将parent的left或者right指向它的某一个子节点. 所以需要保存起来current的parent. (树中的节点关系不能向上的, 和链表非常相似)isLeftChild
: boolean类型,它用户记录我们是在current是在父节点的左侧还是右侧, 以便到时候设置parent的left或者right
-
之后开始查找对应的
key
- 依次向下找到节点, 同时记录
current/parent/isLeftChild
这些变量 - 如果遍历到
current === null
, 那么说明在二叉搜索树中没有该key, 直接返回false即可 - 如果找到, 后面就需要进一步考虑更加复杂的情况
- 依次向下找到节点, 同时记录
-
6.10.2 没有子节点
情况一: 没有子节点
- 这种情况相对比较简单, 需要检测current的left以及right是否都为null
- 都为null之后还要检测current是否就是根(都为null,并且为根, 那么相当于要清空二叉树)
- 否则就把父节点的left或者right字段设置为null即可
代码实现
// 情况一
if (current.left == null && current.right == null) {
if (current == this.root) {
this.root = null
} else if (isLeftChild) {
parent.left = null
} else {
parent.right = null
}
}
代码解析
- 首先, 判断是否是叶结点. 通过current的left&right是否为null
- 如果是叶节点, 再判断current是否是根结点: 回答是, 就将this.root = null即可
- 如果不是根, 再判断是左结点, 还是右结点, 以便于将parent的left或者right设置为null
图解
- 如果只有一个单独的根,直接删除即可
- 如果是叶节点,处理如下
- 如果是3,8,10,12,14,18,25中任何一个叶子节点(current)
- 那么直接将parent(current的父节点)指向该引用的left或者right
- 设置为null即可
6.10.3 一个子节点
情况二: 有一个子节点
- 要删除的current结点, 只有2个连接(如果有两个子结点, 就是三个连接了), 一个连接父节点, 一个连接唯一的子节点
- 需要从这三者之间: 爷爷 - 自己 - 儿子, 将自己(current)剪短, 让爷爷直接连接儿子即可
- 这个过程要求改变父节点的left或者right, 指向要删除节点的子节点
- 在这个过程中还要考虑是否current就是根
代码实现
// 删除有一个子节点的节点
else if (current.right == null) {
if (current == this.root) {
this.root = current.left
} else if (isLeftChild) {
parent.left = current.left
} else {
parent.right = current.left
}
} else if (current.left == null) {
if (current == this.root) {
this.root = current.right
} else if (isLeftChild) {
parent.left = current.right
} else {
parent.right = current.right
}
}
代码解析
- 首先, 需要判断是current的left还是right为null. 因为这样才能决定, 只有从current中取儿子的时候, 取的是current.left还是current.right来给别的地方赋值
- 三种情况:
- current是根节点, 那么直接将this.root = son
- current不是根节点, 是父节点的left节点, 那么parent.left = son
- current不是根节点, 是父节点的right节点, 那么parent.right = son
图解
- 如果是根的情况,直接删除
- 如果不是根,并且只有一个子节点的情况
- 假设要删除的是节点5,而5只有一个子节点
- 其实无所谓自己的儿子节点(3节点)有没有子节点,直接将该节点移动到原来5的位置即可
- 也就是让parent节点(7)节点,直接指向3节点
6.10.4 两个子节点
-
情况三: 两个子节点
-
问题
情况一: 删除9节点
- 处理方式相对简单,将8位置替换到9,或者将10位置替换到9
- 注意: 这里是替换,也就是8位置替换到9时,7指向8,而8还需要指向10
情况二: 删除7节点
- 一种方式是将5拿到7的位置,3依然指向5,但是5有一个right需要指向9,依然是二叉搜索树,没问题
- 另一种方式是在右侧找一个,8
- 也就是将8替换到7的位置,8的left指向5,right指向9,依然是二叉搜索树,没问题
情况三: 删除15节点,并且也在右边找
- 18替换15的位置,20的left指向19,也是一个二叉搜索树,没问题
-
删除规律
- 如果要删除的节点有两个子节点, 甚至子节点还有子节点, 这种情况下需要从下面的子节点中找到一个节点, 来替换当前的节点
- 这个节点有什么特征呢?
- 应该是current节点下面所有节点中最接近current节点的
- 要么比current节点小一点点, 要么比current节点大一点点
- 最接近current, 就可以用来替换current的位置
- 如何找这个节点
- 比current小一点点的节点, 一定是current左子树的最大值
- 比current大一点点的节点, 一定是current右子树的最小值
- 前驱&后继
- 比current小一点点的节点, 称为current节点的前驱
- 比current大一点点的节点, 称为current节点的后继
- 也就是为了能够删除有两个子节点的current, 要么找到它的前驱, 要么找到它的后继
-
寻找后继代码实现
// 找后继的方法
BinarySerachTree.prototype.getSuccessor = function (delNode) {
// 1. 定义变量,保存找到的后继
var successor = delNode
var current = delNode.right
var successorParent = delNode
// 2. 循环查找
while (current != null) {
successorParent = successor
successor = current
current = current.left
}
// 3. 判断寻找的后继节点是否直接就是 delNode 的 right 节点
if (successor != delNode.right) {
successorParent.left = successor.right
successor.right = delNode.right
}
return successor
}
找到后继后的处理代码
// 2.3 删除的节点有两个子节点
else {
// 1. 获取后继节点
var successor = this.getSuccessor(current)
// 2. 判断是否是根节点
if (current == this.root) {
this.root = successor
} else if (isLeftChild) {
parent.left = successor
} else {
parent.right = successor
}
// 3. 将删除节点的左子树 = current.left
successor.left = current.left
}
代码解析
- 根据传入的 delNode 来寻找后继节点
- 判断三种情况
情况一: 是根节点
,那么this.root = successor
. 并且 successor 的 left 应该等于 current 的 left情况二: 是父节点的左节点
,parent.left = successor
,并且 successor 的 left 应该等于 current 的 left情况三: 是父节点的右节点
,parent.right = successor
,并且 successor 的 left 应该等于 current 的 left
- 将
successor.left = current.left
从判断中抽取出来 如何删除15?
- 已完成: 11 的 left 指向 18,18 的 right 指向 13
- 没完成: 19 ? 20这个左子树 ?
- 19 放在 20 的左边代码:
successorParent.left = successor.right
- 20 放在 18 的右边代码:
successor.right = delNode.right
6.10.5 删除节点代码
删除节点完整代码
// 二叉树的删除
BinarySerachTree.prototype.remove = function (key) {
// 1. 寻找要删除的节点
// 1.1 定义变量,保存一些信息
var current = this.root
var parent = null
var isLeftChild = true
// 1.2 开始寻找删除的节点
while (current.key !== key) {
parent = current
if (key < current.key) {
isLeftChild = true
current = current.left
} else {
isLeftChild = false
current = current.right
}
// 某种情况: 已经找到了最后的节点,依然没有找到 ==key
if (current === null) return false
}
// 2. 根据对应的情况删除节点
// 找到了 current.key == key
// 2.1 删除的节点是叶子节点(没有子节点)
if (current.left === null && current.right === null) {
if (current == this.root) {
this.root = null
} else if (isLeftChild) {
parent.left = null
} else {
parent.right = null
}
}
// 2.2 删除的节点有一个子节点
else if (current.right === null) {
if (current == this.root) {
this.root = current.left
} else if (isLeftChild) {
parent.left = current.left
} else {
parent.right = current.left
}
} else if (current.left === null) {
if (current == this.root) {
this.root = current.right
} else if (isLeftChild) {
parent.left = current.right
} else {
parent.right = current.right
}
}
// 2.3 删除的节点有两个子节点
else {
// 1. 获取后继节点
var successor = this.getSuccessor(current)
// 2. 判断是否是根节点
if (current == this.root) {
this.root = successor
} else if (isLeftChild) {
parent.left = successor
} else {
parent.right = successor
}
// 3. 将删除节点的左子树 = current.left
successor.left = current.left
}
return true
}
// 找后继的方法
BinarySerachTree.prototype.getSuccessor = function (delNode) {
// 1. 定义变量,保存找到的后继
var successor = delNode
var current = delNode.right
var successorParent = delNode
// 2. 循环查找
while (current != null) {
successorParent = successor
successor = current
current = current.left
}
// 3. 判断寻找的后继节点是否直接就是 delNode 的 right 节点
if (successor != delNode.right) {
successorParent.left = successor.right
successor.right = delNode.right
}
return successor
}
6.10.6 避开节点删除操作
如何避开节点删除操作
- 在Node类中添加一个boolean的字段, 比如名称为isDeleted
- 要删除一个节点时, 就将此字段设置为true
- 其他操作, 比如find()在查找之前先判断这个节点是不是标记为删除
- 这样相对比较简单, 每次删除节点不会改变原有的树结构
操作缺陷
- 在二叉树的存储中, 还保留着那些本该已经被删除掉的节点
- 这样会造成很大空间的浪费, 特别是针对数据量较大的情况
6.11 二叉搜索树的完整代码
// 封装二叉搜索树
function BinarySerachTree() {
function Node(key) {
this.key = key
this.left = null
this.right = null
}
// 属性
this.root = null
// 方法
// 插入数据: 对外给用户调用的方法
BinarySerachTree.prototype.insert = function (key) {
// 1. 根据 key 创建节点
var newNode = new Node(key)
// 2. 判断根节点是否有值
if (this.root == null) {
this.root = newNode
} else {
this.insertNode(this.root, newNode)
}
}
BinarySerachTree.prototype.insertNode = function (node, newNode) {
if (newNode.key < node.key) {
if (node.left == null) {
node.left = newNode
} else {
this.insertNode(node.left, newNode)
}
} else {
if (node.right == null) {
node.right = newNode
} else {
this.insertNode(node.right, newNode)
}
}
}
// 树的遍历
// 1. 先序遍历
BinarySerachTree.prototype.preOrderTraversal = function (handler) {
this.preOrderTraversalNode(this.root, handler)
}
// 第一次: node -> 11
// 第二次: node -> 7
// 第三次: node -> 5
// 第四次: node -> 3
// 第四次: 3 -> right -> null -> 返回上层
// 第五次: node -> null
BinarySerachTree.prototype.preOrderTraversalNode = function (node, handler) {
if (node != null) {
// 1. 处理经过的节点
handler(node.key)
// 2. 处理经过节点的左子节点
this.preOrderTraversalNode(node.left, handler)
// 3. 处理经过节点的右子节点
this.preOrderTraversalNode(node.right, handler)
}
}
// 二叉树的删除
BinarySerachTree.prototype.remove = function (key) {
// 1. 寻找要删除的节点
// 1.1 定义变量,保存一些信息
var current = this.root
var parent = null
var isLeftChild = true
// 1.2 开始寻找删除的节点
while (current.key !== key) {
parent = current
if (key < current.key) {
isLeftChild = true
current = current.left
} else {
isLeftChild = false
current = current.right
}
// 某种情况: 已经找到了最后的节点,依然没有找到 ==key
if (current === null) return false
}
// 2. 根据对应的情况删除节点
// 找到了 current.key == key
// 2.1 删除的节点是叶子节点(没有子节点)
if (current.left === null && current.right === null) {
if (current == this.root) {
this.root = null
} else if (isLeftChild) {
parent.left = null
} else {
parent.right = null
}
}
// 2.2 删除的节点有一个子节点
else if (current.right === null) {
if (current == this.root) {
this.root = current.left
} else if (isLeftChild) {
parent.left = current.left
} else {
parent.right = current.left
}
} else if (current.left === null) {
if (current == this.root) {
this.root = current.right
} else if (isLeftChild) {
parent.left = current.right
} else {
parent.right = current.right
}
}
// 2.3 删除的节点有两个子节点
else {
// 1. 获取后继节点
var successor = this.getSuccessor(current)
// 2. 判断是否是根节点
if (current == this.root) {
this.root = successor
} else if (isLeftChild) {
parent.left = successor
} else {
parent.right = successor
}
// 3. 将删除节点的左子树 = current.left
successor.left = current.left
}
return true
}
// 找后继的方法
BinarySerachTree.prototype.getSuccessor = function (delNode) {
// 1. 定义变量,保存找到的后继
var successor = delNode
var current = delNode.right
var successorParent = delNode
// 2. 循环查找
while (current != null) {
successorParent = successor
successor = current
current = current.left
}
// 3. 判断寻找的后继节点是否直接就是 delNode 的 right 节点
if (successor != delNode.right) {
successorParent.left = successor.right
successor.right = delNode.right
}
return successor
}
}
7. 红黑树
7.1 二叉搜索树的缺陷
-
二叉搜索树的缺陷
- 二叉搜索树作为数据存储的结构有重要的
优势
- 可以
快速的
找到给定关键字的数据项,并且可以快速的插入和删除数据项
- 可以
- 但是,二叉搜索树有一个很麻烦的问题
- 如果插入的数据是
有序的数据
- 初始化 9 8 12 的二叉树,插入数据 7 6 5 4 3
- 如果插入的数据是
- 二叉搜索树作为数据存储的结构有重要的
-
非平衡树
- 比较好的二叉搜索树数据应该是
左右分布均匀
的 - 但是插入
连续数据
后,分布的不均匀
,这种树为非平衡树
- 对于一颗
平衡二叉树
来说,插入/查找等操作的效率是O(logN)
- 对于一颗
非平衡二叉树
,相当于编写了一个链表,查找效率变成了O(N)
- 比较好的二叉搜索树数据应该是
-
树的平衡性
- 为了能以
较快的时间O(logN)
来操作一颗树,需要保证树总是平衡
的- 至少大部分是平衡的,那么时间复杂度也是接近O(logN)的
- 也就是说树中
每个节点左边的子孙节点
的个数,应该尽可能的等于右边的子孙节点的个数
- 为了能以
-
常见的平衡树
AVL 树
- AVL 树是最早的一种平衡树,他有些办法保持
树的平衡
(每个节点多存储了一个额外的数据) - 因为 AVL 树是
平衡
的,所以时间复杂度也是 O(logN) - 但是,每次插入/删除操作相对于红黑树效率都不高,所以
整体效率不如红黑树
- AVL 树是最早的一种平衡树,他有些办法保持
红黑树
- 红黑树也通过
一些特性
来保持树的平衡 - 因为是平衡树,所以时间复杂度也是在 O(logN)
- 另外插入/删除等操作,红黑树的性能要优于 AVL 树,所以现在平衡树的应用基本都是红黑树
- 红黑树也通过
7.2 红黑树的规则
- 红黑树除了符合二叉搜索树的基本规则外,还添加了一些
特性
节点必须是红色或黑色
根节点是黑色
每个叶子节点都是黑色的空节点(NIL节点)
每个红色节点的两个子节点都是黑色(从每个叶子到根的所有路径上不能有两个连续的红色节点)
从任一节点到其每个叶子的所有路径都包含相同数目的黑色节点
7.3 红黑树的相对平衡
- 上面的规则,确保了红黑树的关键特性
- 从根到叶子的
最长可能路径
,不会超过最短可能路径的两倍长
- 结果就是这个树
基本
是平衡的 - 虽然没有做到绝对的平衡,但是可以保证在最坏的情况下,依然是高效的
- 从根到叶子的
- 如何做到最长路径不超过最短路径的两倍
性质4: 每个红色节点的两个子节点都是黑色(从每个叶子到根的所有路径上不能有两个连续的红色节点)
- 性质4 决定了路径不能有两个相连的红色节点
- 最短的可能路径都是黑色节点
- 最长的可能路径是红色和黑色交替
性质5: 从任一节点到其每个叶子的所有路径都包含相同数目的黑色节点
- 性质5 所有路径都有相同数目的黑色节点
- 这就表明了没有路径能多余任何其他路径的两倍长
7.4 红黑树的变色
- 插入一个新节点时,有可能树不再平衡,可以通过三种方式的变换,让树保持平衡
换色 - 左旋转 - 右旋转
变色
- 为了重新符合红黑树的规则,尝试把
红色
节点变为黑色
,或者把黑色
节点变为红色
- 为了重新符合红黑树的规则,尝试把
- 首先,需要知道插入的
新的节点
通常都是红色
节点- 因为在
插入节点为红色
的时候,有可能插入一次是不违反红黑树任何规则
的 - 而
插入黑色节点
,必然会导致有一条路径上多了黑色节点
,这是很难调整的 - 红色节点可能导致出现
红红相连
的情况,但是这种情况可以通过颜色调换和旋转
来调整
- 因为在
7.5 红黑树变换之旋转
-
左旋转
逆时针旋转
红黑树的两个节点,使得父节点被自己的右孩子取代,而自己成为自己的左孩子
- 图中,身为右孩子的Y取代了X的位置,而X变成了Y的左孩子。此为左旋转
-
右旋转
顺时针旋转
红黑树的两个节点,使得父节点被自己的左孩子取代,而自己成为自己的右孩子
- 图中,身为左孩子的Y取代了X的位置,而X变成了Y的右孩子
-
旋转过程中,节点的子树不会受到影响
7.6 红黑树的插入操作
- 讨论红黑树插入的情况
- 设要插入的
节点为N
,其父节点为P
- 其
祖父节点为G
,其父亲的兄弟节点为U
(即P和U是同一个节点的子节点)
- 设要插入的
7.6.1 红黑树变换情况一
具体情况
- 新节点N位于树的根上,没有父节点
变化情况
- 新节点N颜色变为黑色
- 新节点N颜色变为黑色
操作方案
- 直接将新节点红色变换成黑色即可,满足性质2
7.6.2 红黑树变换情况二
具体情况
- 父节点P为黑
变化情况
- 父节点P颜色不变
- 新节点N颜色不变
操作方案
- 新节点N是红色的,满足性质4和性质5
- 尽管新节点N有两个黑色的叶子节点Nil,但是新节点N是红色的,所以通过他的路径中黑色节点的个数依然相同,满足性质5
7.6.3 红黑树变换情况三
具体情况
- 父节点P为红色
- 叔节点U为红色
- 祖父节点G一定为黑
变化情况
- 父节点P颜色变为黑,位置不变
- 叔节点U颜色变为黑,位置不变
- 祖父节点G颜色变为红,位置不变
操作方案
- 将P和U变换为黑色,并且将G变换为红色
- 现在新节点N有了一个黑色的父节点P,所以每条路径上黑色节点的数目没有改变
- 而从更高的路径上,必然都会经过G节点,所以那些路径的黑色节点数目也是不变的,满足性质5
可能出现的问题
- N的祖父节点G的父节点也可能是红色,这就违反了性质3,可以递归方法调整颜色
- 但是如果递归方法调整颜色到了根节点,就需要进行旋转了。
7.6.4 红黑树变换情况四
具体情况
- 父节点P为红色
- 叔节点U为黑色
- 祖父节点G为黑色
- 新节点N为左孩子
变化情况
- 父节点P颜色变为黑色,位置由父节点变化为祖父节点
- 祖父节点G颜色变为红色,位置由祖父节点变化为新节点N的兄弟节点
- 叔节点U颜色不变,位置由叔节点变化为新节点N的兄弟节点的子节点
操作方案
- 对祖父节点G进行依次
右旋转
- 在旋转查收的树中,以前的父节点P现在是新节点N以前祖父节点G的父节点
- 交换以前的父节点P和祖父节点G的颜色(P变为黑色,G变为红色)
- B节点向右平移,称为G节点的左子节点
- 对祖父节点G进行依次
7.6.5 红黑树变换情况五
具体情况
- 父节点P为红色
- 叔节点U为黑色
- 祖父节点G为黑色
- 新节点N为右孩子
变化情况
- 父节点颜色不变,位置不变
- 叔节点颜色不变,位置变化
- 祖父节点颜色变化,位置变化
- 新节点颜色变化,位置变化
操作结果
- 以父节点P为根,进行左旋转
- 将父节点P当作新插入的红色节点考虑进行右旋转即可
- 新节点N变为黑色,右旋转变为根节点
- 原来的祖父节点G变为红色
- 以原来的祖父节点G为根,右旋转变为新节点N的左子节点
- 以父节点P为根,进行左旋转
7.7 红黑树的案例练习
依次插入 10 9 8 7 6 5 4 3 2 1
7.7.1 插入 10 9 8
插入 10
- 插入节点10作为根节点(情况1)
问题
- 此时节点10为红色
- 不符合规则2
变化
- 将节点10的颜色改为黑色
- 将节点10的颜色改为黑色
插入 9
- 直接插入节点10的左节点中(情况2)
问题
- 无
变化
- 不需要任何变化
- 不需要任何变化
插入 8
- 插入到节点9的左子树中(情况4)
问题
- 此时节点8与节点9同时为红色
- 不符合规则4
变化
变色
- 将节点9变为黑色
- 将节点10变为红色
- 此时根节点10为红色
- 不符合规则2
二次变化
右旋转
- 节点9变为根节点
- 节点10变为节点9的右子节点
- 节点8依然为节点9的左子节点
7.7.2 插入 7 6 5
插入 7
- 插入到节点8的左子树中(情况3)
问题
- 此时节点7与节点8同时为红色
- 不符合规则4
变化
变色
- 将节点8变为黑色
- 将节点9变为红色
- 将节点10变为黑色
- 此时根节点9为红色
- 不符合规则2
二次变化
变色
- 将节点9变为黑色
- 将节点9变为黑色
插入 6
- 插入到节点7的左子树中(情况4)
问题
- 此时节点6与节点7同时为红色
- 不符合规则4
变化
变色
- 将节点7变为黑色
- 将节点8变为红色
右旋转
- 将节点8作为根进行右旋转
- 节点7变为节点9的左子节点
- 节点8变为节点7的右子节点
- 节点6依然为节点7的左子节点
插入 5
- 插入到节点6的左子树中(情况3)
问题
- 此时节点5与节点6同时为红色
- 不符合规则4
变化
变色
- 节点6变为黑色
- 节点7变为红色
- 节点8变为黑色
7.7.3 插入 4 3 2 1
插入 4
- 插入到节点5的左子树中(情况4)
问题
- 此时节点4与节点5同时为红色
- 不符合规则4
变化
变色
- 节点5变为黑色
- 节点6变为红色
- 此时节点6与节点7同为红色
- 不符合规则4
二次变化
右旋转
- 将节点6作为根进行右旋转
- 节点5变为节点7的左子节点
- 节点6变为节点5的右子节点
- 节点4依然为节点5的左子节点
插入 3
- 将节点3插入到节点4的左子树中(情况3)
问题
- 此时节点3与节点4同为红色
- 不符合规则4
变化
变色
- 将节点4变为黑色
- 将节点5变为红色
- 将节点6变为黑色
- 此时节点5和节点7同为红色
- 不符合规则4,为情况4
二次变化
变色
- 将节点7变为黑色
- 将节点9变为红色
- 此时当前根节点9为红色
- 不符合规则1
三次变化
右旋转
- 将节点9作为根进行右旋转
- 节点7变为根节点
- 节点9变为节点7的右子节点
- 原来节点7的右子节点8平移到节点9的左子节点
- 节点5依然为节点7的左子节点
- 其余节点不变化
插入 2
- 将节点2插入到节点3的左子树中(情况4)
问题
- 此时节点2与节点3同为红色
- 不符合规则4
变化
变色
- 将节点3变为黑色
- 将节点4变为红色
- 此时节点4与节点5同为红色
- 不符合规则4
二次变化
右旋转
- 将节点4作为根节点进行右旋转
- 节点3变为节点5的左子节点
- 节点4变为节点3的右子节点
- 节点2依然为节点3的左子节点
插入 1
- 将节点1插入到节点2的左子树中(情况3)
问题
- 此时节点1与节点2同为红色
- 不符合规则4
变化
变色
- 将节点2变为黑色
- 将节点3变为红色
- 将节点4变为黑色
- 此时节点3与节点5同为红色
- 不符合规则4
二次变化
变色
- 将节点5变为黑色
- 将节点7变为红色
- 将节点9变为黑色
- 此时根节点7为红色
- 不符合规则2
三次变化
变色
- 将节点7变为黑色
8. 图结构
8.1 图的相关概念
8.1.1 什么是图
图的介绍
- 实际上, 在数学的概念上, 树是图的一种.
- 结点(图中叫顶点Vertex)之间的关系, 是不能使用树来表示(几叉树都不可以)
- 这个时候, 就使用图来模拟
图的特点
- 一组顶点:通常用 V (Vertex) 表示顶点的集合
- 一组边:通常用 E (Edge) 表示边的集合
- 边是顶点和顶点之间的连线
- 边可以是有向的, 也可以是无向的.(比如A — B, 通常表示无向. A --> B, 通常表示有向)
8.1.2 图的术语
顶点
- 顶点表示图中的一个结点
边
- 边表示顶点和顶点之间的连线(0 - 1有一条边, 1 - 2有一条边, 0 - 2没有边)
- 注意: 这里的边不叫做路径, 路径有其他的概念
相邻顶点
- 由一条边连接在一起的顶点称为相邻顶点
- 比如0 - 1是相邻的, 0 - 3是相邻的. 0 - 2是不相邻的
度
- 一个顶点的度是相邻顶点的数量
- 比如0顶点和其他两个顶点相连, 0顶点的度是2
- 比如1顶点和其他四个顶点相连, 1顶点的度是4
路径
- 路径是顶点v1, v2…, vn的一个连续序列, 比如上图中0-1-5-9就是一条路径
- 简单路径: 简单路径要求不包含重复的顶点. 比如 0-1-5-9是一条简单路径
- 回路: 第一个顶点和最后一个顶点相同的路径称为回路. 比如 0-1-5-6-3-0
无向图
- 无向图表示所有的边都没有方向
- 比如 0 - 1之间有边, 那么说明这条边可以保证 0 -> 1, 也可以保证 1 -> 0
有向图
- 有向图表示的图中的边是有方向的
- 比如 0 -> 1, 不能保证一定可以 1 -> 0, 要根据方向来定
无权图
- 边没有携带权重
- 上面的图中的边是没有任何意义的, 不能收 0 - 1的边, 比4 - 9的边更远或者用的时间更长
带权图
- 带权图表示边有一定的权重
- 这里的权重可以是任意表示的数据: 比如距离或者花费的时间或者票价
8.2 图的表示
8.2.1 顶点表示
- 上面的顶点, 可以抽象成A B C D
- 这些A B C D可以使用一个数组来存储起来(存储所有的顶点)
- A, B, C, D有可能还表示其他含义的数据, 这个时候, 可以另外创建一个数组, 用于存储对应的其他数据
8.2.2 邻接矩阵
-
一种比较常见的表示图的方式: 邻接矩阵
- 邻接矩阵让每个节点和一个整数向关联, 该整数作为数组的下标值
- 用一个二维数组来表示顶点之间的连接
-
图解
-
解析
- 在二维数组中, 0表示没有连线, 1表示有连线
- 通过二维数组, 可以很快的找到一个顶点和哪些顶点有连线.(比如A顶点, 只需要遍历第一行即可)
- 另外, A - A, B - B(也就是顶点到自己的连线), 通常使用0表示
-
邻接矩阵的问题
- 如果是一个无向图, 邻接矩阵展示出来的二维数组, 其实是一个对称图
- 也就是A -> D是1的时候, 对称的位置 D -> 1一定也是1
- 那么这种情况下会造成空间的浪费
- 邻接矩阵还有一个比较严重的问题就是如果图是一个稀疏图
- 那么矩阵中将存在大量的0, 这意味着浪费了计算机存储空间来表示根本不存在的边
- 而且即使只有一个边, 也必须遍历一行来找出这个边, 也浪费很多时间
- 如果是一个无向图, 邻接矩阵展示出来的二维数组, 其实是一个对称图
8.2.3 邻接表
-
另外一种常用的表示图的方式: 邻接表
- 邻接表由图中每个顶点以及和顶点相邻的顶点列表组成
- 这个列表有很多中方式来存储: 数组/链表/字典(哈希表)都可以
-
图解
-
解析
- 比如要表示和A顶点有关联的顶点(边), A和B/C/D有边, 那么可以通过A找到对应的数组/链表/字典, 再取出其中的内容就可以
-
邻接表的问题
- 邻接表计算"出度"是比较简单的
(出度: 指向别人的数量, 入度: 指向自己的数量)
- 邻接表如果需要计算有向图的"入度", 那么是一件非常麻烦的事情
- 必须构造一个"“逆邻接表", 才能有效的计算"入度". 而邻接矩阵会非常简单
- 邻接表计算"出度"是比较简单的
8.3 图结构的封装
8.3.1 创建图类
代码解析
- 创建Graph的构造函数
- 定义了两个属性:
- vertexes: 用于存储所有的顶点, 使用一个数组来保存
- edges: edges用于存储所有的边, 这里采用邻接表的形式
代码实现
// 封装图结构
function Graph() {
// 属性: 顶点(数组)/边(字典)
this.vertexes = [] // 顶点
this.edges = new Dictionay() // 边
// 方法
}
8.3.2 添加方法
添加方法
- 添加顶点: 可以向图中添加一些顶点
- 添加边: 可以指定顶点和顶点之间的边
代码解析
- 将添加的顶点放入到数组中
- 另外, 给该顶点创建一个数组[], 该数组用于存储顶点连接的所有的边.(回顾邻接表的实现方式)
- 添加边需要传入两个顶点, 因为边是两个顶点之间的边, 边不可能单独存在
- 根据顶点v1取出对应的数组, 将v2加入到它的数组中
- 根据顶点v2取出对应的数组, 将v1加入到它的数组中
- 因为这里实现的是无向图, 所以边是可以双向的
代码实现
// 方法
// 添加方法
// 1. 添加顶点的方法
Graph.prototype.addVertex = function (v) {
this.vertexes.push(v)
this.edges.set(v, [])
}
// 2. 添加边的方法
Graph.prototype.addEdge = function (v1, v2) {
this.edges.get(v1).push(v2) // v1 -> v2
this.edges.get(v2).push(v1) // v2 -> v1
}
测试代码
// 测试代码
// 1. 创建图结构
var graph = new Graph()
// 2. 添加顶点
var myVertexes = ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I']
for (var i = 0; i < myVertexes.length; i++) {
g.addVertex(myVertexes[i])
}
// 3. 添加边
// 添加边
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');
效果
toString方法
// 实现 toString 方法
Graph.prototype.toString = function () {
// 1. 定义字符串,保存最终的结果
var resultString = ''
// 2. 遍历所有的顶点,以及顶点对应的边
for (var i = 0; i < this.vertexes.length; i++) {
resultString += this.vertexes[i] + '->'
var vEdges = this.edges.get(this.vertexes[i])
for (var j = 0; j < vEdges.length; j++) {
resultString += vEdges[j] + ' '
}
resultString += '\n'
}
return resultString
}
结果
8.4 图的遍历
- 和其他数据结构一样, 需要可以通过某种算法来遍历结构中每一个数据
- 这样可以保证, 在需要时, 通过这种算法来访问某个顶点的数据以及对应的边
8.4.1 遍历的方式
图的遍历思想
- 图的遍历算法的思想在于必须访问每个第一次访问的节点, 并且追踪有哪些顶点还没有被访问到
- 有两种算法可以对图进行遍历
- 广度优先搜索(Breadth-First Search, 简称BFS)
- 深度优先搜索(Depth-First Search, 简称DFS)
- 两种遍历算法, 都需要明确指定第一个被访问的顶点
遍历的注意点
- 完全探索一个顶点要求查看该顶点的每一条边
- 对于每一条所连接的没有被访问过的顶点, 将其标注为被发现的, 并将其加进待访问顶点列表中
- 为了保证算法的效率: 每个顶点至多访问两次
两种算法的思想
- BFS: 基于队列, 入队列的顶点先被探索
- DFS: 基于栈, 通过将顶点存入栈中, 顶点是沿着路径被探索的, 存在新的相邻顶点就去访问
- 为了记录顶点是否被访问过, 使用三种颜色来反应它们的状态:(或者两种颜色也可以)
- 白色: 表示该顶点还没有被访问
- 灰色: 表示该顶点被访问过, 但并未被探索过
- 黑色: 表示该顶点被访问过且被完全探索过
初始化代码
// 初始化状态颜色
Graph.prototype.initializeColor = function () {
var colors = []
for (var i = 0; i < this.vertexes.length; i++) {
colors[this.vertexes[i]] = 'while'
}
return colors
}
8.4.2 广度优先搜素
-
广度优先搜索算法的思路
- 广度优先算法会从指定的第一个顶点开始遍历图, 先访问其所有的相邻点, 就像一次访问图的一层
- 换句话说, 就是先宽后深的访问顶点
-
图解BFS
-
广度优先搜索的实现
- 创建一个队列Q
- 将v标注为被发现的(灰色), 并将v将入队列Q
- 如果Q非空, 执行下面的步骤:
- 将v从Q中取出队列
- 将v标注为被发现的灰色
- 将v所有的未被访问过的邻接点(白色), 加入到队列中
- 将v标志为黑色
-
代码解析
- 先为每个顶点记录一种颜色, 用于保持它当前的状态
- 创建队列, 这里需要用到之前封装的队列类型, 因此需要导入
- 将开始的顶点放入队列中
- 开始处理队列中的数据
- 先从队列中取出顶点v
- 取出该顶点相邻的顶点数组vList
- 因为之前的v已经被探测过, 所有将v设置为灰色
- 遍历vList所有的所有的顶点, 判断颜色, 如果是白色, 那么将其将入到队列中. 并且将该顶点设置为灰色
- 将v顶点设置为黑色
- 处理v顶点
-
代码实现
// 实现广度优先搜索(BFS)
Graph.prototype.bfs = function (initV, handler) {
// 1. 初始化颜色
var colors = this.initializeColor()
// 2. 创建队列
var queue = new Queue()
// 3. 将顶点加入到队列中
queue.enqueue(initV)
// 4. 循环从队列中取出元素
while (!queue.isEmpty()) {
// 4.1 从队列取出一个顶点
var v = queue.dequeue()
// 4.2 获取和顶点相连的另外顶点
var vList = this.edges.get(v)
// 4.3 将v的颜色设置成灰色
colors[v] = 'gray'
// 4.4 遍历所有的顶点,并且加入到队列中
for (var i = 0; i < vList.length; i++) {
var e = vList[i]
if (colors[e] == 'while') {
colors[e] = 'gray'
queue.enqueue(e)
}
}
// 4.5 访问顶点
handler(v)
// 4.6 将顶点设置为黑色
colors[v] = 'black'
}
}
// 5. 测试bfs
var result = ''
graph.bfs(graph.vertexes[0], function (v) {
result += v + ' '
})
alert(result)
8.4.3 深度优先搜索
深度优先搜索的思路
- 深度优先搜索算法将会从第一个指定的顶点开始遍历图, 沿着路径知道这条路径最后被访问
- 接着原路回退并探索下一条路径
图解DFS
代码解析
- 初始化颜色
- 遍历所有的顶点, 每遍历一个顶点, 让其执行递归函数
- 探测了v顶点, 所有v顶点的颜色设置为灰色
- 访问v顶点, 通过回调函数传入v
- 访问v顶点的相连的顶点, 在访问的过程中判断该顶点如果为白色, 说明未探测, 调用递归方法
- v被探测过, 也被访问过, 将v的颜色设置为黑色
代码实现
// 深度优先搜索(DFS)
Graph.prototype.dfs = function (initV, handler) {
// 1. 初始化颜色
var colors = this.initializeColor()
// 2. 从某个顶点开始依次递归访问
this.dfsVisit(initV, colors, handler)
}
Graph.prototype.dfsVisit = function (v, colors, handler) {
// 1. 将颜色设置为灰色
colors[v] = 'gray'
// 2. 处理v顶点
handler(v)
// 3. 访问v相连的顶点
var vList = this.edges.get(v)
for (var i = 0; i < vList.length; i++) {
var e = vList[i]
if (colors[e] == 'white') {
return this.dfsVisit(e, colors, handler)
}
}
// 4. 将v设置成黑色
colors[v] = 'black'
}
// 6. 测试dfs
result = ''
graph.dfs(graph.vertexes[0], function (v) {
result += v + ' '
})
alert(result)
递归代码图解
9. 排序算法
9.1 大O表示法
9.1.1 认识大O表示法
- 大O表示法
- 在计算机中,
粗略的度量
被称作大O表示法
- 在
数据项个数
发生变化时,算法的效率
会跟着发生改变 - 通常使用一种
算法的速度
会如何跟随着数据量的变化
的
- 在计算机中,
9.1.2 常见的大O表示形式
符号 | 名称 |
---|---|
O(1) | 常数 |
O(log(n)) | 对数 |
O(n) | 线性 |
O(nlog(n)) | 线性和对数乘积 |
O(n²) | 平方 |
O(2ⁿ) | 指数 |
推导大O表示法的方式
:- 用常量1取代运行时间中所有的加法常量
- 在修改后的运行次数函数中,只保留最高阶项
- 如果最高存在且不为1,则去除与这个项相乘的常数
9.2 认识排序算法
9.2.1 排序介绍
- 一旦将数据放置在
某个数据结构
中存储起来后(比如数组), 就可能根据需求对数据进行不同方式的排序 - 由于排序非常重要而且可能非常耗时, 所以已经成为一个计算机科学中广泛研究的课题
9.2.2 常见的排序算法
冒泡排序
选择排序
插入排序
归并排序
计数排序(counting sort)
基数排序(radix sort)
希尔排序
堆排序
桶排序
9.2.3 计算机排序
计算机如何排序
- 计算机有些笨拙, 只能执行指令
- 计算机也很聪明, 只要写出了正确的指令, 可以做无数次类似的事情而不出现错误
- 并且计算机排序也无需担心数据量的大小
- 计算机必须有严密的逻辑和特定的指令
计算机排序的特点
- 计算机只能根据计算机的比较操作原理, 在同一个时间对两个队员进行比较
- 计算机的算法只能一步步解决具体问题和遵循一些简单的规则
简单算法的主要操作
- 比较两个数据项
- 交换两个数据项, 或者复制其中一项
9.2.4 封装排序列表
// 创建列表类
function ArrayList() {
// 属性
this.array = []
// 方法
// 将数据可以插入到数组中的方法
ArrayList.prototype.insert = function (item) {
this.array.push(item)
}
// toString
ArrayList.prototype.toString = function () {
return this.array.join('-')
}
}
9.3 冒泡排序
- 冒泡排序算法相对其他排序
运行效率较低
, 但是在概念上它是排序算法中最简单
的
9.3.1 冒泡排序的思路
冒泡排序的思路
- 对未排序的各元素从头到尾依次比较相邻的两个元素大小关系
- 如果左边的数值大, 则两个元素交换位置
- 向右移动一个位置, 比较下面两个元素
- 当走到最右端时, 数值最大的元素一定被放在了最右边
- 按照这个思路, 从最左端重新开始, 这次走到倒数第二个位置的元素即可
- 依次类推, 就可以将数据排序完成
冒泡排序图解
9.3.2 冒泡排序的实现
-
冒泡排序的代码分析
- 获取数组的长度
- 外层循环, 外层循环应该让 j 依次减少, 因此这里使用了反向的遍历
- 内层循环, 内层循环我们使用 i < j. 因为上面的 j 在不断减小, 这样就可以控制内层循环的次数
- 比较两个数据项的大小, 如果前面的大, 那么就进行交换
-
代码图解流程
-
冒泡排序的代码实现
// 冒泡排序
ArrayList.prototype.bubblesort = function () {
// 1. 获取数组的长度
var length = this.array.length
// 第一次: j = length - 1, 比较到倒数第一个位置
// 第二次: j = length - 2, 比较到倒数第二个位置
// ...
for (var j = length - 1; j >= 0; j--) {
// 第一次进来: i = 0, 比较 0 和 1 位置的两个数据,如果 0 位置大有 1 位置的数据
// 最后一次进来: i = length - 2,比较 length - 2 和 length - 1 的两个数据
for (var i = 0; i < j; i++) {
if (this.array[i] > this.array[i+1]) {
// 交换两个数据
var temp = this.array[i]
this.array[i] = this.array[i+1]
this.array[i+1] = temp
}
}
}
}
9.3.3 冒泡排序的效率
冒泡排序的比较次数
- 如果按一共有7个数字, 那么每次循环时进行了几次的比较呢?
- 第一次循环6次比较, 第二次5次比较, 第三次4次比较…直到最后一趟进行了一次比较
- 对于7个数据项比较次数: 6 + 5 + 4 + 3 + 2 + 1
- 对于N个数据项呢? (N - 1) + (N - 2) + (N - 3) + … + 1 = N * (N - 1) / 2
大O表示法
- 通过大O表示法推导过程, 来推导一下冒泡排序的大O形式
- N * (N - 1) / 2 = N²/2 - N/2,根据规则, 只保留最高阶项, 变成N² / 2
- N² / 2, 根据规则, 去除常量, 编程N²
- 因此冒泡排序的大O表示法为
O(N²)
- 通过大O表示法推导过程, 来推导一下冒泡排序的大O形式
冒泡排序的交换次数
- 冒泡排序的交换次数是多少呢
- 如果有两次比较才需要交换一次(不可能每次比较都交换一次.), 那么交换次数为N² / 4
- 由于常量不算在大O表示法中, 因此, 可以认为交换次数的大O表示也是O(N²)
9.4 选择排序
- 选择排序改进了冒泡排序, 将交换的次数由O(N²)减少到O(N), 但是比较的次数依然是O(N²)
9.4.1 选择排序的思路
-
选择排序思路
- 选定第一个索引位置,然后和后面元素依次比较
- 如果后面的元素, 小于第一个索引位置的元素, 则交换位置
- 经过一轮的比较后, 可以确定第一个位置是最小的
- 然后使用同样的方法把剩下的元素逐个比较即可
- 可以看出选择排序,第一轮会选出最小值,第二轮会选出第二小的值,直到最后
-
选择排序图解
-
思路分析
- 选择排序第一次将第0位置的元素取出, 和后面的元素(1, 2, 3…)依次比较, 如果后面的元素更小, 那么就交换,这样经过一轮之后, 第一个肯定是最小的元素
- 第二次将第1位置的元素取出, 和后面的元素(2, 3, 4…)依次比较, 如果后面的元素更小, 那么就交换,这样经过第二轮后, 第二个肯定是次小的元素
- 第三轮…第四轮…直到最后就可以排好序了.
- 外层循环依次取出0-1-2…N-2位置的元素作为index(N-1不需要取了, 因为只剩它一个了肯定是排好序的)
- 内层循环从index+1开始比较, 直到最后一个
9.4.2 选择排序的实现
-
选择排序的代码分析
- 获取数组的长度
- 外层循环, 需要从外层循环的第0个位置开始, 依次遍历到length - 2的位置
- 先定义一个min, 用于记录最小的位置, 内层循环, 内层循环是从i+1位置开始的数据项, 和i位置的数据项依次比较, 直到length-1的数据项
- 如果比较的位置i的数据项, 大于后面某一个数据项, 那么记录最小位置的数据
- 将min位置的数据, 那么i位置的数据交换, 那么i位置就是正确的数据了
- 注意: 这里的交换是基于之前的交换方法, 这里直接调用即可
-
代码图解流程
-
代码实现
// 选择排序
ArrayList.prototype.selectionSort = function () {
// 1. 获取数组的长度
var length = this.array.length
// 2. 外层循环:从0位置开始取数据
for (var j = 0; j < length - 1; j++) {
// 内层循环:从 i+1 位置开始,和后面的数据进行比较
var min = j
for (var i = min + 1; i < length; i++) {
if (this.array[min] > this.array[i]) {
min = i
}
}
this.swap(min, j)
}
}
9.4.3 选择排序的效率
选择排序的比较次数
- 选择排序和冒泡排序的比较次数都是N*(N-1)/2, 也就是
O(N²)
- 选择排序和冒泡排序的比较次数都是N*(N-1)/2, 也就是
选择排序的交换次数
- 选择排序的交换次数只有N-1次, 用大O表示法就是O(N)
- 所以选择排序通常认为在执行效率上是高于冒泡排序的
9.5 插入排序
- 插入排序是简单排序中效率最好的一种
- 插入排序也是学习其他高级排序的基础, 比如希尔排序/快速排序, 所以也非常重要
9.5.1 插入排序的思路
-
插入排序思路
局部有序
- 插入排序思想的核心是局部有序
- 这意味着, 有一部门元素是按顺序排列好的. 有一部分还没有顺序
插入排序的思路
- 从第一个元素开始,该元素可以认为已经被排序
- 取出下一个元素,在已经排序的元素序列中从后向前扫描
- 如果该元素(已排序)大于新元素,将该元素移到下一位置
- 重复上一个步骤,直到找到已排序的元素小于或者等于新元素的位置
- 将新元素插入到该位置后, 重复上面的步骤
-
插入排序图解
-
思路分析
- 插入排序应该从下标值1开始(因为0位置默认可以被认为是有序的)
- 从1位置开始取出元素, 并且判断该元素的大小和0位置进行比较, 如果1位置元素小于0位置元素, 那么交换, 否则不交换
- 上面步骤执行完成后, 0 - 1位置已经排序好
- 取出2位置的元素, 和1位置进行比较:
- 如果2位置元素大于1位置元素, 说明2位置不需要任何动作. 0 - 1 - 2已经排序好
- 如果2位置元素小于1位置元素, 那么将1移动到2的位置, 并且2继续和0进行比较
- 如果2位置元素大于0位置的元素, 那么将2位置放置在1的位置, 排序完成. 0 - 1 - 2搞定
- 如果2位置元素小于1位置的元素, 那么将0位置的元素移动到1位置, 并且将2位置的元素放在0位置, 0 - 1 - 2搞定
- 按照上面的步骤, 依次找到最后一个元素, 整个数组排序完成
9.5.2 插入排序的实现
代码解析
- 获取数组的长度
- 外层循环, 从1位置开始, 因为0位置可以默认看成是有序的了
- 记录选出的i位置的元素, 保存在变量temp中. i默认等于j
- 内层循环
- 内层循环的判断j - 1位置的元素和temp比较, 并且j > 0
- 那么就将j-1位置的元素放在j位置
- j位置向前移
- 将目前选出的j位置放置temp元素
代码图解流程
代码实现
// 插入排序
ArrayList.prototype.insertionSort = function () {
// 1. 获取数组的长度
var length = this.array.length
// 2. 外层循环: 从第1个位置开始获取数据,向前面局部有序进行插入
for (var i = 0; i < length; i++) {
// 3. 内层循环: 获取i位置的元素,和前面的数据依次进行比较
var temp = this.array[i]
var j = i
while (this.array[j - 1] > temp && j > 0) {
this.array[j] = this.array[j - 1]
j--
}
// 4. 将j位置的数据,放置temp即可
this.array[j] = temp
}
}
9.5.3 插入排序的效率
插入排序的比较次数
- 第一趟时, 需要的最多次数是1, 第二趟最多次数是2, 依次类推, 最后一趟是N-1次
- 因此是1 + 2 + 3 + … + N - 1 = N * (N - 1) / 2
- 然而每趟发现插入点之前, 平均只有全体数据项的一半需要进行比较
- 我们可以除以2得到 N * (N - 1) / 4. 所以相对于选择排序, 其他比较次数是少了一半的
插入排序的复制次数
- 第一趟时, 需要的最多复制次数是1, 第二趟最多次数是2, 依次类推, 最后一趟是N-1次
- 因此是1 + 2 + 3 + … + N - 1 = N * (N - 1) / 2
对于基本有序的情况
- 对于已经有序或基本有序的数据来说, 插入排序要好很多
- 当数据有序的时候, while循环的条件总是为假, 所以它变成了外层循环中的一个简单语句, 执行N-1次
- 在这种情况下, 算法运行至需要N(N)的时间, 效率相对来说会更高
- 另外别忘了, 比较次数是选择排序的一半, 所以这个算法的效率是高于选择排序的
9.6 希尔排序
- 希尔排序是插入排序的一种高效的改进版, 并且效率比插入排序要更快
9.6.1 希尔排序的介绍
-
回顾插入排序
- 希尔排序基于插入排序
- 在插入排序执行到一半的时候, 标记符左边这部分数据项都是排好序的, 而标识符右边的数据项是没有排序的
- 这个时候, 取出指向的那个数据项, 把它存储在一个临时变量中, 接着, 从刚刚移除的位置左边第一个单元开始, 每次把有序的数据项向右移动一个单元, 直到存储在临时变量中的数据项可以成功插入
-
插入排序的问题
- 假设一个很小的数据项在很靠近右端的位置上
- 把这个小数据项移动到左边的正确位置, 所有的中间数据项都必须向右移动一位
- 如果每个步骤对数据项都进行N次复制, 平均下来是移动N/2, N个元素就是
N*N/2 = N²/2
- 所以通常认为插入排序的效率是
O(N²)
- 如果有某种方式, 不需要一个个移动所有中间的数据项, 就能把较小的数据项移动到左边, 那么这个算法的执行效率就会有很大的改进
-
希尔排序的做法
- 先让间隔为5, 进行排序
- 排序后的新序列, 可以让元素离正确位置更近一步
- 再让间隔为3, 进行排序
- 排序后的新序列, 可以让元素离正确位置又近一步
- 最后让间隔为1, 也就是正确的插入排序. 这个时候元素都离正确的位置更近, 那么需要复制的次数一定会减少很多
-
希尔排序的图解
-
选择合适的增量
- 在希尔排序的原稿中, 建议的初始间距是N / 2, 简单的把每趟排序分成两半
- 这个方法的好处是不需要在开始排序前为找合适的增量而进行任何的计算
9.6.2 希尔排序的实现
-
代码解析
- 获取数组的长度
- 计算第一次的间隔, 按照希尔提出的间隔实现
- 增量不断变小, 大于0就继续改变增量
- 实际上就是实现了插入排序
- 保存临时变量, j位置从i开始, 保存该位置的值到变量temp中
- 内层循环, j > gap - 1并且temp大于this.array[j - gap], 那么就进行复制
- 将j位置设置为变量temp
- 每次while循环后都重新计算新的间隔
-
代码实现
// 希尔排序
ArrayList.prototype.shellSort = function () {
// 1. 获取数组的长度
var length = this.array.length
// 2. 初始化的增量(gap -> 间隔/间隙)
var gap = Math.floor(length / 2)
// 3. while 循环(gap不断的减小)
while (gap >= 1) {
// 4. 以gap作为间隔,进行分组,对分组进行插入排序
for (var i = gap; i < length; i++) {
var temp = this.array[i]
var j = i
while (this.array[j - gap] > temp && j > gap - 1) {
this.array[j] = this.array[j - gap]
j -= gap
}
// 5. 将j位置的元素赋值temp
this.array[j] = temp
}
// 6. 增量变化 / 2
gap = Math.floor(gap / 2)
}
}
9.6.3 希尔排序的效率
-
希尔排序的效率
- 希尔排序的效率和增量是有关系的
- 但是, 它的效率证明非常困难, 甚至某些增量的效率到目前依然没有被证明出来
- 但是经过统计, 希尔排序使用原始增量, 最坏的情况下时间复杂度为O(N²), 通常情况下都要好于O(N²)
-
Hibbard 增量序列
- 增量的算法为2^k - 1. 也就是为1 3 5 7…等等
- 这种增量的最坏复杂度为O(N^3/2), 猜想的平均复杂度为O(N^5/4), 目前尚未被证明
-
Sedgewick增量序列
- {1, 5, 19, 41, 109, … }, 该序列中的项或者是94^i - 9*2^i + 1或者是4^i - 32^i + 1
- 这种增量的最坏复杂度为O(N^4/3), 平均复杂度为O(N^7/6), 但是均未被证明
- 总之, 使用希尔排序大多数情况下效率都高于简单排序, 甚至在合适的增量和N的情况下, 还要好于快速排序
9.7 快速排序
- 快速排序几乎可以说是最快的一种排序算法.
9.7.1 快速排序的介绍
-
快速排序的重要性
- 快速排序可以说是排序算法中最常见的, 无论是C++的STL中, 还是Java的SDK中其实都能找到它的影子
- 快速排序也被列为20世纪十大算法之一
-
什么是快速排序
- 快速排序其实是冒泡排序的升级版
- 快速排序可以在一次循环中(其实是递归调用)找出某个元素的正确位置, 并且该元素之后不需要任何移动
-
快速排序的思想
- 快速排序最重要的思想是
分而治之
- 比如下面有这样一顿数字需要排序:
- 第一步: 从其中选出了65
- 第二步: 通过算法: 将所有小于65的数字放在65的左边, 将所有大于65的数字放在65的右边
- 第三步: 递归处理左边的数据, 递归的处理右边的数据
- 最终: 排序完成
- 快速排序最重要的思想是
-
与冒泡排序的区别
- 选择的65可以一次性将它放在最正确的位置, 之后不需要任何移动
- 需要从开始位置两个两个比较, 如果第一个就是最大值, 它需要一直向后移动, 直到走到最后
- 也就是即使已经找到了最大值, 也需要不断继续移动最大值. 而插入排序对数字的定位是一次性的
9.7.2 快速排序的枢纽
-
在快速排序中有一个很重要的步骤就是选取枢纽(pivot也人称为主元).
-
如何选择枢纽
- 取头、中、尾的
中位数
- 取头、中、尾的
-
枢纽代码解析
- 封装了一个函数, 该函数用于选择出来合适的枢纽
- 该函数要求传入left和right, 这样可以根据left和right求出一个center, 在选择它们三者的中位数
- 根据left/right求出center
- 将left放在最前面, 将center放在中间, 将right放在右边
- 将pivot值放在了right的紧挨着的左边
- 这样操作的目的是在之后交换的时候, pivot的值不需要移动来移动去
- 可以在最后选定位置后, 直接再交换到正确的位置即可(也是最终的位置)
- 返回选择出来的枢纽
-
枢纽选择的代码实现
// 1. 选择枢纽
ArrayList.prototype.median = function (left, right) {
// 1. 取出中间的位置
var center = Math.floor((left + right) / 2)
// 2. 判断大小,并且进行交换
if (this.array[left] > this.array[center]) {
this.swap(left, center)
}
if (this.array[center] > this.array[right]) {
this.swap(center, right)
}
if (this.array[left] > this.array[center]) {
this.swap(left, center)
}
// 3. 将center换到right - 1的位置
this.swap(center, right - 1)
return this.array[right - 1]
}
9.7.3 快速排序的实现
-
代码解析
- 两个函数: quickSort和quick
- 外部调用时, 会调用quickSort
- 内部递归时, 会调用quick
- 这里主要讲解一下quick方法
- 先判断递归的结束条件
- 从三个数中获取枢纽值
- 重点代码
- 循环交换合适位置的数值
- 使用两个while循环, 递归的查找合适的i(大于枢纽的值)和合适的j(小于枢纽的值)
- 交换i和j位置的值.
- 当i<j的时候, 两边查找到了同一个位置, 这个时候停止循环
- 查找到的i位置正是pivot应该所在的位置, 和pivot替换即可
- 递归调用该函数, 将left, i - 1传入就是左边排序, 将i + 1, right就是右边排序
- 两个函数: quickSort和quick
-
代码实现
// 2. 快速排序的实现
ArrayList.prototype.quickSort = function () {
this.quick(0, this.array.length - 1)
}
ArrayList.prototype.quick = function (left, right) {
// 1. 结束条件
if (left >= right) return
// 2. 获取数据
var pivot = this.median(left, right)
// 3. 定义变量,用于记录当前找到的位置
var i = left
var j = right - 1
// 4. 开始进行交换
while (i < j) {
while (this.array[++i] < pivot) {}
while (this.array[--j] > pivot) {}
if (i < j) {
this.swap(i, j)
} else {
break
}
}
// 6. 将枢纽放置在正确的位置,i的位置
this.swap(i, right - 1)
// 7. 分而治之
this.quick(left, i - 1)
this.quick(i + 1, right)
}
9.7.4 快速排序的效率
-
最坏情况
- 每次选择的枢纽都是最左边或者最后边的
- 效率等同于冒泡排序
-
平均效率
- 快速排序的平均效率是
O(N * logN)
- 快速排序的平均效率是
9.7.5 快速排序的完整代码
// 快速排序
// 1. 选择枢纽
ArrayList.prototype.median = function (left, right) {
// 1. 取出中间的位置
var center = Math.floor((left + right) / 2)
// 2. 判断大小,并且进行交换
if (this.array[left] > this.array[center]) {
this.swap(left, center)
}
if (this.array[center] > this.array[right]) {
this.swap(center, right)
}
if (this.array[left] > this.array[center]) {
this.swap(left, center)
}
// 3. 将center换到right - 1的位置
this.swap(center, right - 1)
return this.array[right - 1]
}
// 2. 快速排序的实现
ArrayList.prototype.quickSort = function () {
this.quick(0, this.array.length - 1)
}
ArrayList.prototype.quick = function (left, right) {
// 1. 结束条件
if (left >= right) return
// 2. 获取数据
var pivot = this.median(left, right)
// 3. 定义变量,用于记录当前找到的位置
var i = left
var j = right - 1
// 4. 开始进行交换
while (i < j) {
while (this.array[++i] < pivot) {}
while (this.array[--j] > pivot) {}
if (i < j) {
this.swap(i, j)
} else {
break
}
}
// 6. 将枢纽放置在正确的位置,i的位置
this.swap(i, right - 1)
// 7. 分而治之
this.quick(left, i - 1)
this.quick(i + 1, right)
}
}
10. 总结
- 对于前端程序员来讲,算法也很重要,尤其是排序算法,但是网上的视频大多是Java,C++的算法视频居多,这是我根据王红元老师的JavaScript算法视频写的相关笔记,强力为王红元老师打call!😊