基本理念
为什么人们用梳妆台来存放他们的衣服?除了看起来时尚和时尚,他们的优势在于每件衣服都有它应该在的地方。如果您正在寻找一双袜子,您只需检查袜子抽屉。如果你正在寻找一件衬衫,你可以检查一下里面有你的衬衫的抽屉。当你在寻找袜子时,你有多少件衬衫或你拥有多少条裤子都没关系,因为你不需要看它们。你只要看看袜子抽屉,就会发现里面有袜子。
从高层次上讲,哈希表是一种存储(有点像)像衣服梳妆台一样的东西的方式。基本思路如下:
- 您可以获得一些可以存放物品的位置(抽屉)。
- 您想出了一些规则,告诉您每件物品属于哪个位置(抽屉)。
- 当您需要查找某些东西时,您可以使用该规则来确定要查看的抽屉。
这样的系统的优势在于,假设您的规则不太复杂并且您有适当数量的抽屉,您只需在正确的位置查找即可很快找到您要查找的内容。
当你把衣服收起来时,你使用的“规则”可能是“袜子放在左上方的抽屉里,衬衫放在中间的大抽屉里,等等”。但是,当您存储更多抽象数据时,我们会使用一种称为散列函数的东西来为我们完成这项工作。
考虑散列函数的合理方法是将其视为黑盒。您将数据放在一侧,然后一个称为 哈希码 的数字从另一侧出来。从示意图上看,它看起来像这样:
+---------+
|\| hash |/| --> hash code
data --> |/| function|\|
+---------+
所有哈希函数都是确定性的:如果您将相同的数据多次放入函数中,您总是会从另一端得到相同的值。一个好的散列函数应该看起来或多或少是随机的:输入数据的微小变化应该给出截然不同的散列码。例如,字符串"pudu" 和字符串"kudu" 的哈希码可能会彼此大不相同。 (话又说回来,它们可能是相同的。毕竟,如果哈希函数的输出看起来或多或少是随机的,那么我们有可能两次获得相同的哈希码。)
您究竟是如何构建哈希函数的?现在,让我们继续“体面的人不应该想太多”。数学家已经想出了更好和更差的方法来设计散列函数,但为了我们的目的,我们真的不需要太担心内部结构。把散列函数想象成一个函数就很好了
- 确定性(相等的输入产生相等的输出),但是
- 看起来很随机(很难预测一个哈希码给定另一个)。
一旦我们有了哈希函数,我们就可以构建一个非常简单的哈希表。我们将制作一系列“桶”,您可以将其视为类似于我们梳妆台中的抽屉。要将项目存储在哈希表中,我们将计算对象的哈希码并将其用作表中的索引,这类似于“选择该项目进入哪个抽屉”。然后,我们将该数据项放入该索引处的存储桶中。如果那个桶是空的,那就太好了!我们可以把物品放在那里。如果那个桶是满的,我们可以做一些选择。一种简单的方法(称为chained hashing)是将每个存储桶视为一个项目列表,就像您的袜子抽屉可能存储多个袜子一样,然后只需将项目添加到该索引处的列表中。
要在哈希表中查找某些内容,我们使用基本相同的过程。我们首先计算要查找的项目的哈希码,它告诉我们要查找哪个桶(抽屉)。如果项目在表中,它必须在那个桶中。然后,我们只需查看存储桶中的所有项目,看看我们的项目是否在其中。
这样做有什么好处?好吧,假设我们有大量的桶,我们希望大多数桶中不会有太多的东西。毕竟,我们的散列函数看起来有点像随机输出,所以项目在所有桶中均匀分布。事实上,如果我们将“我们的哈希函数看起来有点随机”的概念形式化,我们可以证明每个桶中的预期项目数是项目总数与桶总数的比率。因此,我们无需做太多工作就可以找到我们正在寻找的项目。
细节
解释“哈希表”的工作原理有点棘手,因为哈希表有很多种。下一节将讨论所有哈希表共有的一些通用实现细节,以及不同样式的哈希表如何工作的一些细节。
出现的第一个问题是如何将哈希码转换为表槽索引。在上面的讨论中,我只是说“使用哈希码作为索引”,但这实际上不是一个好主意。在大多数编程语言中,散列码都适用于 32 位或 64 位整数,您不能直接将它们用作存储桶索引。相反,一种常见的策略是创建一个大小为 m 的存储桶数组,为您的项目计算(完整的 32 位或 64 位)哈希码,然后根据表的大小对它们进行修改以获得介于 0 和m-1,包括在内。模数的使用在这里效果很好,因为它的速度相当快,并且可以很好地在较小的范围内传播全范围的哈希码。
(您有时会看到此处使用的按位运算符。如果您的表的大小是 2 的幂,例如 2k,则计算哈希码的按位与,然后计算数字 2k - 1 相当于计算模数,而且速度明显更快。)
下一个问题是如何选择正确数量的存储桶。如果你选择了太多的桶,那么大多数桶将是空的或只有很少的元素(有利于速度 - 你只需要检查每个桶的几个项目),但你会使用一堆空间来简单地存储桶(不是这样太好了,虽然也许你买得起)。另一面也适用 - 如果存储桶太少,平均每个存储桶的元素会更多,查找时间会更长,但会使用更少的内存。
一个好的折衷方案是在哈希表的整个生命周期内动态更改存储桶的数量。哈希表的负载因子,通常表示为 α,是元素数与桶数之比。大多数哈希表选择一些最大负载因子。一旦负载因子超过此限制,哈希表就会增加其槽数(例如,通过加倍),然后将旧表中的元素重新分配到新表中。这称为重新散列。假设表中的最大负载因子是一个常数,这确保了,假设你有一个好的散列函数,进行查找的预期成本仍然是 O(1)。由于定期重建表的成本,插入现在有一个摊销的预期成本,就像删除一样。 (如果负载因子太小,删除同样可以压缩表。)
散列策略
到目前为止,我们一直在讨论链式哈希,这是构建哈希表的许多不同策略之一。提醒一下,链式散列有点像衣服梳妆台 - 每个桶(抽屉)可以容纳多个物品,当您进行查找时,您会检查所有这些物品。
但是,这不是构建哈希表的唯一方法。还有另一个哈希表系列使用称为open addressing 的策略。开放寻址背后的基本思想是存储一个 slots 数组,其中每个 slot 可以是空的,也可以只容纳一个项目。
在开放寻址中,当您执行插入操作时,和以前一样,您会跳转到某个插槽,其索引取决于计算的哈希码。如果该插槽是免费的,那就太好了!你把物品放在那里,你就完成了。但是如果插槽已经满了怎么办?在这种情况下,您可以使用一些辅助策略来找到一个不同的空闲槽来存储该项目。执行此操作的最常见策略使用称为linear probing 的方法。在线性探测中,如果您想要的插槽已满,您只需转移到表中的下一个插槽。如果那个插槽是空的,太好了!你可以把物品放在那里。但是,如果该槽已满,则您将移至表中的下一个槽,依此类推(如果您到达表的末尾,则返回到开头)。
线性探测是构建哈希表的一种非常快速的方法。 CPU 缓存针对locality of reference 进行了优化,因此在相邻内存位置的内存查找往往比在分散位置的内存查找要快得多。由于线性探测插入或删除是通过命中某个数组槽然后线性向前移动来工作的,因此它会导致很少的缓存未命中,并且最终会比理论通常预测的要快得多。 (而且理论预测它会非常快!)
最近流行的另一种策略是cuckoo hashing。我喜欢将杜鹃散列视为散列表的“冻结”。我们有两个哈希表和两个哈希函数,而不是一个哈希表和一个哈希函数。每个项目都可以恰好位于两个位置中的一个 - 它要么位于第一个哈希函数给出的第一个表中的位置,要么位于第二个哈希函数给出的第二个表中的位置。这意味着查找是最坏情况高效的,因为您只需检查两个位置即可查看表中是否有内容。
杜鹃散列中的插入使用与以前不同的策略。我们首先查看可以容纳该项目的两个插槽中的任何一个是否空闲。如果是这样,太好了!我们只是把物品放在那里。但如果这不起作用,那么我们选择一个插槽,将项目放在那里,然后踢出曾经在那里的项目。该物品必须放在某个地方,因此我们尝试将其放在另一张桌子的适当位置。如果这行得通,那就太好了!如果没有,我们将一个项目踢出 那个 表并尝试将其插入到另一个表中。这个过程一直持续到一切都平静下来,或者我们发现自己陷入了一个循环。 (后一种情况很少见,如果发生这种情况,我们有很多选择,例如“将其放入辅助哈希表”或“选择新的哈希函数并重建表。”)
cuckoo hashing 有许多改进的可能,例如使用多个表,让每个插槽容纳多个项目,以及制作一个“存储”来存放其他任何地方都无法容纳的项目,这是一个活跃的研究领域!
还有混合方法。 Hopscotch hashing 是开放寻址和链式哈希之间的混合,可以认为是采用链式哈希表并将每个存储桶中的每个项目存储在项目想要去的位置附近的插槽中。这种策略与多线程配合得很好。 Swiss table 利用了一些处理器可以用一条指令并行执行多个操作的事实来加速线性探测表。 Extendible hashing 专为数据库和文件系统而设计,并使用 trie 和链式哈希表的混合来在加载单个存储桶时动态增加存储桶大小。 Robin Hood hashing 是线性探测的一种变体,其中项目可以在插入后移动,以减少每个元素可以居住的距离的差异。
进一步阅读
有关哈希表基础知识的更多信息,请查看these lecture slides on chained hashing 和these follow-up slides on linear probing and Robin Hood hashing。您可以了解更多关于cuckoo hashing here 和theoretical properties of hash functions here 的信息。