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

2,028 阅读6分钟

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

介绍

哈希表是一种数据结构,通常基于数组实现。 我们知道,数组这种数据结构,如果已知某一项的下标值,那么查询起来的速度是非常快的,这是数组的优点。但是数组也有缺点:

  • 不知道需要查询的数据的下标值时,比如存储的员工信息,只知道员工姓名,那么就不得不一个个遍历,效率不高;
  • 另外,数组的插入和删除元素,也是比较消耗性能的,因为每插入或删除一个元素,那么其后面的每一项都需要往后或往前挪一项,以保证连续性。

哈希表则解决了数组中存在的上述 2 个问题。

  • 首先,如果用哈希表存储员工信息,可以仅凭员工的姓名进行快速的查找;
  • 另外,无论有多少数据,哈希表进行插入或删除的操作,也很快。

哈希表在平均情况下,查找、插入和删除的时间复杂度都是 O(1) 级别。但有些情况,比如冲突较多,也可能会是 O(n)。 那么哈希表是如何基于数组实现这么快速的查改删的呢? 关键,就在于获取数据在数组中的下标值。 思路是将存储的对象的某个属性的值,比如员工姓名 Jay,通过某种算法,转换成数字,作为下标,将该数据存储入数组中。那么现在只要我们知道员工的姓名,就可以很快得到下标值,自然就可以快速进行查改删了。

现在我们自己封装一个实现哈希表结构的类 HashTable
先来研究一下 HashTable 需要哪些属性。

属性

class HashTable {
  constructor() {
    this.storage = []
    this.size = 11
    this.count = 0
  }
  // 下面添加方法
  // ...
}

首先需要一个数组来作为存储数据的容器,所以定义了一个变量 storage,初始值是个空数组;

其次定义变量 size,表示哈希表的容量,即最多能存储数据的个数。这里先让 size 等于 11,后面会有增加或减少容量的方法;

最后,存储在 storage 中的数据,其下标值都是通过字符串转换得到的,那么就可能存在冲突,即多个不同字符串转换后得到同个下标值。如何解决冲突之后会讨论。这里我们需要先定义个变量 count,用于记录当前哈希表中存储的数据的个数,当 count 的数量超过 size 数量的 75% 时,就需要给哈希表扩容。以减少发生冲突的可能性。反之,如果 count 的数量小于 size 数量的 25%,我们就需要缩小哈希表的容量,避免空间的大量浪费。

方法

前面提到,如果要存储员工的信息,我们可以根据员工的姓名,将数据存储进哈希表。那么如何将姓名,比如 Jay 这个单词,转换成合适的数字作为存储(storage)位置的下标呢?答案就是通过我们即将定义的 HashTable 的第一个方法 hashFn(),它其实是一个哈希函数。

哈希函数

通过哈希函数可以将单词转换成一个哈希化后的数字,也就是元素存储位置的下标。我们可以运用幂的连乘来转换。依然以 Jay 这个名字为例:将 J、a 和 y 分别转换为 UTF-16 代码单元值,得到的数字依次为 74、97 和 121,然后进行幂的连乘 74 * 37² + 97 * 37¹ + 121 得到的要作为下标的数字为 105016。

看到这,你可能会问,37 是从哪里冒出来的?为了让得到的下标值可以在哈希表中尽可能均匀的分布,减少不同的单词转换后得到相同数字的概率,经验之一就是进行幂的连乘时,N 次幂的底数最好使用质数(数组的大小也最好使用质数)。而 37 就是一个大小合适的质数,你也可以用 31 或 23 或其它的质数。

哈希化

让我们回到前面计算得到的 105016,作为一个数组的下标值,你会不会觉得它太大了?这才是一个 3 个字母组成的单词,如果字母长度更长些,得到的数字会越来越大。显然,在内存中申请一个这么大的空间是不科学的。我们需要将幂连乘后得到的大数字转换到一个较小的范围内,这个范围就是实现哈希表基于的数组的大小。而这个过程,就是哈希化。后续,我们是通过取模操作,来实现哈希化的。模的大小,即为数组的大小。

霍纳法则(Horner Algorithm)

现在还有一个问题,幂的连乘操作,本身就可能比较耗时,因为性能较低的乘法的次数可能很多。如果得到下标值,也就是执行哈希函数的速度很慢,那么一切就变得失去了意义。我们要如何提高运算的速度呢?答案是通过霍纳法则(中国称之为秦九韶算法)。我们就不去细说到底什么是霍纳法则而是直接继续根据上面的例子进行优化来解释: 74 * 37² + 97 * 37¹ + 121 经过霍纳法则处理后,得到的是 ((74 * 37) + 97) * 37 + 121。也就是说,原本的一元 n 次多项式的求值,需要经过 (n + 1) * n / 2 次乘法和 n 次加法,而霍纳法则只需要 n 次乘法和 n 次加法。计算的时间复杂度从 O(N²) 下降到了 O(N)。

代码实现

// 哈希函数
hashFn(str, size) {
  let hashCode = 0
  for (let i = 0; i < str.length; i++) {
    hashCode = hashCode * 37 + str.charCodeAt(i)
  }
  return hashCode % size
}

hashFn() 传入两个参数,str 是转换的字符串,size 是数组的大小(最好用质数),最终返回的值即为元素存储在数组中的下标值。下面结合上面的例子进行下解释:一开始,我们声明个变量 hashCode,值为 0。然后进行 Jay 的字母的个数,也就是 3 次的循环。

  1. 第一次循环时,0 * 37 得到 0,加上 J 转换为 UTF-16 代码单元值后的数字 74,实现的是 ((74 * 37) + 97) * 37 + 121 中的那个 74;
  2. 第二次循环时,在第一次循环的基础上乘上 37,在加上 a 转码后的数字 97,实现的就是 ((74 * 37) + 97) * 37 + 121 中的 (74 * 37) + 97 部分;
  3. 最后一次循环,就全部实现了 ((74 * 37) + 97) * 37 + 121

所以最巧妙的点在于一开始设置的 let hashCode = 0

本篇文章先实现哈希函数,其余的增删改查操作以及扩容缩容方法,我们放在下篇介绍。

感谢.gif 点赞.png