【问题标题】:Why can a Python dict have multiple keys with the same hash?为什么 Python dict 可以有多个具有相同哈希的键?
【发布时间】:2023-11-13 05:52:01
【问题描述】:

我试图了解 Python hash 的底层功能。我创建了一个自定义类,其中所有实例都返回相同的哈希值。

class C:
    def __hash__(self):
        return 42

我只是假设上述类的一个实例在任何时候都可以在dict 中,但实际上dict 可以有多个具有相同哈希的元素。

c, d = C(), C()
x = {c: 'c', d: 'd'}
print(x)
# {<__main__.C object at 0x7f0824087b80>: 'c', <__main__.C object at 0x7f0823ae2d60>: 'd'}
# note that the dict has 2 elements

我做了更多的实验,发现如果我重写 __eq__ 方法使得类的所有实例比较相等,那么 dict 只允许一个实例。

class D:
    def __hash__(self):
        return 42
    def __eq__(self, other):
        return True

p, q = D(), D()
y = {p: 'p', q: 'q'}
print(y)
# {<__main__.D object at 0x7f0823a9af40>: 'q'}
# note that the dict only has 1 element

所以我很想知道dict 如何拥有多个具有相同哈希的元素。

【问题讨论】:

  • 正如您自己发现的那样,如果对象本身不相等,集合和字典可以包含多个具有相等哈希值的对象。你在问什么?桌子是如何工作的?这是一个非常普遍的问题,有很多现有材料......
  • @delnan 我在发布问题后正在考虑更多关于这个问题;这种行为不能仅限于 Python。你是对的。我想我应该更深入地研究一般的哈希表文献。谢谢。

标签: python hash dictionary set equality


【解决方案1】:

