Extendible Hash Table 算法实现

Extendible Hash Table 属于动态哈希的一种,网上有很多关于它的介绍,但是真的在实现它的时候,或多或少有着很多问题。网上很多教程光讲怎么扩容,不讲收缩,而且网上很多都是概念性的东西,不讲代码实操。因 CMU 15-445 的课程需要,自己捣鼓了一下算法流程,这里分享一下。

在看之前请自行了解 Extendible Hash Table 的概念:

https://www.geeksforgeeks.org/extendible-hashing-dynamic-approach-to-dbms/

http://www.mathcs.emory.edu/~cheung/Courses/554/Syllabus/3-index/extensible-hashing.html

约定

在开始讲解算法前,我们先进行一些规则的约定。

  • 目录叫 Directory, 桶叫 bucket,bucket 在 directory 的序号叫 index。
  • 每一个 bucket 的空间指向一个 page,存在多个 bucket 公用同一个 page 的情况
  • 扩容叫 grow,收缩叫 shrink。
  • 初始的 $GlobalDepth=0$,且只有一个 Bucket,该 bucket 的 $LocalDepth=0,Index=0$。

image-20211105143555494

  • 进行分流的操作统一从低位开始,比如 $GlobalDepth=2, HASH(A)=1110$,因为我们从低位开始,所以 $MASK=11$,那么我们进行 HASH(A) & Mask = 10,也就是序号为 2 的bucket。

  • 我们定义 split image bucket 就是该 bucket 在分裂时候产生的兄弟,比如 10 的兄弟是 00。也就是 bucket 序号为 0,2 的两个桶。如下图,0 bucket 分裂后生成 00 和 10 两个 bucket,它们两个 bucket 互为 split image bucket。但是它们仍然公用一个 page 0。

    image-20211105145235687

辅助类算法

根据 bucket 的 local depth 生成其对应的 mask

mask 的作用就是通过和 HASH 值 & 运算得到 bucket index。

1
2
3
4
uint32_t HashTableDirectoryPage::GetLocalDepthMask(uint32_t bucket_idx) {
uint8_t depth = local_depths_[bucket_idx];
return (1 << depth) - 1;
}

找到一个 bucket 的 split image bucket index

1
2
3
4
uint32_t HashTableDirectoryPage::GetSplitImageIndex(uint32_t bucket_idx) {
uint32_t local_depth = local_depths_[bucket_idx];
return bucket_idx ^ (1 << (local_depth - 1));
}

Directory Grow

根据规则,当某一个 local depth 大于 global depth 时,则需要对 directory 进行 grow。Grow 时,将原有的 bucket 复制一遍,然后追加到末尾上去即可。

1
2
3
4
5
6
7
8
9
10
void HashTableDirectoryPage::IncrGlobalDepth() {
int origin_num = 1 << global_depth_;
int new_index = origin_num;
int origin_index = 0;
for (; origin_index < origin_num; new_index++, origin_index++) {
bucket_page_ids_[new_index] = bucket_page_ids_[origin_index];
local_depths_[new_index] = local_depths_[origin_index];
}
global_depth_++;
}

注意:这里并没有包含数据的迁移,扩容后,每一对 bucket 都还是使用着同一块 page 空间。

Directory Shrink

当所有 bucket 的 local depth 均小于 global depth 的时候,就可以对 directory 进行一次收缩。收缩直接 global_depth - 1即可。

Insert 插入(含 Grow)

  1. 计算插入数据 A Key 的 Hash 值。
  2. 与 global depth 进行 & 运算,得到其 bucket index。
  3. 如果 bucket index 没满,直接插入即可,返回成功,否则进行步骤 4。
  4. 增加对应 bucket index 的 local depth,然后根据新的 local depth 计算得到它的 image split bucket index。
  5. 如果新的 local depth 大于 global depth,对 directory 进行扩容。
  6. 给 image split bucket index 申请一个新的 page 空间,从 bucket index 的 bucket page 中拷贝数据 data,然后清空 bucket index 中的内容。根据当前 local depth 产生的 mask 对 data 重新分配。注意,data 中的数据只会落入当前 bucket page 和 image split bucket page 两个 page 中(注意我这里说的是 page 而不是 bucket)。至此,原数据的迁移完成。
  7. 将所有同一级的 bucket 设置为相同的 local depth 和 page,这样说不清,直接看代码吧。
  8. 重新尝试插入 A,回到步骤 1。

第 7 步代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
//-------------
uint32_t diff = 1 << dir_page->GetLocalDepth(split_bucket_index);
for (uint32_t i = split_bucket_index; i >= 0; i -= diff) {
dir_page->SetBucketPageId(i, split_bucket_page_id);
dir_page->SetLocalDepth(i, dir_page->GetLocalDepth(split_bucket_index));
if (i < diff) {
// avoid negative because we are using unsigned int32 i
break;
}
}
for (uint32_t i = split_bucket_index; i < dir_page->Size(); i += diff) {
dir_page->SetBucketPageId(i, split_bucket_page_id);
dir_page->SetLocalDepth(i, dir_page->GetLocalDepth(split_bucket_index));
}
for (uint32_t i = split_image_bucket_index; i >= 0; i -= diff) {
dir_page->SetBucketPageId(i, image_bucket_page);
dir_page->SetLocalDepth(i, dir_page->GetLocalDepth(split_bucket_index));
if (i < diff) {
// avoid negative
break;
}
}
for (uint32_t i = split_image_bucket_index; i < dir_page->Size(); i += diff) {
dir_page->SetBucketPageId(i, image_bucket_page);
dir_page->SetLocalDepth(i, dir_page->GetLocalDepth(split_bucket_index));
}

Remove 删除

  1. 计算删除数据 A Key 的 Hash 值。
  2. 与 global depth 进行 & 运算,得到其 bucket index。
  3. 从 bucket index 对应的 page 中进行查找,找到了就直接删除,找不到就返回不存在。
  4. 判断删除后的 bucket 是否为空,如果为空,对该 bucket 进行 Shrink 操作。

Shrink 收缩

Shrink 这块比较复杂, 我的实现比较简单,就是只有当 remove 后 bucket 为空时才进行 Shrink,并不会自动扫描当前有哪些 bucket 为空,然后进行 Shrink。这样做其实没必要,毕竟频繁的扩容收缩会带来性能上的损耗。这也是为什么很多文章根本不讲 Shrink 的原因。

注意,bucket 的 local depth 为 0 时,是不进行 Shrink 的。

只有当 bucket 和其 split image bucket 的 local depth 相同时,才会进行 Shrink。

  1. 检查 target bucket 是否为空,不为空直接返回。
  2. 检查 target bucket 的 local depth 是否大于 0,且和其 split image bucket 的 local depth 相同,不符合要求直接返回。
  3. 遍历整个 directory ,将所有指向 target bucket page 的 bucket 全部重新指向 split image bucket 的 page。
  4. 将重新指向过的 bucket 的 local depth 均 -1。
  5. 尝试进行 directory 的 shrink。

总结

写的比较简单,适合有基础的人看,毕竟也没有那么多时间做一个详尽的图文教程了。希望大家在 CMU 15-445 的海洋里面快乐的遨游。