「这是我参与2022首次更文挑战的第6天,活动详情查看:2022首次更文挑战」。
散列表(哈希表)
在 js 中没有散列表的概念,js中我们用的是对象(Object)、Set 或者 Map,它们的本质也是键值对的集合(散列表)。
另外,散列表的别称特别多,比如哈希表、散列映射、映射、字典、关联数组等等,平时叫哈希表的情况比较多,本文统一称呼散列表,不影响。
为什么会有散列表这种数据结构?
数组的查询实在太慢了。
即使使用二分查找,查找数组中的元素也需要 O(logn) 的时间复杂度。
有没有一种方式能够以 O(1) 的时间复杂度实现查询呢?
有,科学家们发明了散列表(哈希表)这样的数据结构,来综合了数组和链表的优点,插入删除时间复杂度为 O(1),查找时间复杂度也是 O(1)。
散列表的底层其实是数组加上散列函数,而散列函数就是实现查找时间复杂度为 O(1) 的关键。
散列函数
散列函数是一个这样的函数:输入一个数据,内部通过某种方式计算出一个数字,输出这个数字。
散列函数将输入映射到了数字,且必须满足如下要求:
- 每次输入输出必须一致。
- 不同的输入映射到不同的数字。
假设现在有一些商品,牛奶3元,苹果4元,梨子6元,我们来创建一个商品散列表。
假设散列函数是返回输入字符的 ASCII 值相加再对3求余。
那么可以得到商品和散列函数输出的映射关系:
milk -> 429 % 3 -> 0 价格为 3 元
apple -> 424 % 3 -> 1 价格为 4 元
pear -> 530 % 3 -> 2 价格为 6 元
那么,我们就可以把这三个商品的价格存储到一个数组中,存储的索引位置就是散列函数输出的值。
[3, 4, 6] 数组索引位置 0,1,2 分别存储三种商品的价格
这样我们就实现了查询的时间复杂度为 O(1),因为散列函数准确地指出了价格的存储位置,根本就不需要查找,直接返回。
比如我们要查询苹果的价格,直接计算散列函数得出索引是1,然后返回数组中存储的索引为1的值,为4。
再来看散列函数的要求:
每次输入输出必须一致
必须一致,因为如果不一致,假设查找苹果的价格,这次是 3,下次是 5,怎么能行?
不同的输入映射到不同的数字
不能重复,如果重复了,有两个商品的价格都存放到同一个索引了,会出错。
冲突
前面的例子中,我们的商品很少,只有三个。
假设现在商品多起来了,有一个新的商品叫 mlik
,和 牛奶 milk
仅仅是字母顺序不同。
当把商品 mlik 放到商品散列表时,使用同样的散列函数,计算出的值和 milk 是一样的,都是 0。
这下可咋办,索引为 0 的位置已经被牛奶 milk 占领了。
如果把 mlik 也放到索引为 0 的位置,会把之前的牛奶 milk 给覆盖的!
这种情况被称为冲突,也叫碰撞(collision)。
如何解决冲突?
解决冲突有很多方法,比如拉链法:
在 mlik
和 milk
冲突的位置存储一个链表,链表中再分别存储 mlik
和 milk
的价格。
但这样的话就会有一个问题,查询苹果和梨子的价格依然很快,要查询牛奶就会慢一些,毕竟链表查询要一个一个去找。
而且还有另外一个问题,假设我们以这种方式存储数据,结果散列表的数据全都分布到链表上去了,如下图:
这样的话我们的散列表就几乎变成一个链表了,这可不行!
科学家们想了一些方法,来尽量避免这个问题:
- 填装因子
- 良好的散列函数
填装因子
填装因子 === 散列表包含的元素数 / 散列表位置总数
[ null, 1 , null, 0, null] -> 填装因子为 2/5 -> 0.4
[ 1, 2 , 3, 0, 5] -> 填装因子为 5/5 -> 1
填装因子越低,发生冲突的可能性越小,散列表的性能越高。
如果填装因子太大,就调整散列表的长度,比如:
[null, 2, 3]
调整为
[null, 2, 3, null, null, null]
填装因子由 2/3 变成 1/3
是不是很简单,别看方法很简单,但却可以有效地减少冲突的发生。
一个很不错的经验规则是:一旦填装因子大于 0.7,就调整散列表的长度。
良好的散列函数
糟糕的散列函数让值扎堆,导致大量的冲突;
良好的散列函数让数组的值均匀分布。
什么样的散列函数是良好的呢?其实,各种编程语言的内部实现散列表时,都已经处理好了,比如SHA函数。
学习一个知识,很多时候深究起来就没完没了了,我觉得知道概念,了解即可。
散列表的应用场景
《图解算法》一书中举了这些例子
- 电话薄、DNS解析
- 防止投票重复
- 缓存/记住数据,以免服务器再通过处理来生成它们。
在我看来,这些其实都是创建映射关系,实现快速查找。
散列表在js中的实现
以 Object 为例,实现上文的商品列表:
const priceMap = {
milk: 3,
apple: 4,
pear: 6
}
其内部也通过散列函数计算了这三个商品的 hash 值,然后存储到数组的对应索引中,只是 js 内部的散列函数计算方式会更加复杂。
js 判断对象上是否有某属性(散列表的查找)
-
直接查找,看是否为空
-
in 操作符
const obj = {
name: 'lin'
}
obj.name !== undefined // 直接判断
obj['name'] !== undefined
'name' in obj // in 操作符
obj.hasOwnProperty('name') // hasOwnProperty
当然,js 中的 Map、WeakMap、Set、WeakSet,也都是键值对的集合,只是他们各有不同,具体可参考 阮一峰 ECMAScript 6 (ES6) 标准入门教程 第三版
小结
你几乎不用自己去实现散列表,各种语言内部已经实现了。
散列表有以下特点:
- 散列表内部是由数组和散列函数实现的。
- 散列表的查找、插入和删除的速度都非常快。
- 散列表适合用于模拟映射关系。
散列表对比数组和链表的时间复杂度如下表:
操作 | 散列表(平均) | 散列表(最坏) | 数组 | 链表 |
---|---|---|---|---|
查找 | O(1) | O(n) | O(n) | O(n) |
插入 | O(1) | O(n) | O(n) | O(1) |
删除 | O(1) | O(n) | O(n) | O(1) |