Python 的内置字典是如何实现的?
Posted
技术标签:
【中文标题】Python 的内置字典是如何实现的?【英文标题】:How are Python's Built In Dictionaries Implemented? 【发布时间】:2021-12-07 19:22:39 【问题描述】:有谁知道python的内置字典类型是如何实现的?我的理解是它是某种哈希表,但我一直无法找到任何明确的答案。
【问题讨论】:
【参考方案1】:Python 字典使用Open addressing (reference inside Beautiful code)
注意! 开放式寻址,又称封闭式散列,如***所述,不应与其相反的开放式哈希混淆!
开放寻址意味着 dict 使用数组槽,当对象的主要位置在 dict 中时,使用“扰动”方案在同一数组中的不同索引处寻找对象的位置,其中对象的哈希价值发挥作用。
【讨论】:
“不要与它相反的开放散列混淆!(我们在接受的答案中看到)。” - 我不确定当你写的时候哪个答案被接受,或者那个答案当时所说的 - 但这个带括号的评论目前不适用于已接受的答案,最好将其删除。【参考方案2】:这是我能够汇总的有关 Python dicts 的所有内容(可能比任何人都想知道的要多;但答案很全面)。
Python 字典被实现为 哈希表。
哈希表必须允许哈希冲突,即即使两个不同的键具有相同的哈希值,表的实现也必须具有明确地插入和检索键值对的策略。
Python dict
使用开放寻址 来解决哈希冲突(解释如下)(参见dictobject.c:296-297)。
Python 哈希表只是一个连续的内存块(有点像一个数组,因此您可以通过索引进行O(1)
查找)。
表中的每个槽只能存储一个条目。这很重要。
表中的每一个entry实际上是三个值的组合:
下图是一个 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) & mask
(其中mask = PyDictMINSIZE - 1
,但这并不重要)。请注意,检查的初始槽 i
取决于密钥的 散列。
如果该槽为空,则将条目添加到槽中(我的意思是,<hash|key|value>
)。但是如果那个插槽被占用了怎么办!?很可能是因为另一个条目具有相同的哈希(哈希冲突!)
如果插槽被占用,CPython(甚至 PyPy)会比较插槽中条目的 哈希和键(通过比较我的意思是 ==
比较而不是 is
比较)分别针对要插入的当前条目的哈希和键(dictobject.c:337,344-345)。如果 both 匹配,则认为该条目已经存在,放弃并继续插入下一个条目。如果哈希或密钥不匹配,则开始探测。
探测只是意味着它按插槽搜索插槽以找到一个空插槽。从技术上讲,我们可以一个接一个,i+1, i+2, ...
并使用第一个可用的(即线性探测)。但是由于 cmets 中很好地解释了原因(请参阅 dictobject.c:33-126),CPython 使用随机探测。在随机探测中,以伪随机顺序选择下一个槽。该条目被添加到第一个空槽。对于本次讨论,用于选择下一个插槽的实际算法并不重要(有关探测算法,请参阅dictobject.c:33-126)。重要的是探测槽直到找到第一个空槽。
查找也会发生同样的事情,只是从初始槽 i 开始(其中 i 取决于键的哈希)。如果散列和键都与槽中的条目不匹配,它开始探测,直到找到匹配的槽。如果所有插槽都用完,则报告失败。
顺便说一句,如果dict
已满三分之二,则会调整其大小。这样可以避免减慢查找速度。 (见dictobject.h:64-65)
注意:我对 Python Dict 实现进行了研究,以回应我自己的question,关于 dict 中的多个条目如何具有相同的哈希值。我在这里发布了经过稍微编辑的回复版本,因为所有研究都与这个问题非常相关。
【讨论】:
你说,当哈希和密钥都匹配时,它(插入操作)放弃并继续前进。在这种情况下插入不会覆盖现有条目吗? 感谢@Praveen 的精彩解释。我认为如果你也提供一个字典中插入、查找和删除的例子会更好。 @PraveenGollakota,感谢您的回答......我的一个朋友在今天的 CS 直播课上在讨论字典时提出了同样的问题,当时他看到将不可散列类型作为键值传递的错误.. . 我很幸运地找到了你的答案并把它传给了他 条目指针中的键和值是否指向 PyObjects(即 PyObject *)?【参考方案3】:Python 的内置字典是如何实现的?
这是短期课程:
它们是哈希表。 (有关 Python 实现的详细信息,请参见下文。) 一种新的布局和算法,从 Python 3.6 开始,使它们 按插入键排序,并且 占用更少的空间, 几乎没有性能成本。 另一个优化可以在 dicts 共享键时节省空间(在特殊情况下)。从 Python 3.6 开始,有序方面是非官方的(让其他实现有机会跟上),但 official in Python 3.7。
Python 的字典是哈希表
很长一段时间以来,它都是这样工作的。 Python 会预先分配 8 个空行并使用散列来确定键值对的粘贴位置。例如,如果键的哈希以 001 结尾,它会将其粘贴在 1(即第 2 个)索引中(如下例所示。)
<hash> <key> <value>
null null null
...010001 ffeb678c 633241c4 # addresses of the keys and values
null null null
... ... ...
每行在 64 位架构上占用 24 个字节,在 32 位架构上占用 12 个字节。 (请注意,此处的列标题只是用于我们目的的标签 - 它们实际上并不存在于内存中。)
如果散列的结尾与先前存在的键的散列相同,这就是冲突,然后它将键值对粘贴在不同的位置。
存储5个key-value后,再添加一个key-value对时,hash冲突的概率太大,所以字典的大小翻了一番。在 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__
。要获得这种行为,我相信您需要在实例化下一个对象 (see PEP 412) 之前完成填充 __dict__
。这意味着您应该在 __init__
或 __new__
中分配所有属性,否则您可能无法节省空间。
但是,如果您在执行 __init__
时知道所有属性,您还可以为您的对象提供 __slots__
,并保证根本不创建 __dict__
(如果在父母中不可用),甚至允许__dict__
,但保证您预见的属性无论如何都存储在插槽中。有关__slots__
、see my answer here 的更多信息。
另见:
PEP 509 -- 给dict添加私有版本 PEP 468 -- 在函数中保留**kwargs
的顺序。
PEP 520 -- 保留类属性定义顺序
PyCon 2010: The Might Dictionary - Brandon Rhodes
PyCon 2017: The Dictionary Even Mightier - Brandon Rhodes
PyCon 2017: Modern Python Dictionaries A confluence of a dozen great ideas - 雷蒙德·赫廷格
dictobject.c - CPython 在 C 中的实际 dict 实现。
【讨论】:
您说“我们”,并且“让 Python 的其他实现有机会赶上”——这是否意味着您“了解事物”并且这可能会成为永久功能?按规范排序的字典有什么缺点吗? 排序的缺点是,如果期望对 dicts 进行排序,则它们不能轻易切换到未排序的更好/更快的实现。不过,情况似乎不太可能。我“知道事情”是因为我看了很多演讲,阅读了很多核心成员和其他比我在现实世界中享有更高声誉的人写的东西,所以即使我没有立即可用的资源可以引用,我通常也知道我在说什么。但我认为你可以从 Raymond Hettinger 的一次演讲中明白这一点。 您有点含糊地解释了插入是如何工作的(“如果散列与预先存在的键的散列结束相同,......那么它将键值对粘贴在不同的位置” - 任何? ),但您没有解释查找和成员资格测试是如何工作的。也不太清楚哈希是如何确定位置的,但我认为大小始终是 2 的幂,并且您获取哈希的最后几位... @Alexey 我提供的最后一个链接为您提供了注释良好的 dict 实现 - 您可以在其中找到执行此操作的函数,当前位于第 969 行,称为find_empty_slot
: github.com/python/cpython/blob/master/Objects/dictobject.c#L969 - 并开始在第 134 行有一些描述它的散文。以上是关于Python 的内置字典是如何实现的?的主要内容,如果未能解决你的问题,请参考以下文章