python学习笔记 -- dict内部实现(转)


首先来看看构建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;
    Py_ssize_t ma_used;
    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函数是不存在的,且由于映射的本质(无限到有限)必然出出现一个位置有多个元素要‘占据’,这就需要解决冲突。现有的解决冲突的方法:

  • 开放定址法
  • 链地址法
  • 多哈希函数法
  • 建域法

其中建域法基本思想为假设哈希函数的值域为[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)
index <- hash1(k),freeslot<-Null,根据me_key与me_value选择2、3、4一个执行;
查看index处的值处于’有效元素占据‘状态,判断data[index]与v是否一致(地址或内容),一致,则返回查找成功;转5
index所指向的位置处于’原始空‘状态,查找失败,若freeslot==Null返回index;否则返回freeslot;转5
index所指向的位置处于’有效元素离去‘状态,freeslot<-index, 转5
index <- hash2(index),,转2

dict的lookdict方法实现充分体现了python对内存的利用率与空间换时间提高效率上,表现为如下方面:
内存利用率:当找到原始空状态时,如果前面已经找到dummy态的entry,则会将其返回。
提高效率:ma_table始终指向有效散列空间的开始位置,在开辟新空间后,small_table就弃之不用了,ma_table改指向新开辟空间的首位置。

another one


dict实现

本文描述了Python是如何实现dictionary。dictionary是由主键key索引,可以被看作是关联数组,类似于STL的map。有如下的基本操作。
d = {'a': 1, 'b': 2}
d['c'] = 3
d
{'a': 1, 'b': 2, 'c': 3}

hash table

Python中dictionary是以hash表实现,而hash表以数组表示,数组的索引作为主键key。Hash函数的目标是key均匀的分布于数组中,良好的hash函数能够最小化减少冲突的次数。
在本文中,我们采用string作为key为例子。

arguments: string object
returns: hash
function string_hash:
    if hash cached:
        return it
    set len to string's length
    initialize var p pointing to 1st char of string object
    set x to value pointed by p left shifted by 7 bits
    while len >= 0:
        set var x to (1000003 * x) xor value pointed by p
         increment pointer p
    set x to x xor length of string object

    cache x as the hash so we don't need to calculate it again
    return x as the hash

如果你执行hash(‘a’),函数将会执行string_hash()返回12416037344,在此我们假设采用的是64位系统。
假设数组的大小是x, 该数组用来存储key/value, 我们用掩码x-1计算这个数组的索引。例如,如果数组的大小是8,‘a’的索引是hash(‘a’)&7=0, ‘b’的索引是3, ‘c’的索引是2。’z’的索引是3与’b’的索引一致,由此导致冲突。



我们看到Python中的hash函数在key是连续的时候表现很好的性能,原因是它对于处理这种类型的数据有通用性。但是,一旦我们加入了’z’,由于数据的不连贯性产生了冲突。
当产生冲突的时候,我们可以采用链表来存储这对key/value,但是这样增加了查找时间,不再是O(1)的复杂度。在下一节中,我们将要具体描述一下Python中dictionary解决这种冲突的方法。

Open addressing

散列地址方法是解决hash冲突的探测策略。对于’z’,由于索引3已经被占用,所以我们需要寻找一个没有使用的索引,这样添加key/value的时候肯能花费更多的时间,但是查找时间依然是O(1),这是我们所期望的结果。
这里采用quadratic探测序列来寻找可用的索引,代码如下:

i is the current slot index
set perturb to hash
forever loop:
  set i to i << 2 + i + perturb + 1
  set slot index to i & mask
  if slot is free:
      return it
  right shift perturb by 5 bits

让我们来看看它是如何工作的,令i=33 -> 3 -> 5 -> 5 -> 6 -> 0…
索引5将被用作’z’的索引值,这不是一个很好的例子,因为我们采用了一个大小是8的数组。对于大数组,算法显示它的优势。
出于好奇,我们来看看当数组大小是32,mask是31时,探测序列为3 -> 11 -> 19 -> 29 -> 5 -> 6 -> 16 -> 31 -> 28 -> 13 -> 2…
如果你想了解更多,可以察看dictobject.c的源代码。


