JS 数据结构 —— 哈希表(下篇)

1,971 阅读6分钟

「这是我参与2022首次更文挑战的第6天,活动详情查看:2022首次更文挑战」。

上篇中,我们简单介绍了哈希表的定义,并开始自己封装实现一个哈希表结构的类,完成了属性和哈希函数的定义,本篇文章继续实现自定义哈希表结构类的增删改查以及扩容缩容方法。

增与改 update()

update 方法传入 2 个参数,keyvaluekey 用于确定数据存储在 storage 的具体位置,也就是下标值。value 就是要存储的数据。在动手编写具体代码之前,我们先来解决可能遇到的冲突问题 —— 如果传入的不同 key 经由 hashFn 处理得到的值一样该怎么办?本次封装我采用的是链地址法来解决冲突。

解决冲突 —— 链地址法

既然有可能存在同个下标值需要存储多个数据的情况,那么 storage 数组每个位置就不应该直接存放单个数据,而是再放一个数组(或是链表)。数据根据 key 找到 storage 的某一项,然后再与该项存放的数组里的每一项元素进行对比,如果原本即已存在,就更改数据;原本没有,就添加数据。数据的存储形式,是一个长度固定为 2 的数组(或者说更像是 typescript 里的元组),第 0 项为 key,第 1 项为 value。简单画个哈希表的示意图如下:

yuque_diagram.jpg

代码实现如下:

// 增 & 改
update(key, value) {
  // 1.获取下标
  const index = this.hashFn(key, this.size)
  // 2.判断该位置是否存在数组
  let bucket = this.storage[index]
  // 如果没有数组则添加个空数组
  if (!bucket) {
    this.storage[index] = bucket = []
  }
  // 有数组则进行循环遍历
  for (let i = 0; i < bucket.length; i++) {
    // 是否存储了 key 值相等的元素
    if (key === bucket[i][0]) {
      // 有则修改
      bucket[i][1] = value
      return
    }
  }
  // 没有则添加
  bucket.push([key, value])
  this.count++
  // 检查是否需要扩容(后面会介绍)
  if (this.count > this.size * 0.75) this.resize(this.getPrime(this.size * 2))
}

可以做个测试,定义一个哈希表,存入些数据,前 3 个为新增,最后一个为修改,然后打印一下该哈希表实例对象:

// 测试
const hashTable = new HashTable()
hashTable.update('Jay', { name: 'Jay', age: 22 }) // 新增
hashTable.update('Zhou', { name: 'Zhou', age: 23 }) // 新增
hashTable.update('Chaim', { name: 'Chaim', age: 28 }) // 新增
hashTable.update('Chaim', { name: 'ChaimHL', age: 28 }) // 修改
console.log(hashTable)

结果如下图所示:

image.png

查 find()

搞定了增改操作,查询操作的实现就比较简单了,因为原理是一样的。直接放代码:

// 查
find(key) {
  // 1.获取下标
  const index = this.hashFn(key, this.size)
  // 2.判断该位置是否存在数组
  let bucket = this.storage[index]
  if (bucket) {
    // 原本存在数组
    const found = bucket.find((item) => item[0] === key)
    return found ? found[1] : undefined
  } else {
    // 原本不存在数组
    return undefined
  }
}

查询操作无非就是通过传入的 key 值去获取到对应的下标值,然后判断一下该下标处是否存在数组(bucket),如果没有,则直接返回 undefined;如果存在数组,则遍历该数组,看看是否存在某一元素(也是数组)的第 0 项的值等于 key 的,有则返回该元素的第 1 项的值,即为 key 对应的 value

删 remove()

其实增删改查都是查,前面的会了,删除操作的代码也是水到渠成。无非就是先尝试找到该元素,如果找到了就删除并返回;如果没有找到就返回空数组。

