哈希表通常是基于数组进行实现的,但是相对于数组,它有很多优势:
那么哈希表真的是无敌的吗,肯定不是,它的不足如下:
******接下来我们选择三个案例来理解哈希表:
公司员工姓名与员工编号存储
联系人和电话存储
50000个单词及其对应解释的存储
如果我们在开发中遇到了以上三种情况,会使用哪种数据结构呢?数组?链表?
可以使用数组存储吗?当然是可以的,但是如果我们使用数组存储了10000名员工信息时,当要查找某个员工的编号时,如何找,通过什么找,下标吗?数组的最大优势就是通过下标获取数据。我们这里可以通过员工的编号来设置下标,再来获取员工的姓名,所以案例一我们暂且得以解决;但是使用同样的方法可以解决案例二和三吗,我们可以用电话号码和单词的解释作为下标吗,当然不行!这时有人出来抬杠说:我可以在数组里存对象,获取数据的时候通过遍历就行了啊。真的有人会这样做吗哈哈哈@·@
因此这里有一个值得我们深思的问题:我们可以将姓名、单词、联系人转成数组的下标吗,答案是当然可以,所以,接着往下看
现在我们确立了目标:将字符串转为下标值 :
如何将字母转换为数字呢(且不重复)。当然,计算机给了我们许多的编码方案来用数字代替单词的字符,例如ASCII码。
但是这里我们为了更方便理解后面的概念,决定自己设计一个编码系统。就是很简单的a-1,b-2,c-3,空格为0(不考虑大小写),这样一来我们共有27个字符
接下来我们设计了两种数据转换方案:
方案一:数字相加
我们可以将字母拆分然后对应数字相加,例如hello=h(8)+e(5)+l(12)+l(12)+o(15)=44.
但是相对应的问题来了,这种情况我们不能避免有的单词和他数值相等,这就产生了冲突。这样必然会产生数据的覆盖问题。
一个下标存储多个单词显然是不合理的(虽然这种情况使用链地址法可以解决,但是效率大大折损)。
因此归根结底就是其产生数组的下标太少
因此此方法,卒
方案二:幂的连乘
方案一的值太小,那么我们方案二找一个大一点的值,即,幂的连乘。
依然使用hello举例hello=8X274+5X273+12X272+12X271+15X270=4359030
这样一来我想我们可以保证了元素下标的唯一性。但是问题来了,我们如果有了过长的单词而超出了数组的范围,我们该如何应对。就算我们有了那么大的数组,但是这回产生很多的无效单词,那么,我想这也是没有任何意义的
因此就引出了我们下一个知识点来解决此问题——哈希化
哈希化:将大数字转化成数组范围内下标的过程,我们就称之为哈希化
哈希函数:通常我们会将单词转换为大数字,大数字在进行哈希化的代码实现放在一个函数中,这个函数就叫做哈希函数
哈希表:将最终数据插入到这个数组中时,我们就称其为哈希表
我们经常使用取余的方式压缩数组
假设我们现在需要,在0-199的数组中选取5个数组放在长度为10的数组中。那么我可以将0-199(target)的数组压缩到0-9的数字,这时我们需要一个固定的range
下标值的结果为:index=target/range
例如13%10=3,199%10=9,很明显当一个数被10整除时,余数一定在0~9之间。
很显然这样的情况重复值明显减少了。但是也会有重复的情况,这样就产生了地址冲突,我们接着看
尽管我们使用了哈希函数来处理上面情况,但是当我们遇到一种情况:
如果我们0-199的数字选取的五个数字是:43,55,76,88,53时,我们对应他们的位置3-5-6-8-3.这时我们发现第一个与最后一个下标相同
我们通常及那个这种情况称为冲突
这样一来我们必须要考虑到这种情况。因此有了前人给出的下面两种方案来解决:
链地址法和开放地址法
链地址法是一种比较常见的解决冲突的方案.
如图表述,实际上就是将我们每个数组元素位置换为数组或者链表,如果我们发现有重复的元素,则就其添加到哈希表下标上的链表或数组中,当我们查询时则先根据下标找到相应的位置,在通过顺序遍历的方式从链表或数组中来查找我们要寻找的数据。
这里我们会有疑问。在每个下标处我们是该使用链表还是数组呢?
关于这个问题我认为他们在这种情况下的效率是差不多的,因为我们上文说到这里通过线性遍历的方式来查找元素。
开放地址法的主要工作方式是寻找空白的单元格来添加重复的数据.
如上图所示,我们如果现在要新插入一个41的元素该如何操作,此时41本应在1的位置,但是此位置已经包含了数据,这时我们发现还有几个空位,我们可以将41放在其余几个空位其中的一个吗,应该放在哪一个位置呢。
接下来就有了我们的三种方式:线性探测、二次探测和再哈希法。
顾名思义,就是线性的查找空白的单元。
插入41:如图我们向插入41,却发现位置已经被人占据,那么我们index++开始一步一步的寻找没有值的数组元素即可
查询41:在下标处开始寻找,照样index++一步一步寻找,直到有空白元素时停止(因为插入41时不可能跳过空位置)
删除41:删除操作一个数据项时, 不可以将这个位置下标的内容设置为null, 因为会给查询留下坑,因此可以将其特殊化处理。
**问题:**聚集
聚集:比如我在没有任何数据的时候, 插入的是22-23-24-25-26, 那么意味着下标值:2-3-4-5-6的位置都有元素. 这种一连串填充单元就叫做聚集。聚集会影响哈希表的性能, 无论是插入/查询/删除都会影响.比如我们插入一个41, 会发现连续的单元都不允许我们放置数据, 并且在这个过程中我们需要探索多次.
二次探索可以解决线性探索中留下的聚集问题。
实际上二次探测在线性探测的基础上进行了优化
线性探测, 我们可以看成是步长为1的探测, 比如从下标值x开始, 那么线性测试就是x+1, x+2, x+3依次探测.
那么二次探测就是比如从下标值x开始, x+1², x+2², x+3².这样就可以一次性探测比较长的距离, 比避免那些聚集带来的影响.
**问题:**如果我们连续插入的是32-112-82-2-192, 那么它们依次累加的时候步长的相同的.也就是这种情况下会造成步不一样的一种聚集. 还是会影响效率.
了消除线性探测和二次探测中无论步长+1还是步长+平法中存在的问题, 还有一种最常用的解决方案: 再哈希法.
再哈希法时依赖关键字的探测序列, 而不是每个关键字都一样.
再哈希法的做法就是: 把关键字用另外一个哈希函数, 再做一次哈希化, 用这次哈希化的结果作为步长.
第二次哈希化需要具备如下特点:
对应的哈希函数:
装填因子表示当前哈希表中已经包含的数据项和整个哈希表长度的比值.
ps:这个概念我们在后面的哈希表扩容会用到
装填因子 = 总数据项 / 哈希表长度.
开放地址法的装填因子最大是多少呢? 1, 因为它必须寻找到空白的单元才能将元素放入.
链地址法的装填因子呢? 可以大于1, 因为拉链法可以无限的延伸下去(当然后面效率就变低了)
经前人研究发现表明链地址发相对于后者的效率时比较高的,所以在真实开发中, 使用链地址法的情况较多, 因为它不会因为添加了某元素后性能急剧下降。
例如Java中的HashMap采用的就是链地址法
接下来我们会使用链地址法来实现哈希表。。
首先函数就是实现哈希化的操作,即将一个大数压缩为范围内的小数
哈希函数封装事实上并没有多么复杂,根据我们上文提到的哈希化中所讲,大概步骤就是先将字符串转为相应的数字编码,再去取余,或者位运算等等。
我们知道优秀的哈希函数应该尽可能的让计算过程变得简单,并且可以快速的算出结果。那么如何提高哈希表的速度呢?
我们知道在程序中应该尽量减少乘除法的使用,因为他们的性能是比较的低的。在上文我们提到了使用幂的连乘这种方法来将str变大,
依然使用hello举例hello=8X274+5X273+12X272+12X271+15X270=4359030。如果我们使用这种计算方式最终使用的乘法的次数是n(n+1)/2次 时间复杂度为O(n2).
优化方案:
使用霍纳法则:通过如下变换我们可以得到一种快得多的算法,即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 次 时间复杂度为O(n).
还有一个需要考虑的问题就是如何将这些数据在哈希表中均匀的分布。解决方案就是在我们使用常量的地方使用素数。
用到素数的地方:
至于具体原因可以看一下这篇文章:
https://blog.csdn.net/zhishengqianjun/article/details/79087525
哈希函数代码实现:
// 1.将字符串变为较大的数字(使用霍纳算法)
// 2.将大数压缩再数组范围内(取余操作)
function hashFunction(str,size) {
var hashCode=0;
for (let i = 0; i < str.length; i++) {
hashCode=37*hashCode+str.charCodeAt(i);
console.log(hashCode);
}
var index=hashCode%size;
return index;
}
实际上哈希表的代码封装并没有想象中的那么难,对于哈希表的封装我们使用链地址法来实现。
对于链地址法我想对于上文的介绍你应该不会那么陌生了。
我们不难看出我们的需要一个数组,即图上的0-9.然后数组中又放进一个数组或者是链表(bucket)(我们后续操作使用数组进行),然后里面的这个数组再去放置我们的key和value,这两个元素我们可以继续使用数组存放。所以我们的数据的结构可能会是这样:
[[[key,value],[key,value],[key,value]]],[[[key,value],[key,value],[key,value]],[[[key,value],[key,value],[key,value]]
那我们就开始封装一个哈希表:
function HashTable() {
// 定义属性
this.storage=[];
this.count=0;
this.limit=7;
// 方法
//1.哈希函数
//2.插入和修改数据
}
步骤:
1.使用哈希函数获取我们在storage的对应位置index
2.根据index取出我们的bucket
3.如果bucket为空,我们创建bucket
4.遍历bucket判断我们到底是插入还是修改数据(如果找到咋修改key对应的value并返回)
5.进行添加操作
6.判断是否需要扩容操作(这里下一小节单独讲)
附上代码:
HashTable.prototype.put = function (key,value) {
// 1.使用哈希函数获取我们在storage的对应位置index
var index=HashTable.hashFunc(key,this.limit);
// 2.根据index取出我们的bucket
var bucket=this.storage[index];
// 3.如果bucket为空,我们创建bucket
if (bucket==null) {
bucket=[];
// 创建bucket
this.storage[index]=bucket;
}
// 4.遍历bucket判断我们到底是插入还是修改数据(如果找到咋修改key对应的value并返回)
for (var i = 0; i < bucket.length; i++) {
var element = bucket[i];
if (element[0]==key) {
element[1]=value;
return;
}
}
// 5.进行添加操作
bucket.push([key,value]);
this.count+=1;
//判断是否扩容操作(根据装填因子公式计算)
if (this.count>this.limit*0.75) {
//(这里下一小节单独讲)
}
return;
}
步骤:
1.使用哈希函数获取对应的index
2.根据index获取bucket
3.判断bucket是否为空,若为空则返回null
4.顺序遍历bucket,若没有则返回null,有则返回value
附上代码:
HashTable.prototype.get = function (key) {
// 1.使用哈希函数获取对应的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,若没有则返回null,有则返回value
for (var i = 0; i < array.length; i++) {
var element = array[i];
if (element[0]==key) {
return element[1]
}
}
return null
}
步骤:
1.根据哈希函数获取index
2.根据index获取bucket
3.判断bucket是否存在,返回null
4.线性查找bucket,寻找对应数据,并且remove
5.判断是否需要缩容操作(这里下一小节单独讲)
6.没有找到返回null
附上代码:
HashTable.prototype.remove = function (key) {
// 1.根据哈希函数获取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,寻找对应数据,并且remove
for (var i = 0; i < array.length; i++) {
var element = array[i];
if (element[0] == key) {
bucket.splice(i,1);//删除数组中元素
this.count-=1;
//判断是否需要缩容操作
if (this.limit>7&&this.count<this.limit*0.25) {
//(这里下一小节单独讲)
}
return element[1];
}
}
// 5.没有找到返回null
return null;
}
根据count判空
附上代码:
HashTable.prototype.isEmpty=function(){
return this.count==0;
}
返回count
附上代码:
HashTable.prototype.size=function(){
return this.count;
}
我们上面封装了哈希表的一些基本的操作,但是你有没有发现一个问题,就是我们的数据项长度一直是为7的,是固定的。这是不是就会导致我们的loadFactor(这个知识点再第三章)大于1。所以我们无限制的插入新的数据,这会造成我们的效率降低,因此我们需要给storage扩容。
我们这里在loadFactor>0.75的时候进行扩容,为什么?因为高效呀,哈哈。Java的哈希表就是在装填因子大于0.75时进行扩容的。
ps:心细的同学应该在哈希表封装的代码中发现我有留扩容的口.
那么我们扩容的思路是什么呢?
首先需要知道,我们的storage容量最好应该选取一个质数,这里我们后面再讨论,我们先使用将其增大两倍的方式进行扩容。那么我们只扩大storage容量就可以了吗,当然不行。因为我们再put()和get()等方法的第一行使用的就是利用哈希函数找到对应index。那么你可以思考一下这里的问题,因此在storage修改的同时我们也要将所有的数据项进行修改。
具体的步骤我写一下:
下面贴代码:
HashTable.prototype.resize = function (newLimit) {
// 1. 保存旧的数组内容到oldstorage
var oldstorage = this.storage;
// 2. 重置所有的属性
this.storage = [];
this.count = 0;
this.limit = newLimit;
// 3. 遍历oldstorage中的所有bucket
for (var i = 0; i < oldstorage.length; i++) {
var bucket = oldstorage[i];
// 4. 若没有数据则continue
if (bucket == null) {
continue
}
// 5. 若有数据取出重新插入(因为我们的所有属性这时的limit也已经重置了,因此不用担心产生递归问题)
for (var j = 0; j < bucket.length; j++) {
var element = element1[j];
this.put(element[0], element[1])
}
}
}
这里我们单独来讨论一下上一小节遗留下的问题——质数,也就是storage容量。
什么是质数:又称素数,其表示大于1的自然数,只能被1和自己整除的数。
那么我们来封装一个判断一个数是否为素数的方法:
思路1:只能被1与本身整除,不能被2到num-1整除
思路2:数学定理:一个数进行若进行因式分解则一定会有一个数是小于等于其开平方跟,一个一个大于等于
所以我们想一下肯定是思路二更加高效的。所以我们采用这种方式
附上代码:
function isPrime(num) {
var temp = Math.sqrt(num);
for (var i = 2; i <= temp; i++) {
if (num%i==0) {
return false
}
}
return true
}
好了既然质数这个问题解决,我们下面解决第6节哈希表扩容storage容量不是质数问题,我们当时的storage容量是直接乘2的,并没有进行质数化处理。
思路很简单,先判断是否为质数,然后不是的话直接执行加的操作就行了
附上代码:
/* 判断是否为质数 */
HashTable.prototype.isPrime = function (num) {
var temp = Math.sqrt(num);
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
}
如何使用,很简单,这里用put里的为例:
if (this.count > this.limit * 0.75) {
var newnum=this.getPrime(this.limit*2);
this.resize(newnum)
}
ok,关于哈希表的内容,这里就告一段落
另外附上源码:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
</body>
<script>
function HashTable() {
// 定义属性
this.storage = [];
this.count = 0;
this.limit = 7;
// 方法
HashTable.prototype.hashFunc = function (str, size) {
var hashCode = 0;
for (let i = 0; i < str.length; i++) {
hashCode = 37 * hashCode + str.charCodeAt(i);
}
var index = hashCode % size;
return index;
}
/* put() 插入和修改数据方法 */
HashTable.prototype.put = function (key, value) {
// 1.使用哈希函数获取我们在storage的对应位置index
var index = this.hashFunc(key, this.limit);
// 2.根据index取出我们的bucket
var bucket = this.storage[index];
// 3.如果bucket为空,我们创建bucket
if (bucket == null) {
bucket = [];
// 创建bucket
this.storage[index] = bucket;
}
// 4.遍历bucket判断我们到底是插入还是修改数据(如果找到咋修改key对应的value并返回)
for (var i = 0; i < bucket.length; i++) {
var element = bucket[i];
if (element[0] == key) {
element[1] = value;
return;
}
}
// 5.进行添加操作
bucket.push([key, value]);
this.count += 1;
// 5.判断是否扩容
if (this.count > this.limit * 0.75) {
var newsize = this.limit * 2
var newnum = this.getPrime(newsize);
this.resize(newnum)
}
return;
}
/* get() 获取元素方法 */
HashTable.prototype.get = function (key) {
// 1.使用哈希函数获取对应的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,若没有则返回null,有则返回value
for (var i = 0; i < bucket.length; i++) {
var element = bucket[i];
if (element[0] == key) {
return element[1]
}
}
return null
}
/*remove() 删除元素方法 */
HashTable.prototype.remove = function (key) {
// 1.根据哈希函数获取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,寻找对应数据,并且remove
for (var i = 0; i < bucket.length; i++) {
var element = bucket[i];
if (element[0] == key) {
bucket.splice(i, 1);//删除数组中元素
this.count -= 1;
// 判断是否缩容
if (this.limit > 7 && this.count < this.limit * 0.25) {
this.resize(Math.floor(this.limit / 2));
}
return element[1];
}
}
// 5.没有找到返回null
return null;
}
/*isEmpty() 判空*/
HashTable.prototype.isEmpty = function () {
return this.count == 0;
}
/*size() 元素个数*/
HashTable.prototype.size = function () {
return this.count;
}
/* 哈希表扩容 */
HashTable.prototype.resize = function (newLimit) {
// 1. 保存旧的数组内容到oldstorage
var oldstorage = this.storage;
// 2. 重置所有的属性
this.storage = [];
this.count = 0;
this.limit = newLimit;
// 3. 遍历oldstorage中的所有bucket
for (var i = 0; i < oldstorage.length; i++) {
var bucket = oldstorage[i];
// 4. 若没有数据则continue
if (bucket == null) {
continue
}
// 5. 若有数据取出重新插入(因为我们的所有属性这时的limit也已经重置了,因此不用担心产生递归问题)
for (var j = 0; j < bucket.length; j++) {
var element = element1[j];
this.put(element[0], element[1])
}
}
}
/* 判断是否为质数 */
HashTable.prototype.isPrime = function (num) {
var temp = Math.sqrt(num);
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
}
}
// 测试:
var map = new HashTable();
map.put('a', '123');
map.put('b', '321');
map.put('c', '521');
map.put('d', '520');
console.log(map.get('a'));
map.put('abc', '111');
console.log(map.get('a'));
map.remove('a');
console.log(map.get('a'));
</script>
</html>