python源码分析:dict对象的实现-1

首先来看看构建dict的基础设施:

typedef struct {
Py_ssize_t me_hash;
PyObject *me_key;
PyObject *me_value;
} PyDictEntry;

这个结构体为dict中key-value,其中的me_hash为me_key的hash值,[空间换时间]。除此之外,我们发现me_key与me_value都是PyObject指针类型,这也说明了为什么dict中的key与value可以为python中的任何类型数据。

struct _dictobject {
PyObject_HEAD
Py_ssize_t ma_fill; /* # Active + # Dummy */
Py_ssize_t ma_used; /* # Active */
Py_ssize_t ma_mask;
PyDictEntry *ma_table;
PyDictEntry *(*ma_lookup)(PyDictObject *mp, PyObject *key, long hash);
PyDictEntry ma_smalltable[PyDict_MINSIZE];
};这个结构体便是dict了。按照我们通常的理解,dict应该是可变长对象啊!为什么这里还有PyObject_HEAD,而不是PyObject_VAR_HEAD。仔细一看,dict的可变长与string,list,tuple仍有不同之外,后者可以通过PyObject_VAR_HEAD中的ob_size来指明其内部有效元素的个数。但dict不能这样做,所以dict干脆绕开PyObject_VAR_HEAD,而且除了有ma_used这个字段来交代出其有效元素的个数,还需要ma_fill来交代清楚曾经有效元素的个数(用来计算加载率)。

ma_mask,则牵扯到hash中的散列函数;
ma_smalltable,python一向的有限空间换时间,一个小池子来应付大多数的小dict(不超过PyDict_MINSIZE);
ma_lookup,则是一次探测与二次探测函数的实现。

在展开dict实现细节前,先把dict使用的解决冲突的开放定址法介绍一下。我们知道哈希,就是将一个无限集合映射到一个有限集,如果选择理想的hash函数,能够将预期处理到的元素均匀分布到有限集中即可在O(1)时间内完成元素查找。但理想的hash函数是不存在的,且由于映射的本质(无限到有限)必然出出现一个位置有多个元素要‘占据',这就需要解决冲突。现有的解决冲突的方法:

  1. 开放定址法
  2. 链地址法
  3. 多哈希函数法
  4. 建域法

其中建域法基本思想为假设哈希函数的值域为[0,m-1],则设向量HashTable[0..m-1]为基本表,另外设立存储空间向量OverTable[0..v]用以存储发生冲突的记录。

其中前两种方法实现最为简单高效,下面回顾下开放定址与链地址法。

开放定址法:形成hash表时,某元素在第一次探测其应该占有的位置时,如果发现此处(记为A)已经被别人占了,那就在从A开始,再次探测(当然这次探测使用的hash函数与第一次已经不一样了),如果发现还是被别人占了,那么继续探测,至到找到一个可用位置(也有可能在当下条件下永远找不到)。开放地址法有一个至关重要的问题需要解决,那就是在一个元素离开hash表时,如何处理离开后的位置状态。如果设置为原始空状态,那么后续的有效元素就无法识别了,因为在查找时同样是依据上面的探测规则进行查找,所以必须告诉探测函数某个位置虽然无有效元素了,但后续的探测可能会出现有效元素。我们可以发现,开放定址法很容易发生冲突(主要是一次探测以上成功的元素占取其它元素应该在第一次探测成功的位置),所以就需要加大hash有效空间。

链地址法:链地址法的思想很简单,你不是可能会出现多个元素对应同一个位置,那么我就在这个位置拉出一个链表来存放所以hash到这个位置的元素。很简单吧,还节约内存呢!很遗憾,python的设计者没有选它。

那为什么python发明者选择了开放定址而不是链地址法,在看python源码时看到这么一段话:

Open addressing is preferred over chaining since the link overhead for chaining would be substantial (100% with typical malloc overhead).

由于链地址法需要动态的生成链表结点(malloc),所以时间效率不如开放定址法(但开放定址法的装载率不能高于2/3,相对于链地址法的空间开销也是毋庸置疑的),由此可以看出python的设计时代已经不是那个内存只有512k可供使用的时代了,对内存的苛刻已经让步于效率。当然这需要考虑到python由于实现动态而必须靠自身的设计将损失的时间效率尽可能地补回来。

好了,交待完开放定址法与为什么python设计者选择它后,我们来看看dict如何实现这个算法的。前面已经看到每个key-value由一个Entry结构体实现,python就是利用entry自身的信息来指明每个位置的状态:原始空状态、有效元素离去状态、有效元素占据状态。

  • 原始空:me_key:Null ;me_value:Null
  • 有效元素离去:me_key:dummy; me_value:Null
  • 有效元素占据:me_key:not Null and not dummy ;me_value:not Null

其中dict的hash方法与冲突解决方法的思路如下:

lookdict(k,v)

  1. index <- hash1(k),freeslot<-Null,根据me_key与me_value选择2、3、4一个执行;
  2. 查看index处的值处于’有效元素占据‘状态,判断data[index]与v是否一致(地址或内容),一致,则返回查找成功;转5
  3. index所指向的位置处于’原始空‘状态,查找失败,若freeslot==Null返回index;否则返回freeslot;转5
  4. index所指向的位置处于’有效元素离去‘状态,freeslot<-index, 转5
  5. index <- hash2(index),,转2

dict的lookdict方法实现充分体现了python对内存的利用率与空间换时间提高效率上,表现为如下方面:

  1. 内存利用率:当找到原始空状态时,如果前面已经找到dummy态的entry,则会将其返回。
  2. 提高效率:ma_table始终指向有效散列空间的开始位置,在开辟新空间后,small_table就弃之不用了,ma_table改指向新开辟空间的首位置。

 

发表评论