这是我能够汇总的有关 Python dicts 的所有内容(可能比任何人都想知道的要多;但答案很全面)。感谢 Duncan 指出 Python dicts 使用插槽并引导我进入这个兔子洞。

  • Python 字典被实现为 哈希表
  • 哈希表必须允许哈希冲突,即即使两个键具有相同的哈希值,表的实现也必须具有明确地插入和检索键值对的策略。
  • Python dict 使用开放寻址来解决哈希冲突(解释如下)(参见dictobject.c:296-297)。
  • Python 哈希表只是一个连续的内存块(有点像一个数组,所以你可以通过索引进行O(1) 查找)。
  • 表中的每个槽只能存储一个条目。这很重要
  • 表中的每个条目实际上是三个值的组合 - 。这是作为 C 结构实现的(参见 dictobject.h:51-56
  • 下图是一个python哈希表的逻辑表示。下图中,左边的0、1、...、i、...是哈希表中slots的索引(仅供说明,不与显然是桌子!)。

    # Logical model of Python Hash table
    -+-----------------+
    0| <hash|key|value>|
    -+-----------------+
    1|      ...        |
    -+-----------------+
    .|      ...        |
    -+-----------------+
    i|      ...        |
    -+-----------------+
    .|      ...        |
    -+-----------------+
    n|      ...        |
    -+-----------------+
    
  • 当一个新的 dict 被初始化时,它以 8 个 slots 开始。 (见dictobject.h:49

  • 当向表中添加条目时,我们从某个槽开始,i,它基于键的散列。 CPython 使用初始的i = hash(key) &amp; maskmask = PyDictMINSIZE - 1,但这并不重要)。请注意,检查的初始槽 i 取决于密钥的 hash
  • 如果该槽为空,则将条目添加到槽中(我的意思是,&lt;hash|key|value&gt;)。但是如果那个插槽被占用了怎么办!?很可能是因为另一个条目具有相同的哈希(哈希冲突!)
  • 如果插槽被占用,CPython(甚至 PyPy)将比较针对要插入的当前条目的键的插槽 (dictobject.c:337,344-345)。如果 both 匹配,则认为该条目已经存在,放弃并继续插入下一个条目。如果哈希或密钥不匹配,则开始探测
  • 探测只是意味着它逐个插槽搜索插槽以找到一个空插槽。从技术上讲,我们可以一个一个地进行,i+1,i+2,...并使用第一个可用的(即线性探测)。但是由于 cmets 中很好地解释了原因(请参阅 dictobject.c:33-126),CPython 使用 随机探测。在随机探测中,以伪随机顺序选择下一个槽。该条目被添加到第一个空槽。对于本次讨论,用于选择下一个插槽的实际算法并不重要(有关探测算法,请参阅dictobject.c:33-126)。重要的是探测槽直到找到第一个空槽。
  • 查找也会发生同样的事情,只是从初始槽 i 开始(其中 i 取决于键的哈希)。如果散列和键都与槽中的条目不匹配,它开始探测,直到找到匹配的槽。如果所有插槽都用尽,则报告失败。
  • 顺便说一句,如果字典已满三分之二,它将调整大小。这样可以避免减慢查找速度。 (见dictobject.h:64-65

给你! dict 的 Python 实现在插入项目时检查两个键的哈希相等和键的正常相等 (==)。所以总而言之,如果有两个键,abhash(a)==hash(b),但是a!=b,那么两者都可以和谐地存在于一个 Python 字典中。但是如果hash(a)==hash(b) a==b,那么它们不能都在同一个字典中。

因为我们必须在每次哈希冲突后进行探测,太多哈希冲突的一个副作用是查找和插入会变得非常慢(正如 Duncan 在comments 中指出的那样)。

我想我的问题的简短回答是,“因为这就是它在源代码中的实现方式;)”

虽然很高兴知道这一点(对于极客点?),但我不确定如何在现实生活中使用它。因为除非你试图明确地破坏某些东西,否则为什么两个不相等的对象会有相同的哈希值?

【讨论】:

  • 这解释了填充字典的工作原理。但是,如果在检索 key_value 对期间发生哈希冲突怎么办。假设我们有 2 个对象 A 和 B,它们都哈希为 4。所以首先 A 被分配到 slot 4,然后 B 通过随机探测被分配到 slot。当我想检索B时会发生什么。B哈希到4,所以python首先检查槽4,但是密钥不匹配所以它不能返回A。因为B的槽是通过随机探测分配的,所以B如何再次返回在 O(1) 时间内?
  • @Bolt64 随机探测并不是真正随机的。对于相同的键值,它总是遵循相同的探测序列,因此它最终会找到 B。字典不能保证是 O(1),如果你遇到很多冲突,它们可能需要更长时间。在旧版本的 Python 中,很容易构造一系列会发生冲突的键,在这种情况下,字典查找变成 O(n)。这可能是 DoS 攻击的载体,因此较新的 Python 版本会修改散列,使其更难故意这样做。
  • @Duncan 如果 A 被删除然后我们在 B 上执行查找怎么办?我猜您实际上并没有删除条目,而是将它们标记为已删除?这意味着字典不适合连续插入和删除....
  • @gen-y-s 是的,已删除和未使用的查找处理方式不同。未使用会停止搜索匹配,但删除不会。在插入时,已删除或未使用的都被视为可以使用的空插槽。连续插入和删除很好。当未使用(未删除)的槽数下降得太低时,哈希表将被重建,就像它对于当前表来说太大了一样。
  • 对于邓肯试图补救的碰撞点,这不是一个很好的答案。从您的问题中参考实施,这是一个特别糟糕的答案。理解这一点的首要条件是,如果发生冲突,Python 会再次尝试使用公式计算哈希表中的下一个偏移量。如果键不同,则检索时,它使用相同的公式查找下一个偏移量。它没有任何随机性。
【解决方案2】:

有关 Python 散列如何工作的详细说明,请参阅我对 Why is early return slower than else? 的回答

基本上,它使用哈希来选择表中的一个槽。如果槽中有一个值并且哈希匹配,它会比较项目以查看它们是否相等。

如果哈希匹配但项目不相等,那么它会尝试另一个插槽。有一个公式可以选择这个(我在参考答案中描述过),它会逐渐引入哈希值的未使用部分;但是一旦它用完它们,它最终会通过哈希表中的所有槽。这保证最终我们要么找到匹配的项目,要么找到一个空槽。当搜索找到一个空槽时,它会插入值或放弃(取决于我们是添加还是获取值)。

需要注意的重要一点是没有列表或桶:只有一个具有特定槽数的哈希表,每个哈希用于生成候选槽序列。

【讨论】:

  • 感谢您为我指出有关哈希表实现的正确方向。关于哈希表,我读到的比我想读的要多得多,我在一个单独的答案中解释了我的发现。 *.com/a/9022664/553995
【解决方案3】:

编辑:下面的答案是处理哈希冲突的可能方法之一,但是 不是 Python 是如何做到的。下面引用的 Python wiki 也是不正确的。 @Duncan 下面给出的最佳来源是实现本身:https://github.com/python/cpython/blob/master/Objects/dictobject.c我为混淆道歉。


它将元素的列表(或桶)存储在哈希中,然后遍历该列表,直到在该列表中找到实际的键。一张图说一千多个字:

您在这里看到John SmithSandra Dee 都哈希到152。桶152 包含它们。在查找Sandra Dee 时,它首先在存储桶152 中找到列表,然后遍历该列表直到找到Sandra Dee 并返回521-6955

以下是错误的,它仅用于上下文:Python's wiki 上,您可以找到(伪?)Python 执行查找的代码。

这个问题实际上有几种可能的解决方案,查看*文章以获得很好的概述:http://en.wikipedia.org/wiki/Hash_table#Collision_resolution

【讨论】:

  • 感谢您的解释,尤其是使用伪代码指向 Python wiki 条目的链接!
  • 抱歉,这个答案完全是错误的(wiki 文章也是如此)。 Python 不会在散列中存储元素列表或存储桶:它在散列表的每个槽中仅存储一个对象。如果它首先尝试使用的插槽被占用,那么它会选择另一个插槽(尽可能长时间地拉入散列的未使用部分),然后再选择一个。由于没有哈希表超过三分之一,它最终必须找到一个可用的插槽。
  • @Duncan,Python 的 wiki 说它是以这种方式实现的。我很乐意找到更好的来源。 wikipedia.org 页面绝对没有错,它只是上述可能的解决方案之一。
  • @Duncan 你能解释一下......尽可能长时间地提取未使用的哈希部分吗? 所有在我的例子中,哈希值为 42。谢谢!
  • @PraveenGollakota 按照我的答案中的链接,详细解释了如何使用哈希。对于 42 的散列和具有 8 个插槽的表,最初仅使用最低 3 位来查找插槽号 2,但如果该插槽已被使用,则其余位开始发挥作用。如果两个值具有完全相同的哈希值,则第一个值进入第一个尝试的槽,第二个进入下一个槽。如果有 1000 个具有相同哈希值的值,那么我们最终会尝试 1000 个插槽,然后才能找到该值,并且字典查找变得非常非常慢!
【解决方案4】:

哈希表,通常必须允许哈希冲突!你会很不走运,两件事最终会归结为同一件事。在下面,在具有相同哈希键的项目列表中有一组对象。通常,该列表中只有一件事,但在这种情况下,它将继续将它们堆叠到同一个中。它知道它们不同的唯一方法是通过等于运算符。

发生这种情况时,您的性能会随着时间的推移而下降,这就是您希望哈希函数尽可能“随机”的原因。

【讨论】:

    【解决方案5】:

    在线程中,当我们将用户定义类的实例作为键放入字典时,我没有看到 python 究竟对它做了什么。让我们阅读一些文档:它声明只有可散列的对象可以用作键。 Hashable 都是不可变的内置类和所有用户定义的类。

    用户定义的类有 __cmp__() 和 __hash__() 默认方法;与他们一起,所有对象 比较不相等(除了自己)和 x.__hash__() 返回从 id(x) 派生的结果。

    因此,如果您的类中有一个持续的 __hash__,但没有提供任何 __cmp__ 或 __eq__ 方法,那么您的所有实例对于字典来说都是不相等的。 另一方面,如果您提供任何 __cmp__ 或 __eq__ 方法,但不提供 __hash__,则您的实例在字典方面仍然不相等。

    class A(object):
        def __hash__(self):
            return 42
    
    
    class B(object):
        def __eq__(self, other):
            return True
    
    
    class C(A, B):
        pass
    
    
    dict_a = {A(): 1, A(): 2, A(): 3}
    dict_b = {B(): 1, B(): 2, B(): 3}
    dict_c = {C(): 1, C(): 2, C(): 3}
    
    print(dict_a)
    print(dict_b)
    print(dict_c)
    

    输出

    {<__main__.A object at 0x7f9672f04850>: 1, <__main__.A object at 0x7f9672f04910>: 3, <__main__.A object at 0x7f9672f048d0>: 2}
    {<__main__.B object at 0x7f9672f04990>: 2, <__main__.B object at 0x7f9672f04950>: 1, <__main__.B object at 0x7f9672f049d0>: 3}
    {<__main__.C object at 0x7f9672f04a10>: 3}
    

    【讨论】:

      最近更新 更多