接下来,让我们看看Python内部的代码是如何实现的?

Dictionary C structures

如下的C语言结构体用来存储一个dictionary条目:key/value对,结构体内有Hash值,key值和value值。PyObject是Python对象的基类。
1
typedef struct {
2
Py_ssize_t me_hash;

3
PyObject *me_key;
4
PyObject *me_value;

5
} PyDictEntry;
如下的结构体代表了一个dictionary。ma_fill是已使用slot与未使用slot之和。当一对key/value被删除了,该slot被标记为未使用。ma_used是已使用slot的数目。ma_mask等于数组大小减一,用来计算slot的索引。ma_table是该数组,ma_smalltable是初始数组大小为8.

typedef struct _dictobject PyDictObject;
struct _dictobject {
    PyObject_HEAD
    Py_ssize_t ma_fill;
    Py_ssize_t ma_used;
    Py_ssize_t ma_mask;
    PyDictEntry *ma_table;
    PyDictEntry *(*ma_lookup)(PyDictObject *mp, PyObject *key, long hash);
    PyDictEntry ma_smalltable[PyDict_MINSIZE];
};

Dictionary initialization

当我们第一次创建一个dictionary的时候,将会调用PyDict_New()。我们以伪码表示该函数的主要功能。

returns new dictionary object
function PyDict_New:
    allocate new dictionary object
    clear dictionary's table
    set dictionary's number of used slots + dummy slots (ma_fill) to 0
    set dictionary's number of active slots (ma_used) to 0
    set dictionary's mask (ma_value) to dictionary size - 1 = 7
    set dictionary's lookup function to lookdict_string
    return allocated dictionary object

Adding items

当增加一对新的key/value时,将调用PyDict_SetItem()。该函数的参数有dictionary对象的指针和key/value对。它检测key是否为字符串以及计算hash值或者重复使用已经存在的index。Insertdict()用来增加新的key/value,在dictionary的大小被重新调整之后,而且已使用slot的数量超过数组大小的2/3.
为什么是2/3?这样能够保证probing序列能够快速地找到空闲的slot。稍后我们将看看调整数组大小的函数。

arguments: dictionary, key, value
returns: 0 if OK or -1
function PyDict_SetItem:
    set mp to point to dictionary object
    if key's hash cached:
        use hash
    else:
        calculate hash
    set n_used to dictionary's number of active slots (ma_used)
    call insertdict with dictionary object, key, hash and value
    if key/value pair added successfully and capacity over 2/3:
        call dictresize to resize dictionary's table

Insertdict()使用查找函数来找到一个未使用的slot。接下来我们来看看这个函数,lookdict_string()利用hash值和掩码来计算slot的索引。如果它找不到slot索引的key等于hash&mask,它会使用扰码来探测。

arguments: dictionary object, key, hash
returns: dictionary entry
function lookdict_string:
    calculate slot index based on hash and mask
    if slot's key matches or slot's key is not set:
        returns slot's entry
    if slot's key marked as dummy (was active):
        set freeslot to this slot's entry
    else:
        if slot's hash equals to hash and slot's key equals to key:
            return slot's entry
        set var freeslot to null
    we are here because we couldn't find the key so we start probing
    set perturb to hash
    forever loop:
        set i to i << 2 + i + perturb + 1
        calculate slot index based on i and mask
        if slot's key is null:
            if freeslot is null:
                return slot's entry
            else:
                return freeslot

        if slot's key equals to key or slot's hash equals to hash
            and slot is not marked as dummy:
            return slot's entry
        if slot marked as dummy and freeslot is null:
            set freeslot to slot's entry
        right shift perturb by 5 bits

