首先来看看构建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
本文描述了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
- lookdict_string
- 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
- lookdict_string
- 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
- lookdict_string
- 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’,将以如下的数组结束