哈希表的原理与实现

哈希表的原理与实现

⭐在数组中查找的效率问题

当我们需要将信息作为键值对存储时,往往第一反应是存储在数组中,但当我们要去查找其中内容时,却要一个一个查找,这样效率很慢,那么我们能不能直接获取到我们需要的内容的下标呢?

例如,我们存了很多手机号,现在想要查找小明的联系方式,我们就要通过遍历数组的方式,一个一个对比,直到查找到小明

而我们的哈希表就可以完美地解决这个问题,它可以通过 小明 这个key,直接获取到它的下标值,然后快速定位

这一步操作的实现原理是:通过将key的值进行变换,生成下标值来存储,而获取时同样可以根据这个下标值来直接查找

那么如何将key转换为数字呢?

⭐将key转换为数字

我们了解过很多编码:

  1. ASCII编码:ASCII(American Standard Code for Information Interchange)是一种广泛使用的字符编码标准,于1963年开始制定,最终于1968年发布。ASCII编码使用7位二进制数表示字符,包括英文字母、数字和常见的符号,为计算机之间的信息交流提供了基础。
  2. Unicode编码:随着计算机技术的发展和全球化的需求,ASCII编码无法满足不同语言和字符集的需求。Unicode编码于1991年发布,旨在为世界上所有字符提供唯一的标识。Unicode编码包含了几乎所有语言的字符,以及符号、表情等。
  3. UTF-8编码:UTF-8(Unicode Transformation Format-8)是一种可变长度的字符编码方案,可以用来表示Unicode编码中的字符。UTF-8编码通过变长的字节序列来表示不同的字符,对于ASCII字符可以使用一个字节表示,而对于其他非ASCII字符需要多个字节。
  4. 其他编码方案:除了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. 开放地址法

🌙链地址法

见图知意,用链表再去存储冲突的元素

🌙开放地址法

开放地址法通过寻找空白的单元格来存放重复的数据

而我们如何选择单元格进行插入呢?

首先最好想到的就是就近插入:

📕线性探测

当插入遇到冲突时,向后继续查找,直到找到空位就插进去

当查询时,做的操作也类似,若找到的结果不是我们想要的,就继续向后线性探测,直到找到对应的值,如果查找到空位置时还没有找到我们要的值,说明表内没有我们要的数据,就结束查找,但我们要考虑到元素删除后也为空的情况,若元素在被删除元素的后方,就找不到了不是?

所以我们规定,在删除元素的时候不能直接将其赋空,要留下标记,证明这里以前有过元素,让程序继续向后查找

线性探测的问题:

如果数据是连续插入的,就会出现聚集,新插入的元素可能会需要探测很长的距离,影响性能,所以我们就有了二次探测

📕二次探测

二次探测,顾名思义,以二次方为步长进行探测(1^2 2^2 3^2...),这样就可以一次性探测比较长的区域,避免聚集带来的影响

虽然二次探测可以在一定程度上降低聚集的可能性,但还是有概率出现步长不一的聚集,所以只能说是线性探测的一个优化

要想彻底解决聚集的问题,我们还要使用再哈希法

📕再哈希法

当不同的关键字映射到相同的数组下标时,可以再使用另一个哈希函数再做一次哈希化,将这次哈希化的结果作为步长

常用哈希函数:stepSize = constant - (key % constant),其中constant是质数,且小于数组的容量

PS:在真实开发中,链地址法用得较多

⭐哈希函数应具备的特点

  1. 计算速度要快,所以我们要尽量少使用乘法和除法
  2. 分布要均匀,不管是链地址法还是开放地址法,过多的冲突都会影响到效率

使用霍纳法则优化之前我们设计的哈希函数

我们之前设计的函数为: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)

其次,还有一个比较重要的点:哈希表的容量应尽可能设置为质数

选择质数的原因有以下几点:

  1. 均匀分布:使用质数作为容量可以帮助确保键被均匀地分布在哈希表的桶中。如果容量选择不当(如选择非质数),可能会导致哈希冲突更为频繁,使得某些桶中的元素较多,造成性能下降。
  2. 减少碰撞:选择质数作为容量可以有效地减少哈希冲突的概率,因为质数与其他常用数字之间的公约数较少。这样可以减少键经过哈希函数后映射到同一个桶的情况,提高查询和插入操作的效率。
  3. 支持扩容:选择质数作为容量还有一个优点是,当需要进行哈希表的扩容时,质数容量可以减少出现冲突的可能性。如果容量选择的是非质数,扩容后的哈希表容易与之前的容量存在公因子,导致哈希函数映射冲突更为频繁。

⭐扩容

当哈希表中数据存储过多时,其中哈希冲突也会增多,这为我们存储和查找都带来性能上的消耗,所以我们在一定情况下(常见的条件:填装因子,即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}`);
})
相关推荐
闪电悠米4 小时前
力扣hot100-41.缺失的第一个正数-原地哈希详解
数据结构·算法·哈希算法
WL学习笔记4 小时前
堆(数据结构)
数据结构·
wabs6668 小时前
关于图论【卡码网100.最大岛屿的面积的思考】
数据结构·算法·图论
yuannl1010 小时前
图的最短路径Dijkstra
数据结构
zander25811 小时前
138. 随机链表的复制
数据结构·算法·链表
过期动态11 小时前
【LeetCode 热题 100】找到字符串中所有字母异位词
java·数据结构·算法·leetcode·职场和发展·rabbitmq
来一碗刘肉面11 小时前
栈在递归中的应用
数据结构·算法
皓月斯语11 小时前
程序设计语言的特点
开发语言·数据结构·c++
邪修king12 小时前
C++ 进阶终章:异常机制与智能指针全解 —— 从错误处理到 RAII 资源管理,打通现代 C++ 的核心命脉
android·数据结构·c++
Adios79420 小时前
设置交集大小至少为2
数据结构·算法·leetcode