// 删
remove(key) {
  const index = this.hashFn(key, this.size)
  let bucket = this.storage[index]
  if (bucket) {
    const index = bucket.findIndex((item) => item[0] === key)
    if (index !== -1) {
      // 原本存在数组并且数组中存在第 0 项等于 key 的元素
      this.count--
      // 检查是否需要缩容(下面会介绍)
      if (this.size > 11 && this.count < this.size * 0.25) {
        this.resize(this.getPrime(Math.floor(this.size / 2)))
      }
      return bucket.splice(index, 1)[0]
    }
  }
  // 没有删除元素则返回空数组
  return []
}

return bucket.splice(index, 1)[0] 之所以要添加个 [0],因为 splice 返回的是被删除的元素组成的一个数组,要把被删的元素取出来,当然得到的被删除的元素还是一个 [key, value] 数组。

扩容 & 缩容 resize()

定义哈希表这个类时,我们让 this.size = 11,即初始时每个哈希表实例的大小都是 11。如果我们要存储的数据量很大,必然导致哈希表每个位置的数组(bucket)的长度都很长,那么效率就变低了。所以我们需要有个适时改变哈希表容量的机制,而这个机制的执行,与装填因子的大小有关。

装填因子(Load Factor)

装填因子的值等于哈希表中存储的数据的个数(count)与哈希表本身的大小(size)的比值,即 loadFactor = count / size。一般情况下,采用链地址法解决冲突的哈希表,当装填因子大于 0.75 时,需要进行扩容,小于 0.25 时,需要进行缩容。

实现思路

  1. 新建一个变量 oldStorage 指向原本存储一个个 bucketstorage 数组;
  2. this.storage 重新赋值为一个空数组,count 重新赋值为 0,然后让 size 变为传入的 newSize,其值大概为原来的两倍(扩容)或一半(缩容);
  3. 将保存在 oldStorage 里的所有数据重新装入新的 storage

代码如下:

// 扩容 & 缩容
resize(newSize) {
  // 1.先将原本的 storage 的值保存起来
  const oldStorage = this.storage
  // 2.然后进行改变容量的操作
  this.storage = []
  this.size = newSize
  this.count = 0
  // 3.将所有存储的数据重新放入新的 storage
  oldStorage.forEach((bucket) => {
    if (bucket) bucket.forEach((item) => this.update(item[0], item[1]))
  })
}

得到质数

其中,第 2 步里,传入的 newSize 最好让它为质数,有利于让存储在哈希表中的数据分布得更均匀。那么怎么做到让 newSize 为质数呢?我们可以新定义一个 getPrime() 方法,该方法传入一个数字 num 作为参数。先判断 num 是否为质数,是则直接返回 num,不是就 +1,直到 num 成为质数。

// 得到质数
getPrime(num) {
  while (!this.isPrime(num)) {
    num++
  }
  return num
}

下面实现用于判断是否为质数的 isPrime() 方法:

判断质数 isPrime()

一个数如果能进行因式分解,得到的两个数一定是一个 <= 这个数的平方根,另一个则 >= 这个数的平方根。所以我们可以循环遍历从 2 到该数的平方根,作为除数与该数进行运算,如果得到的结果为 0,说明该数能被 1 和本身整除,即不是质数。

// 判断质数
isPrime(num) {
  // 获取 num 的平方根
  const squareRoot = Math.sqrt(num)
  for (let i = 2; i <= squareRoot; i++) {
    if (num % i === 0) {
      return false
    }
  }
  return true
}

这里不直接循环遍历到 num,是因为在 num 不是质数时,也就是 for 循环需完整遍历时,遍历次数少,效率更高。

哈希表的缺点

最后,哈希表当然也有缺点,下面举 4 个通常情况下的问题为例:

  • 空间利用率不高,底层使用的数组可能并不是每个单元都被利用了;
  • 数据是无序的,不能按某一顺序遍历;
  • 不能快速找出最大值或最小值
  • 存储对象时,对象的 key 值是不可以重复的。

感谢.gif 点赞.png