Hash Table: CPython中字典(dict)的实现方式

众所周知在Python中,字典(dict)的插入、删除、查询操作的平均复杂度都是O(1)。对比来看其他常见的数据结构,比如动态数据、链表、二叉树等等都无法同时满足三种操作的平均复杂度为O(1)。面对这种开了挂的数据结构,之前只知道dict是用Hash Table实现的,这次通过阅读Python的wiki把零散的实现细节拼凑了起来:

Hash函数:

hash函数是实现Hash Table的基础,hash函数可以接收一个对象并返回一个数值,这个数值就是hash值,前提是传入的对象是不可变对象,在Python中数值型,字符串,元组等属于不可变对象,而数组、dict等对象属于可变对象。如果传入一个可变对象,hash函数会抛出异常,比如a = {}  a[[1,2]] = 1就会抛出异常,异常信息大概是说这里的[1,2]属于数组,数组是一个unhashable的类型,这样的解释等于没有解释。那么为什么数组是unhashable的呢,因为数组作为可变对象,如果我们采用数组的地址作为计算hash值的依据,对于两个数组比如a = [1, 2] b = [1, 2],a == b is True,但是a与b的地址是不一样的,所以不能采用数组的地址作为hash依据;如果采用数组的值作为hash的依据,因为数组是可变对象,当数组发生变化时hash值是不能跟着变化的,这个数组对象的hash值与数组的值不再是hash关系,所以也不能采用数组的值作为hash依据,所以数组这类可变对象是unhashable的。但是对于一个合格的hash函数来说,还需要满足在绝大多数情况下当a != b 时 hash(a) != hash(b),这并不容易,而且是比较有难度的数学问题,我就无法解释了,但无论如何CPython已经实现了一个合格的hash函数,并且将在后面要介绍的PyDictEntry中用到。

PyDictEntry:

我们知道dict是key-value的结构,而其中的key又可以通过hash(key)获得一个hash value。通过查看CPython的源码可以发现,Python实现了这样一个数据结构: <hash value, key, value>,就是将key的hash值,key以及value封装到一起形成一个PyDictEntry(Python源码中的命名,就不乱翻译了)。这个数据结构就是dict中每个key-value本身的真实形式。在后面要介绍的dict插入、查询、删除都要使用到PyDictEntry

Hash Table:

说到这里Hash Table就非常容易解释了,Hash Table就是一个数组,这个数组采用了连续的地址,数组中每个元素的长度都是一样的,并且每个元素就是上面介绍的PyDictEntry的实例。我们都知道在C语言中根据下标从数组中获取一个元素的复杂度是O(1),因为数组的起始地址p + 下标k * 每个元素的长度L,就可以获得下标k这个元素的起始地址了。CPython的Hash Table是一个初始长度为8的可变长度数组。变长数组的查询肯定还是O(1),至于插入跟删除是不是O(1)后面会介绍,现在我们离dict的O(1)只差一步了,就是如何把一个key的hash value对应到变长数组的下标。下面要介绍的Hash Table的插入,查询,删除这三种操作的实现方式,实际上就是完成了这一步。

Hash Table插入:

根据上面的介绍,Hash Table的插入就是指在一个Hash Table数组中插入PyDictEntry。我们假设目前Hash Table的长度是n,把一个hash value对n取模就可以得到一个0到n-1之间的数字,之所以是取模是因为hash value可能是负数。很显然取模后的值正好是Hash Table的一个下标,假如这个下标对应的元素是空的,我们就直接插入PyDictEntry,但是这个下标有可能已经被其他PyDictEntry占用了,如果占用的PyDictEntry与被占用的PyDictEntry两者的hash value 以及key都是一样的,就说明这个插入操作其实是修改操作,所以只要更新value就可以了,如果不是这种情况那就是发生了冲突,面对冲突CPython做了两件事情来避免或者解决:机制1:设置一个装填系数,比如0.5,就是说当Hash Table中的元素个数达到数组长度的1/2时,数组长度就扩大一倍,在CPython中这个装填系数是2/3,数组的长度扩大了,冲突的可能性就大大降低了,这个机制可以避免大多数的冲突,但是毕竟不是绝对避免。所以当冲突无法避免时就需要机制2来解决了;机制2:.假设发生冲突的是下标j,Hash Table的长度是2**k,就根据这个下标j通过一个probe算法生成一个下标probe(j), 假设probe(j)等于f,就把这个冲突的PyDictEntry尝试放到下标f里,当然下标f可能依然是冲突的,那就放到下标probe(f)里,依次类推直到找到一个空的下标。一个好的probe算法就可以保证寻找的次数是很少的并且最终必然能找到,至于CPython用的probe算法为什么可以保证这一点嘛,还是那句话,这个是离散数学问题,恕我没有能力解释了。。。probe算法虽然没有能力介绍但是有一点是可以看出来的,根据这个算法,对于一个冲突的下标j,使用probe算法最终会找到一个空的下标,这个推论将在下面要介绍的Hash Table查询中用到。

Hash Table插入的复杂度:

还有一个问题:机制1中的扩容操作,每次扩大一倍以后,需要把原有Hash Table里的PyDictEntry重新插入到新的数组里,这带来了额外的开销,那么Hash Table的插入复杂度还是O(1)吗?根据求极限方式可以证明当n足够大时,扩容带来的复杂度增加最终会被平均掉。这里简单的证明下(不严谨,但可以说明问题):先假设装填系数是1, 就是说满了才扩容一倍,因为初始的长度是8,所以扩容都是发生在2的平方数(假设这个2的平方数是i,i依次是8,16,32。。。),在i这些节点上重新插入带来的额外开销也是i次,所以对于长度是n的Hash Table,额外的开销就是8+16+32+...+i+...n/2,这个等比数列求和等于n-8,n-8次的额外开销被插入n次平均一下就是O(1),所以总体的开销是O(1)+O(1)还是O(1)。这不禁让我想起了那句名言:“所谓的效率,无非就是空间换时间”

Hash Table查询:

其实Hash Table的查询不需要介绍了,因为查询与插入是一样的,先根据key的hash value找下标,如果找到的hash value与key都匹配则返回对应的value,查询就完成了复杂度是O(1),但是也有冲突的时候,就需要用到probe算法了,根据上面提到的推论,最终还是能找到,这个时候复杂度就比O(1)大了,但是在大部分的情况下扩容避免了冲突的发生,即便在冲突发生时,通过probe算法还是可以很快的找到hash value与key都匹配的PyDictEntry,所以平均复杂度依然是O(1)。

Hash Table删除:

下意识的,我们认为删除操作与查询操作是一样的,只不过是查询到后删掉而已,跟查询没有本质区别。这样理解是对的,但是这样做却是错的,因为Hash Table的删除操作并不是真的删掉查询到的下标对应的PyDictEntry,或者说并不能把这个下标的元素设置成空指针,为什么呢?其实在上面介绍查询的时候还有一点没有说明,插入始终能成功,但是查询就不是了,假如要查询的key不存在,那应当在O(1)内抛出异常,那如何做到O(1)而不是O(logn)呢,其实很简单,从插入的算法来看,插入的顺序是一定的,所以在查询的顺序上发现一个下标对应的元素是空的就可以停止查询了,因为在假设没有删除操作的情况下,如果要查询的key不存在,那么在查询到这个key之前的下标都必然是不为空的,那么在有删除操作的情况下,上面加粗的判断依然要成立,所以删除不能让被删除的下标指向空,但是不指向空不是等于没删除吗,怎么办呢?C语言自有妙计,不能指向Null就指向dummy指针,*dummy不与任何值相等,包括Null。这样就既实现了删除(不与任何值相等)又避免了降低查询效率(也不与Null相等)。

总结:

写到这里感觉自己等于什么都没讲,毕竟离散数学才是本质,但是数学证明嘛,只能呵呵了。好在计算机与纯数学还是有区别的,计算机可以把一些数学上错误的“定理”拿来先用着,比如上面的Hash函数,比如md5。

推荐阅读更多精彩内容