哈希表
哈希表通常是基于数组进行实现的, 但是相对于数组, 它也很多的优势:
- 它可以提供非常快速的插入-删除-查找操作
- 无论多少数据, 插入和删除值需要接近常量的时间: 即O(1)的时间级. 实际上, 只需要几个机器指令即可
- 哈希表的速度比树还要快, 基本可以瞬间查找到想要的元素
- 哈希表相对于树来说编码要容易很多.
哈希表相对于数组的一些不足:
- 哈希表中的数据是没有顺序的, 所以不能以一种固定的方式(比如从小到大)来遍历其中的元素.
- 通常情况下, 哈希表中的key是不允许重复的, 不能放置相同的key, 用于保存不同的元素.
- 很难找到特殊的值,比如最大值和最小值。
单词转数字:
- 将每一个字母对应的数字相加得到数组下标。(容易产生冲突)
- 将每一个字母对应的数字乘以相应的次幂后得到数组的下标。(产生冲突的概率较小,但是可能创建的数组会非常大)
为了解决第二种方法产生的数组非常大的问题,因此我们需要将大的数值转为较小的数字。由此引出了哈希化的概念。
哈希化: 将大数字转为数组下标范围内下标的过程,我们称之为哈希化。
哈希函数: 通常我们将单词转为大数字,然后大数字再进行哈细化的代码封装在一个函数中,我们成称这个函数为哈希函数。
哈希表: 最后将数据存储到数组中,对整个结构的封装,我们称之为是一个哈希表。
哈希化后产生冲突
我们在将大的数字转为小的数字的时候,即哈希化后时可能会产生冲突。我们之前存储的值,可能会被后来哈希化后的值所覆盖,有两种解决方案,一个是链地址法,另一个为开放地址法。
一、链地址法
核心思路:在链地址法中数组的每一个索引处不是存储的简单的一个值,而是一个数组或一个链表用来保存数据,这样就可以避免冲突。
二、开放地址法
开放地址法是哈希化后得到下标值,然后存储到将数据存储到相应的位置,如果此位置已经有数据,然后向后面得索引出查找空位置,将数据存储到空位置处。
查找空位的探测方法有三种方法:
- 线性探测
- 二次探测
- 再哈希法
一、线性探测
插入数据:得到index下标后,如果index为空,则直接插入,否则以步长为1,依次向后查找空位,找到空位后将数据插入空位中。
查询数据:得到index下标后,比较是否为要查询的数据,如果是直接返回结果。否则以步长为1,依次向后查找。
删除数据:得到index下标后,比较是否为要删除的数据,如果是直接删除,在删除的位置填充为-1,否则以步长为1,依次向后查找,删除数据,相应的为填充为-1;
*注意: *因为将它设置为null可能会影响我们之后查询其他操作, 所以通常删除一个位置的数据项时, 我们可以将它进行特殊处理(比如设置为-1).
查询数据时,如果向后遍历遇到空时,我们就停止查找,因为插入的数据不会插入到空位置之后。遇到-1时继续查找。
线性探测的弊端: 如果数组中存储的数据为23,24,25,27,28这样一组连续的数据,会出现聚集现象,即数组中一连串中都没有空位置,这会导致,如插入33时,我们需要多次探索多次才能找到空位,这会影响哈希表的效率。
二、二次探测
二次探索是对步长做了优化,如哈希化后为index,按照index+12 ,index+22 ,index+32 ,以2次方的方式进行探测,可以避免线性探测出现的聚集现象。
二次探测的缺点:如果插入的是22,32,42 ,52 ,62 ,也会出现不同步的聚集现象。但是这种出现的概率较小。
三、再哈希法
二次探测的算法产生的探测序列步长是固定的: 1, 4, 9, 16, 依次类推.
现在需要一种方法: 产生一种依赖关键字的探测序列, 而不是每个关键字都一样.
那么, 不同的关键字即使映射到相同的数组下标, 也可以使用不同的探测序列.
再哈希法的做法就是: 把关键字用另外一个哈希函数, 再做一次哈希化, 用这次哈希化的结果作为步长.
对于指定的关键字, 步长在整个探测中是不变的, 不过不同的关键字使用不同的步长.
第二次哈希化需要具备如下特点:
- 和第一个哈希函数不同. (不要再使用上一次的哈希函数了, 不然结果还是原来的位置)
- 不能输出为0(否则, 将没有步长. 每次探测都是原地踏步, 算法就进入了死循环)
其实, 我们不用费脑细胞来设计了, 计算机专家已经设计出一种工作很好的哈希函数:
- stepSize = constant - (key - constant)
- 其中constant是质数, 且小于数组的容量.
- 例如: stepSize = 5 - (key % 5), 满足需求, 并且结果不可能为0.
效率
填装因子
比较效率和填装因子有关系。
填装因子等于哈希表中已经包含的数据项数比上哈希表的总容量。
填装因子 = 当前数据个数 / 哈希表长度
效率比较
开放地址法:
-
线性探测:随着填装因子的增大,探测长度呈指数增长。
-
二次探测:随着填装因子的增大,探测长度增大也很多,但是比线性探测要好。
链地址法:链地址法随着填装因子的增大,平均探测长度增长较缓慢,因此探测效率较高。
总结:在很多情况下我们使用链地址法来处理冲突。因为链地址法的平均效率较高。
哈希函数的实现
一、计算哈希值
之前我们计算哈希值的方式:
- cats = 327³+127²+20*27+17= 60337
- 使用的方式为a(n)xna(n-1)x(n-1)+…+a(1)x+a(0)的普通多项式
- 这种方式需要乘法 n+(n-1)+…+1=n(n+1)/2次,加法n次
- 时间复杂度为O(n2)
- 但是乘法是非常耗时的,所以我们应该寻找一种乘法次数少的方法来计算哈希值。
改进后的方式:霍纳法则
- 我们可以通过霍纳法则来转换多项式
- a(n)xna(n-1)x(n-1)+…+a(1)x+a(0)= ((…(((a(n)x +a(n-1))x+a(n-2))x+ a(n-3))…)x+a(1))x+a(0)
- 转换后的多项式:乘法:n次 ,加法:n次
- 时间复杂度为O(n)
- 通过霍纳法则来计算哈希值提高了计算的效率。
在计算哈希值时使用到常量时,最好使用质数,质数可以避免死循环等问题。
哈希表的实现
hashmap的实现,最层是一个数组,数组中每个索引对应了一个Map,Map中存放每一个数据。
并且实现了当装填因子大于0.75时,进行扩容。 小于0.25时,进行缩小容量。
///哈希表hashmap
class hashTable {
constructor() {
//定义哈希数组
this.storage = [];
//当前的元素
this.count = 0;
//哈希表的疮长度
this.limit = 7;
}
//哈希函数
hashFun(str, size) {
let hashCode = 0;
//计算出来整个字符串的hashcode
for (let i = 0; i < str.length; i++) {
//霍尔法则算法
hashCode = hashCode * 37 + str.charCodeAt(i);
}
var index = hashCode % size;
//返回下标
return index;
}
//插入和修改方法
put(key, value) {
//获取到在hash表中的索引
const index = this.hashFun(key, this.limit);
//判断当前index中是否有数据,如果没有,创建新的map,将key,value存入当中
let bucket = this.storage[index];
if (bucket == null) {
bucket = new Map();
this.storage[index] = bucket;
}
//判断是否为修改
if (!this.storage[index].get(key)) {
this.count++;
}
//将key和value添加到map中
bucket.set(key, value);
//判断是否扩容
if (this.count > this.limit * 0.75) {
const newlimit = this.getPrime(this.limit * 2);
console.log(newlimit);
this.reSize(newlimit);
}
}
//获取值
get(key) {
//获取到在hash表中的索引
const index = this.hashFun(key, this.limit);
//判断当前index中是否有数据,如果没有,返回null
let bucket = this.storage[index];
if (bucket == null) {
return null;
}
//返回存储的值
if (bucket.get(key) == null) {
return null;
}
return bucket.get(key);
}
//删除值
remove(key) {
//获取到在hash表中的索引
const index = this.hashFun(key, this.limit);
//判断当前index中是否有数据,如果没有,返回false,表示删除失败
let bucket = this.storage[index];
if (bucket == null) {
return false;
}
//如果存在,删除值,返回true,删除成功
if (bucket.has(key)) {
bucket.delete(key);
//判断bucket中是否还有元素
if (bucket.size == 0) {
bucket = null;
}
this.storage[index] = bucket;
this.count--;
if (this.limit > 7 && this.count < this.limit * 0.25) {
//缩容
const newlimit = this.getPrime(Math.floor(this.limit / 2));
this.reSize(newlimit);
}
return true;
} else {
return false;
}
}
isEmpty() {
return this.count === 0 ? true : false;
}
size() {
return this.count;
}
//扩容
reSize(newLimit) {
//保存旧的storage数据
let oldStore = this.storage;
//创建新的storage
this.storage = [];
this.limit = newLimit;
this.count = 0;
//将oldstorage的数据遍历存储到新的storage
oldStore.forEach((bucket) => {
console.log(bucket);
//判断当前bucket是否为null
if (bucket == null) {
return;
}
bucket.forEach((value, key) => {
//这里的this回向上层作用域中查找
this.put(key, value);
});
});
}
//判断是否为质数
isPrime(num) {
if (num < 2) {
return false;
} else if (num == 2) {
return true;
} else {
//质数分解后的因子肯定一个大于根号值,一个小于根号值
//因此如果小于根号值中没有因子,说明该数是质数
const temp = parseInt(Math.sqrt(num));
console.log(temp);
for (let i = 2; i <= temp; i++) {
if (num % i == 0) {
return false;
}
console.log(i);
}
return true;
}
}
//获取质数
getPrime(num) {
//判断是否为素数,如果是就继续增加
while (!this.isPrime(num)) {
num++;
}
//返回素数
return num;
}
}
//测试
const HM = new hashTable();
HM.put("wang", 45);
HM.put("liu", 12);
HM.put("li", 10);
HM.put("zhang", 56);
HM.put("zhao", 90);
HM.put("lin", 60);
HM.remove("li");
HM.remove("lin");
// HM.remove("oi");
console.log(HM);
alert(HM.limit);