故天将降大任于是人也,必先苦其心志,劳其筋骨,饿其体肤

写了好几天,总算是把这个哈希表写完了,还不小心排名排到了20/136名。

每次提交代码都得跑一次 clang format,要不然就算可以通过,也判断为 0 分。每次都是引用、指针符号位置的问题,不过现在渐渐改正了。比如int* a要改成int *a

image

CMU15445 2023fall 链接:https://15445.courses.cs.cmu.edu/fall2023/

Geeks for geeks 有一篇文章,可以让你基本认识该哈希表,建议先阅读它再来看本文,本文只做一些补充以及记录一些我踩的坑。根据Andy大佬的要求,我不能够在此公开任何源代码,所以只能够写一些思路。

当然,我无比认可该文章的一些观点,都是原句:

1. The directory size may increase significantly if several records are hashed on the same directory while keeping the record distribution non-uniform.
2. Size of every bucket is fixed.
3. Memory is wasted in pointers when the global depth and local depth difference becomes drastic.
4. This method is complicated to code.

ExtendibleHashTable 的局限性

如果某个 Directory 已经满了,其内部的 Bucket 也已经满了,此时无法插入该值。也就是说,该哈希表的容量上限虽然是

\[2^{header\_max\_depth}*2^{max\_global\_depth}*max\_bucket\_size \]

但是如果某个值对应的 hash,刚好是一个已经无法插入新值的 Directory,由于无法将它 rehash 到其他地方,所以虽然哈希表没有满,但是已经无法插入新值。

当然,如果使用 Cuckoo Hashing 那样的方式,说不定就可以把整个哈希表刚好用满(如果够好运的话)。

Page guard,必须在创建 Guard 对象之前获得锁

在构造 BasicPageGuard 之前,可以通过 Project1 里写的 NewPage、FetchPage 获得一个以及被 Pin 的 Page。所以只需要对获得的裸指针包装,并且在析构函数里进行 Unpin。

在构造 Read、Write page guard 之前,就必须要获得该 page 的读写锁,并且在析构函数里进行释放锁。

这种方式不是 C++ 推荐的 RAII 方式,但是最终提供的从 BufferPoolManager 中获得的 PageGuard 是基本可以认为是 RAII 实现的。

移动赋值函数operator=(&&)

在使用移动赋值函数之前,需要检查一下 this 里的对象是否需要被释放。如果不进行释放就做赋值,那么该 Page 的 Pin 和锁将不会被释放,随后会出现死锁,或无法获得新缓冲区。

Directory数量只增加不减少

根据 Project2 的 specification,可知:

  1. 创建一个新表的时候,表是空表,必须没有 directory 和 bucket
  2. Directory 和 bucket 必须在插入值的时候创建

但是,header 需要扩容、缩容?

不需要,首先 Header 的头文件中没有关于容量的变量,并且在 specification 中只说了 Directory Grow/Shrink,Bucket Split/Merge。

私有函数GetSplitImageIndex的含义

根据 discord 上大家的讨论:

image

可知,所谓 split image,就是在 bucket split 的时候创建的那个新 bucket 的下标,要通过该桶的 LocalDepth 以及其 index 来计算它。有两个地方可以用得上它。一个是在 bucket split 的时候,一个是在 bucket merge 的时候。

也可以这么说,在 Directory Grow 的时候,是把[0,Size())的内容复制到[Size(), 2*Size()),而你要通过这个函数确认原来的 Bucket 所对应的下标,在复制之后,后半部分是哪个下标指向了这个 Bucket。

似乎还是有些不清晰,下面使用一个例子来介绍该函数的定义:

下图,Directory 的 GlobalDepth 是 2,每个一 Bucket 的 LocalDepth 也已经是 2,并且每个 Bucket 容量是 1,他们已经满了,接下来要插入一个元素 f,假设 hash(f) 的最后几位是 0b111,hash(d) 的最后几位是 0b011

image

由于 LocalDepth == GlobalDepth,所以需要 Directory Grow。由于 Grow 是做复制,所以最后成为了下图。

image

接下来进行 Bucket Split。Bucket Split 是把原来那个 d 所在的桶中的键值对,全部计算一遍新的哈希值(因为此时的 GlobalDepth 已经发生变化了,所以哈希值会发生变化),把应该放在 7 的键值对取出来,更新 Directory 记录的 page id。

image

图中 f 所在的桶所在的下标,其 SplitImageIndex 便是 3。同样,3 的 SplitImageIndex,就是 7。

但是 4 的 SplitImageIndex 是?刚刚算的 3 的,也就是 0b011,其 LocalDepth 是 3,故只保留最后 3 位,把最高那一位翻转,也就是 0b011 翻转为 0b111,就找到了 7。此时 4 0b100 的 LocalDepth 是 2,所以只保留最后两位,翻转最高位,得知其 SplitImageIndex 是 0b10,也就是 2。

为什么需要 recursively merge

如图,这种情况是可能存在的:

image

假如此时删除 f,那么就需要 recursively merge 了。

缓冲区限制

在 Project1 中实现的 BufferPoolManager,有一个缓冲区大小设置的参数。在 Project2 中的测试用例里,缓冲区大小限制为 3,所以如果进行 recursively merge 的时候不释放 PageGuard,将无法继续分配新的缓冲区。3 个缓冲区确实够用,因为在 header 中获取到 directory 的时候就已经可以释放锁了。