Python的内置词典如何实现?


294

有谁知道python内置字典类型是如何实现的?我的理解是,这是某种哈希表,但我无法找到任何确定的答案。


4
这是有关2.7至3.6的Python词典的有见地的讨论。链接
索伦

Answers:


494

这是我能够汇总的有关Python字典的所有内容(可能比任何人都想知道的要多;但是答案很全面)。

  • Python字典实现为哈希表
  • 哈希表必须允许哈希冲突,即,即使两个不同的键具有相同的哈希值,表的实现也必须具有明确插入和检索键和值对的策略。
  • Python dict使用开放式寻址来解决哈希冲突(如下所述)(请参阅dictobject.c:296-297)。
  • Python哈希表只是一个连续的内存块(有点像一个数组,因此您可以O(1)按索引进行查找)。
  • 表中的每个插槽只能存储一个条目。这个很重要。
  • 中的每个条目实际上是三个值的组合:<hash,key,value>。这被实现为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最初使用i = hash(key) & mask(where mask = PyDictMINSIZE - 1,但这并不重要)。请注意,i选中的初始插槽取决于密钥的哈希值
  • 如果该插槽为空,则将条目添加到该插槽(通过输入,我是说<hash|key|value>)。但是,如果那个插槽被占用!?最可能是因为另一个条目具有相同的哈希(哈希冲突!)
  • 如果该插槽被占用,则CPython(甚至PyPy)将插槽中条目的哈希值与键(即==比较而不是is比较)与要插入的当前条目的哈希值和键(dictobject.c)进行比较。 :337,344-345)。如果两者都匹配,则认为该条目已存在,放弃并继续下一个要插入的条目。如果哈希或密钥不匹配,则开始探测
  • 探测仅表示它按插槽搜索插槽以找到一个空插槽。从技术上讲,我们可以一个接一个地i+1, i+2, ...使用,然后使用第一个可用的(线性探测)。但是由于注释中详细解释的原因(请参阅dictobject.c:33-126),CPython使用了随机探测。在随机探测中,以伪随机顺序选择下一个时隙。该条目将添加到第一个空插槽。对于此讨论,用于选择下一个时隙的实际算法并不是很重要(有关探测的算法,请参见dictobject.c:33-126)。重要的是对插槽进行探测,直到找到第一个空插槽为止。
  • 查找也会发生相同的情况,只是从初始插槽i(其中i取决于键的哈希值)开始。如果哈希和密钥都与插槽中的条目不匹配,它将开始探测,直到找到具有匹配项的插槽。如果所有插槽均已耗尽,则报告失败。
  • 顺便说一句,dict如果三分之二满了,它将被调整大小。这样可以避免减慢查找速度。(见dictobject.h:64-65

注意:我对Python Dict的实现进行了研究,以回答我自己的问题:字典中的多个条目如何具有相同的哈希值。我在此处发布了对此回复的略作修改的版本,因为所有的研究也都与此问题相关。


8
您说过,当哈希和密钥都匹配时,它(插入操作)就会放弃并继续前进。在这种情况下不插入覆盖现有条目吗?
0xc0de

65

Python的内置词典如何实现?

这是短期课程:

  • 它们是哈希表。(有关Python实现的详细信息,请参见下文。)
  • 从Python 3.6开始,新的布局和算法使它们
    • 通过插入密钥排序,以及
    • 占用更少的空间,
    • 几乎不牺牲性能。
  • 当字典共享密钥时(在特殊情况下),另一种优化方法可以节省空间。

从Python 3.6开始,有序方面是非官方的(给其他实现一个跟上的机会),但在Python 3.7中却是正式的

Python的字典是哈希表

长期以来,它完全像这样工作。Python将预分配8个空行,并使用哈希值确定键值对的粘贴位置。例如,如果密钥的哈希值以001结尾,则它将其保留在1(即2nd)索引中(如以下示例所示)。

   <hash>       <key>    <value>
     null        null    null
...010001    ffeb678c    633241c4 # addresses of the keys and values
     null        null    null
      ...         ...    ...

在64位体系结构上,每一行占用24个字节,在32位体系结构上占用12个字节。(请注意,列标题只是出于我们的目的而使用的标签-它们实际上并不存在于内存中。)

如果散列的结尾与预先存在的键的散列相同,则为冲突,然后它将键值对保留在不同的位置。

存储5个键值之后,添加另一个键值对时,哈希冲突的可能性太大,因此字典的大小增加了一倍。在64位处理中,在调整大小之前,我们有72个字节为空,而在此之后,由于有10个空行,我们浪费了240个字节。

这需要很多空间,但是查找时间是相当恒定的。密钥比较算法是计算哈希值,转到预期位置,比较密钥的ID-如果它们是同一对象,则它们相等。如果没有,那么比较的哈希值,如果他们一样,他们是不相等的。否则,我们最终比较键是否相等,如果相等,则返回值。最终的相等比较可能会很慢,但是较早的检查通常会缩短最终的比较,从而使查找非常快。

冲突会减慢速度,并且从理论上讲,攻击者可以使用哈希冲突来执行拒绝服务攻击,因此我们对哈希函数的初始化进行了随机化处理,以便为每个新的Python进程计算不同的哈希。

上述浪费的空间使我们修改了字典的实现,具有令人兴奋的新功能,即现在可以通过插入来对字典进行排序。

新的紧凑型哈希表

相反,我们首先为插入索引预分配一个数组。

由于我们的第一个键值对位于第二个插槽中,因此我们这样进行索引:

[null, 0, null, null, null, null, null, null]

并且我们的表只是按插入顺序填充:

   <hash>       <key>    <value>
...010001    ffeb678c    633241c4 
      ...         ...    ...

因此,当我们查找键时,我们使用哈希检查我们期望的位置(在这种情况下,我们直接转到数组的索引1),然后转到哈希表中的该索引(例如索引0) ),检查键是否相等(使用前面所述的相同算法),如果相等,则返回值。

