CMU 15-445 -- Hash Tables - 04
- 引言
- Hash Tables
- Hash Functions
- Hashing Scheme
- 小结
- Dynamic Hash Tables
- Chained Hashing (链式哈希)
- Extendible Hashing(可扩展哈希)
- Linear Hashing(线性哈希)
- 总结
引言
本系列为 CMU 15-445 Fall 2022 Database Systems 数据库系统 [卡内基梅隆] 课程重点知识点摘录,附加个人拙见,同样借助CMU 15-445课程内容来完成MIT 6.830 lab内容。
本节开始之前,先看一下目前课程的进度状态:
为了支持 DBMS 更高效地从 pages 中读取数据,DBMS 的设计者需要灵活运用一些数据结构及算法,其中对于 DBMS 最重要的两个是:
- Hash Tables
- Trees
它们可能被用在 DBMS 的多个地方,包括:
- Internal Meta-data
- Core Data Storage
- Temporary Data Structures
- Table Indexes
在做相关的设计决定时,通常需要考虑两个因素:
- Data Organization:如何将这些数据结构合理地放入 memory/pages 中,以及为了支持更高效的访问,应当存储哪些信息
- Concurrency:如何支持数据的并发访问
Hash Tables
Hash Table 主要分为两部分:
- Hash Function:
- How to map a large key space into a smaller domain
- Trade-off between being fast vs. collision rate
- Hashing Scheme:
- How to handle key collisions after hashing
- Trade-off between allocating a large hash table vs. additional instructions to find/insert keys
Hash Functions
由于 DBMS 内使用的 Hash Function 并不会暴露在外,因此没必要使用加密(cryptographic)哈希函数,我们希望它速度越快,collision rate 越低越好。目前各 DBMS 主要在用的 Hash Functions 包括:
- MurmurHash (2008)
- Google CityHash (2011)
- Google FarmHash (2014)
- CLHash (2016)
Hashing Scheme
- Linear Probe Hashing (线性探测法) :就是在发生冲突的时候,找到最近的下一个空闲位置,将 item 插入。
当 keys 可能出现重复,但 value 不同时,有两种做法:
- Separate Linked List(分离链表):这种方法使用链表来处理哈希表中冲突的情况。当发生冲突时,即多个键被映射到同一个哈希桶(存储位置),它们将被存储在一个链表中。每个节点包含键和对应的值。通过遍历链表,可以在哈希表中找到具有相同键的不同值。这样,即使键相同,不同的值也可以被存储和访问。
- Redundant Keys(冗余键):这种方法在哈希表中允许存储相同的键,但每个键都关联着不同的值。当发生冲突时,新的键值对可以被插入到相同的哈希桶中,作为已有键的一个冗余副本。通过遍历哈希桶中的冗余键,可以找到具有相同键的不同值。
如下图所示:
通常为了减少 collision 和 comparisons,Hash Table 的大小应当是 table 中元素量的两倍以上。
- Robin Hood Hashing: 是 Linear Probe Hashing 的变种,为了防止 Linear Probe Hashing 出现连续区域导致频繁的 probe 操作。基本思路是 “劫富济贫”,即每次比较的时候,同时需要比较每个 key 距离其原本位置的距离(越近越富余,越远越贫穷),如果遇到一个已经被占用的 slot,如果它比自己富余,则可以直接替代它的位置,然后把它顺延到新的位置。
- Cuckoo Hashing (布谷鸟哈希)
本部分参考: 布谷鸟哈希(Cuckoo hash)
首先要理解布谷鸟的行为,这也是算法的核心:
- 布谷鸟妈妈从不筑巢,它将自己的鸟蛋生在其他鸟类的巢穴里,要别的鸟给它孵蛋
- 新出生的布谷鸟会本能地将巢穴里的其他蛋踢开(kick out ),推出鸟巢,以确保自己在鸟巢里可以独享宠爱
布谷鸟哈希有几种变种,先介绍一个哈希桶和两个哈希函数的版本:
insert逻辑:
- 若值x已存在哈希表中,则直接返回
- 若insert后哈希表空间会不够,则先进行扩容,再rehash,再继续3、4、5
- 用哈希函数h1(x)计算出下标i1,当bucket[i1]为空时,说明鸟巢可用,插入x
- 若bucket[i1]非空,用新值x将bucket[i1]上的老值x’踢开(kick out),对应小布谷鸟将老蛋踢出巢穴,老蛋当然也不能坐以待毙,继续kick out别的蛋,老值x’的下一个位置用哈希函数h2(x)寻找
- 重复2,直到达到最大循环次数MaxLoop(插入失败);或者所有被踢开的值都找到新位置(插入完成)
lookup逻辑:查找逻辑非常简单,去可能的两个巢穴里寻找,即去下标h1(x)和h2(x)寻找,若没有匹配上,则不存在(从这里可以发现查找是非常快的,且时间复杂度稳定是O(1))
下图展示的是两个哈希函数,两个哈希桶的版本:
为了防止我们不会陷入一个无限循环中,一旦我们发现了一个死循环或者达到最大循环次数,我们就需要对现有的hash table进行扩容。
- 如果使用两个hash function,那么我们大概只需要当hash table达到50%容量左右时,才需要进行扩容重建
- 如果使用三个hash function,那么我们大概需要当hash table达到90%容量的左右时,才需要进行扩容重建
小结
以上介绍的 Hash Tables 要求使用者能够预判所存数据的总量,否则每次数量超过范围时都需要重建 Hash Table。它可能被应用在 Hash Join 的场景下,如下图所示:
由于 A, B 表的大小都知道,我们就可以预判到 Hash Table 的大小。
Dynamic Hash Tables
与 Static Hash Tables 需要预判最终数据量大小的情况不同,Dynamic Hash Tables 可以按需扩容缩容,本节主要介绍 Chained Hashing,Extendible Hashing 和 Linear Hashing。
Chained Hashing (链式哈希)
Chained Hashing 是 Dynamic Hash Tables 的 HelloWorld 实现,每个 key 对应一个链表,每个节点是一个 bucket,装满了就再往后挂一个 bucket。需要写操作时,需要请求 latch。
- 在查找元素时,根据元素的哈希键计算出对应的槽位,并遍历该槽位的链表桶,搜索具有相同哈希键的元素
- 对于插入和删除操作,也可以看作是查找操作的一般化。在插入元素时,需要进行查找并确定插入位置;在删除元素时,也需要查找并删除相应元素
这么做的好处就是简单,坏处就是最坏的情况下 Hash Table 可能降级成链表,使得操作的时间复杂度降格为 。
Extendible Hashing(可扩展哈希)
在Chained Hashing中,一种可行的方法是在链表变得过长时,将桶(bucket)进行分割,而不是让链表无限增长。这种方法可以避免链表过长导致的性能问题,并且需要进行分割时,只需要对特定的桶进行重新分配。
JDK 1.8中当链表长度达到8时,就将链表转换为红黑树的操作,相信大家都已经背烂了,下面给出一种不同的实现思路。
Extendible Hashing 的基本思路是一边扩容,一边 rehash,如下图所示:
Linear Hashing(线性哈希)
基本思想:维护一个指针,指向下一个将被拆分的 bucket,每当任意一个 bucket 溢出(标准自定,如利用率到达阈值等)时,将指针指向的 bucket 拆分。
总结
Hash Tables 提供 O(1) 的访问效率,因此它被大量地应用于 DBMS 的内部实现中。即便如此,它并不适合作为 table index 的数据结构,而 table index 的首选就是下节将介绍的 B+ Tree。
本节参考课程PDF