哈希表的解析和实现

238 阅读8分钟

哈希表

哈希表通常是基于数组进行实现的, 但是相对于数组, 它也很多的优势:

  • 它可以提供非常快速的插入-删除-查找操作
  • 无论多少数据, 插入和删除值需要接近常量的时间: 即O(1)的时间级. 实际上, 只需要几个机器指令即可
  • 哈希表的速度比树还要快, 基本可以瞬间查找到想要的元素
  • 哈希表相对于树来说编码要容易很多.

哈希表相对于数组的一些不足:

  • 哈希表中的数据是没有顺序的, 所以不能以一种固定的方式(比如从小到大)来遍历其中的元素.
  • 通常情况下, 哈希表中的key是不允许重复的, 不能放置相同的key, 用于保存不同的元素.
  • 很难找到特殊的值,比如最大值和最小值。

单词转数字:

  1. 将每一个字母对应的数字相加得到数组下标。(容易产生冲突)
  2. 将每一个字母对应的数字乘以相应的次幂后得到数组的下标。(产生冲突的概率较小,但是可能创建的数组会非常大)

为了解决第二种方法产生的数组非常大的问题,因此我们需要将大的数值转为较小的数字。由此引出了哈希化的概念。

哈希化: 将大数字转为数组下标范围内下标的过程,我们称之为哈希化。

哈希函数: 通常我们将单词转为大数字,然后大数字再进行哈细化的代码封装在一个函数中,我们成称这个函数为哈希函数。

哈希表: 最后将数据存储到数组中,对整个结构的封装,我们称之为是一个哈希表。

哈希化后产生冲突

我们在将大的数字转为小的数字的时候,即哈希化后时可能会产生冲突。我们之前存储的值,可能会被后来哈希化后的值所覆盖,有两种解决方案,一个是链地址法,另一个为开放地址法。

一、链地址法

核心思路:在链地址法中数组的每一个索引处不是存储的简单的一个值,而是一个数组或一个链表用来保存数据,这样就可以避免冲突。

img

二、开放地址法

开放地址法是哈希化后得到下标值,然后存储到将数据存储到相应的位置,如果此位置已经有数据,然后向后面得索引出查找空位置,将数据存储到空位置处。

查找空位的探测方法有三种方法:

  1. 线性探测
  2. 二次探测
  3. 再哈希法
一、线性探测

插入数据:得到index下标后,如果index为空,则直接插入,否则以步长为1,依次向后查找空位,找到空位后将数据插入空位中。

查询数据:得到index下标后,比较是否为要查询的数据,如果是直接返回结果。否则以步长为1,依次向后查找。

删除数据:得到index下标后,比较是否为要删除的数据,如果是直接删除,在删除的位置填充为-1,否则以步长为1,依次向后查找,删除数据,相应的为填充为-1;

*注意: *因为将它设置为null可能会影响我们之后查询其他操作, 所以通常删除一个位置的数据项时, 我们可以将它进行特殊处理(比如设置为-1).

查询数据时,如果向后遍历遇到空时,我们就停止查找,因为插入的数据不会插入到空位置之后。遇到-1时继续查找。

线性探测的弊端: 如果数组中存储的数据为23,24,25,27,28这样一组连续的数据,会出现聚集现象,即数组中一连串中都没有空位置,这会导致,如插入33时,我们需要多次探索多次才能找到空位,这会影响哈希表的效率。

二、二次探测

二次探索是对步长做了优化,如哈希化后为index,按照index+12 ,index+22 ,index+32 ,以2次方的方式进行探测,可以避免线性探测出现的聚集现象。

二次探测的缺点:如果插入的是22,32,42 ,52 ,62 ,也会出现不同步的聚集现象。但是这种出现的概率较小。

三、再哈希法

二次探测的算法产生的探测序列步长是固定的: 1, 4, 9, 16, 依次类推.

现在需要一种方法: 产生一种依赖关键字的探测序列, 而不是每个关键字都一样.

那么, 不同的关键字即使映射到相同的数组下标, 也可以使用不同的探测序列.

再哈希法的做法就是: 把关键字用另外一个哈希函数, 再做一次哈希化, 用这次哈希化的结果作为步长.

对于指定的关键字, 步长在整个探测中是不变的, 不过不同的关键字使用不同的步长.

第二次哈希化需要具备如下特点:

  • 和第一个哈希函数不同. (不要再使用上一次的哈希函数了, 不然结果还是原来的位置)
  • 不能输出为0(否则, 将没有步长. 每次探测都是原地踏步, 算法就进入了死循环)

其实, 我们不用费脑细胞来设计了, 计算机专家已经设计出一种工作很好的哈希函数:

  • stepSize = constant - (key - constant)
  • 其中constant是质数, 且小于数组的容量.
  • 例如: stepSize = 5 - (key % 5), 满足需求, 并且结果不可能为0.

img

效率

填装因子

比较效率和填装因子有关系。

填装因子等于哈希表中已经包含的数据项数比上哈希表的总容量。

填装因子 = 当前数据个数 / 哈希表长度

效率比较

开放地址法:

  1. 线性探测:随着填装因子的增大,探测长度呈指数增长。

    img

  2. 二次探测:随着填装因子的增大,探测长度增大也很多,但是比线性探测要好。

    img

    链地址法:链地址法随着填装因子的增大,平均探测长度增长较缓慢,因此探测效率较高。

    img

    总结:在很多情况下我们使用链地址法来处理冲突。因为链地址法的平均效率较高。