我们保留了恒定的查找时间,在某些情况下速度损失较小,而在另一些情况下速度有所增加,其好处是,与现有的实现相比,它可以节省大量空间,并且可以保留插入顺序。唯一浪费的空间是索引数组中的空字节。

Raymond Hettinger 于2012年12月在python-dev上引入了此功能。它最终在Python 3.6中进入了CPython 。通过插入排序被认为是3.6的实现细节,以使Python的其他实现有机会赶上。

共用金钥

节省空间的另一种优化方法是共享密钥的实现。因此,我们没有重复使用共享密钥和密钥散列的冗余字典,而不是拥有占据所有空间的冗余字典。您可以这样想:

     hash         key    dict_0    dict_1    dict_2...
...010001    ffeb678c    633241c4  fffad420  ...
      ...         ...    ...       ...       ...

对于64位计算机,每个额外的字典每个键最多可以节省16个字节。

自定义对象和替代项的共享密钥

这些共享密钥字典旨在用于自定义对象__dict__。为了获得这种行为,我相信您需要__dict__在实例化下一个对象之前完成填充(请参阅PEP 412)。这意味着您应该在__init__或中分配所有属性__new__,否则可能无法节省空间。

但是,如果您知道执行时的所有属性,则__init__还可以提供__slots__对象,并保证__dict__根本不会创建该对象(如果在父级中不可用),甚至允许__dict__但保证您可以预见的属性是仍然存储在插槽中。有关更多信息__slots__请在此处查看我的答案

也可以看看:


1
您说了“我们”,并且“让Python的其他实现有机会赶上来”-这是否意味着您“知道了一切”,并且这可能成为永久性功能?规范要求的命令是否有不利之处?
toonarmycaptain

下达命令的不利之处在于,如果期望命令被命令,则它们将不容易切换到未命令的更好/更快的实现。不过似乎不太可能。我之所以“知道”,是因为我观看了大量的演讲,并且阅读了许多核心成员和其他人在现实世界中享有声誉比我更好的文章,因此,即使我没有即时可用的消息来源,我通常也知道我在说什么 但是我认为您可以从Raymond Hettinger的一次演讲中得到这一点。
亚伦·霍尔

1
您在某种程度上含糊地解释了插入的工作原理(“如果散列以与先前存在的键的散列相同的方式结束,……那么它将把键值对粘贴在不同的位置” –可以吗?),但是您没有解释查找和成员资格测试的工作方式。也不太清楚哈希是如何确定位置的,但是我想它的大小始终是2的幂,而您需要哈希的最后几位……
Alexey,

@Alexey我提供的最后一个链接为您提供了经过良好注释的dict实现-您可以在其中找到执行此功能的函数,当前位于969行,名为find_empty_slotgithub.com/python/cpython/blob/master/Objects/dictobject.c #L969-从134行开始,有一些散文对此进行了描述。
亚伦·霍尔

46

Python字典使用Open寻址在Beautiful代码中参考

注意! 如Wikipedia所述,开放式寻址(又名封闭式哈希)不应与其相反的开放式哈希混淆

开放式寻址意味着dict使用数组插槽,并且当对象的主要位置位于dict中时,使用“扰动”方案在同一数组中的不同索引处查找对象的位置,其中对象的哈希值发挥了作用。


5
“不要将其与相反的开放哈希混为一谈!(我们在公认的答案中看到了这一点)。” -我不确定当您写下该答案时是接受哪个答案,或者那个答案当时说的是什么-但是带括号的注释当前并不代表接受的答案,最好将其删除。
托尼·德罗伊
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.