为啥 Haskell Maps 实现为平衡二叉树而不是传统的哈希表?

Posted

技术标签:

【中文标题】为啥 Haskell Maps 实现为平衡二叉树而不是传统的哈希表?【英文标题】:Why are Haskell Maps implemented as balanced binary trees instead of traditional hashtables?为什么 Haskell Maps 实现为平衡二叉树而不是传统的哈希表? 【发布时间】:2013-09-25 08:41:07 【问题描述】:

根据我对 Haskell 的有限了解,Maps(来自 Data.Map)似乎应该像其他语言中的字典或哈希表一样使用,但被实现为自平衡二叉搜索树。

这是为什么?使用二叉树将查找时间减少到 O(log(n)),而不是 O(1),并且要求元素在 Ord 中。当然有充分的理由,那么使用二叉树有什么好处呢?

还有:

在哪些应用程序中二叉树会比哈希表更糟糕?反过来呢?是否有很多情况下,其中一种会比另一种更可取? Haskell 中有传统的哈希表吗?

【问题讨论】:

仅供参考,虽然传统哈希表存在答案中描述的问题,但存在本质上相似并提供相似时间复杂度的持久数据结构:哈希数组映射尝试,在 Clojure 等中使用。 "相对于 O(1)" 仅在一般情况下。在最坏的情况下,哈希表查找是 O(n)。 仅在没有开放寻址的哈希表中实现。最坏的情况(这将导致 O(n) 查找时间)在开放寻址中非常不可能,几乎不值得考虑。 @newacct 对于布谷鸟哈希,查找是最坏情况的 O(1) 时间。 @delnan:当然,为此,插入的最坏情况时间很糟糕。 【参考方案1】:

如果没有可变状态,哈希表就无法高效实现,因为它们基于数组查找。键被散列,散列确定索引到存储桶数组中。如果没有可变状态,将元素插入哈希表会变成 O(n),因为必须复制整个数组(替代的非复制实现,如 DiffArray,introduce a significant performance penalty)。二叉树实现可以共享它们的大部分结构,因此只需要在插入时复制几个指针。

Haskell 当然可以支持传统的哈希表,只要更新在一个合适的 monad 中。 hashtables package 可能是使用最广泛的实现。

二叉树和其他非变异结构的一个优点是它们是持久的:可以保留旧的数据副本而无需额外的簿记。例如,这在某种事务算法中可能很有用。它们也是自动线程安全的(尽管更新在其他线程中不可见)。

【讨论】:

Clojure 哈希映射似乎是一种持久的哈希表式数据结构。当然,Haskell 可能早于这种数据结构的发明(或至少被广泛接受)。看来这东西叫做“哈希数组映射的特里树”。 有哈希数组映射的 trie 数据结构,这些数据结构对于 Haskell 来说是纯函数式的,特别是 Data.HashMap.LazyData.HashMap.StrictData.HashSet,它们在 unordered-containers 中。 @delnan:“哈希数组映射的特里树”不是哈希表,因为该结构通常被理解。使用哈希作为键,哈希数组映射的特里树更接近于Data.IntMap(来自containers)。 @JohnL 我明白这一点。这就是我说“-ish”的原因。实现与使用哈希的关联容器一样不同,但它仍然是接口和复杂性最接近的。 这可能是我喜欢线性类型的主要原因之一。【参考方案2】:

传统的哈希表在其实现中依赖于内存突变。可变内存和引用透明度已经结束,因此将哈希表实现降级为IOST monads。树可以通过将旧叶子留在内存中并返回指向更新树的新根节点来持久有效地实现。这让我们拥有纯粹的Maps。

典型的参考是 Chris Okasaki 的Purely Functional Data Structures。

【讨论】:

【参考方案3】:

这是为什么?使用二叉树将查找时间减少到 O(log(n)) 而不是 O(1)

查找只是其中一种操作;在许多情况下,插入/修改可能更重要;还有内存方面的考虑。选择树表示的主要原因可能是它更适合纯函数式语言。作为“真实世界的 Haskell”puts it:

地图为我们提供了与其他语言中的哈希表相同的功能。在内部,映射被实现为平衡二叉树。与哈希表相比,在具有不可变数据的语言中,这是一种更有效的表示形式。这是纯函数式编程对我们编写代码的影响程度最明显的例子:我们选择可以清晰表达并高效执行的数据结构和算法,但我们对特定任务的选择通常与命令式语言中的对应物不同。

这个:

并要求元素在 Ord 中。

似乎不是一个很大的缺点。毕竟,对于哈希映射,您需要键为 Hashable,这似乎更具限制性。

在哪些应用程序中二叉树会比哈希表更糟糕?反过来呢?是否有很多情况下,其中一种会比另一种更可取? Haskell 中有传统的哈希表吗?

很遗憾,我无法提供广泛的比较分析,但有一个hash map package,您可以在this blog post 中查看其实现细节和性能数据并自行决定。

【讨论】:

这些理由似乎很薄弱。树在内存使用或插入性能方面并不优于哈希表,无论是一般情况下还是在这种特定情况下。而且我怀疑 hashablility 比 orderability 更具限制性 - 在大多数情况下,您只需组合成员的哈希而不是链接成员的比较。 “在内存使用或插入性能方面,树并不优于哈希表” — RWH 的重点在于,当以纯函数式语言实现时,它们是。 “而且我怀疑 hashablility 比 orderability 更具限制性”——Ord 是由编译器自动派生的,再简单不过了。 RE 性能:在纯粹的功能实现中,是的,但这只是因为该实现是愚蠢的。如果您的第一段的重点是插入必须复制底层数组,那么只需。 RE 简单:它不必比Ord更简单,它只需要一样简单。虽然deriving Hash 今天不工作,但可以很容易地添加它,就像派生 Ord 和 Eq 一样:成员明智。您甚至不必考虑如何组合成员的哈希值,您可以重复使用另一种实现(例如用于 Python 元组的实现)。 “那个实现一定很慢”——一些图上的链接会很好。我在回答中的那个看起来还不错(它已经 2 岁了,所以谁知道之后发生了什么)。也不要忘记内存占用。至于Ord,它已经自动派生了,几乎所有标准类型及其叔叔都有它;你承认自己Hashable 甚至没有标准化。 RE 性能:我说的是 John L 也描述的:纯函数式哈希表必须复制整体,因此修改是线性时间。您所指的数据结构不是纯粹是功能性的,它使用可变状态。 RE 内存占用:您是说哈希表比树占用更多空间吗? BST 每个条目至少需要两个额外的字(子指针),哈希表可以低至 0 个字(开放寻址,100% 负载因子),但通常需要一到两个字(取决于负载因子和哈希值是否被缓存)。【参考方案4】:

我对使用二叉树的优势的回答是:范围查询。从语义上讲,它们需要一个总的预排序,并在算法上从一个平衡的搜索树组织中获利。对于简单的查找,恐怕只有好的 Haskell 特定答案,但本身不是好的答案:查找(实际上是散列)只需要一个 setoid(其键类型的相等/等价),它支持有效的散列指针(出于充分的理由,没有在 Haskell 中排序)。像各种形式的尝试(例如,用于元素更新的三元尝试,其他用于批量更新的尝试)散列到数组(打开或关闭)通常比在二叉树中的元素搜索更有效,无论是空间还是时间。 Hashing 和 Tries 可以通用定义,尽管必须手动完成——GHC 没有派生它(还没有?)。诸如 Data.Map 之类的数据结构往往适用于原型设计和热点之外的代码,但在它们很热的地方,它们很容易成为性能瓶颈。幸运的是,Haskell 程序员不需要关心性能,只需要关心他们的经理。 (由于某种原因,我目前在 80 多个 Data.Map 函数中找不到访问搜索树的关键兑换功能的方法:范围查询界面。我找错地方了吗?)

【讨论】:

以上是关于为啥 Haskell Maps 实现为平衡二叉树而不是传统的哈希表?的主要内容,如果未能解决你的问题,请参考以下文章

为什么 MongoDB (索引)使用B-树而 Mysql 使用 B+树

具有N个结点的平衡二叉树的深度一定不小于logn对么?为啥

将空二叉树填充为二叉搜索树而不改变结构(节点链接)

JDK8的HashMap为啥要引入红黑树?

平衡二叉树的时间复杂度为啥是对数

Java 平衡二叉树 实现