邂逅数据结构和算法
什么是数据结构?
- 数据结构就是在计算机中**,存储和组织数据的方式**
- 我们知道,计算机中数据量非常庞大,如何以高效的方式组织和存储呢?
- 这就好比一个庞大的图书馆中存放了大量的书籍,我们不仅仅要把书放进去,还应该放在合适的位置
什么是算法?
解决问题的办法/步骤逻辑
认识栈结构
- 数组
- 数组是一种线性结构,并且可以在数组的任意位置插入和删除数据
- 但是有时候,我们为了实现某些功能,必须对这种任意性加以限制
- 而栈和队列就是比较常见的受限的线性结构
- 栈,是一种受限的线性表,后进先出(LIFO)
- 限制是仅允许在表的一端进行插入和删除运算,这一端为栈顶
- 限制是仅允许在表的一端进行插入和删除运算,这一端为栈顶
栈的应用
- 程序中什么是使用栈实现的?
- 函数调用栈
- 函数之间相互调用:A调用B,B中又调用C,C中又调用D
- 会先将A入栈,A没有执行完,不会出栈
- 在A执行过程中调用B,会将B压入栈,这个时候B在栈顶,A在栈底
- 最后栈顺序是:栈底A -> B -> C -> D
- D执行完,DCBA依次弹出栈
栈结构面试题
有六个元素6,5,4,3,2,1的顺序进栈,问下列哪一个不是合法的出栈序列(C)
解题思路:可以在进栈的过程中出栈
A 5 4 3 6 1 2
- 6入栈
- 5入栈
- 5出栈
- 4入栈
- 4出栈
- 3入栈
- 3出栈
- 6出栈
- 2入栈
- 1入栈
- 1出栈
- 2出栈
B 4 5 3 2 1 6
C 3 4 6 5 2 1
D 2 3 4 1 5 6
栈结构的实现
- 实现栈结构有两种常见的方式:
- 基于数组实现
- 基于链表实现
基于数组实现
// 封装栈类
function Stack() {
// 栈中的属性
this.items = []
// 栈的相关操作
// 1.将元素压入栈
Stack.prototype.push = function (element) {
this.items.push(element)
}
// 2.从栈中取出元素
Stack.prototype.pop = function(){
return this.items.pop()
}
// 3.查看一下栈顶元素
Stack.prototype.peek = function(){
return this.items[this.items.length - 1]
}
// 4.判断栈是否为空
Stack.prototype.isEmpty = function () {
return this.items.length === 0
}
// 5.获取栈中元素的个数
Stack.prototype.size = function () {
return this.items.length
}
// 6.toString方法
Stack.prototype.toString = function () {
let resultString = ''
for(let 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)
console.log(s);
十进制转二进制
十进制数字和2整除取余数,直到结果是0为止
// 函数:十进制转二进制
function decTobin(decNumber) {
// 1.定义找对象
var stack = new Stack()
// 2.循环操作
while(decNumber > 0) {
// 获取余数,放入栈中
stack.push(decNumber % 2)
// 获取整除后的结果,作为下一次运算的数字
decNumber = Math.floor(decNumber / 2)
}
// 3.从栈中去除0和1
let binaryString = ''
while(!stack.isEmpty()){
binaryString += stack.pop()
}
return binaryString
}
alert(decTobin(100))
队列结构
- 受限的线性结构:
- 我已经学习了一种受限的线性结构:栈结构
- 这种受限的数据结构对于解决某些特定问题,会有特别的效果
- 另外一个受限的数据结构:队列
- 队列,它是一种受限的线性表,先进先出(FIFO)
- 受限之处在于它只允许在表的前端进行删除操作
- 而在表的后端进行插入操作
队列的应用
- 打印队列:
- 有五份文档需要打印,这些文档会按照次序放入到打印队列中
- 打印机会依次从队列中取出文档,优先放入的文档,优先被取出,并且对该文档进行打印
- 依次类推,直到队列中不再有新的文档
- 线程队列:
- 在开发中,为了让任务可以并行处理,通常会开启多个线程
- 但是,我们不能让大量的线程同时运行处理任务(占用过多的资源)
- 这个时候,如果需要开启线程处理任务的情况,我们就会使用线程队列
- 线程队列会按照次序来启动线程,并且处理对应的任务
队列类的创建
- 队列的实现和栈一样,有两种方案:
- 基于数组实现
- 基于链表实现
// 封装队列类
function Queue() {
// 属性
this.items = []
// 方法
// 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 () {
let resultString = ''
for(let i = 0; i < this.items.length; i++){
resultString += this.items[i] + " "
}
return resultString
}
}
// 使用队列
let queue = new Queue()
queue.enqueue('a')
queue.enqueue('b')
queue.enqueue('c')
alert(queue)
击鼓传花
- 击鼓传花是一个常见的面试算法题,使用队列可以非常方便的实现最终的结果
- 原游戏规则:
- 班级中玩一个游戏,所有学生围成一圈,从某位学生手里开始向傍边的同学传一束花
- 击鼓人停下的一刻,花落在谁手里,谁就出来表演节目
- 修改游戏规则:
- 围成一圈,数到某个数字的人自动淘汰
- 最后剩下的这个人会获得胜利,请问最后剩下的是原来在哪一个位置上的人?
- 封装一个基于队列的函数
- 参数所有参与人的姓名,基于的数字
- 结果:最终剩下的一个人的姓名
//面试题:击鼓传花
function passGame(nameList, num) {
// 1.创建一个队列结构
let queue = new Queue()
// 2.将所有人依次加入到队列中
for (let i = 0; i < nameList.length; i++) {
queue.enqueue(nameList[i])
}
// 3.开始数数字
while (queue.size() > 1) {
// 不是num的时候,重新加入到队列的末尾
// 是num这个数字的时候,将其从队列中删除
// 3.1 num数字之前的人重新放入到队列的末尾
for (let i = 0; i < num - 1; i++) {
queue.enqueue(queue.dequeue())
}
// 3.2 num对应这个人,直接从队列中删除
queue.dequeue()
}
let endName = queue.front()
alert(endName)
return nameList.indexOf(endName)
}
names = ["a", 'b', 'c', 'd', 'e']
alert(passGame(names,3))
优先级队列
- 优先级队列的特点:
- 我们知道,普通的队列插入一个元素,数据会被放在后端,并且需要前面所有的元素都处理完才会处理后面的数据
- 但是优先级队列,在插入一个元素的时候会考虑该数据的优先级
- 和其他数据优先级进行比较
- 比较完成后,可以得出这个元素在队列中正确的位置
- 其他处理方式,和基本队列的处理方式一样
- 优先级队列主要考虑的问题:
- 每个元素不再是一个数据,而且包含数据的优先级
- 在添加方式中,根据优先级放入正确的位置
// 封装优先级队列
function PriorityQueue() {
// 实现类似java中的内部类
function QueueElement(element, priority) {
this.element = element
this.priority = priority
}
// 封装属性
this.items = []
// 实现插入方法
PriorityQueue.prototype.enqueue = function (element, priority){
// 1.创建QueueElement对象
let queueElement = new QueueElement(element,priority)
// 2.判断队列是否为空
if(this.items.length == 0){
this.items.push(queueElement)
}else {
let added = false
for(let 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() {
let resultString = ''
for(let i = 0; i < this.items.length; i++){
resultString += this.items[i].element + '-' + this.items[i].priority
}
return resultString
}
}
// 测试代码
let pq = new PriorityQueue()
pq.enqueue('a',1)
pq.enqueue('b',8)
pq.enqueue('c',5)
pq.enqueue('d',3)
// 会默认调用toString方法
alert(pq)
链表以及数组的缺点
- 链表和数组一样,可以用于存储一系列的元素,但是链表和数组的实现机制完全不同
- 数组
- 是存储多个元素最常用的数据结构
- 数组的缺点:
- 数组的创建通常需要申请一段连续的内存空间(一整块的内存),并且大小是固定的(大多数编程语言数组都是固定的)
- 所以当当前数组不能满足容量需求时,需要扩容(一般情况下是申请一个更大的数组,比如2倍,然后将数组中的元素复制过去)
- 在数组开头或者中间位置插入数据成本很高,需要进行大量元素的位移
- 尽管我们已经学过的JS的Array类方法可以帮我们做这些事,但背后的原理依然是这样
链表的优势
- 要存储多个元素,另外一个选择就是链表
- 但不同于数组,链表中的元素在内存中不必是连续的空间
- 链表的每个元素由一个存储元素自身的节点和一个指向下一个元素的引用(有些语言称之为指针或者连接)组成
- 相对于数组,链表有一些优势
- 内存空间不是必须连续的,可以充分利用计算机的内存,实现灵活的内存动态管理
- 链表不必在创建时就确定大小,并且大小可以无限的延伸下去
- 链表在插入和删除数据时,时间复杂度可以达到O(1),相对数组效率很高
- 相对于数组,链表有一些缺点:
- 链表在访问任何一个位置时,都需要从头开始访问(无法跳过第一个元素访问任何一个元素)
- 无法通过下标直接访问元素,需要从头一个个访问,直到找到对应的元素
- 总结
- 如果需要频繁的插入和删除操作,就选择链表
- 如果需要频繁的通过下标查找元素,就选择数组
链表结构的封装
- 封装LinkedList的类,表示我们的链表结构
- 在LinkedList类中有一个Node类,用于封装每个节点上的信息(和优先队列中的封装一样)
- 链表中我们保存两个属性,一个是链表的长度,一个链表中的第一个节点
// 封装链表类
function LinkedList(){
// 内部的类:节点类
function Node(data) {
this.data = data
this.next = null
}
// 属性
this.head = null
this.length = 0
}
append方法
- 向链表尾部追加数据
- 链表本身为空,新添加的数据是唯一的节点
- 链表不为空,需要向其他节点后面追加节点
// 1.追加方法
LinkedList.prototype.append = function(data) {
let newNode = new Node(data)
// 判断是否添加的是第一个节点
if(this.length == 0) {
this.head = newNode
} else {
let current = this.head
while(current.next){
current = current.next
}
current.next = newNode
}
this.length += 1
}
toString方法
- 该方法比较简单,主要是获取每一个元素
- 还是从head开头,因为获取链表的任何元素都必须从第一个节点开头
- 循环遍历每一个节点,并且取出其中的element,拼接成字符串
- 将最终字符串返回
// 2.toString方法
LinkedList.prototype.toString = function() {
// 1.定义变量
let current = this.head
let listString = ''
// 2.循环获取节点
while(current) {
listString += current.data + " "
current = current.next
}
return listString
}
insert方法
在任意位置插入数据
- 添加到第一个位置
- 添加到第一个位置,表示新添加的节点是头,就需要将原来的头结点,作为新节点的next
- head指向新节点
- 添加到其他位置
- 如果是添加到其他位置,就需要先找到这个节点位置了
- 我们通过while循环,一点点向下找,并且在这个过程中保存上一个节点和下一个节点
- 找到正确位置后,将新节点的next指向下一个节点,将上一个节点的next指向新的节点
// 3.insert方法
LinkedList.prototype.insert = function(position, data) {
// 1.对position进行越界判断
if(position < 0 || position > this.length) return false
// 2.根据data创建newNode
let newNode = new Node(data)
// 3.判断插入的位置是否是第一个
if(position == 0) {
newNode.next = this.head
this.head = newNode
} else {
let index = 0
let current = this.head
let 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.获取对应的信息
let current = this.head
let index = 0
while(index < position) {
current = current.next
index++
}
return current.data
}
indexOf方法
// 5.indexOf方法
LinkedList.prototype.indexOf = function(data) {
// 1.定义变量
let current = this.head
let index = 0
while(current){
if(current.data === data){
return index
}
index += 1
current = current.next
}
return -1
}
updata方法
修改某个位置的元素
// update方法
LinkedList.prototype.update = function(position, newData){
// 1.越界判断
if(position < 0 || position >= this.length) return false
// 2.先查找正确的节点
let current = this.head
let index = 0
while(index++ < position){
current = current.next
}
// 3.将position位置的node的data修改成newDate
current.data = newData
return true
}
removeAt方法
- 从列表的特定位置移除一项
LinkedList.prototype.removeAt() = function (position) {
if(position < 0 || position >= this.length) return false
if(postion == 0) {
this.head = this.head.next
} else {
let current = this.head
let previous = null
let index = 0
while(index++ < position) {
previous = current
current = current.next
}
previous.next = current.next
}
this.length -= 1
return true
}
remove方法
- 从列表中移除一项
LinkedList.prototype.remove = function(data) {
let position = this.indexOf(data)
return this.removeAt(position)
}
isEmpty和size方法
- 如果链表中不包含任何元素,返回true
- 如果链表长度大于0则返回false
LinkedList.prototype.isEmpty = function(){
return this.length == 0
}
LinkedList.prototype.size = function() {
return this.length
}
双向链表
- 单向链表的缺点:
- 回到上一个节点很困难
- 双向链表:
- 既可以从头遍历到尾,又可以从尾遍历到头
- 一个节点既有向前连接的引用,也有一个向后连接的引用
- 可以使用一个head和tail分别指向头部和尾部的节点
- 每个节点由三部分组成:前一个节点的指针(prev)、保存的元素(item)、后一个节点的指针(next)
- 双向链表的第一个节点的prev是null
- 双向链表的最后的节点的next是null
- 双向链表的缺点:
- 每次插入或删除某个节点时,需要处理四个引用,而不是二个,实现起来困难
- 占用内存更大一些
- 但是使用很方便
封装双向链表
// 封装双向链表
function DoublyLinkedList() {
// 内部类:节点类
function Node(data) {
this.data = data
this.prev = null
this.next = null
}
// 属性
this.head = null
this.tail = null
this.length = 0
}
append方法
// 1.append方法
DoublyLinkedList.prototype.append = function (data) {
// 1.根据data创建新节点
let 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
}
this.length + 1
}
转为字符串方法
// 2.将链表转成字符串形式
// 2.1 toString方法
DoublyLinkedList.prototype.toString = function () {
return this.backwardString()
}
// 2.2 forwardString方法
DoublyLinkedList.prototype.forwardString = function () {
let current = this.tail
let resultString = ""
while (current) {
resultString += current.data + " "
current = current.prev
}
return resultString
}
// 2.3 backwardString方法
DoublyLinkedList.prototype.backwardString = function () {
let current = this.head
let resultString = ""
while (current) {
resultString += current.data + " "
current = current.next
}
return resultString
}
}
insert方法
- 向列表的特定位置插入一个新的项
// 3.insert方法
DoublyLinkedList.prototype.insert = function (position, data) {
if (position < 0 || position > this.length) return false
let newNode = new Node(data)
// 判断列表是否为空
if (this.length == 0) {
this.head = newNode
this.tail = newNode
} else {
// 判断position是否等于0
if (position == 0) {
this.head.prev = newNode
newNode.next = this.head
this.head = newNode
} else if(position == this.length) {
newNode.prev = this.tail
this.tail.next = newNode
this.tail = newNode
} else {
let current = this.head
let index = 0
while(index++ < position){
current = current.next
}
// 修改指针
newNode.next = current
newNode.prev = current.prev
current.prev.next = newNode
current.prev = newNode
}
}
this.length += 1
return true
}
get方法
// 4.get方法
DoublyLinkedList.prototype.get = function(position) {
if (position < 0 || position >= this.length) return null
// this.length / 2 > position: 从头向后遍历
// this.length / 2 < position: 从后向前遍历
let current = this.head
let index = 0
while(index++ < position) {
current = current.next
}
return current.data
}
indexOf方法
- 返回元素在列表中的索引,如果列表中没有该元素返回-1
// 5.indexOf方法
DoublyLinkedList.prototype.indexOf = function(data){
let current = this.head
let index = 0
while(current) {
if(current.data === data) {
return index
}
index += 1
current = current.next
}
return -1
}
update方法
// 6.update方法
DoublyLinkedList.prototype.update = function (position, newData) {
if (position < 0 || position >= this.length) return false
let current = this.head
let index = 0
while (index++ < position) {
current = current.next
}
current.data = newData
return true
}
removeAt方法
// 7.removeAt方法
DoublyLinkedList.prototype.removeAt = function(position) {
if(position < 0 || position >= this.length) return null
let 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) {
this.tail.prev.next = null
this.tail = this.tail.prev
}else {
let index = 0
while(index++ < position){
current = current.next
}
current.prev.next = current.next
current.next.prev = current.prev
}
}
this.length -= 1
return current.data
}
remove方法
// remove方法
DoublyLinkedList.prototype.remove = function(data) {
let index = this.indexOf(data)
return this.removeAt(index)
}
其他方法
// 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
}
集合
- 几乎每种编程语言中,都有集合结构
- 集合比较常见的实现方式是哈希表
- 集合通常是一组无序的,不能重复的元素构成
- 和数学中的集合名词比较相似,但是数学中的集合范围更大一些,也允许集合中的元素重复
- 在计算机中,集合通常表示的结构中元素是不允许重复的
- ES6中包含了Set类,所以我们其实可以不封装,直接使用它
- 但是为了明确集合的内部实现机制,我们这里自己来封装一下这个Set类
集合的封装
// 封装集合类
function Set() {
// 属性
this.items = {}
// 方法
}
集合的操作
// 方法
// add方法
Set.prototype.add = function (value) {
// 判断当前集合中是否已经包含该元素
if(this.has(value)) {
return false
}
// 将元素添加到元素中
this.items[value] = value
return true
}
// has方法
Set.prototype.has = function (value) {
return this.items.hasOwnProperty(value)
}
// remove方法
Set.prototype.remove = function(value){
// 判断集合中时候包含
if(this.has(value)) {
return false
}
// 将元素从属性中删除
delete this.items[value]
return true
}
// clear方法
Set.prototype.clear = function(){
this.items = {}
}
// size方法
Set.prototype.size = function () {
return Object.keys(this.items).length
}
// 获取集合中所有的值
Set.prototype.values = function () {
return Object.keys(this.items)
}
集合间操作
- 集合间通常有如下操作
- 并集:对于给定的两个集合,返回一个包含两个集合中所有元素的新集合
- 交集:对于给定的两个集合,返回一个包含两个集合中共有集合的新集合
- 差集:对于给定的两个集合,返回一个包含所有存在于第一个集合且不存在于第二个集合的元素的新集合
- 子集:验证一个给定集合是否是另一个集合的子集
// 并集
Set.prototype.union = function(otherSet) {
let unionSet = new Set()
let values = this.values()
for(let i = 0; i < values.length; i++) {
unionSet.add(values[i])
}
values = otherSet.values()
for(let i = 0; i < values.length; i++) {
unionSet.add(values[i])
}
return unionSet
}
// 交集
Set.prototype.intersection = function(otherSet) {
let intersection = new Set()
let values = this.values()
for(let i = 0; i < values.length; i++) {
if(otherSet.has(values[i])){
intersection.add(values[i])
}
}
return intersection
}
// 差集
Set.prototype.difference = function (otherSet) {
let difference = new Set()
let values = this.values()
for(let i = 0; i < values.length; i++) {
if(!otherSet.has(values[i])){
difference.add(values[i])
}
}
return difference
}
// 子集
Set.prototype.subset = function ( otherSet ) {
// 遍历集合A中所有的元素,如果集合A中的元素,在集合B中不存在,那么false
// 否则返回true
let values = this.values()
for(let i = 0; i < values.length; i++){
let item = values[i]
if(!otherSet.has(item)){
return false
}
}
return true
}
字典
- 数组-集合-字典是几乎编程语言都会默认提供的数据类型
- 在JS中默认提供了数组
- ES6中增加了集合和字典
- 字典的特点
- 字典的主要特点是一一对应的关系
- 比如保存一个人的信息,在合适的情况下取出这些信息
- 使用数组的方式:[18, “coder”, 1.88],可以通过下标值取出信息
- 使用字典的方式:{“age”: 18, “name” : “coderwhy”, “height”: 1.88}, 可以通过key取出value
- 另外字典中的key是不可以重复的,而value可以重复,并且字典中的key是无序的
- 字典和映射的关系:
- 有些编程语言中称这种映射关系为字典,因为它确实和生活中的字典比较相似
- 有些编程语言中称这种映射关系为Map
- 字典和数组
- 字典和数组相比的话,字典可以非常方便的通过key来搜索对应的value
- 字典和对象:
- 很多编程语言对字典和对象区分比较明显,对象通常是在编译期就确定下来的结构,不可以添加或者删除属性,而字典通常会使用类似于哈希表的数据结构去实现一种可以动态的添加数据的结构
- 在js中,似乎对象本身就是一种字典,所以在早期,可以使用对象去替代字典
哈希表
- 哈希表是一种非常重要的数据结构
- 几乎所有的编程语言都有直接或者间接的应用这种数据结构
- 哈希表通常是基于数组进行实现的,但是相对于数组,它也有很多的优势:
- 也可以提供非常快速的插入-删除-查找操作
- 无论多少数据,插入和删除值需要接近常量的时间:即O(1)的时间级,实际上,只需要几个机器指令就可以完成
- 哈希表的速度比树还要快,基本可以瞬间查找到想要的元素
- 哈希表相对于树来说编码要容易的多
- 数组
- 数组进入插入操作时,效率比较低
- 数组进行查找操作的效率
- 如果是基于索引进行查找效率非常高
- 基于内容进行查找效率不高
- 数组进行删除操作,效率也不高
- 哈希表相对于数组的一些不足:
- 哈希表中的数据是没有顺序的,所以不能以一种固定的方式(比如从小到大)来遍历其中的元素
- 通常情况下,哈希表中的key是不允许重复的,不能放置相同的key,用于保存不同的元素
哈希表到底是什么?
- 它的结构就是数组,但是它神奇的地方在于对下标值的一种变换(这种变化利于我们查找元素),这种变换我们可以称之为哈希函数,通过哈希函数可以获取到HashCode
字母转数字的方案
- 怎么将一个字符串转成数组的下标值呢?
- 单词/字符串转下标值,其实就是字母/文字转数字
- 我们需要设计一种方案,可以将单词转成适当的下标
- 其实计算机中很多的编码方案就是用数字代替单词的字符,就是字符编码
- 比如ASCII编码:a是97,b是98,一次类推122代表z
- 我们也可以设计一个自己的编码系统,比如a是1,b是2,c是3,依次类推,z是26
- 当然我们可以加上空格用0代替,就是27个字符(不考虑大小写问题)
- 方案一
- 一种转换单词的简单方案就是把单词每个字符的编码求和
- 例如单词cats转成数字:3+1+20+19 = 43
- 那么43就作为cats单词的下标存在数组中
- 问题:多个单词可能计算出来的下标结果是一样的
- 数组中一个下标值位置只能存储一个数据
- 如果存入后来的数据,必然会造成数据的覆盖
- 一个下标存储这么多单词显然是不合理的
- 方案二
- 我们想通过一个算法,让cats转成数字后不那么普通
- 一种方案是使用幂的连乘
- 我们的单词也可以使用这种方案来表示:比如cats = 3*27^3 + 1… = 60337
- 这样得到的数字可以基本保证它的唯一性,不会和别的单词重复
- 问题:会出现过大的下标值
- 创建这么大的数组是没有意义的
- 两种方案总结:
- 第一种方案(把数字相加求和)产生的数组下标太少
- 第二种方案(与27的幂相乘求和)产生的数组下标又太多
认识哈希化
-
现在需要一种压缩方法,把幂的连乘方案中得到的巨大整数范围压缩到可接受的数组范围中
-
对于英文单词,多大的数组才合适呢?
- 如果只有50000个 单词,可能会定义一个长度为50000的数组
- 但是实际情况中,往往需要更大的空间来存储这些单词
- 因为我们不能保证单词会按顺序映射到每一个位置上
- 比如两倍的大小:100000
-
如何压缩呢?
- 现在,就找一种方式,把0到超过7000000000000的范围,压缩为从0到100000
- 有一种简单的方法就是使用取余操作符,它的作用是得到一个数被另外一个数整除后的余数
-
取余操作的实现:
-
先看一个小点的数字范围压缩到一个小点的空间中
-
假设把从0~199的数字,比如使用largeNumber代表,
压缩为从0到9的数字,比如使用smallRange代表
-
下标值的结果:index = largeNumber % smallRange
-
当一个数被10整除时,余数一定在0~9之间
-
比如13%10=3, 157%10=7
-
这中间还是会有重复,不过重复的数量明显变小了,因为我们的数组是100000,而只有50000个单词
-
哈希表的一些概念
- 哈希表的几个概念
- 哈希化:将大数字转化成数组范围内下标的过程,我们称之为哈希化
- 哈希函数:通常我们会将单词转成大数字,大数字在进行哈希化的 代码实现放在一个函数中,这个函数我们称之为哈希函数
- 哈希表:最终将数据插入到的这个数组,对整个结构的封装,我们就称之为一个哈希表
- 问题
- 虽然我们在100000的数组中,放50000个单词已经足够
- 但是通过哈希化的下标值依然可能会重复,如何解决呢?
什么是冲突
- 尽管50000个单词,我们使用了100000个位置来存储,并且通过一种相对比较好的哈希函数来完成,但是依然可能会发生冲突
- 比如melioration这个单词,通过哈希函数得到它数组的下标值后,发现那个位置上已经存在一个单词demystify
- 因为它经过哈希化和melioration得到的下标实现相同的
- 这种情况我们称为冲突
- 冲突不可避免,我们只能解决冲突
- 如何解决这种冲突吗?常见的情况有两种方案
- 链地址法
- 开放地址法
链地址法
- 链地址法是一种比较常见的解决冲突的方案
- 图片解析:
- 链地址法解决冲突的办法是每个数组单元中存储的不再是单个数据,而是一个链条
- 这个链条可以使用数组或者链表
- 比如链表,也就是数组单元中存储着一个链表,一旦发现重复,将重复的元素插入链表首端或末端即可
- 当查询时,先根据哈希化后的下标值找到对应的位置,再取出链表,一次查找数据
- 数组还是链表?
- 因为都是线性查找,所以数组和链表的效率差不多
- 但是在某些实现中,会将新插入的数据放在数组或者链表的最前面,因为觉得新插入的数据使用可能性更大
- 这种情况最好采用链表,因为数组在首位插入数据是需要所有其他项后移的,效率不高
- 当然,我觉得这个也看业务需求,不见得新的数据就访问次数更多,比如我们微信新好友的联系频率不见得比我们的老朋友更多
- 所以,个人觉得选择数据或者链表都是可以的
开放地址法
- 开放地址法的主要工作方式是寻找空白的单元格来添加重复的数据
- 探索这个空白位置的方式有三种方法
- 线性探测
- 二次探测
- 再哈希法
线性探测
- 线性的查找空白的单元
- 插入的32
- 经过哈希化得到的index=2,但是在插入的时候,发现该位置已经有了82
- 线性探测就是从index位置 + 1 开始一点点查找合适的位置来放置32,
- 空的位置就是合适的位置,在上图中就是index=3的位置,这个时候32就会放在该位置
- 查询32
- 查询32和插入32相似
- 首先经过哈希化得到index=2,比如2的位置结果和查询的数值是否相同,相同那么就直接返回
- 不相同,线性查找,从index位置+1开始查找和32一样
- 这里有一个特别需要注意的地方,如果32的位置我们之前没有插入,是否将整个哈希表查询一遍来确定32存不存在?
- 当然不是,查询过程有一个约定,就是查询到空位置,就停止
- 因为查询到空位置,32之前不可能跳过空位置去其他位置
- 删除32
- 删除操作和插入查询比较类似,但是也有一个特别注意点
- 删除一个数据项时,不可以将这个位置下标的内容设置为null,为什么呢
- 因为将它设置为null可能会影响我们之后查询其他操作,所以通常删除一个位置的数据项时,我们可以将它进行特殊处理(比如设置为-1)
- 当我们之后看到-1位置的数据项时,就知道查询时要继续查询,但是插入时这个位置可以放置数据
- 线性探测的问题
- 聚集,什么是聚集?
- 比如我们在没有任何数据的时候,插入的是22-23-24-25-26,那么意味着下标值:2-3-4-5-6的位置都有元素
- 这种一连串填充单元就叫做聚集
- 聚集会影响哈希表的性能,无论是插入/查询/删除都会影响
- 比如我们插入一个32,会发现连续的单元都不允许我们放置数据,并且在这个过程中我们需要探索多次
- 二次探测可以解决一部分这个问题
二次探测
- 线性探测存在的问题
- 如果之前的数据是连续插入的,那么新插入的一个数据可能需要探测很长的距离
- 二次探测在线性探测的基础上进行了优化:
- 主要优化的是探测时的步长
- 线性探测,可以看成时步长为1的探测,比如从下标x开始,x+1,x+2依次探测
- 二次探测,对步长做了优化,比如从下标x开始,x+1^2, x + 2^2, x + 3 ^2
- 这样就可以一次性探测比较长的距离,避免那些聚集带来的影响
- 二次探测的问题:
- 但是二次探测依然存在问题,比如我们连续插入的是32-112-82-2-192,那么它们依次累加的时候步长是相同的
- 也就是在这种情况下会造成步长不一的一种聚集,还是会影响效率(当然这中可能性相对于连续的数字会小一些)
- 怎么根本解决这个问题?让每个人的步长不一样,来看看哈希法吧
再哈希法
- 再哈希法:
- 二次探测的算法产生的探测序列步长是固定的:1,4,9,16,依次类推
- 现在需要一种方法:产生一种依赖关键字的探测序列,而不是每个关键字都一样
- 那么,不同的关键字即使映射到相同的数组下标,也可以使用不同的探测序列
- 再哈希法的做法就是:把关键字用另外一个哈希函数,再做一次哈希化,用这次哈希化的结果作为步长
- 对于指定的关键字,步长在整个探测中是不变的,不过不同的关键字使用不同的步长
- 第二次哈希化需要具备如下特点:
- 和第一个哈希函数不同(不然结果还是原来的位置)
- 不能输出为0(否则,将没有步长,每次探测都是原地踏步,算法就进入了死循环)
- 计算机专家已经设计出一种很好的哈希函数
- stepSize = constant - (key % constant)
- 其中constant是质数,且小于数组的容量
哈希化的效率
- 哈希表中执行插入和搜索操作效率是非常高的
- 如果没有冲突,那么效率就会更高
- 如果发生冲突,存取时间就依赖后来的探测长度
- 平均探测长度以及平均存取时间,取决于填装因子,随着填装因子变大,探测长度也越来越长
- 随着填装因子变大,效率下降的情况,在不同开放地址法方案中比链地址法更严重,所以我们来对比一下它们的效率,再决定我们选取的方案
- 先了解一个概念:填装因子
- 装填因子表示当前哈希表中已经包含的数据项和整个哈希表长度的比值
- 装填因子 = 总数据项 / 哈希表长度
- 开放地址法的装填因子最大是多少呢? 1 ,因为它必须寻找到空白的单元才能将元素放入
- 链地址法的装填因子呢?可以大于1,因为拉链法可以无限的延伸下去(当然后面效率就变低了)
***
优秀的哈希函数
- 好的哈希应该尽可能让计算的过程变得简单,提高计算的效率
- 哈希表的主要优点是它的速度
- 提高速度的一个办法就是让哈希函数中尽量少的有乘法和除法,因为它们的性能是比较低的
- 设计好的哈希函数应该具备哪些优点呢?
- 快速的计算
- 哈希表的优势在于效率,所以快速获取到对应的hashCode非常重要
- 均匀的分布
- 哈希表中,无论是链地址法还是开放地址法,当多个元素映射到同一位置的时候,都会影响效率
- 所以,优秀的哈希函数应该尽可能将元素映射到不同的位置,让元素在哈希表中均匀的分布
- 快速的计算
霍纳法则
均匀分布
- 均匀的分布
- 在设计哈希表时,我们已经有办法处理映射到相同下标值的情况:链地址法或者开放地址法
- 但是为了提高效率,最好的情况还是让数据在哈希表中均匀分布
- 因此,我们需要在使用常量的地方,尽量使用质数
- 质数的使用
- 哈希表的长度
- N次幂的底数
哈希函数的实现
// 设计哈希函数
// 1.将字符串转成比较大的数字:hashCode
// 2.将大的数字hashCode压缩到数组范围之内
function hashFunc(str, size) {
// 1.定义hashCode变量
let hashCode = 0
// 2.霍纳算法计算hashCode的值
// cats -> Unicode编码
for (let i = 0; i < str.length; i++) {
hashCode = 37 * hashCode + str.charCodeAt(i)
}
// 3.取余操作
let index = hashCode % size
return index
}
alert(hashFunc('abc',7))
封装哈希表
- 采用链地址法来实现哈希表
- 实现的哈希表(基于storage的数组)每个index对应的是一个数组(bucket)(当然基于链表也可以)
- bucket中继续使用数组将key和value都放进去
- 最终哈希表的数据格式:[[ [k,v], [k,v], [k,v]]]
//封装哈希表类
function HashTable() {
// 属性
// 数组
this.storage = []
// 当前存放了多少元素
this.count = 0
// 容量
this.limit = 7
// 方法
}
插入和修改操作封装
- 哈希表的插入和修改操作是同一个函数
- 因为当使用者传入一个[key, value]时
- 如果原来不存在key,那么就是插入操作
- 如果已经存在该key,那么就是修改操作
- 代码解析:
- 根据传入的key获取对应的hashCode,也就是数组的index
- 从哈希表的index位置上取出桶
- 查看上一步的bucket是否为null
- 为null,表示之前在该位置没有放置过任何的内容,那么就新建一个数组
- 查看是否之前已经放置过key对应的value
- 如果放置过,那么就依次替换操作,而不是插入新的数据
- 我们使用一个变量override来记录是否是修改操作
- 如果不是修改操作,那么插入新的数据
- 在bucket中push新的[key,vlaue]即可
- 注意:这里需要将count+1,因为数据增加了一项
// 插入&修改操作
HashTable.prototype.put = function(key, value) {
// 1.根据key获取对应的index
let index = this.hashFunc(key, this.limit)
// 2.根据index取出对应的bucket(桶)
let bucket = this.storage[index]
// 3.判断该bucket是否为null
if(bucket == null) {
bucket = []
this.storage[index] = bucket
}
// 4.判断是否是修改数据
for (let i = 0; i < bucket.length; i++){
let 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)
}
}
获取操作封装
- 根据key获取对应的index
- 根据index获取对应的bucket
- 判断bucket是否为null,如果为null,直接返回null
- 线性遍历bucket中每一个key是否等于传入的key,返回对应的value
- 遍历完成,没有找到对应的key,直接返回null
// 获取操作
HashTable.prototype.get = function (key) {
// 1.根据key获取对应的index
let index = this.hashFunc(key, this.limit)
// 2.根据index取出对应的bucket(桶)
let bucket = this.storage[index]
// 3.判断bucket是否为null
if(bucket == null) {
return null
}
// 4.遍历bucket
for( let i = 0; i < bucket.length; i++) {
let tuple = bucket[i]
if(key == tuple[0]){
return tuple[1]
}
}
// 5.依然没有找到,那么返回null
return null
}
删除操作封装
- 根据key获取对应的index
- 根据index获取对应的bucket
- 判断bucket是否存在,如果不存在,那么直接返回null
- 线性查找bucket,寻找对应的数据,并且删除
- 依然没有找到,返回null
// 删除操作
HashTable.prototype.remove = function (key) {
// 1.根据key获取对应的index
let index = this.hashFunc(key, this.limit)
// 2.根据index取出对应的bucket(桶)
let bucket = this.storage[index]
// 3.判断bucket是否为null
if (bucket == null) {
return null
}
// 4.遍历bucket
for (let i = 0; i < bucket.length; i++) {
let tuple = bucket[i]
if (key == tuple[0]) {
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.依然没有找到,返回null
return null
}
其他方法和测试
// 其他方法
// 判断哈希表是否为空
HashTable.prototype.isEmpty = function () {
return this.count == 0
}
// 获取哈希表中元素的个数
HashTable.prototype.size = function () {
return this.count
}
哈希表扩容的思想
- 为什么需要扩容?
- 目前,我们是将所有的数据项放在长度为7的数组中的
- 因为我们使用的是链地址法,loadFactor可以大于1,所以这个哈希表可以无限制的插入新数据
- 但是,随着数据量的增多,每一个index对应的bucket会越来越长,也就造成效率的降低
- 所以,在合适的情况对数组进行扩容,比如扩容两倍
- 如何进行扩容?
- 扩容可以简单的键容量扩大两倍(质数的问题后面说)
- 但是在这种情况下,所有的数据量一定要同时进行修改(重新调用哈希函数,来获取到不同的位置)
- 比如hashCode=12的数据项,在length=8的时候,idnex=4,在长度为16的时候,index=12
- 这是一个耗时的过程,但是如果数组需要扩容,那么这个过程是必要的
- 什么情况下扩容呢?
- loadFactor > 0.75的时候
哈希表扩容的实现
// 哈希表的扩/缩容
HashTable.prototype.resize = function (newLimit) {
// 1.保存旧的数组内容
let oldStorage = this.storage
// 2.重置所有的属性
this.storage = []
this.count = 0
this.limit = newLimit
// 3.遍历oldStorage中所有的bucket
for(let i = 0; i < oldStorage.length; i++) {
// 3.1取出对应的bucket
let bucket = oldStorage[i]
// 3.2判断bucket是否为null
if (bucket == null) {
continue
}
// 3.3 bucket中有数据,那么取出数据,重新输入
for(let j = 0; j < bucket.length; j++){
let tuple = bucket[i]
this.put(tuple[0], tuple[1])
}
}
}
普通判断质数算法
- 容量最好是质数
- 虽然在链地址法中将容量设置为质数,没有在开放地址法中重要
- 但是其实链地址法中质数作为容量也更有利于数据的均匀分布,所以,我们还是完成这个步骤
- 常见的面试题:判断一个数是质数
- 质数的特点:
- 质数也称为素数
- 质数表示大于1的自然数中,只能被1和自己整除的数
// 判断质数
// 只能被1和自己整除
// 不能被2到num-1之间的数字整除
function isPrime(num) {
for(let i = 2; i < num; i++) {
if( num % i == 0 ) {
return false
}
}
return true
}
alert(isPrime(3))
alert(isPrime(11))
alert(isPrime(12))
高效判断质数算法
- 对于每一个数n,并不需要判断2到n-1
- 其实我们遍历到等于sprt(n)即可
- 因为一个数进行因式分解,得到的两个数一定是一个小于等于sqrt(n), 一个大于等于sqrt(n)
function isPrime(num) {
let temp = parseInt(Math.sqrt(num))
for (let i = 2; i <= temp; i++) {
if( num % i == 0) {
return false
}
}
return true
}
alert(isPrime(3))
alert(isPrime(11))
alert(isPrime(12))
实现容量恒为质数
// 判断某个数字是否是质数
hashTable.prototype.isPrime = function(num) {
let temp = parseInt(Math.sqrt(num))
for (let 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
}
树结构
什么是树?
- 真实的树:
- 树通常有一个根,连接着根的是树干
- 树干到上面之后会进行分叉成树枝,树枝还会分叉成更小的树枝
- 在树枝的最后是叶子
- 类似于家谱和公司部门关系图
树的优点
- 数组:
- 优点:
- 数组的主要优点是根据下标值访问效率会很高
- 但是如果我们希望根据元素来查找对应的位置呢?
- 比较好的方式是先对数组进行排序,再进行二分查找
- 缺点:
- 需要先对数组进行排序,生成有序数组,才能提高查找效率
- 另外数组在插入和删除数据时,需要有大量的位移操作(插入到首位或者中间位置的时候),效率很低
- 优点:
- 链表:
- 优点:
- 链表的插入和删除操作效率都很高
- 缺点:
- 查找效率很低,需要从头开始依次访问链表中的每个数据项,直到找到
- 优点:
- 哈希表:
- 优点:
- 插入/查询/删除效率都非常高
- 缺点:
- 空间利用率不高,底层使用的数组,并且某些单元是没有被利用的
- 元素是无序的,不能按照固定的顺序来遍历哈希表中的元素
- 不能快速找出哈希表中的最大值或者最小值
- 优点:
- 树结构:
- 综合了上面数据结构的优点
- 效率一般情况下没有哈希表高
- 因为数据结构是非线性的,可以表示一对多的关系
- 比如文件的目录结构
树的术语
- 树:n( n>= 0) 个节点构成的有限集合
- 当n=0时,称为空树
- 对于任一颗非空树(n > 0), 它具备以下性质:
- 树中有一个称为"根"的特殊节点,用r表示;
- 其余节点可分为m(m>0)个互不相交的有限集T1,T2,… , Tm, 其中每个集合本身又是一棵树,称为原来树的"子树"
- 节点的度:节点的子树个数(子节点个数)
- 树的度:树的所有节点中最大的度数
- 叶节点:度为0的节点(也称为叶子节点)
- 父节点:有子树的节点是其子树的根节点的父节点
- 子节点:若A节点是B节点的父节点,则称B节点是A节点的子节点
- 兄弟节点:具有同一父节点的各节点彼此是兄弟节点
- 路径和路径长度:从节点n1到nk的路径为一个节点序列,路径所包含边的个数为路径的长度
- 节点的层次:规定根节点在1层,其他任一节点的层数是其父节点的层数加1
- 树的深度:树中所有节点的最大层次是这棵树的深度
树结构的表示
-
最普通的表示方式
- 因为子节点个数不确定,指针也不好确定
- 因为子节点个数不确定,指针也不好确定
-
儿子兄弟表示法
- 左指针指向子节点
- 右指针指向右边的兄弟节点
- 儿子-兄弟表示法旋转
- 发现其实所有的树本质上都可以使用二叉树模拟出来
二叉树
二叉树的概念
- 如果树中每个节点最多只能有两个子节点,这样的树就称为"二叉树"
- 二叉树很重要,因为几乎所有的树都可以表示成二叉树的形式
- 二叉树的定义
- 二叉树可以为空,也就是没有节点
- 若不为空,则它是由根节点和称为其左子树TL和右子树TR的两个不相交的二叉树组成
- 二叉树的五种形态:
二叉树的特性
- 二叉树有几个比较重要的特性,在笔试题中比较常见:
- 一个二叉树第i层的最大节点树: 2^(i-1), i >= 1
- 深度为k的二叉树有最大节点总数为: 2^k - 1, k >= 1
- 对于任何非空二叉树T,若n0表示叶节点的个数、n2是度为2的非叶节点个数,那么两者满足关系n0 = n2 + 1
完美二叉树
- 完美二叉树,也称为满二叉树
- 在二叉树中,除了最下一层的叶节点外,每层节点都有2个子节点,就构成了满二叉树
完全二叉树
- 完全二叉树
- 除二叉树最后一层外,其他各层的节点树都达到最大个数
- 且最后一层从左向右的叶节点连续存在,只缺右侧若干节点
- 完美二叉树是特殊的完全二叉树
- 下面不是完全二叉树,因为D节点还没有右节点,但是E节点有了左右节点
二叉树的存储
-
二叉树的存储常见的方式是数组和链表
-
使用数组
- 完全二叉树:按从上至下,从左到右顺序存储
- 左节点为父节点下标*2
- 右节点为父节点下标*2+1
- 非完全二叉树:
- 非完全二叉树要转成完全二叉树才可以按照上面的方案存储
- 但是会造成很大的空间浪费
- 完全二叉树:按从上至下,从左到右顺序存储
-
二叉树最常见的方式还是使用链表存储
- 每个节点封装成一个Node,Node中包含存储的数据、左节点的引用、右节点的引用
什么是二叉搜索树?
-
二叉搜索树(BST),也称二叉排序树或二叉查找树
-
二叉搜索树是一颗二叉树,可以为空:
-
如果不为空,满足以下性质(左小右大):
- 非空左子树的所有键值小于其根节点的键值
- 非空右子树的所有键值大于其根节点的键值
- 左、右子树本身也都是二叉搜索树
-
二叉树的特点:
- 左小右大
- 查找效率非常高
-
查找数据的方式就是二分查找的思想
- 查找所需的最大次数等于二叉搜索树的深度
- 插入节点时,也利用类似的方法,一层层比较大小,找到新节点合适的位置
二叉搜索树的封装
- 封装BinarySearchTree的构造函数
- 还需要封装一个用于保存每一个节点的类Node
- 该类包含三个属性:节点对应的key,指向的左子树,指向的右子树
- 对于BinarySearchTree来说,只需要保存根节点即可,因为其他节点都可以通过根节点找到
// 封装二叉搜索树
function BinarySearchTree() {
function Node(key) {
this.key = key
this.left = null
this.right = null
}
// 属性
this.root = null
// 方法
}
二叉搜索树常见操作
- 二叉搜索树有哪些常见的操作?
- insert(key):向树中插入一个新的键
- search(key):在树中查找一个键,如果节点存在,则返回true;如果不存在,则返回false
- inOrderTraverse:通过中序遍历方式遍历所有节点
- preOrderTraverse:通过先序遍历方式遍历所有节点
- postOrderTraverse:通过后序遍历方式遍历所有节点
- min:返回树中最小的值/键
- max:返回树中最大的值/键
- remove(key):从树中移除某个键
插入的封装
- 首先,根据传入的key,创建对应的Node
- 其次,向树中插入数据需要分成两种情况
- 第一次插入,直接修改根节点即可
- 其他次插入,需要进行相关的比较 决定插入的位置
// 封装二叉搜索树
function BinarySearchTree() {
function Node(key) {
this.key = key
this.left = null
this.right = null
}
// 属性
this.root = null
// 方法
// 插入数据:对外给用户调用的方法
BinarySearchTree.prototype.insert = function (key) {
// 1.根据key创建节点
let newNode = new Node(key)
// 2.判断根节点是否有值
if (this.root = null) {
this.root = newNode
} else {
this.insertNode(this.root, newNode)
}
}
BinarySearchTree.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)
}
}
}
}
遍历二叉搜索树
- 树的遍历:
- 遍历一棵树是指访问树的每个节点(也可以对每个节点进行某些操作)
- 二叉树的遍历常见的有三种方式:
- 先序遍历
- 中序遍历
- 后序遍历
先序遍历
- 遍历过程:
- 访问根节点
- 先序遍历其左子树
- 先序遍历其右子树
//先序遍历
BinarySearchTree.prototype.preOrderTraversal = function (handler) {
this.preOrderTraversalNode(this.root, handler)
}
BinarySearchTree.prototype.preOrderTraversalNode = function (node, handler) {
if (node != null) {
handler(node.key)
this.preOrderTraversalNode(node.left, handler)
this.preOrderTraversalNode(node.right, handler)
}
}
中序遍历
- 遍历过程为:
- 中序遍历其左子树
- 访问根节点
- 中序遍历其右子树
// 中序遍历
BinarySearchTree.prototype.midOrderTraversal = function (handler) {
this.midOrderTraversalNode(this.root , handler)
}
BinarySearchTree.prototype.midOrderTraversalNode = function (node, handler) {
if(node != null) {
this.midOrderTraversalNode(node.left,handler)
handler(node.key)
this.midOrderTraversalNode(node.right,handler)
}
}
后序遍历
- 遍历过程为:
- 后序遍历其左子树
- 后序遍历其右子树
- 访问根节点
// 后序遍历
BinarySearchTree.prototype.postOrderTraversal = function (handler) {
this.postOrderTraversalNode(this.root,handler)
}
BinarySearchTree.prototype.postOrderTraversalNode = function (node, handler) {
if(node != null){
this.postOrderTraversalNode(node.left,handler)
this.postOrderTraversalNode(node.right,handler)
handler(node.key)
}
}
最大值最小值
// 最大值和最小值
BinarySearchTree.prototype.min = function() {
let node = this.root
while(node.left !== null ) {
node = node.left
}
return node.key
}
BinarySearchTree.prototype.max = function () {
let node = this.root
while(node.right !== null ) {
node = node.right
}
return node.key
}
搜索特定的值
// 搜索特定的值
// 1.循环方式
BinarySearchTree.prototype.search = function (key) {
let node = this.root
while (node != null ) {
if( key < node.key){
node = node.left
} else if( key > node.key) {
node= node.right
} else {
return true
}
}
return false
}
删除操作分析
- 找到要删除的节点,没有招待,不需要删除
- 找到删除节点
- 删除叶子节点
- 删除只有一个子节点的节点
- 删除有两个子节点的节点
// 二叉搜索树的删除
BinarySearchTree.prototype.remove = function (key) {
let current = this.root
let parent = null
let isLeftChild = true
// 1.开始查找删除的节点
while(current.key != key) {
parent = current
if(key < current.key) {
isLeftChild = true
current = current.left
}else {
isLeftChild = false
current = current.right
}
if (current == null ) return false
}
// 2.根据对应的情况删除节点
// 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
}
if(isLeftChild){
parent.left = current.left
} else {
parent.right = current.left
}
} else if (current.left == null ) {
if(current == this.root) {
this.root = current.right
}
if(isLeftChild) {
parent.left = current.right
} else {
parent.right = current.right
}
}
// 2.3 删除的节点有两个子节点
else {
// 1.获取后继节点
let successor = this.getSuccessor(current)
// 2.判断是否为根节点
if(current === this.root){
this.root = successor
} else if (isLeftChild) {
parent.left = successor
} else {
parent.right = successor
}
successor.left = current.left
}
}
// 获取后继节点,即从要删除的节点的右边开始查找最小的值
function getSuccessor(delNode) {
let successor = delNode
let current = delNode.right
let successorParent = delNode
// 循环查找current的右子树节点
while(current !== null ) {
successorParent = successor
successor = current
current = current.left
}
// 判断寻找到的后继节点是否是要删除的节点的right
if(successor !== delNode.right) {
successorParent.left = successor.right
successor.right = delNode.right
}
return successor
}
二叉搜索树的缺陷
- 二叉搜索树作为数据存储的结构有重要的优势:
- 可以快速地找到给定关键字的数据项,并且可以快速地插入和删除数据项
- 但是,二叉搜索树有一个很麻烦的问题:
- 如果插入的数据是有序的数据,比如:
- 有一颗初始化为9 8 12的二叉树
- 插入下面的数据:7 6 5 4 3(左边会特别长)
- 深度深,查找效率低
- 如果插入的数据是有序的数据,比如:
- 非平衡树:
- 比较好的二叉搜索树数据应该是左右分布均匀的
- 但是插入连续数据后,分布的不均匀,我们称之为非平衡树
- 对于一颗平衡二叉树来说,插入/查找等操作的效率是O(logN)
- 对于一颗非平衡二叉树,相当于编写了一个链表,查找效率变成了O(N)
树的平衡性
- 为了能以较快的时间O(logN)来操作一棵树,我们需要保证树总是平衡的:
- 至少大部分是平衡的,那么时间复杂度也是接近O(logN)的
- 也就是说树中的每个节点左边的子孙节点的个数,应该尽可能的等于右边的子孙节点的个数
- 常见的平衡树有哪些呢?
- AVL树:
- 是最早的一种平衡树,它有些办法保持树的平衡(每个节点多存储了一个额外的数据)
- 因为AVL树是平衡的,所以时间复杂度也是O(logN)
- 但是,每次插入和删除操作相对于红黑树效率都不高,所以整体效率不如红黑树
- 红黑树:
- 红黑树也通过一些特性来保持树的平衡
- 因为是平衡树,所以时间复杂度是在O(logN)
- 另外插入和删除操作,红黑树的性能要优于AVL树,所以现在平衡树的应用基本都是红黑树
红黑树
- 红黑树是数据结构中难点中的难点
红黑树的规则
- 红黑树,除了符合二叉搜索树的基本规则外,还添加了以下特性:
- 节点是红色或者黑色
- 根节点是黑色
- 每个叶子节点都是黑色的空节点(NULL节点)
- 每个红色节点的两个子节点都是黑色。(从每个叶子到根的所有路径上不能有两个连续的红色节点)
- 从任一节点到每个叶子的所有路径都包含相同数目的黑色节点
红黑树的相对平衡
- 前面的约束,确保了红黑树的关键特性:
- 从根到叶子的最长可能路径,不会超过最短可能路径的两倍长
- 结果就是这个树基本是平衡的
- 虽然做不到绝对的平衡,但是可以保证在最坏的情况下,依然是高效的
- 为什么可以做到 最长路径不超过最短路径的两倍 呢?
- 性质4 决定了路径不能有两个相连的红色节点
- 最短的可能路径都是黑色节点
- 最长的可能路径是红色和黑色交替
- 性质5所有路径都有相同数目的黑色节点
- 这就表明了没有路径能多余任何其他路径的两倍长
- 最短路径为只有黑色的路径,长度为n,最长路径为红黑交替的路径,长度为n+n-1=2n-1
红黑树的变色
- 插入一个新节点时,有可能树不再平衡,可以通过三种方式的变换,让树保持平衡
- 换色-左旋转-右旋转
- 变色:
- 为了重新符合红黑树的规则,尝试把红色节点变为黑色,或者 黑色节点变为红色
- 首先,插入的新节点通常都是红色的
- 因为在插入节点红色的时候,有可能插入一次是不违反红黑树任何规则的
- 而插入黑色节点,必然导致有一条路径上多了黑色节点,这是很难调整的
- 红色节点可能出现红红相连的情况,但是这种情况可以通过颜色调换和旋转来调整
红黑树的旋转
- 左旋转
- 逆时针旋转红黑树的两个节点,使得父节点被自己的右孩子取代,而自己成为自己的左孩子
- 右旋转:
- 顺时针旋转红黑树的两个节点,使得父节点被自己的左孩子取代,而自己成为自己的右孩子
插入操作
- 插入的情况
- 设要插入的节点为N,其父节点为P
- 其祖父节点为G,其父亲的兄弟节点为U(即P和U是同一个节点的子节点)
- NPGU
- 情况一:
- 新节点N位于树的根上,没有父节点
- 这种情况下,我们直接将红色变成黑色即可
- 情况二:
- 新节点的父节点P是黑色
- 情况三:
- P为红色,U也为红色
- 父红叔红祖黑
- 变成父黑叔黑祖红
-
可能出现的问题:
- G的父节点是红色,可以递归处理调整颜色(把NPGU当成一个红节点插入树中)
- 如果递归调整颜色到了根节点,就需要进行旋转了
-
情况四:
- 父红叔黑祖黑,N是左儿子
- 处理方式:
- 父黑
- 祖红
- 右旋转
-
情况五:
- 父红叔黑祖黑,N是右儿子
- 处理方式:
- 以P为根左旋转
- 将P作为新插入的红色节点考虑,形成情况四的结果
- N自己变成黑色
- 祖变成红色
- 以祖为根,进行右旋转
红黑树案例
案例:依次插入 10 9 8 7 6 5 4 3 2 1
红黑树的删除
- 红黑树的删除
- 结合二叉搜索树的删除操作
- 红黑树的插入操作
图论
什么是图?
- 图结构是一种与树结构相似的数据结构
- 在数学概念上,树是图的一种,以图为研究对象,研究顶点和边组成的图形的数学理论和方法
- 主要研究的目的是事物之间的关系,顶点代表事物,边代表两个事物间的关系
图结构的应用
- 人与人之间的关系网
- 杭州地铁图
- 城市之间距离
图结构的特点
- 一组顶点:通常用V表示顶点的集合
- 一组边:通常用E表示边的集合
- 边是顶点和顶点之间的连线
- 边可以是有向的,也可以是无向的
欧拉七桥问题
- 欧拉给出了连通图可以一笔画的充要条件是:
- 奇点的数目不是0个就是2个
- 连到一点的边的数目如是奇数条,就称为奇点
- 如果是偶数条就称为偶点
- 想一笔画成,中间点均是偶点
- 奇点只能在两端
- 个人思考
- 将岛和桥抽象成了点和边
- 抽象是数学的本质
- 汇编语言是对机器语言的抽象,高级语言是对汇编语言的抽象
图的术语
- 顶点:图中的某一个节点
- 边:顶点之间的连线
- 相邻顶点:一条边连接在一起的顶点
- 度:是相邻顶点的数量
- 路径: 是顶点的一个连续序列
- 简单路径:要求不包含重复的顶点
- 回路:第一个顶点和最后一个顶点相同
- 无向图:所有的边没有方向
- 有向图:图中的边是有方向的
- 无权图:边没有携带权重
- 带权图:边有一定的权重(比如花费的时间和票价)
图的邻接矩阵表示
- 邻接矩阵
- 让每个节点和一个整数相关联,该整数作为数组的下标值
- 我们用一个二维数组来表示顶点之间的连接
- 通过二维数组,我们可以很快的找到一个顶点和哪些顶点有连线
- 无向图中是个对称矩阵,对角线都是0
- 邻接矩阵的问题:
- 如果图是一个稀疏图
- 那么矩阵中将存在大量的0,意味着我们浪费了计算机存储空间来表示根本不存在的边
图的邻接表表示
- 邻接表
- 邻接表由图中每个顶点以及和顶点相邻的顶点列表组成
- 这个列表有很多种方式来存储:**数组/链表/字典(哈希表)**都可以
- 图片解析:
- 我们要表示A顶点有关联的顶点,A和BCD有边
- 那么我们可以通过A找到对应的数组/链表/字典,在取出其中的内容就可以
- 邻接表的问题:
- 计算出度比较简单(出度:指向别人的数量,入度:指向自己的数量)
- 计算入度需要遍历整个表,非常麻烦
图结构封装
<script src="字典.js"></script>
<script>
// 封装图结构
function Graph() {
// 属性:顶点(数组)/边(字典)
this.vertexes = []
this.edges = new Dictionary()
// 方法
}
</script>
添加顶点边
// 方法
// 添加顶点
Graph.prototype.addVertex = function (v) {
this.vertexes.push(v)
this.edges.set(v,[])
}
// 添加边的方法
Graph.prototype.addEdge = function (v1, v2) {
this.edges.get(v1).push(v2)
// 无向图
this.edges.get(v2).push(v1)
}
toString()方法
//实现toString方法
Graph.prototype.toString = function () {
// 1.定义字符串,保存最终结果
let resultString = ""
// 2.遍历所有的顶点,以及顶点对应的边
for(let i = 0; i < this.vertexes.length; i++) {
resultString += this.vertexes[i] + "->"
let vEdges = this.edges.get(this.vertexes[i])
for(let j = 0; j< vEdges.length; j++) {
resultString += vEdges[j] + " "
}
resultString += "\n"
}
return resultString
}
图的遍历
- 图的遍历思想
- 和树的遍历思想是一样的
- 图的偏历意味着需要将图中每个顶点访问一遍,并且不能有重复的访问
- 有两种算法可以对图进行遍历
- 广度优先搜索BFS
- 深度优先搜索DFS
- 两种遍历算法,都需要明确指定第一个被访问到的顶点
遍历的思想
- 两种算法的思想:
- BFS:基于队列,入队列的顶点先被探索
- DFS:基于栈或使用递归,通过将顶点存入栈中,顶点是沿着路径被探索的,存在新的相邻顶点就去访问
- 为了记录顶点是否被访问过,我们使用三种颜色来反应它们的状态
- 白色:顶点没有被访问
- 灰色:被访问过,但没有被探索过
- 黑色:被访问过且被探索过其邻接点
// 初始化状态颜色
Graph.prototype.initializeColor = function () {
let colors = []
for(let i = 0; i < this.vertexes.length; i++) {
color[this.vertexes[i]] = "white"
}
return colors
}
广度优先搜索BFS
- 广度优先搜索算法的思路:
- 从指定的第一个顶点开始遍历图,先访问其所有的相邻点,就像一次访问图的一层
- 就是先宽后深的访问顶点
- 实现:
- 创建一个队列Q
- 将v标注为灰色,并放入队列Q
- 如果Q非空,执行下面的步骤:
- 将v从Q中取出队列
- 将v标注为被发现的灰色
- 将v所有的未被访问过的邻接点,加入到队列中
- 将v标志为黑色
// 初始化状态颜色
Graph.prototype.initializeColor = function () {
let colors = []
for(let i = 0; i < this.vertexes.length; i++) {
colors[this.vertexes[i]] = "white"
}
return colors
}
//广度优先搜索()
Graph.prototype.bfs = function(initV, handler) {
// 1.初始化颜色
let colors = this.initializeColor()
// 2.创建队列
let queue = new Queue()
// 3.将顶点加入到队列中
queue.enqueue(initV)
// 4.循环从队列中取出元素
while(!queue.isEmpty()){
// 4.1从队列中取出一个顶点
let v = queue.dequeue()
// 4.2 获取和顶点相连的另外顶点
let vList = this.edges.get(v)
// 4.3将v的颜色设置灰色
colors[v] = "gray"
// 4.4遍历所有的顶点,并且加入到队列中
for(let i = 0; i < vList.length; i++) {
let e = vList[i]
if(colors[e] == "white") {
colors[e] = "gray"
queue.enqueue(e)
}
}
// 4.5 访问顶点
handler(v)
// 4.6 将顶点设置黑色
colors[v] = "black"
}
}
深度优先搜索
- 深度优先搜索的思路:
- 深度优先搜索算法将会从第一个指定的顶点开始遍历图,沿着路径知道这条道路最后被访问了
- 接着原路回退并探索下一条路径
- 算法的实现
- 使用栈或者递归
- 方便书写,我们使用递归(递归本质上就是函数栈的调用)
// 深度优先搜索(DFS)
Graph.prototype.dfs = function (initV, handler) {
// 1.初始化颜色
let 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相邻的其他顶点
let vList = this.edges.get(v)
for (let i = 0; i < vList.length; i++) {
let e = vList[i]
if (colors[e] == "white") {
this.dfsVisit(e, colors, handler)
}
}
colors[v] = "black"
}
大O表示法
- 推导大O表示法的方式:
- 用常量取代运行时间中所有的加法常量
- 在修改后的运行次数函数中,只保留最高阶项
- 如果最高存在且不为1,则去除与这个项相乘的常数
排序算法
- 排序算法
- 简单排序:冒泡排序、选择排序、插入排序
- 高级排序:希尔排序、快速排序
封装列表
- 方便我们测试排序算法
// 创建列表类
function ArrayList() {
this.array = []
ArrayList.prototype.insert = function (item) {
this.array.push(item)
}
ArrayList.prototype.toString = function () {
return this.array.join("-")
}
// 实现排序算法
}
let list = new ArrayList()
list.insert(66)
list.insert(88)
list.insert(12)
list.insert(87)
list.insert(100)
list.insert(5)
list.insert(566)
list.insert(23)
alert(list)
冒泡排序
思路
代码
// 实现排序算法
// 交换数据方式的抽取
ArrayList.prototype.swap = function (m, n) {
let temp = this.array[m]
this.array[m] = this.array[n]
this.array[n] = temp
}
// 冒泡排序
ArrayList.prototype.bubblesort = function () {
// 1.获取数组的长度
let length = this.array.length
// i为比较次数,从length-1 比较到 第0个位置
for (let i = length - 1; i > 0; i--) {
// 第一次进来, i = 0 ,比较0 和1位置
// 第一次进来结束时:i = length - 2,
for (let j = 0; j < i; j++) {
if (this.array[j] > this.array[j + 1]) {
this.swap(j, j + 1)
}
}
}
}
选择排序
思路
代码
// 选择排序
ArrayList.prototype.selectionSort = function () {
// 1.获取数组长度
let length = this.array.length
// 2.
for(let i = 0; i < length-1; i++) {
let min = i
for(let j = i + 1; j < length; j++) {
if(this.array[min] > this.array[j]){
min = j
}
}
this.swap(min, i)
}
}
插入排序
思路
代码
// 插入排序
ArrayList.prototype.insertionSort = function () {
// 1.获取数组的长度
let length = this.array.length
// 2.外层循环,从第1个位置开始获取数据,向前面局部有序进行插入
for (let i = 1; i < length; i++){
let temp = this.array[i]
let 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
}
}
希尔排序
思路
增量
代码
// 希尔排序
ArrayList.prototype.shellSort = function () {
// 1.获取数组的长度
let length = this.array.length
// 2.初始化的增量(gap -> 间隔/间隙)
let gap = Math.floor(length / 2)
// 3.while循环(gap不断减小)
while(gap >= 1) {
// 4. 以gap作为间隔,进行分组,对分组进行插入排序
for (let i = gap; i < length; i++) {
let temp = this.array[i]
let 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
}
}
gap = Math.floor(gap / 2)
}
效率
快速排序
- 快速排序几乎可以说是目前所有排序算法中,最快的一种排序算法
思想
- 是冒泡排序的升级版
- 冒泡排序需要多次交换,才能在一次循环中,将最大值放在正确位置
- 而快速排序可以在一次循环中(其实是递归调用),找到某个元素的正确位置,并且该元素之后不需要任何移动
- 快速排序最重要的思想就是分而治之
思路
- 选定枢纽,放到最后
- 前面左指针找到比枢纽大的,右指针找到比枢纽小的
- 直到两个指针指向一个元素
- 枢纽和该元素互换位置
枢纽
如何选择枢纽?
- 直接选择第一个元素作为枢纽
- 使用随机数
- 取头、中、尾的中位数
// 1.选择枢纽
ArrayList.prototype.median = functionn (left, right) {
// 1.取出中间的位置
let 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[right]){
this.swap(left, right)
}
// 3.将center换到right-1的位置,省了移动right的一步
this.swap(center, right -1 )
return this.array[right - 1]
}
代码
// 快速排序
// 1.选择枢纽
ArrayList.prototype.median = function (left, right) {
// 1.取出中间的位置
let 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的位置,省了移动right的一步
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.获取枢纽
let pivot = this.median(left, right)
// 3.定义变量,用于记录当前找到的位置
let i = left
let 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
}
}
// 5. 将枢纽放到正确的位置,i的位置
this.swap(i,right-1)
// 6.分而治之
this.quick(left, i-1)
this.quick(i + 1, right)
}