哈希函数的实现

一、计算哈希值

之前我们计算哈希值的方式:

  • cats = 327³+127²+20*27+17= 60337
  • 使用的方式为a(n)xna(n-1)x(n-1)+…+a(1)x+a(0)的普通多项式
  • 这种方式需要乘法 n+(n-1)+…+1=n(n+1)/2次,加法n次
  • 时间复杂度为O(n2)
  • 但是乘法是非常耗时的,所以我们应该寻找一种乘法次数少的方法来计算哈希值。

改进后的方式:霍纳法则

  • 我们可以通过霍纳法则来转换多项式
  • a(n)xna(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)
  • 转换后的多项式:乘法:n次 ,加法:n次
  • 时间复杂度为O(n)
  • 通过霍纳法则来计算哈希值提高了计算的效率。

在计算哈希值时使用到常量时,最好使用质数,质数可以避免死循环等问题。

哈希表的实现

hashmap的实现,最层是一个数组,数组中每个索引对应了一个Map,Map中存放每一个数据。

并且实现了当装填因子大于0.75时,进行扩容。 小于0.25时,进行缩小容量。

 ///哈希表hashmap
 class hashTable {
   constructor() {
     //定义哈希数组
     this.storage = [];
     //当前的元素
     this.count = 0;
     //哈希表的疮长度
     this.limit = 7;
   }
   //哈希函数
   hashFun(str, size) {
     let hashCode = 0;
     //计算出来整个字符串的hashcode
     for (let i = 0; i < str.length; i++) {
       //霍尔法则算法
       hashCode = hashCode * 37 + str.charCodeAt(i);
     }
 ​
     var index = hashCode % size;
     //返回下标
     return index;
   }
 ​
   //插入和修改方法
   put(key, value) {
     //获取到在hash表中的索引
     const index = this.hashFun(key, this.limit);
     //判断当前index中是否有数据,如果没有,创建新的map,将key,value存入当中
     let bucket = this.storage[index];
     if (bucket == null) {
       bucket = new Map();
       this.storage[index] = bucket;
     }
 ​
     //判断是否为修改
     if (!this.storage[index].get(key)) {
       this.count++;
     }
 ​
     //将key和value添加到map中
     bucket.set(key, value);
 ​
     //判断是否扩容
     if (this.count > this.limit * 0.75) {
       const newlimit = this.getPrime(this.limit * 2);
       console.log(newlimit);
       this.reSize(newlimit);
     }
   }
   //获取值
   get(key) {
     //获取到在hash表中的索引
     const index = this.hashFun(key, this.limit);
     //判断当前index中是否有数据,如果没有,返回null
     let bucket = this.storage[index];
     if (bucket == null) {
       return null;
     }
     //返回存储的值
     if (bucket.get(key) == null) {
       return null;
     }
     return bucket.get(key);
   }
   //删除值
   remove(key) {
     //获取到在hash表中的索引
     const index = this.hashFun(key, this.limit);
     //判断当前index中是否有数据,如果没有,返回false,表示删除失败
     let bucket = this.storage[index];
     if (bucket == null) {
       return false;
     }
     //如果存在,删除值,返回true,删除成功
     if (bucket.has(key)) {
       bucket.delete(key);
       //判断bucket中是否还有元素
       if (bucket.size == 0) {
         bucket = null;
       }
       this.storage[index] = bucket;
       this.count--;
 ​
       if (this.limit > 7 && this.count < this.limit * 0.25) {
         //缩容
         const newlimit = this.getPrime(Math.floor(this.limit / 2));
         this.reSize(newlimit);
       }
 ​
       return true;
     } else {
       return false;
     }
   }
 ​
   isEmpty() {
     return this.count === 0 ? true : false;
   }
 ​
   size() {
     return this.count;
   }
   //扩容
   reSize(newLimit) {
     //保存旧的storage数据
     let oldStore = this.storage;
     //创建新的storage
     this.storage = [];
     this.limit = newLimit;
     this.count = 0;
 ​
     //将oldstorage的数据遍历存储到新的storage
     oldStore.forEach((bucket) => {
       console.log(bucket);
       //判断当前bucket是否为null
       if (bucket == null) {
         return;
       }
       bucket.forEach((value, key) => {
          //这里的this回向上层作用域中查找
         this.put(key, value);
       });
     });
   }
   //判断是否为质数
   isPrime(num) {
     if (num < 2) {
       return false;
     } else if (num == 2) {
       return true;
     } else {
       //质数分解后的因子肯定一个大于根号值,一个小于根号值
       //因此如果小于根号值中没有因子,说明该数是质数
       const temp = parseInt(Math.sqrt(num));
       console.log(temp);
       for (let i = 2; i <= temp; i++) {
         if (num % i == 0) {
           return false;
         }
         console.log(i);
       }
       return true;
     }
   }
 ​
   //获取质数
   getPrime(num) {
     //判断是否为素数,如果是就继续增加
     while (!this.isPrime(num)) {
       num++;
     }
     //返回素数
     return num;
   }
 }
 //测试
 const HM = new hashTable();
 HM.put("wang", 45);
 HM.put("liu", 12);
 HM.put("li", 10);
 HM.put("zhang", 56);
 HM.put("zhao", 90);
 HM.put("lin", 60);
 ​
 HM.remove("li");
 HM.remove("lin");
 // HM.remove("oi");
 console.log(HM);
 alert(HM.limit);
 ​