文章目录
散列表的由来
散列表(Hash Table
)由数组拓展而来,利用了数组支持按照下标随机访问数据的特性。我们通过散列函数把元素的键值映射为下标,然后将数据存储在数组中对应下标的位置。当我们按照键值查询元素时,我们用同样的散列函数,将键值转换为数组下标,从对应的下标中取出数据。
散列函数
散列函数hash(key)
是把元素的键值映射为数组下标的函数,其中key
表示元素键值,hash(key)
的值表示元素键值key
经过散列函数计算得到的散列值。
- 设计要求
- 散列函数不能太复杂
- 散列函数生成的值为非负整数,要尽可能随机而且均匀分布
- 同一个键值经过散列函数得到的下标相同,不同键值经过散列函数得到的下标不同
- 设计方法
- 数据分析法
- 直接寻址法
- 平方取中法
- 折叠法
- 随机数法
- 例子
- 手机号:取后四位
- 英文单词:将单词中每个字母的
ASCII
码进位相加,然后再和散列表的大小求余取模做为散列值。
散列冲突及其解决方法
事实上散列函数的第三条设计要求是很难满足的,即便像业界著名的MD5
、SHA
、CRC
等哈希算法,也无法完全避免散列冲突。而且,由于数组的存储空间有限,也会加大散列冲突的概率。散列冲突的主要解决方法有开放寻址法和链表法两种。
开放寻址法 Open Addressing
核心思想:如果出现了散列冲突,就重新探测一个空闲位置,将其插入。
线性探测法 Linear Probing
当我们往散列表中插入数据时,如果某个数据经过散列函数散列之后,存储位置已经被占用,我们就从当前位置开始,依次往后查找,看是否有空闲位置,直到找到为止。
- 插入元素:当我们往散列表中插入数据时,如果某个数据经过散列函数散列之后存储位置已经被占用了,就从当前位置开始,依次往后查找,直到找到一个空闲位置为止。
- 查找元素:相应地,查找元素时,首先通过散列函数求出要查找元素的键值对应的散列值,然后比较数组中下标为散列值的元素和要查找的元素。如果相等就是我们要查找的元素,否则按顺序依次查找;如果遍历到数组中的空闲位置还没有找到就说明要查找的元素并不在数组中。
- 删除元素:删除元素时,把删除的元素标记为
deleted
,当查找元素的时候,遇到标记为deleted
的元素继续往下探测。 - 缺点:当散列表中插入的数据越来越多时,线性探测时间会越来越久,极端情况下时间复杂度会退化到O(n)。
二次探测 Quadratic Probing
线性探测法每次探测的步长为1,探测的下标序列为hash(key)+0, hash(key)+1, hash(key)+2, ...
,而二次探测法每次探测的步长为
i
2
i^2
i2,探测的下标序列为hash(key)+0, hash(key)+1, hash(key)+4, ...
双重散列 double hashing
使用一组散列函数hash1(key),hash2(key),...
。先使用第一个散列函数,如果计算得到的存储位置被占用,就使用第二个散列函数,以此类推,直到找到空闲的存储位置。
- 装载因子
不管采用哪种探测方法,当散列表中空闲位置不多的时候,发生散列冲突的概率都会大大提高。为了尽可能保证散列表的操作效率,一般情况下,我们会尽可能保证散列表中有一定比例的空闲槽位。装载因子表示散列表中空闲位置的多少,装载因子 = 散列表中的元素个数/散列表的长度
。装载因子越大,说明散列表的空闲位置越少,冲突越多,散列表的性能下降。
为了保证散列表的操作效率,可以设置装载因子阈值。当装载因子超过阈值时,对散列表进行动态扩容以降低装载因子。装载因子阈值的设置需要权衡时间、空间复杂度。为了解决一次性扩容出现的耗时过多情况,可以将扩容操作均摊到元素多次插入过程中完成,避免一次性扩容耗时过多。这种实现方式,插入一个数据的时间复杂度为O(1)
。(这里散列表的动态扩容底层实际上是数组的动态扩容,不过在进行数据搬移时需要重新计算每个数据的存储位置。)
链表法 Chaining
链表法是一种更加常用的散列冲突解决方法。在散列表中,每个桶(槽位、数组下标)对应一条链表,所有散列值相同的元素都会放到相同桶对应的链表中。
- 插入元素:通过散列函数计算对应的槽位,将元素插入到对应链表尾部,时间复杂度
O(1)
。 - 查找删除元素:通过散列函数计算对应的槽位,遍历链表查找或删除元素,时间复杂度为
O(k)
,与链表长度k
成正比。理想情况下k=n/m
,其中n
是散列中元素个数,m
是散列表中槽位的个数。
总结
- 开放地址法:
- 优点:数据存储在数组中,有效利用CPU缓存加快查询速度;序列化比链表法简单
- 缺点:删除数据比较麻烦;冲突的代价比较高,装载因子不能过大,比链表法浪费空间
- 使用场景:数据量比较小、装载因子小,如
Java
中的ThreadLocalMap
。
- 链表法
- 优点:内存使用效率高;可以容忍大装载因子(在散列函数的值随机均匀的情况下,装载因子增大意味着链表长度变长);链表法中的链表可以更改为其他高效的动态数据结构(如跳表、红黑树)
- 缺点:对CPU缓存不友好;序列化复杂;存储小的对象比较消耗内存
- 使用场景:适合存储大对象、大数据量的散列表,比起开放地址法更加可灵活,支持更多的优化策略,比如使用黑树代替链表,如
Java
中的LinkedHaseMap
。
相关面试题
- word文档中的英文单词拼写检查功能
常见英文单词有20万个左右,假设单词平均长度为10个字母,用散列表存储整个英文词典,占据2MB存储空间。当用户输入某个英文单词时,我们拿用户输入的单词去散列表中查找,查到说明拼写正确,否则说明拼写有误。 - 按照访问次数对10万条URL访问日志排序
遍历10万条数据,以URL为key,访问次数为value,存入散列表,同时记录访问次数的最大值K,时间复杂度为O(n)
。如果K不是很大,可以使用桶排序,时间复杂度为O(n)
;如果K非常大(大于10万),使用快速排序,时间复杂度为O(nlogn)
。 - 有两个字符串数组,每个数组有大约10万条数据,如何快速找出两个数组中相同的字符串
以第一个字符串数组构建散列表,字符串为key,出现次数为value。在散列表中遍历查找第二个字符串数组,若存在则为相同的字符串。 - 如何设计一个工业级的散列表
工业级散列表的要求:支持快速的查询、插入、删除操作;内存占用合理,不能浪费太多的内存空间;性能稳定,在极端情况下也不能退化到无法接受的情况。
设计思路:合理的散列函数;定义装载因子阈值,设计动态扩容策略;选择合适的散列冲突解决方法。
散列表+链表
散列表:动态数据结构,支持高效的数据查找删除插入,但数据经过散列函数打乱后无规律存储,不支持按照某种顺序快速的进行遍历。跳表用来存储有序数据,时间复杂度O(logn)
;双向链表可以按照某种顺序存储元素。
- LRU缓存淘汰算法:维护一个按照访问时间从大到小排列的链表结构,由于缓存大小有限,当缓存空间不够(维护一个计数变量)需要淘汰一个数据的时候,就删除链表头部的结点。
- 链表实现:向缓存中插入数据的时候,首先查找缓存中是否存在,若存在则将其移动到链表尾部;若缓存中不存在,当缓存已满的时候删除头部元素,将其插入尾部,当缓存未满的时候直接插入尾部。时间复杂度为
O(n)
- 散列表+双向链表实现:使用双向链表存储数据,每个链表的前驱prev和后继next指针用于串联双向链表,hnext指针为使用链表法解决散列冲突的拉链指针(结构示意图见https://time.geekbang.org/column/article/64858
- 查找数据:借助散列表,时间复杂度
O(1)
- 删除数据:借助散列表找到要删除的数据,在双向链表中删除,时间复杂度
O(1)
- 查找数据:借助散列表,时间复杂度
- 添加数据:首先查找数据是否在缓存中。若在缓存中将其移动到双向链表尾部,若不在查看缓存是否已满。若缓存已满将双向链表头部结点删除,再将数据放到链表尾部;若缓存未满直接将数据放到链表尾部。时间复杂度
O(1)
。
- 链表实现:向缓存中插入数据的时候,首先查找缓存中是否存在,若存在则将其移动到链表尾部;若缓存中不存在,当缓存已满的时候删除头部元素,将其插入尾部,当缓存未满的时候直接插入尾部。时间复杂度为
- Redis有序集合
- 在有序集合中,每个成员对象有两个重要的重要的属性,key和score。我们会通过score和key查找数据
- 操作
- 添加一个成员对象
- 按照键值删除一个成员对象
- 按照键值查找一个成员对象
- 按照分值区间查找数据
- 按照分值对成员对象排序
- 实现
- 按照score将成员对象组织成跳表的形式
- 按照key构建一个散列表
- Java LinkedHashMap
- 通过双向链表和散列表这两种数据结构组合实现,“Linked”指的是双向链表。