字典内部剖析
开篇先提出几个疑问:
- 所有的类型都可以做字典的键值吗?
- 字典的存储结构是如何实现的?
- 散列冲突时如何解决?
最近看了一些关于字典的文章,决定通过自己的理解把他们写下来;本章将详细阐述上面的几个问题,通过源码的剖析,尽量还原字典的真相。
键值要求:
在python中只有可以散列的数据类型才能作为字典里的键(只有键有这个要求,值并不需要是可散列的数据类型)
那什么是可散列的数据类型?
在Python词汇表(https://docs.python.org/3/glossary.html#term-hashable)中,关于可散列类型的定义有这样一段话:
如果一个对象是可散列的,那么这个对象的生命周期中,它的散列值是不变的,而且这个对象需要实现__hash__()方法。另外可散列对象还要有__qe__()方法,这样才能跟其他键做比较。如果两个可散列对象是相等的,那么它们的散列值一定是一样的。
原子不可变数据类型(str、 bytes和数值类型)都是可散列类型,frozenset也是可散列的,根据其定义,frozenset里只能容纳可散列类型。元祖的话,只有当一个元祖包含的所有元素都是可散列类型的情况下,它才是可散列的如图:
内部存储实现
字典这个数据结构活跃在所有Python程序的背后,即便你的源码里并没有直接用到它,dict是Python语言的基石,模块的命名空间,实例的属性和函数的关键字参数等都可以看到字典的身影,dictnotes.txt中有介绍字典的应用及可调参数优化;正是因为字典至关重要,Python对它的实现做了高度优化,而散列表则是字典类型性能出众的根本原因。
散列表:
散列表(hash table)其实是一个稀疏数组(总是有空白元素的数组成为稀疏数组)。在一般的数据结构教材中,散列表里的单元通常叫作表元(bucket)。在dict的散列表当中,每个键值对都占用一个表元,每个表元都有两个部分,一个是对键的引用,另一个是对值的引用。因为所有表元的大小一致,所以可以通过偏移量来读取某个表元。表元的索引是键经过散列函数处理后得到的,散列函数的目的是使键均匀地分布在数组中;
提高散列表效率两种方式:①散列函数(散列函数的优劣直接决定搜索效率的高低) ②减低散列表装载率(装载率超过2/3时,散列冲突发生的概率就会大大增加)都是为了避免hash冲突;得益于此,Python的字典中有数百万个元素,多数的搜索过程里并不会发生冲突,平均下来每次搜索可能会有一到两次冲突,在正常情况下,就算最不走运的键所遇到的冲突次数用一只手也能数过来。
散列表算法:
如果要把一个对象放入散列表,那么首先要计算这个元素键的散列值,Python中可以用hash()方法来做这件事情。如果两个对象在比较的时候是相等的,那它们的散列值必须相等;越是相似但是不相等的对象,它们的散列值的差别应该越大;
注:从 Python3.3开始,str、 bytes和 datetime对象的散列值计算过程中多了随机的“加盐”这一步。所加盐值是 Python进程内的一个常量,但是每次启动PYthon解释器都会生成一个不同的盐值,随机盐值的加入是为了防止DOS攻击而采取的一种安全措施。在_hash特殊方法的文档(https://docs.python.org/3/reference/datamodel.html#object.__hash__)里有相关的详细信息
如果用长度为 x 的数组存储键/值对,则我们需要用值为 x-1 的掩码计算槽(slot,存储键/值对的单元,表元)在数组中的索引,用散列值与x-1进行取余计算即可得到表元下标。假如字典中所用数组的长度是 8(默认字典中数组最小长度),当进行{'a':1,'b':2,'c':3,'z':26}生成字典操作时,那么键'a'
的索引为:hash('a') & 7 = 0
,同理'b'
的索引为 3 ,'c'
的索引为 2 ,而'z'
的索引与'b'
相同,也为 3 ,这就出现了散列冲突。如下图
可以看出,Python的哈希函数在键彼此连续的时候表现得很理想,这主要是考虑到通常情况下处理的都是这类形式的数据。然而,一旦我们添加了键'z'
就会出现冲突,因为这个键值并不毗邻其他键,且相距较远。应证上面不相似的数据差别小,当然,我们也可以用索引为键的哈希值的链表来存储键/值对,但会增加查找元素的时间,时间复杂度也不再是 O(1) 了
散列冲突:
如上述情况一样,由于散列表的下标范围是有限的,而元素关键字的值是接近无限的,因此可能会出现不同的哈希值获取的下标一样这种情况。此时,两个元素映射到同一个下标处,造成散列冲突。
解决散列冲突的方法有两种:拉链法(将所有冲突的元素用链表连接)及 开放寻址法(通过哈希冲突函数得到新的地址) 下图为拉链法示例图
Python中是通过开放寻址法来进行处理散列冲突的
开放寻址法:
开放寻址法是一种用探测手段处理冲突的方法。在上述键'z'
冲突的例子中,索引 3 在数组中已经被占用了,因而需要探寻一个当前未被使用的索引。增加和搜寻键/值对需要的时间均为 O(1)。
搜寻空闲槽用到了一个二次探测序列(quadratic probing sequence),其代码如下:
for (perturb = hash; ; perturb >>= PERTURB_SHIFT) { i = (i << 2) + i + perturb + 1; ep = &ep0[i & mask];
PERTURB_SHIFT 默认值为5 ,每次循环perturb(散列值)进行位运算,与mask(数组长度-1)进行取余运算,不断获取新的下标,直到获取可用的表元
说了这么多,你可能恍然大悟,原来是这样的!! 当然更多的估计还是一知半解,那么下面直接抛源码,开干!
CPython源码剖析
更多介绍可以参阅dictobject.c
的源码
关联容器的entry(表元)
我们将把关联容器的一个(键,值)元素对称为一个entry或slot。在Python中,一个entry的定义如下:
typedef struct{ Py_ssize_t me_hash; # key的散列 PyObject *me_key; # 键 PyObject *me_value; # 值 }PyDictEntry
PyDictObject中其实存放都是PyObject*,这也是Python中dict什么都能装的下的原因,因为在Python中,无论什么东西归根到底都是一个PyObject对象;PyDictObject中entry可以再3种状态间转换:Unused态、Active态、Dummy态
Unused态:entry中的me_key和me_value都是NULL,Unused