哈希表的原理与实现
⭐在数组中查找的效率问题
当我们需要将信息作为键值对存储时,往往第一反应是存储在数组中,但当我们要去查找其中内容时,却要一个一个查找,这样效率很慢,那么我们能不能直接获取到我们需要的内容的下标呢?
例如,我们存了很多手机号,现在想要查找小明的联系方式,我们就要通过遍历数组的方式,一个一个对比,直到查找到小明
而我们的哈希表就可以完美地解决这个问题,它可以通过 小明 这个key,直接获取到它的下标值,然后快速定位
这一步操作的实现原理是:通过将key的值进行变换,生成下标值来存储,而获取时同样可以根据这个下标值来直接查找
那么如何将key转换为数字呢?
⭐将key转换为数字
我们了解过很多编码:
- ASCII编码:ASCII(American Standard Code for Information Interchange)是一种广泛使用的字符编码标准,于1963年开始制定,最终于1968年发布。ASCII编码使用7位二进制数表示字符,包括英文字母、数字和常见的符号,为计算机之间的信息交流提供了基础。
- Unicode编码:随着计算机技术的发展和全球化的需求,ASCII编码无法满足不同语言和字符集的需求。Unicode编码于1991年发布,旨在为世界上所有字符提供唯一的标识。Unicode编码包含了几乎所有语言的字符,以及符号、表情等。
- UTF-8编码:UTF-8(Unicode Transformation Format-8)是一种可变长度的字符编码方案,可以用来表示Unicode编码中的字符。UTF-8编码通过变长的字节序列来表示不同的字符,对于ASCII字符可以使用一个字节表示,而对于其他非ASCII字符需要多个字节。
- 其他编码方案:除了ASCII、Unicode和UTF-8,还存在其他各种编码方案用于特定的应用领域。例如,GB2312、GBK和GB18030等是中文编码方案,在中国地区广泛使用。
如上是我们常用的一些编码,他们负责将我们的字符以编码的形式进行解析存储,这样就可以解决我们的问题------通过一定的编码方案,将key值转换为数字,然后通过一定的算法将其排列组合成一串独一无二的数字,这就是我们要使用的下标
我们先一步一步来准备:
假如我们自己定义一套编码:a表示1,b表示2,以此类推,z表示26,只考虑小写的情况下,我们可以将小写字母组成的单词以我们的编码形式表示
例如:apple --> 1 16 16 8 5
变为编码后,我们可以通过取和的形式进行转换 --> 1+16+16+8+5=46
同样我们也可以以幂的连乘的形式进行转换-->1*37^4 + 16*37^3 + 16*37^2 + 8*37^1 + 5*37^0 = 749221
第一种方式通过不同的排列组合会有很多重复,排除不看,而第二种方式计算的结果虽然不会重复,但过于大了
这时候我们就需要对第二种方案进行改进
⭐哈希化
将大数字转化成数组范围内下标的过程,我们就称之为哈希化。
我们需要一种算法,将我们通过幂的连乘得到的巨大整数压缩到合适的大小
如果我们总共要存的内容有5000个,但由于计算后不能保证每个位置都有元素,所以我们需要进行适当的扩容操作,例如取两倍的空间
而如何进行压缩呢?我们首先想到的是取余操作(%)s
比如我们要将七位数的数字压缩到10000内,我们就可以对七位数的数字进行取余,而除数则是我们的10000,这样就可以保证得到的余数在0~9999内,现在这样操作后,细心的同学就会发现,这相当于对后几位数字做了一个截取,这样缩短了内容的情况下一定也会产生值的重复,但不得不提,在我们这样操作后,重复的概率还是小了很多的
而我们整个连乘、哈希化的过程,封装成函数后就被称作哈希函数
最终将整个数据插入到数组中后,整个数组结构就成为哈希表
下面我们解决下标重复的问题
⭐冲突
我们俗称的下标重复,专业术语叫做冲突
我们常用的有两种解决方案:
- 链地址法(拉链法)
- 开放地址法
🌙链地址法
见图知意,用链表再去存储冲突的元素
🌙开放地址法
开放地址法通过寻找空白的单元格来存放重复的数据
而我们如何选择单元格进行插入呢?
首先最好想到的就是就近插入:
📕线性探测
当插入遇到冲突时,向后继续查找,直到找到空位就插进去
当查询时,做的操作也类似,若找到的结果不是我们想要的,就继续向后线性探测,直到找到对应的值,如果查找到空位置时还没有找到我们要的值,说明表内没有我们要的数据,就结束查找,但我们要考虑到元素删除后也为空的情况,若元素在被删除元素的后方,就找不到了不是?
所以我们规定,在删除元素的时候不能直接将其赋空,要留下标记,证明这里以前有过元素,让程序继续向后查找
线性探测的问题:
如果数据是连续插入的,就会出现聚集,新插入的元素可能会需要探测很长的距离,影响性能,所以我们就有了二次探测
📕二次探测
二次探测,顾名思义,以二次方为步长进行探测(1^2 2^2 3^2...),这样就可以一次性探测比较长的区域,避免聚集带来的影响
虽然二次探测可以在一定程度上降低聚集的可能性,但还是有概率出现步长不一的聚集,所以只能说是线性探测的一个优化
要想彻底解决聚集的问题,我们还要使用再哈希法
📕再哈希法
当不同的关键字映射到相同的数组下标时,可以再使用另一个哈希函数再做一次哈希化,将这次哈希化的结果作为步长
常用哈希函数:stepSize = constant - (key % constant)
,其中constant是质数,且小于数组的容量
PS:在真实开发中,链地址法用得较多
⭐哈希函数应具备的特点
- 计算速度要快,所以我们要尽量少使用乘法和除法
- 分布要均匀,不管是链地址法还是开放地址法,过多的冲突都会影响到效率
使用霍纳法则优化之前我们设计的哈希函数
我们之前设计的函数为:a[0]xⁿ + a[1]xⁿ⁻¹ + ... + a[n-1]x + a[n]
通过将后面的x^n进行提取,得到:((...(((a[0]x + a[1])x) + a[2])x + a[3])x...)x + a[n-1])x + a[n]
的形式
我们通过这种转换,将原本需要执行n*(n+1)/2次乘法的算法变为了只需要执行n次乘法的形式
又原先的O(n^2)降低到了O(n)
其次,还有一个比较重要的点:哈希表的容量应尽可能设置为质数
选择质数的原因有以下几点:
- 均匀分布:使用质数作为容量可以帮助确保键被均匀地分布在哈希表的桶中。如果容量选择不当(如选择非质数),可能会导致哈希冲突更为频繁,使得某些桶中的元素较多,造成性能下降。
- 减少碰撞:选择质数作为容量可以有效地减少哈希冲突的概率,因为质数与其他常用数字之间的公约数较少。这样可以减少键经过哈希函数后映射到同一个桶的情况,提高查询和插入操作的效率。
- 支持扩容:选择质数作为容量还有一个优点是,当需要进行哈希表的扩容时,质数容量可以减少出现冲突的可能性。如果容量选择的是非质数,扩容后的哈希表容易与之前的容量存在公因子,导致哈希函数映射冲突更为频繁。
⭐扩容
当哈希表中数据存储过多时,其中哈希冲突也会增多,这为我们存储和查找都带来性能上的消耗,所以我们在一定情况下(常见的条件:填装因子,即hashmap中存储键值对的数量与哈希表大小之比大于0.75)进行扩容,比如我们将容量扩大两倍,但前面提到过,哈希表的容量应尽可能设置为质数,所以我们需要进行处理,让其恒为一个质数
但要注意,进行扩容时,其内存储的所有元素都要进行重新存储
同理,我们也可以在一定情况下(填装因子小于0.25)进行缩容
⭐实现代码
js
/**
* 当遇到冲突时使用桶进行存储,哈希值采用霍纳法则进行计算
*/
export default class HashMap {
constructor() {
this.map = []
//我们将初始定为7
this.size = 7
//进行哈希运算的base设为37
this.base = 37
this.valueCount = 0
}
/**
* 存储方法
* @param {*} value 要存储的元素
*/
add(key, value) {
//当填装因子大于0.75,进行扩容
if(this.valueCount/this.size > 0.75){
this.resize(true)
}
//计算下标
const index = this.hash(key, this.base, this.size)
//判断该位置上是否已存在桶
if(!this.map[index]) {
//没有,创建后加入
this.map[index] = []
this.map[index].push([key, value])
}else {
//有,查看是否有重复的key,若有则进行修改,没有则新加元素,这里我们加到链表尾部
const keyIndex = this.map[index].indexOf(key)
keyIndex === -1?this.map[index].push([key, value]):this.map[index][keyIndex] = [key, value]
}
this.valueCount++
return true
}
/**
* 查找操作
* @param {*} key 所需value对应的key值
*/
get(key) {
//计算下标值
const index = this.hash(key,this.base,this.size)
//获取桶
const bucket = this.map[index]
if(bucket) {
//遍历桶查找key
for(let i = 0;i < bucket.length;i++) {
if(bucket[i].includes(key)) {
return bucket[i][1]
}
}
}
return null
}
delete(key) {
//计算下标值
const index = this.hash(key,this.base,this.size)
//获取桶
const bucket = this.map[index]
if(bucket) {
//遍历桶查找key
for(let i = 0;i < bucket.length;i++) {
if(bucket[i].includes(key)) {
bucket.splice(i, 1)
}
}
}
this.valueCount--
//当填装因子小于0.25,进行resize
if(this.valueCount/this.size < 0.25){
this.resize(false)
}
}
/**
* 遍历操作
* @param {Function} callback 回调,执行时会依次向内传入key,value以及在哈希表中的index和桶中的map
*/
forEach(callback) {
this.map.forEach((bucket, index) => {
//查看bucket是否定义
if(bucket) {
//定义了,遍历执行回调
bucket.forEach(bucketItem => {
callback(bucketItem[0], bucketItem[1], index)
})
}
})
}
/**
* 对哈希表进行缩容或扩容操作
* @param {Boolean} flag true-扩容,false-缩容
*/
resize(flag) {
/**
* 计算质数下标
* @param {Boolean} flag true-扩容,false-缩容
*/
const getNewPrimeSize = (flag) => {
let newSize = flag ? this.size * 2 : parseInt(this.size / 2)
while(isPrime(newSize)) {
newSize++
}
return newSize
/**
* 判断是否为质数
* @param {Number} num
*/
function isPrime(num) {
for(let i = 2;i <= Math.sqrt(num);i++) {
if(num % i === 0) {
return false
}
}
return true
}
}
//最小容量为7
if(!flag && this.size <= 7) {
return
}
const newMap = []
const newSize = getNewPrimeSize(flag)
this.size = newSize
this.forEach((key, value) => {
//计算下标
const index = this.hash(key, this.base, this.size)
//判断该位置上是否已存在桶
if(!newMap[index]) {
//没有,创建后加入
newMap[index] = []
newMap[index].push([key, value])
}else {
//有,查看是否有重复的key,若有则进行修改,没有则新加元素,这里我们加到链表尾部
const keyIndex = newMap[index].indexOf(key)
keyIndex === -1?newMap[index].push([key, value]):newMap[index][keyIndex] = [key, value]
}
return true
})
this.map = newMap
}
/**
*
* @param {String} value key值
* @param {Number} base hash计算取的底数
* @param {Number} mapLength map的长度,根据他对结果进行取余,缩小哈希值
*/
hash(value, base, mapLength) {
let hashValue = 0
for(let i = 0;i < value.length;i++) {
hashValue = hashValue * base + value.charCodeAt(i)
}
return hashValue % mapLength
}
}
//test
let map = new HashMap()
map.add(123, 321)
map.add('111a', 321)
map.add('b222', 321)
map.add('c333', 321)
map.forEach((key, value, index) => {
console.log(`[${key}-${value}] in ${index}`);
})
console.log('================================================');
map.add(444, 321)
map.add(55567, 321)
map.add('name','小何')
map.add('age', 18)
map.delete(123)
map.forEach((key, value, index) => {
console.log(`[${key}-${value}] in ${index}`);
})