数据结构和算法(九)之哈希表实现
前面,我们使用了大量的篇幅来解析哈希表的理论知识。
现在,我们进入代码的实施阶段,但是实施之前,先来深入一个比较重要的话题:哈希函数。
一. 哈希函数
我们这里来探讨一下,设计好的哈希函数应该具备哪些优点。
快速的计算
-
好的哈希函数应该尽可能让计算的过程变得简单,应该可以快速计算出结果。
- 哈希表的主要优点是他的速度,所以在速度上不能满足,那么就达不到设计的目的了。
- 提高速度的一个方法就是让哈希函数中尽量少的有乘法和除法。因为他们的性能是比较低的。
-
在前面,我们计算哈希值得时候使用的方式
-
c a t s = 3 ∗ 2 7 3 + 1 ∗ 2 7 2 + 20 ∗ 27 + 17 = 60337 cats=3*27^3+1*27^2+20*27+17=60337 cats=3∗273+1∗272+20∗27+17=60337
-
这种方式是直观的计算结果,那么这种计算方式会进行几次乘法几次加法呢?当然,我们可能不止4项,可能有更多项
-
我们抽象一下,这个表达式其实是一个多项式: a ( n ) ∗ x n + a ( n − 1 ) x ∗ ( n − 1 ) + . . . + a ( 1 ) ∗ x + a ( 0 ) a(n)*x^{n+a(n-1)x}*(n-1)+...+a(1)*x+a(0) a(n)∗xn+a(n−1)x∗(n−1)+...+a(1)∗x+a(0)
-
现在问题就变成了多项式有多少次乘法和加法:
- 乘法次数: n + ( n − 1 ) + . . . + 1 = n ( n + 1 ) / 2 n+(n-1)+...+1=n(n+1)/2 n+(n−1)+...+1=n(n+1)/2
- 加法次数:
n
次
-
-
多项式的优化:霍纳法则
- 解决这类求值问题的高效算法——霍纳法则。在中国,霍纳法则也被称为秦九韶算法。
- 通过如下变换我们可以得到一种快得多的算法,即 P n ( x ) = a n x n + a ( 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 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 Pn(x)=anxn+a(n−1)x(n−1)+…+a1x+a0=((…(((anx+an-1)x+an-2)x+an-3)…)x+a1)x+a0,这种求值的安排我们称为霍纳法则。
- 变换后,我们需要多少次乘法,多少次加法呢?
- 乘法次数:N次
- 加法次数:N次
- 如果使用大
O
表示时间复杂度的话,我们直接从 O ( N 2 ) O(N^2) O(N2)降到了 O ( N ) O(N) O(N).
均匀的分布
- 均匀的分布
- 在设计哈希表时,我们已经有办法处理映射到相同下标值得情况:链地址法或者开放地址法。
- 但是,为了提高效率,最好的情况还是让数据在哈希表中均匀分布。
- 因此,我们需要在使用常量的地方,尽量使用质数。
- 哪些地方我们会使用到常量呢?
- 质数的使用:
- 哈希表的长度。
- N次幂的底数(我们之前使用的是27)
- 下面我们简单来说一下为什么。
- 哈希表的长度使用质数:
- 这个在链地址法中事实上重要性不是特别明显,明显的是在开放地址中的再哈希法中。
- 再哈希法中质数的重要性:
- 假设表的容量不是质数,例如:表长为15(下标值0~14)
- 有一个特定关键字映射到0,步长为5.探测序列是多少呢?
0—5—10—0—5—10
,依次类推,循环下去。- 算法只尝试着三个单元,如果这三个单元已经有了数据,那么会一直循环下去,直到程序崩溃。
- 如果容量是一个质数,比如13探测序列是多少呢?
0—5—10—2—7—12—4—9—1—6—11—3
,一直这样下去。- 不仅不会产生循环,而且可以让数据在哈希表中更加均匀的分布。
- 链地址法中质数没有那么重要,甚至在
Java
中故意是2的N次幂Java
中的哈希表采用的是链地址法。HashMap
的初始化长度是16,每次自动扩展(我们还没有聊到扩展的话题),长度必须是2的次幂。- 这是为了服务于从
key
映射到index
的算法。 HashMap
中为了提高效率,采用了位运算的方法。HashMap
中index
的计算公式:index=HashCode(key) & (Length - 1)
- 比如计算
book
的hashcode
,结果为十进制的3029737
,二进制的1011100011101011101001
- 假定
HashMap
长度是默认的16,计算Length-1
的结果为十进制的15,二进制的1111
- 把以上两个结果做与运算,
101110001110101101001 & 1111 =1001
,十进制为9,所以index=9
- 这样的方式相对于取模来说性能是高的,因为计算机更运算容易计算二进制的数据。
- 但是,我发现
JavaScript
中进行大数据的位运算时会出问题,所以我的代码实现中还是使用了取模。
- N次幂的底数,使用质数:
- 这里采用质数的原因是为了产生的数据不按照某种规律递增。
- 比如我们这里有一组数据时按照4进行递增的:0 4 8 12 16,将其映射到长度为 8 的哈希表中。
- 他么的位置是多少呢?0—4—0—4,以此类推。
- 如果我们哈希表本身不是质数,而我们递增的数量可以使用质数,比如 5,那么 0 5 10 15 20
- 它们的位置是多少呢?0—5—2—7—4.依次类推。也可以尽量让数据均匀的分布。
- 我们之前使用的是27,这次可以使用一个接近的数,比如31/37/41等等。一个比较常用的树是37.
哈希函数实现
-
现在,我们就给出哈希函数的实现:
function hashFunc(str,max) { // 1.初始化hashCode的值 let hashCode = 0 // 2.霍纳算法,来计算hashCode的数值 for(let i=0;i<str.length;i++) { hashCode = 37 * hashCode + str.charCodeAt(i) } // 3.取模运算 hashCode = hashCode % max return hashCode }
-
代码解析:
- 理解了前面所有的内容,其实代码就非常简单了。
-
代码测试:
alert(hashFunc("abc",7)); // 4 alert(hashFunc("cba",7)); // 3 alert(hashFunc("nba",7)); // 5 alert(hashFunc("mba",7)); // 1
二. 哈希表
我们这里采用链地址法来实现哈希表:
实现哈希表(基于
storage
的数组)每个index
对应的是一个数组(bucket
).(当然基于链表也可以)
bucket
中存放什么呢?我们最好将key
和value
都放进去,我们继续使用一个数组。(其实其他语言使用元组更好)最终我们的哈希表的数据格式是这样:
[[[k,v],[k,v],[k,v]],[[k,v],[k,v]],[[k,v]]]
创建哈希表
-
我们像封装其他数据结构一样,先来创建一个哈希表的类:
-
HashTable
// 创建 HashTable构造函数 function HashTable() { // 定义属性 this.storage = [] this.count = 0 this.limit = 8 // 定义相关的方法 // 哈希函数 HashTable.prototype.hashFunc = function(str,max) { // 1.初始化hashCode的值 let hashCode = 0 // 2.霍纳算法,来计算hashCode的数值 for(let i=0;i < str.length;i++) { hashCode = 37 *hashCode +str.charCodeAt(i) } // 3.取模运算 hashCode = hashCode % max return hashCode } }
-
代码解析:
storage
作为我们的数组,数组中存放相关的元素。count
表示当前已经存在了多少数据。limit
用于标记数组中一共可以存放多少个元素。- 另外,我们直接将哈希函数定义在了
HashTable
中。
插入 & 修改数据
-
现在,我们来做向哈希表中插入数据
// 插入数据方法 hashTable.prototype.put = function (key,value) { // 1.获取key对应的index let index = this.hashFunc(key, this.limit) // 2.取出数组(也可以使用链表) let bucket = this.storage[index] // 3.判断这个数组是否存在 if(bucket === undefined) { // 3.1创建桶 bucket = [] this.storage[index] = bucket } alert(bucket) // 4.判断是新增还是修改原来的值 let averride = false for(let i=0;i<bucket.length;i++) { let tuple = bucket[i] if(tuple[0] === key){ tuple[1] = value override = true } } // 5.如果是新增,前一步没有覆盖 if(!override) { bucket.push([key,value]) this.count++ } }
-
代码解析:
- 步骤1:根据传入的
key
获取对应的hashCode
,也就是数组的index
- 步骤2:从哈希表的
index
位置中取出桶(另外一个数组) - 步骤3:查看上一步的
bucket
是否为null
- 为
null
,表示之前在该位置没有放置过任何的内容,那么就新建一个数组[]
- 为
- 步骤4:查看是否之前已经放置过
key
对应的value
- 如果放置过,那么就是依次替换操作,而不是插入新的数据
- 我们使用一个变量
override
来记录是否修改操作
- 步骤5:如果不是修改操作,那么插入新的数据。
- 在
bucket
中push
新的[key,value]
即可 - 注意:这里需要将
count+1
,因为数据增加了一项。
- 在
- 步骤1:根据传入的
获取数据
-
有插入和修改数据,就应该有根据
key
获取value
// 获取存放的数据 HashTable.prototype.get = function (key) { // 1.获取key对应的index let index = this.hashFunc(key,this.limit) // 2.获取对应的bucket let bucket = this.storage[index] // 3.如果bucket为null,那么说明这个位置没有数据 if(bucket == null) { return null } // 4.有bucket,判断是否有对应的key for(let i=0;i<bucket.length;i++){ let tuple = bucket[i] if(tuple[0] === key) { return tuple[1] } } // 5.没有找到,return null return null }
-
代码解析:
- 步骤1:根据
key
获取hashCode
(也就是index
) - 步骤2:根据
index
取出bucket
。 - 步骤3:因为如果
bucket
都是null
,那么说明这个位置之前并没有插入过数据 - 步骤4:有了
bucket
,就遍历,并且如果找到,就将对应的value
返回即可。 - 步骤5:没有找到,返回
null
- 步骤1:根据
删除数据
-
我们根据对应的
key
,删除对应key/value
// 删除数据 HashTable.prototype.remove = function (key) { // 1.获取 key 对应的 value let index = this.hashFunc(key,this.limit) // 2.获取对应的bucket let buckket = this.storage[index] // 3.判断桶是否为null,为null则说明没有对应的数据 if(bucket === null) { return null } // 4.遍历bucket,寻找对应的数据 for(let i=0;i<bucket.length;i++) { let tuple = bucket[i] if(tuple[0] === key){ bucket.splice(i,1) this.count-- return tuple[1] } } return null }
其他方法
-
判断哈希表是否为空:
isEmpty
// isEmpty方法 HashTable.prototype.isEmpty = function () { return this.count === 0 }
-
获取哈希表中数组的个数
// size方法 HashTable.prototype.size = function () { return this.count }
哈希表测试
// 测试哈希表
// 1.创建哈希表
let ht = new HashTable()
// 2.插入数据
ht.put("abc","123")
ht.put("cba","321")
ht.put("nba","521")
ht.put("mba","520")
// 3.获取数据
alert(ht.get("abc"))
ht.put("abc","111")
alert(ht.get("abc"))
// 4.删除数据
alert(ht.remove("abc"))
alert(ht.get("abc"))
三. 哈希表扩容
哈希表扩容的思想
- 为什么需要扩容?
- 目前,我们是将所有的数据项放在长度为8的数组中的。
- 因为我们使用的是链地址法,
loadFactor
可以大于1,所以这个哈希表可以无限制的插入新数据。 - 但是,随着数据量的增多,每一个
index
对应的bucket
会越来越长,也就造成效率的降低。 - 所以,在合适的情况对数组进行扩容。比如扩容两倍。
- 如何进行扩容?
- 扩容可以简单的将容量增加大两倍(不是质数吗?质数的问题后面再讨论)
- 但是这种情况下,所有的数据项一定要同时进行修改(重新哈希化,来获取到不同的位置)
- 比如
hashCode=12
的数据项,在length=8
的时候,index=4
在长度为16的时候呢?index=12
- 这是一个耗时的过程,但是如果数组需要扩容,那么这个过程是必要的。
- 什么情况下扩容呢?
- 比较常见的情况是
loadFactor>0.75
的时候进行扩容。 - 比如
java
的哈希表就是在装填因子大于0.75的时候,对哈希表进行扩容
- 比较常见的情况是
哈希表扩容的实现
-
我们来实现扩容函数
// 哈希表扩容 HashTable.prototype.resize = function (newLimit) { // 1.保存旧的数组内容 let oldStorage = this.storage // 2.重置属性 this.limit = newLimit this.count = 0 this.storage = [] // 3.遍历旧数组中的所有数据项,并且重新插入到哈希表中 oldStorage.forEach(function (bucket) { // 1.bucket为null,说明这里没有数据 if(bucket == null) { return } // 2.bucket 中有数据,那么将里面的数据重新哈希化插入 for(let i=0; i<bucket.length;i++) { let tuple = bucket[i] this.put(tuple[0],tuple[1]) } }.bind(this)) }
-
代码解析:
- 步骤1:先将之前数组保存起来,因为我们待会儿会将
storage=[]
- 步骤2:之前的属性值需要重置。
- 步骤3:遍历所有的数据项,重新插入到哈希表中
- 步骤1:先将之前数组保存起来,因为我们待会儿会将
-
在什么时候调用扩容方法呢?
- 在每次添加完新的数据时,都进行判断。(也就是
put
方法中)
- 在每次添加完新的数据时,都进行判断。(也就是
-
修改
put
方法- 代码第5步中的内容
// 插入数据方法 HashTable.prototype.put = function (key,value) { // 1.获取key对应的index let index = this.hashFunc(key,this.limit) // 2.取出数组(也可以使用链表) // 数组中放置数据的方式:[[ [k,v],[k,v],[k,v]],[ [k,v],[k,v]], [[k,v]]] let bucket =this.storage[index] // 3.判断这个数组是否存在 if(bucket === undefined) { // 3.1创建桶 bucket = [] this.storage[inidex] = bucket } // 4.判断是新增还是修改原来的值 let override = false for(let i=0;i<bucket.length;i++) { let tuple = bucket[i] if(tuple[0] === key) { tuple[1] = value override = true } } // 5.如果是新增,前一步没有覆盖 if(!override) { bucket.push([key,value]) this.count++ // 数组扩容 if(this.count > this.limit * 0.75) { this.resize(this.limit * 2) } } }
-
如果我们不断地删除数据呢?
- 如果不断地删除数据,当
loadFactor < 0.25
的时候,最好将数量限制在一半。
- 如果不断地删除数据,当
-
修改
remove
方法- 修改代码第4步的内容
// 删除数据 HashTable.prototype.remove = function (key) { // 1.获取 key 对应的 value let index = this.hashFunc(key,this.limit) // 2.获取对应的bucket let buckket = this.storage[index] // 3.判断桶是否为null,为null则说明没有对应的数据 if(bucket === null) { return null } // 4.遍历bucket,寻找对应的数据 for(let i=0;i<bucket.length;i++) { let tuple = bucket[i] if(tuple[0] === key){ bucket.splice(i,1) this.count-- // 缩小数组的容量 if(this.limit > 8 && this.count < this.limit *0.25) { this.resize(Math.floor(this.limit/2)) } } return tuple[1] } return null }
四. 容量质数
我们前面提到过,容量最好是质数
虽然在链地址法中将容量设置为质数,没有在开放地址法中重要,但是其实链地址法中质数作为容量也更利于数据的均匀分布。所以,我们还是完成一下这个步骤。
判断质数
-
我们这里先讨论一下常见的面试题,判断一个数是质数。
-
质数的特点:
- 质数也被称为素数
- 质数表示大于1的自然数中,只能被1和自己整除的数
-
OK,了解了这个特点,就可以写出他的算法:
function isPrime(num) { for(let i=2;i<num;i++){ if(num % i == 0){ return false } } return true } // 测试 alert(isPrime(3)) // true alert(isPrime(27)) // false alert(isPrime(37)) // true
-
但是,这种做法的效率并不高。为什么呢?
- 对于每个数
n
,其实并不需要从2
判断到n-1
- 一个数若可以进行因数分解,那么因数分解时得到的两个数一定是一个小于等于
sqrt(n)
,一个大于等于sqrt(n)
. - 比如 16 可以被分解为
4 * 4 ,2 * 8
- 所以其实我们遍历到等于
sqrt(n)
即可
function isPrime(num) { // 1.获取平方根 let temp = parseInt(math.sqrt(num)) // 2.循环判断 for (let i=2;i<=temp;i++){ if(num % i == 0){ return false } } return true }
- 对于每个数
扩容的质数
-
首先,将初始的
limit
为 8,改成 7 -
前面,我们有对容量进行扩展,方式是:原来额容量 x 2
- 比如之前的容量是 7 ,那么扩容后就是 14。14还是一个质数吗?
- 显然不是,所以我们还需要一个方法,来是想一个新的容量为质数的算法。
-
那么我们可以封装获取新的容量的代码(质数)
// 判断一个数是否为质数 HashTable.prototype.isPrime = function (num) { let temp = parseInt(Math.sqrt(num)) // 2.循环判断 for(let i=2;i<=temp;i++) { if(num % i == 0) { return false } } return true } // 获取质数 HashTable.prototype.getPrime = function (num) { while (!isPrime) { num++ } return num }
-
插入数据的代码:
// 扩容数组的数量 if(this.count > this.limit * 0.75) { let primeNum = this.getPrime(this.limit * 2) this.resize(primeNum) }
-
删除数据的代码:
// 缩小数组的容量 if(this.limit > 7 && this.count <this.limit * 0.25) { let primeNum = this.getPrime(Math.floor(this.limit / 2)) this.resize(primeNum) }
五. 完整代码
// 创建HashTable构造函数
function HashTable() {
// 定义属性
this.storage = []
this.count = 0
this.limit = 8
// 定义相关方法
// 判断是否是质数
HashTable.prototype.isPrime = function (num) {
let temp = parseInt(Math.sqrt(num))
// 2.循环判断
for (let i = 2; i <= temp; i++) {
if (num % i == 0) {
return false
}
}
return true
}
// 获取质数
HashTable.prototype.getPrime = function (num) {
while (!isPrime(num)) {
num++
}
return num
}
// 哈希函数
HashTable.prototype.hashFunc = function(str, max) {
// 1.初始化hashCode的值
let hashCode = 0
// 2.霍纳算法, 来计算hashCode的数值
for (let i = 0; i < str.length; i++) {
hashCode = 37 * hashCode + str.charCodeAt(i)
}
// 3.取模运算
hashCode = hashCode % max
return hashCode
}
// 插入数据方法
HashTable.prototype.put = function (key, value) {
// 1.获取key对应的index
let index = this.hashFunc(key, this.limit)
// 2.取出数组(也可以使用链表)
// 数组中放置数据的方式: [[ [k,v], [k,v], [k,v] ] , [ [k,v], [k,v] ] [ [k,v] ] ]
let bucket = this.storage[index]
// 3.判断这个数组是否存在
if (bucket === undefined) {
// 3.1创建桶
bucket = []
this.storage[index] = bucket
}
// 4.判断是新增还是修改原来的值.
let override = false
for (let i = 0; i < bucket.length; i++) {
let tuple = bucket[i]
if (tuple[0] === key) {
tuple[1] = value
override = true
}
}
// 5.如果是新增, 前一步没有覆盖
if (!override) {
bucket.push([key, value])
this.count++
if (this.count > this.limit * 0.75) {
let primeNum = this.getPrime(this.limit * 2)
this.resize(primeNum)
}
}
}
// 获取存放的数据
HashTable.prototype.get = function (key) {
// 1.获取key对应的index
let index = this.hashFunc(key, this.limit)
// 2.获取对应的bucket
let bucket = this.storage[index]
// 3.如果bucket为null, 那么说明这个位置没有数据
if (bucket == null) {
return null
}
// 4.有bucket, 判断是否有对应的key
for (let i = 0; i < bucket.length; i++) {
let tuple = bucket[i]
if (tuple[0] === key) {
return tuple[1]
}
}
// 5.没有找到, return null
return null
}
// 删除数据
HashTable.prototype.remove = function (key) {
// 1.获取key对应的index
let index = this.hashFunc(key, this.limit)
// 2.获取对应的bucket
let bucket = this.storage[index]
// 3.判断同是否为null, 为null则说明没有对应的数据
if (bucket == null) {
return null
}
// 4.遍历bucket, 寻找对应的数据
for (let i = 0; i < bucket.length; i++) {
let tuple = bucket[i]
if (tuple[0] === key) {
bucket.splice(i, 1)
this.count--
// 缩小数组的容量
if (this.limit > 7 && this.count < this.limit * 0.25) {
let primeNum = this.getPrime(Math.floor(this.limit / 2))
this.resize(primeNum)
}
}
return tuple[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.保存旧的数组内容
let oldStorage = this.storage
// 2.重置属性
this.limit = newLimit
this.count = 0
this.storage = []
// 3.遍历旧数组中的所有数据项, 并且重新插入到哈希表中
oldStorage.forEach(function (bucket) {
// 1.bucket为null, 说明这里面没有数据
if (bucket == null) {
return
}
// 2.bucket中有数据, 那么将里面的数据重新哈希化插入
for (let i = 0; i < bucket.length; i++) {
let tuple = bucket[i]
this.put(tuple[0], tuple[1])
}
}).bind(this)
}
}