一、哈希表
1.什么是哈希表?
哈希表通常是基于数组实现的,但是相对于数组,它存在更多优势:
- 哈希表可以提供非常快速的插入-删除-查找操作;
- 无论多少数据,插入和删除值都只需要非常短的时间,即O(1)的时间级。实际上,只需要几个机器指令即可完成;
- 哈希表的速度比树还要快,基本可以瞬间查找到想要的元素。但是相对于树来说编码要简单得多。
哈希表同样存在不足之处:
- 哈希表中的数据是没有顺序的,所以不能以一种固定的方式(比如从小到大 )来遍历其中的元素。
- 通常情况下,哈希表中的key是不允许重复的,不能放置相同的key,用于保存不同的元素。
哈希表是基于数组实现的,但是相对于数组和链表来说,它的查找效率更高,因为它通常会对插入元素的下标值进行变换。这种变换称为哈希函数,通过哈希函数获取hashCode,并通过取余操作获取元素在数组中的下标。
哈希表的一些概念:
- 哈希化:将大数字转化成数组范围内下标的过程,称之为哈希化;
- 哈希函数:我们通常会将单词转化成大数字,把大数字进行哈希化的代码实现放在一个函数中,该函数就称为哈希函数;
- 哈希表:对最终数据插入的数组进行整个结构的封装,得到的就是哈希表。
综上所述,哈希表简单理解,就是把单词通过幂的连乘(可以唯一标识一个玩意儿,例如6543=6 * 103 + 5 * 102 + 4 * 10 + 3,还有cats = 3 * 273 + 1 * 272 + 20 * 27 + 17 =60337;)唯一标识出来,然后放入一个长度为n的数组中,放入时通过取余来判断放入的位置(比如数组长度是12,那么余数的范围是0-11,正好对应数组的12个下标)
2.哈希化之后的位置冲突解决
哈希化过后的下标依然可能重复,如何解决这个问题呢?这种情况称为冲突,冲突是不可避免的,我们只能解决冲突。
(1)链地址法(拉链法)
如下图所示,我们将每一个数字都对10进行取余操作,则余数的范围0~9作为数组的下标值。并且,数组每一个下标值对应的位置存储的不再是一个数字了,而是存储由经过取余操作后得到相同余数的数字组成的数组或链表。
这样可以根据下标值获取到整个数组或链表,之后继续在数组或链表中查找就可以了。而且,产生冲突的元素一般不会太多。
总结:链地址法解决冲突的办法是每个数组单元中存储的不再是单个数据,而是一条链条,这条链条常使用的数据结构为数组或链表,两种数据结构查找的效率相当(因为链条的元素一般不会太多)。
(2)开放地址法
开放地址法的主要工作方式是寻找空白的单元格来放置冲突的数据项。
根据探测空白单元格位置方式的不同,可分为三种方法:
线性探测、二次探测、再哈希法。
装填因子:当前哈希表中已经包含的数据项和整个哈希表长度的比值
装填因子 = 总数据项 / 哈希表长度
开放地址法的装填因子最大为1,因为只有空白的单元才能放入元素;
链地址法的装填因子可以大于1,因为只要愿意,拉链法可以无限延伸下去。
3.优秀的哈希函数
哈希表的优势在于它的速度,所以哈希函数不能采用消耗性能较高的复杂算法。提高速度的一个方法是在哈希函数中尽量减少乘法和除法。
性能高的哈希函数应具备以下两个优点:
- 快速的计算:减少乘法;
- 均匀的分布:数组长度为质数;
(1)快速的计算
霍纳法则:在中国霍纳法则也叫做秦久韶算法,具体算法为:
求多项式的值时,首先计算最内层括号内一次多项式的值,然后由内向外逐层计算一次多项式的值。这种算法把求n次多项式f(x)的值就转化为求n个一次多项式的值。
变换之前:
乘法次数:n(n+1)/2次;
加法次数:n次;
变换之后:
乘法次数:n次;
加法次数:n次;
如果使用大O表示时间复杂度的话,直接从变换前的O(N²)降到了O(N)。
(2)均匀分布
为了保证数据在哈希表中均匀分布,当我们需要使用常量的地方,尽量使用质数;比如:哈希表的长度、N次幂的底数等。
Java中的HashMap采用的是链地址法,哈希化采用的是公式为:index = HashCode(key)&(Length-1)
,即将数据化为二进制进行与运算,而不是取余运算。这样计算机直接运算二进制数据,效率更高。但是JavaScript在进行叫大数据的与运算时会出现问题,所以以下使用JavaScript实现哈希化时还是采用取余运算。
4.封装哈希表
所有的冲突解决方案以:链地址法(拉链法)为主。
(1)基础结构的搭建
这里需要初始化三个属性,一个是用来存储元素的数组storage,一个是用来计算装载因子的变量:元素个数count,一个是数组长度limit。
其中装载因子是要用于判断数组是否需要扩容,如果装载因子>0.75,那么就要扩容,装载因子<0.25就要缩容。
//封装哈希表(基于链地址法解决冲突情况)
class HashTable {
constructor() {
//1.定义一个数组来存储元素
this.storage = [];
//2.count:已经存储的总长度,用来计算装载因子
//装载因子(已经存储的/数组总长)用来判断是否扩容
this.count = 0;
//3.数组的长度
this.limit = 7;
}
}
(2)哈希函数的设计
通过前面部分的分析,我们的哈希函数要分为两步:
1、把字符串转换成较大的hashCode(这里幂的底数选择37)
2、hashCode和哈希表的长度取余,返回位置
//1、将字符串转换成比较大的数字:hashCode
//2、把hashCode压缩到数组范围(size)内
hashFun(str, size) {
//1.定义hashCode变量
let hashCode = 0;
//2.霍纳算法O(N),计算hashCode的值
// cats => 获取Unicode编码
for (let i = 0; i < str.length; i++) {
//这里的理论比较不好理解,代码先记住吧。
hashCode = 37 * hashCode + str.charCodeAt(i);
}
//3.取余数
let index = hashCode % size;
//4.返回位置索引
return index;
}
(3)put插入/修改的方法
哈希表的插入和修改操作是同一个函数:因为,当使用者传入一个<key,value>时,如果原来不存在该key,那么就是插入操作,如果原来已经存在该key,那么就是修改操作。
主要思路:
- 先通过哈希函数计算出key要插入的位置index
- 定义一个bucket来存储该位置的值(初始没有值是undefined)
- 判断插入的位置是否有bucket,如果没有就创建一个空数组
- 如果有了bucket,就遍历它,对比每个key,看是否已经添加,如果已经添加那么就要修改当前key对应的value值,修改完return出去
- 如果遍历完没有return,说明需要添加,直接push添加就行了。
put(key, value) {
//1.获取位置
let index = this.hashFun(key, this.limit);
let bucket = this.storage[index];
console.log('当前桶:', index, bucket);
//2.判断插入的位置是否已经创建数组(桶)
if (bucket == undefined) {
bucket = []; //创建桶然后放到索引位置
this.storage[index] = bucket;
}
//3.如果已经有了桶,那么就遍历看是否已经添加
for (let i = 0; i < bucket.length; i++) {
let tuple = bucket[i];
if (tuple[0] == key) {
//3.1如果添加了,就修改value
tuple[1] = value;
return true;
}
}
//4.循环结束如果没有返回,那么就要新增
bucket.push([key, value]);
//5.装载因子增加
this.count++;
return true;
}
测试:
let hashTable = new HashTable();
hashTable.put('name', 'zqq'); //5位置
hashTable.put('age', 18); //2位置
console.log(hashTable.storage);
//结果:[null, null, [['age',18]], null, null, [['name','zqq']], null]
(4)get获取某个key对应的value
主要思路:
- 首先,根据key通过哈希函数获取它在storage中对应的索引值index;
- 然后,根据索引值获取对应的bucket;
- 接着,判断获取到的bucket是否为null,如果为null,直接返回null;
- 随后,线性遍历bucket中每一个key是否等于传入的key。如果等于,直接返回对应的value;
- 最后,遍历完bucket后,仍然没有找到对应的key,直接return null即可。
get(key) {
//1.先找到位置
let index = this.hashFun(key, this.limit);
//2.找到该位置的桶
let bucket = this.storage[index];
//3.判断桶是否为空,为空说明肯定没有key
if(bucket == undefined) return null;
//4.如果不为空,那么就遍历桶
for(let i = 0; i < bucket.length; i++) {
let tuple = bucket[i]; //存储每一轮的元组
//5.如果找到key,就返回它对应的value
if(tuple[0] == key) {
return tuple[1];
}
}
//6.如果遍历完没有return,说明没找到
return null;
}
测试:
let hashTable = new HashTable();
hashTable.put('name', 'zqq');
hashTable.put('age', 18);
console.log(hashTable.storage);
console.log(hashTable.get('age')); //18
console.log(hashTable.get('www')); //null
(5)remove删除某个key对应的元组
主要思路:
- 首先,根据key通过哈希函数获取它在storage中对应的索引值index;
- 然后,根据索引值获取对应的bucket;
- 接着,判断获取到的bucket是否为null,如果为null,直接返回null;
- 随后,线性查找bucket,寻找对应的数据,并且删除;
- 最后,依然没有找到,返回null;
remove(key) {
//1.先找到位置
let index = this.hashFun(key, this.limit);
//2.找到该位置对应的桶
let bucket = this.storage[index];
//3.如果桶是空的,那么直接返回true
if(bucket == undefined) return null;
//4.如果桶不为空,就遍历寻找key所对应的元组
for(let i = 0; i < bucket.length; i++) {
let tuple = bucket[i];
if(tuple[0] == key) {
bucket.splice(i,1);
this.count --; //别忘了插入个数-1
return tuple[1];
}
}
//5.如果走到这里,说明没有这个元素
return null;
}
测试:
let hashTable = new HashTable();
hashTable.put('name', 'zqq');//位置5
hashTable.put('age', 18); //位置2
console.log(hashTable.storage);//结果:[null, null, [['age',18]], null, null, [['name','zqq']], null]
//测试获取元素
console.log(hashTable.get('age')); //18
console.log(hashTable.get('www')); //null
//测试删除
console.log(hashTable.remove('age'));//18
console.log(hashTable.storage);//[null,[],null,null,null,[['name','zqq']],null]
(6)其他方法:isEmpty、size
//判断哈希表是否为null
isEmpty() {
return this.count == 0
}
//获取哈希表中元素的个数
size() {
return this.count
}
3.哈希表的扩容
(1)为什么需要扩容?
前面我们在哈希表中使用的是长度为7的数组,由于使用的是链地址法,装填因子(loadFactor)可以大于1,所以这个哈希表可以无限制地插入新数据。
但是,随着数据量的增多,storage中每一个index对应的bucket数组(链表)就会越来越长,这就会造成哈希表效率的降低
什么情况下需要扩容?
- 常见的情况是loadFactor > 0.75的时候进行扩容;
如何进行扩容?
- 简单的扩容可以直接扩大两倍(关于质数,之后讨论);
- 扩容之后所有的数据项都要进行同步修改;
实现思路:
- 首先,定义一个变量,比如oldStorage指向原来的storage;
- 然后,创建一个新的容量更大的数组,让this.storage指向它;
- 最后,将oldStorage中的每一个bucket中的每一个数据取出来依次添加到this.storage指向的新数组中;
(2)扩容代码
//哈希表的扩容,传入新的数组长度
resize(newLimit) {
//1.定义一个变量存储旧数组
let oldStorage = this.storage;
//2.初始化数组长度
this.storage = [];
this.count = 0;
this.limit = newLimit;
//3.把旧数组中存放的东西依次拿出来添加到新数组
for(let i = 0; i < oldStorage.length; i++) {
let bucket = oldStorage[i];
//如果当前桶里没东西,就继续下一轮循环
if(bucket == undefined) continue;
//如果桶里有东西,就遍历它
for(let i = 0; i < bucket.length; i++) {
//调用put方法依次把元组放进新的数组中
//注意put方法接收两个参数(key,value)
let tuple = bucket[i];
this.put(tuple[0],tuple[1]);
}
}
//4.直到所有的桶里的元素都迁移完毕,那么扩容就欧了
}
上面这个方法应该什么时候调用呢?
在我们插入元素且count改变后,应该对count进行一个判断,如果装载因子loadFactor > 0.75就进行扩容(这里先两倍)
//判断是否需要扩容操作
count++;
if(this.count > this.limit * 0.75){
this.resize(this.limit * 2)
}
在我们删除元素且count改变后,进行判断,如果装载因子loadFactor< 0.25就缩容。(这里先两倍)。
this.count --; //别忘了插入个数-1
if(this.limit > 7 && this.count < this.limit * 0.25) {
this.resize(Math.floor(this.limit / 2));
(3)如何判断一个数为质数?
所谓质数,就是只能被1和它本身整除的数,那么有了这个思路,我们可以这样去判断一个数是否为质数:
//判断一个数是否为质数
function isPrime(num) {
if(num <= 1) return false;
for (let i = 2; i < num; i++) {
if (num % i == 0) return false;
}
return true;
}
但是实际上还有更高效的判断方法,想象一下,一个数字如果要找两个因数,那么这两个因数一定是一个a在平方根的左侧,另一个b在平方根的右侧,如果我们把循环条件设置为开平方根的数字,那么如果左侧的a中没有可以整除的结果,右侧的b就不用再找了。
//判断一个数是否为质数更高效的方法
function isPrimeNb(num) {
if(num <= 1) return false;
let sqrtNum = Math.sqrt(num);
for (let i = 2; i < sqrtNum; i++) {
if (num % i == 0) return false;
}
return true;
}
(4)实现扩容时,容量为质数
先把上一步写的方法加进去:
//判断一个数是否为质数
isPrime(num) {
if(num <= 1) return false;
let sqrtNum = Math.sqrt(num);
for (let i = 2; i < sqrtNum; i++) {
if (num % i == 0) return false;
}
return true;
}
把当前数变成质数:
扩容/缩容之后,通过循环调用isPrime判断得到的容量是否为质数,不是则+1,直到是为止。比如原长度:7,2倍扩容后长度为14,14不是质数,14 + 1 = 15不是质数,15 + 1 = 16不是质数,16 + 1 = 17是质数,停止循环,由此得到质数17。
//把当前数变成质数
getPrime(num) {
while(!isPrime(num)) {
num++;
}
return num;
}
然后在插入方法中:
//6.判断是否需要扩容
if(this.count > this.limit * 0.75) {
let newLimit = this.getPrime(this.limit * 2);
this.resize(newLimit);
}
在删除方法中:
if(this.limit > 7 && this.count < this.limit * 0.25) {
let newLimit = Math.floor(this.limit / 2);
let newPrimeLimit = this.getPrime(newLimit);
this.resize(newPrimeLimit);
}
测试:
let hashTable = new HashTable();
hashTable.put('name', 'zqq');
hashTable.put('age', 18);
hashTable.put('class1','Tom')
hashTable.put('class2','Mary')
hashTable.put('class3','Gogo')
hashTable.put('class4','Tony')
hashTable.put('class5','5')
hashTable.put('class6','6')
hashTable.put('class7','7')
hashTable.put('class8','8')
console.log(hashTable.limit); //17
二、图
1.什么是图?
图结构是一种与树结构有些相似的数据结构,图的特点:
- 一组顶点:通常用 V (Vertex)表示顶点的集合;
- 一组边:通常用 E (Edge)表示边的集合;
边是顶点和顶点之间的连线;
边可以是有向的,也可以是无向的。比如A----B表示无向,A —> B 表示有向;
表示图的常用方式为:邻接矩阵。
可以使用二维数组来表示邻接矩阵;
邻接矩阵让每个节点和一个整数相关联,该整数作为数组的下标值;
使用一个二维数组来表示顶点之间的连接;
邻接矩阵的问题:
如果图是一个稀疏图,那么邻接矩阵中将存在大量的 0,造成存储空间的浪费。
另外一种表示图的常用方式为:邻接表。
邻接表由图中每个顶点以及和顶点相邻的顶点列表组成;
这个列表可用多种方式存储,比如:数组/链表/字典(哈希表)等都可以;
邻接表的问题:
邻接表可以简单地得出出度,即某一顶点指向其他顶点的个数;
但是,邻接表计算入度(指向某一顶点的其他顶点的个数称为该顶点的入度)十分困难。此时需要构造逆邻接表才能有效计算入度。
2.封装图结构
图结构应该有两个属性,顶点和边
顶点(vertexes):用数组来存储每个顶点
边(edges):这里其实添加的是顶点和边的对应关系,用字典来存储,键存储顶点,值存储的是改顶点连接的其他顶点们(数组)
class Graph {
constructor() {
this.vertexes = []; //存储顶点
this.edges = new Map(); //存储顶点和边的对应关系(顶点和边集合的键值对)
//字典可以用之前封装的结构也用ES6自带的Map
}
}
3.add添加顶点和边
添加顶点:两个属性都要添加,在edges中应该初始化一个空数组用来存储边的对应
添加边:传两个参数,分别是连个顶点,由于是无向边,所以要互相指一下
//添加顶点
addVertex(v) {
this.vertexes.push(v);
//添加顶点的同时要初始化存储顶点对应边的数据结构
this.edges.set(v, []); //将边添加到字典中,新增的顶点作为键,对应的值为一个存储边的空数组
}
//添加边
addEdge(v1, v2) {
this.edges.get(v1).push(v2);//取出字典对象edges中存储边的数组,并添加关联顶点
this.edges.get(v2).push(v1);//表示的是无向表,故要添加互相指向的两条边
}
4.toString输出图的结构
实现以邻接表的形式输出图中各顶点,即输出每个顶点及该顶点对应的边关系
toString() {
//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
}
测试:
let graph = new Graph();
let myVertexes = ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I'];
//这里最好循环添加,因为我们除了添加顶点之外,还要初始化顶点对应的边数组
for(let i = 0; i < myVertexes.length; i++) {
graph.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')
//4.输出结果
console.log(graph.toString());
结构图:
输出结果:
5.图的遍历
图的遍历思想与树的遍历思想一样,意味着需要将图中所有的顶点都访问一遍,并且不能有重复的访问(上面的toString方法会重复访问);
遍历图的两种算法:
- 广度优先搜索(Breadth - First Search,简称BFS);
- 深度优先搜索(Depth - First Search,简称DFS);
两种遍历算法都需要指定第一个被访问的顶点。
(1)广度优先遍历(BFS)
广度优先搜索算法的思路:
广度优先搜索算法会从指定的第一个顶点开始遍历图,先访问其所有的相邻顶点,就像一次访问图的一层;
也可以说是先宽后深地遍历图中的各个顶点。
a、用颜色作为是否已访问节点的标识
white:未入队(未访问), grey:在队列中(访问中), black:已出队(完全访问)
初始化时颜色全部改成白色
initializeColor() {
let colors = [];
for(let i = 0; i < this.vertex.length; i++) {
//全部初始化为白色(未访问)
colors[this.vertex[i]] = 'white';
}
return colors;
}
b、利用队列实现
基于队列可以简单地实现广度优先搜索算法:
- 首先创建一个队列Q(尾部进,首部出);
- 调用封装的initializeColor方法将所有顶点初始化为白色;
- 指定第一个顶点A,将A标注为灰色(被访问过的节点),并将A放入队列Q中;(顶点先入队)
- 循环遍历队列中的元素,只要队列Q非空,就执行以下操作:
- 先将灰色的A从Q的首部取出;
- 取出A后,将A的所有未被访问过(白色)的相邻顶点依次从队列Q的 尾部加入队列,并变为灰色。以此保证,灰色的相邻顶点不重复加入队列;
- A的全部相邻节点加入Q后,A变为黑色,在下一次循环中被移除Q外;
//广度优先遍历(利用队列实现),需要传入开始的顶点
bfs(initV) {
//1.初始化颜色
let colors = this.initializeColor();
//2.声明一个队列,开始顶点先入队
let queue = new Queue();
queue.enqueue(initV);
//3.开始遍历,只要队列不为空就继续遍历
while(!queue.isEmpty()) {
//3.1从队头取出一个顶点
let front = queue.dequeue();
console.log(front);
colors[front] = 'black';
//3.2出队的顶点的连接顶点依次入队,同时更改颜色
let vLinks = this.edges.get(front);
for(let i = 0; i < vLinks.length; i++) {
//这里一定要判断是否已经入队,不然会陷入死循环
if(colors[vLinks[i]] == 'white') {
queue.enqueue(vLinks[i]);
colors[vLinks[i]] = 'grey';
}
}
}
}
(2)深度优先遍历(DFS)
深度优先算法的思路:
深度优先搜索算法将会从指定的第一个顶点开始遍历图,沿着一条路径遍历直到该路径的最后一个顶点都被访问过为止;
接着沿原来路径回退并探索下一条路径,即先深后宽地遍历图中的各个顶点。
实现思路:
- 可以使用栈结构来实现深度优先搜索算法;
- 深度优先搜索算法的遍历顺序与二叉搜索树中的先序遍历较为相似,同样可以使用递归来实现(递归的本质就是函数栈的调用)。
基于递归实现深度优先搜索算法:定义dfs方法用于调用递归方法dfsVisit,定义dfsVisit方法用于递归访问图中的各个顶点。
在dfs方法中:
首先,调用initializeColor方法将所有顶点初始化为白色;
然后,调用dfsVisit方法遍历图的顶点;
在dfsVisit方法中:
首先,将传入的指定节点v标注为灰色;
接着,处理顶点V;
然后,访问V的相邻顶点;
最后,将顶点v标注为黑色;
//深度优先遍历(利用递归实现)
dfs(initV) {
//1.把所有的顶点初始化为白色
let colors = this.initializeColor();
//2.遍历顶点
this.dfsVisit(initV, colors);
}
//递归函数
dfsVisit(initV, colors) {
console.log(initV); //访问并输出
colors[initV] = 'black'; //遍历完改为黑色
let vLinks = this.edges.get(initV); //拿到邻居
for (let i = 0; i < vLinks.length; i++) {
if (colors[vLinks[i]] == 'white') {
this.dfsVisit(vLinks[i],colors);
}
}
}