哈希表的原理与实现

哈希表的原理与实现

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

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

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

而我们的哈希表就可以完美地解决这个问题,它可以通过 小明 这个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}`);
})
相关推荐
韭菜盖饭2 小时前
LeetCode每日一题3261---统计满足 K 约束的子字符串数量 II
数据结构·算法·leetcode
♡喜欢做梦2 小时前
【数据结构】ArrayList与LinkedList详解!!!——Java
java·开发语言·数据结构·链表
好心的小明3 小时前
【深圳大学】数据结构A+攻略(计软版)
数据结构
熬夜学编程的小王3 小时前
【初阶数据结构篇】插入、希尔、选择、堆排序
数据结构·c++·插入排序·选择排序·希尔排序
三小尛3 小时前
快速排序(C语言)
数据结构·算法·排序算法
椅子哥3 小时前
数据结构--排序算法
java·数据结构·算法·排序算法
DDDiccc3 小时前
JAVA学习日记(十五) 数据结构
数据结构·学习
Nydia.J3 小时前
【学习笔记】数据结构(七)
数据结构·考研
2301_799084673 小时前
超全排序C语言实现
c语言·数据结构·算法·排序算法
苏唱!!!!3 小时前
二叉树-堆
数据结构