我们在dictionary中增加如下的key/value:{‘a’: 1, ‘b’: 2′, ‘z’: 26, ‘y’: 25, ‘c’: 5, ‘x’: 24}。流程如下:

  • PyDict_SetItem: key = ‘a’, value = 1
    • hash = hash(‘a’) = 12416037344
    • insertdict
      • lookdict_string
        • slot index = hash & mask = 12416037344 & 7 = 0
        • slot 0 is not used so return it
      • init entry at index 0 with key, value and hash
      • ma_used = 1, ma_fill = 1
  • PyDict_SetItem: key = ‘b’, value = 2
    • hash = hash(‘b’) = 12544037731
    • insertdict
      • lookdict_string
        • slot index = hash & mask = 12544037731 & 7 = 3
        • slot 3 is not used so return it
      • init entry at index 3 with key, value and hash
      • ma_used = 2, ma_fill = 2
  • PyDict_SetItem: key = ‘z’, value = 26
    • hash = hash(‘z’) = 15616046971
    • insertdict
      • lookdict_string
        • slot index = hash & mask = 15616046971 & 7 = 3
        • slot 3 is used so probe for a different slot: 5 is free
      • init entry at index 5 with key, value and hash
      • ma_used = 3, ma_fill = 3
  • PyDict_SetItem: key = ‘y’, value = 25
    hash = hash(‘y’) = 15488046584
    insertdict
    lookdict_string
    slot index = hash & mask = 15488046584 & 7 = 0
    slot 0 is used so probe for a different slot: 1 is free
    init entry at index 1 with key, value and hash
    ma_used = 4, ma_fill = 4
  • PyDict_SetItem: key = ‘c’, value = 3
    hash = hash(‘c’) = 12672038114
    insertdict
    lookdict_string
    slot index = hash & mask = 12672038114 & 7 = 2
    slot 2 is free so return it
    init entry at index 2 with key, value and hash
    ma_used = 5, ma_fill = 5
  • PyDict_SetItem: key = ‘x’, value = 24
    hash = hash(‘x’) = 15360046201
    insertdict
    lookdict_string
    slot index = hash & mask = 15360046201 & 7 = 1
    slot 1 is used so probe for a different slot: 7 is free
    init entry at index 7 with key, value and hash
    ma_used = 6, ma_fill = 6

This is what we have so far:



由于8个slot中的6已经被使用了即超过了数组容量的2/3,dictresize()被调用来分配更大的内存,该函数同时拷贝旧数据表的数据进入新数据表。
在这个例子中,dictresize()调用时,minused是24也就是4×ma_used。当已使用slot的数目很大的时候(超过50000)minused是2×ma_used。为什么是4倍?这样能够减少调整步骤的数目而且增加稀疏度。
v新数据表的大少应该大于24,它是通过把当前数据表的大小左移一位实现的,直到她大于24.例如 8 -> 16 -> 32.

arguments: dictionary object, (2 or 4) * active slots
returns: 0 if OK, -1 otherwise
function dictresize:
    calculate new dictionary size:
        set var newsize to dictionary size
        while newsize less or equal than (2 or 4) * active slots:
            set newsize to newsize left shifted by 1 bit
    set oldtable to dictionary's table
    allocate new dictionary table
    set dictionary's mask to newsize - 1
    clear dictionary's table
    set dictionary's active slots (ma_used) to 0
    set var i to dictionary's active + dummy slots (ma_fill)
    set dictionary's active + dummy slots (ma_fill) to 0
    copy oldtable entries to dictionary's table using new mask
    return 0
}

当数据表大小进行调整的时候所发生的:分配了一个大小是32的新数据表


removing items

函数PyDict_DelItem()用来删除一个条目。计算key的hash值,然后调用查找函数返回这个条目。该条目的key设置为哑元。这个哑元包含了过去使用过的key值但现在还是未使用的状态。

arguments: dictionary object, key
returns 0 if OK, -1 otherwise
function PyDict_DelItem:
    if key's hash cached:
        use hash
    else:
        calculate hash
    look for key in dictionary using hash
    if slot not found:
        return -1
    set slot's key to dummy
    set slot's value to null
    decrement dictionary active slots
    return 0

如果我要从dictionary中删除key ’c’,将以如下的数组结束


注意:删除操作并没有改变数组的大小,即使已使用slot的数目远小于slot的总数目。

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

推荐阅读更多精彩内容