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。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,378评论 6 481
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,356评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 152,702评论 0 342
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,259评论 1 279
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,263评论 5 371
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,036评论 1 285
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,349评论 3 400
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,979评论 0 259
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,469评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,938评论 2 323
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,059评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,703评论 4 323
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,257评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,262评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,485评论 1 262
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,501评论 2 354
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,792评论 2 345

推荐阅读更多精彩内容

  • 一、基本数据类型 注释 单行注释:// 区域注释:/* */ 文档注释:/** */ 数值 对于byte类型而言...
    龙猫小爷阅读 4,254评论 0 16
  • 作者:July、wuliming、pkuoliver 说明:本文分为三部分内容,第一部分为一道百度面试题Top K...
    cyj_ya阅读 800评论 0 0
  • 昨天晚上加班,结束和同事一起吃烧烤,没成想今天一天都在闹肚子。 有三点感触。 1、想谴责无良商家。 2、想调整心态...
    4be88d09a5e2阅读 278评论 0 0
  • 兰花 丝丝缕缕镌刻 清凉光滑的表面 像极了你的缠绵 偷偷数着心事 细数你眉间心络的情意 傻傻地爱成一世的眷恋, 每...
    爱上一叶浮萍阅读 336评论 7 20
  • 第一天上班,她睡过头了,不知道为什么闹钟响了三次,居然都听不见,后悔,抱怨得心使她非常不安。想想当初如何过...
    尼古拉菲菲阅读 287评论 0 1