为什么Python字典可以具有相同散列的多个键?


90

我想了解幕后的Pythonhash函数。我创建了一个自定义类,其中所有实例都返回相同的哈希值。

class C:
    def __hash__(self):
        return 42

我只是假设上述类的一个实例随时都可以位于中dict,但是实际上adict可以具有相同散列的多个元素。

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

因此,我很想知道adict可以如何将多个元素具有相同的哈希值。


3
如您所知,如果对象和对象不相等,则集合和字典可以包含多个具有相等哈希值的对象。你在问什么?表格如何运作?使用大量现有材料,这是一个非常普遍的问题……

@delnan发布问题后,我在想更多有关此的问题;该行为不能仅限于Python。而且你是对的。我想我应该更深入地研究哈希表的一般文献。谢谢。
Praveen Gollakota 2012年

Answers:


55

有关Python哈希的工作原理的详细说明,请参见我的答案,为什么早期返回比其他方法慢?

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

如果哈希值不匹配或项目不相等,则尝试另一个槽。有一个公式可以选择(我在参考答案中对此进行了描述),并且它会逐渐提取哈希值的未使用部分;但一旦将其全部用尽,它将最终在哈希表中的所有插槽中工作。这样可以保证最终我们找到匹配的项目或空的插槽。当搜索找到一个空插槽时,它会插入值或放弃(取决于我们要添加还是获取值)。

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


7
感谢您为我指示有关哈希表实现的正确方向。关于哈希表,我读了很多书,而且我在一个单独的答案中解释了我的发现。stackoverflow.com/a/9022664/553995
Praveen Gollakota 2012年

112

这是我能够汇总的有关Python字典的所有内容(可能比任何人都想知道的要多;但是答案很全面)。向邓肯大声疾呼,指出Python指令使用插槽并将我引向这个兔子洞。

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

    # Logical model of Python Hash table
    -+-----------------+
    0| <hash|key|value>|
    -+-----------------+
    1|      ...        |
    -+-----------------+
    .|      ...        |
    -+-----------------+
    i|      ...        |
    -+-----------------+
    .|      ...        |
    -+-----------------+
    n|      ...        |
    -+-----------------+
    
  • 初始化新字典时,它将以8个插槽开始。(见dictobject.h:49

  • 在向表中添加条目时,我们从某个插槽开始,i该插槽基于键的哈希值。CPython使用initial i = hash(key) & mask。在mask = PyDictMINSIZE - 1,但这并不重要)。只需注意,检查的初始插槽i取决于密钥的哈希值
  • 如果该插槽为空,则将该条目添加到该插槽(通过输入,我的意思是<hash|key|value>)。但是,如果那个插槽被占用了呢?最可能是因为另一个条目具有相同的哈希(哈希冲突!)
  • 如果插槽被占用,CPython(甚至是PyPy)会将插槽中的条目的哈希值与键(即==比较而不是is比较)与要插入的当前条目的键(dictobject.c: 337344-345)。如果两者都匹配,则认为该条目已存在,放弃并继续下一个要插入的条目。如果哈希或密钥不匹配,它将开始探测
  • 探测仅表示它按插槽搜索插槽以找到一个空插槽。从技术上讲,我们可以一个接一个地进行,i + 1,i + 2,...,然后使用第一个可用的(线性探测)。但是由于注释中详细解释的原因(请参阅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在评论中指出的那样)。

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

尽管这是一个很好的了解(对于极客点?),但我不确定如何在现实生活中使用它。因为除非您试图显式破坏某些内容,否则为什么两个不相等的对象会具有相同的哈希值?


8
这解释了如何填充字典。但是,如果在检索key_value对的过程中发生哈希冲突该怎么办。假设我们有2个对象A和B,它们都哈希到4。因此,首先通过随机探测将A分配给插槽4,然后将B分配给插槽。当我想检索B时会发生什么。B哈希为4,因此python首先检查插槽4,但密钥不匹配,因此它不能返回A。由于B的插槽是通过随机探测分配的,B又如何返回在O(1)时间内?
sayantankhan 2014年

4
@ Bolt64随机探测并不是真正的随机。对于相同的键值,它始终遵循相同的探测顺序,因此最终将找到B。不能保证字典为O(1),如果遇到很多冲突,它们可能需要更长的时间。使用旧版本的Python时,很容易构造一系列会冲突的键,在这种情况下,字典查找变为O(n)。这可能是DoS攻击的媒介,因此较新的Python版本会修改哈希值,从而使故意执行此操作变得更加困难。
邓肯

2
@Duncan如果删除A,然后在B上执行查找,该怎么办?我猜您实际上并没有删除条目,而是将它们标记为已删除?这意味着该字典不适合连续插入和删除....
gen-ys 2015年

2
@ gen-ys yes删除和未使用的对查找的处理方式不同。未使用会停止搜索匹配项,但不会删除。在插入时,已删除或未使用的将被视为可以使用的空插槽。连续插入和删除都可以。当未使用(未删除)的插槽数降得太低时,将以与哈希表对于当前表太大一样的方式重建哈希表。
邓肯

1
在邓肯试图补救的碰撞点上,这不是一个很好的答案。从您的问题中参考实施的答案特别差劲。理解这一点最主要的是,如果发生冲突,Python将再次尝试使用公式来计算哈希表中的下一个偏移量。在检索中,如果键不相同,它将使用相同的公式来查找下一个偏移量。没有什么随机的。
埃文·卡罗尔

20

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


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

哈希表

在这里,您会看到John Smith并且Sandra Dee都哈希到152。铲斗152包含它们两者。查找时,Sandra Dee它首先在存储桶中找到列表152,然后循环浏览该列表,直到Sandra Dee找到并返回521-6955

以下是错误的,仅在上下文中存在:Python的Wiki上,您可以找到(伪?)代码Python如何执行查找。

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


感谢您的解释,尤其感谢您使用伪代码链接到Python Wiki条目!
Praveen Gollakota 2012年

2
抱歉,这个答案是完全错误的(Wiki文章也是如此)。Python不在哈希表中存储元素列表或存储桶:它在哈希表的每个插槽中仅存储一个对象。如果它首先尝试使用的插槽被占用,则它将选择另一个插槽(尽可能长地拉入哈希的未使用部分),然后再选择另一个插槽。由于没有哈希表超过三分之一,因此最终必须找到可用的插槽。
邓肯2012年

Python Wiki的@Duncan说它是通过这种方式实现的。我很乐意找到更好的来源。wikipedia.org页面绝对没有错,它只是上述可能的解决方案之一。
罗伯·沃特斯

@Duncan您能解释一下吗……尽可能长地提取哈希的未使用部分?在我看来,所有哈希值总计为42。谢谢!
Praveen Gollakota 2012年

@PraveenGollakota跟随我的答案中的链接,其中详细地解释了如何使用哈希。对于散列为42的表和具有8个插槽的表,最初仅使用最低的3位来找到插槽号2,但是如果该插槽已被使用,则其余的位会起作用。如果两个值具有完全相同的哈希值,则第一个值将尝试进入第一个插槽,第二个值将获取下一个插槽。如果有1000个具有相同散列的值,那么我们最终将尝试1000个插槽,然后再找到该值,并且字典查找变得非常慢!
邓肯2012年

4

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

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


2

在线程中,当我们将它作为键放入字典中时,我没有看到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}
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.