Python中字典对象的实现

散列表概述


关联容器的设计总会极大地关注搜索键的效率,因为通常我们使用关联容器,都是希望根据我们手中已有的某个元素来快速获得与之有某种联系的另一元素。一般而言,关联容器的实现都会基于设计良好的数据结构。比如C++的STL中的map就是一种关联容器,map的实现基于RB-tree(红黑树)。RB-tree是一种平衡二元树,能够提供良好的搜索效率,理论上,其搜索的复杂度为O(logN)。

Python中同样提供关联式容器,即PyDictObject对象。与map不同的是,PyDictObject对搜索的效率要求及其苛刻,这也是因为PyDictObject在Python本身的实现中被大量地采用,比如会通过PyDictObject来建立Python字节码的运行环境,其中会存放(变量名,变量值)的元素对,通过查找变量名获得变量值。因此,PyDictObject没有如map一样采用平衡二元树,而是采用了散列表(hash table),因为理论上,在最优情况下,散列表能提供O(1)复杂度的搜索效率。

散列表的基本思想是通过一定的函数将需搜索的键值映射为一个整数,将这个整数视为索引值去访问某片连续的内存区域。看一个简单的例子,有10个整数1,2,……,10。其依次对应a, b, ……, j。申请一块连续内存,并依次存储a, b, ……, j:当需要寻找与2对应的字母时,只需通过一定的函数将其映射为整数,显然,2本身就是一个整数,我们可以使用2自身。然后访问这片连续内存的第2个位置,就能得到与2对应的字母b。

将元素映射为整数的过程对于散列表来说尤为关键,用于映射的函数成为散列函数(hash function),而映射后的值称为元素的散列值(hash value)。

在使用散列表的过程中,不同的对象经过散列函数的作用,可能被映射为相同的散列值。而且随着需要存储的数据的增多,这样的冲突就会发生得越来越频繁。散列冲突是散列技术与生俱来的问题。

有很多种方法可以用来解决产生的散列冲突,比如说开链法,这是SGI STL中的hash table所采用的方法;而Python中所采用的是另一种方法,即开放定址法。

当产生散列冲突时,Python会通过一个再次探测函数,计算下一个候选位置,如果这个位置可用,则可将待插入元素放到这个位置;如果这个位置不可用,则Python会再次通过探测函数,获得下一个候选位置,如此不断探测,总会找到一个可用的位置。

这样,沿着探测函数,从一个位置出发可以依次到达多个位置,这些位置形成了一个探测链。当需要删除某条探测链上的某个元素时,问题就产生了。假如这条链的首元素位置为a,尾元素的位置为c,现在需要删除中间的某个位置b上的元素。如果直接将位置b上的元素删除,则会导致探测链的断裂,引起严重的问题。想象一下,在下一次搜索c时,会从位置a开始,通过探测函数,沿着探测链,一步一步向位置c靠近,但是在到达位置b时,发现这个位置上的元素钚属于这个探测链,因此会以为探测链在这里结束,导致不能达到为止c,自然也不能搜索到位置c上的元素,所以结果是搜索失败。而实际上,待搜索的元素确实存在于散列表中。

所以,在采用开放定址的冲突解决策略的散列表中,删除某条探测链上的元素时不能真正地删除,而是进行一种“伪删除”操作,必须要让该元素还存在于探测链上,担当承前启后的重任。对于这种伪删除技术。

PyDictObject


在python中一个关联容器的元素称为一个entry。
[dictobject.h]
typedef struct {
    long me_hash;      /* cached hash code of me_key */
    PyObject *me_key;
    PyObject *me_value;
} PyDictEntry;

可以看到,在PyDictObject中其实存放的都是PyObject*,这也是Python中的dict什么都能装得下的原因,因为在Python中,不论什么都是一个PyObject对象。

me_hash域中存储的是me_key的散列值,利用一个域来记录这个散列值可以避免每次查询的时候都要重新计算一遍散列值。在Python中,在一个PyDictObject对象生存变化的过程中,其中的entry还会在不同的状态间转换。PyDictObject中entry可以在三种状态间转换:Unused态,Active态,Dummy态。

1.当一个entry的me_key和me_value都是NULL时,entry处于Unused态。表明该entry中并没有存储(key, value)对,而且在此之前,也没有存储过。每一个entry在初始化的时候都会出于这种状态,而且只有在Unused态下,entry的me_key域才会为NULL。

2.当entry中存储了一个(key,value)对时,entry便转换到了Active态。在Active态下,me_key和me_value都不能为NULL。更进一步,me_key不能是dummy。

3.当entry中存储的(key,value)对被删除后,entry中的me_key将指向dummy,entry进入Dummy态。这是一种惰性删除技巧,是为了保证冲突的探测序列不会在这里被中断。


一个PyDictObject对象实际上是一大堆entry的集合,总控这些集合的结构如下:

[dictobject.h]
#define PyDict_MINSIZE 8
typedef struct _dictobject PyDictObject;
struct _dictobject {
    PyObject_HEAD
    int ma_fill;  /* # Active + # Dummy */
    int ma_used;  /* # Active */
    int ma_mask;
    PyDictEntry *ma_table;
    PyDictEntry *(*ma_lookup)(PyDictObject *mp, PyObject *key, long hash);
    PyDictEntry ma_smalltable[PyDict_MINSIZE];
};
从注释中可以清楚地看到,ma_fill域中维护着从PyDictObject创建到现在曾经以及正处于Active态的entry个数,而ma_used则维护着当前正处于Active态的entry。

当创建一个PyDictObjec对象时,至少有PyDict_MINSIZE个entry被同时创建。在dictobject.h中,这个值被设定为8,这个值被认为是通过大量的实验得出的最佳值。既不会太浪费内存空间,又能很好地满足Python中大量使用PyDictObject的环境的需求,而不需要再次调用malloc申请内存空间。

当然,我们可以自己调节这个值来调节Python的运行效率。当一个PyDictObject是一个比较小的dict时,即entry数量少于8个,ma_table域将指向这与生俱来的8个entry。而当PyDictObject中的entry数量超过8个时,Python认为这家伙是一个大dict了,将会额外申请内存空间,并将ma_table指向这块空间。这样,无论何时,ma_table域总不会为NULL,这带来了一个好处,不用再运行时一次又一次不厌其烦地检查ma_table的有效性,因为ma_table总是有效的。

你可能感兴趣的:(Python,python,字典,dict,散列函数,对象)