在这片文章中会介绍 Python2 中字典的实现,Hash 冲突的解决方法以及在 C 语言中 Python 字典的具体结构,并分析了数据插入和删除的过程。翻译自python-dictionary-implementation 并加入了译者的一些思考。

字典的使用

字典通过 key 被索引,我们可以将其视为一个关联数组。

现在添加 3 组键值对到字典中:

>>> d = {'a': 1, 'b': 2}
>>> d['c'] = 3
>>> d
{'a': 1, 'b': 2, 'c': 3}

字典的值可以通过这种方式被访问,在访问不存在的键时,会抛出一个异常:

>>> d['a']
1
>>> d['b']
2
>>> d['c']
3
>>> d['d']
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
KeyError: 'd'

Hash Tables

Python 中的字典是通过 Hash Tables 来实现。Hash Tables 本身是个数组,具体操作时,通过 hash 函数来取得数组的索引。

hash 函数会将键均匀的放在数组中,一个优秀的 hash 函数会最小化 hash 冲突。

hash 冲突:不同 key 但通过 hash 函数运算后得到相同的 hash 结果。

如下面的例子,对 int 或者 string 类型的对象进行 hash 运算。

>>> map(hash, (0, 1, 2, 3))
[0, 1, 2, 3]
>>> map(hash, ("namea", "nameb", "namec", "named"))
[-1658398457, -1658398460, -1658398459, -1658398462]

这里会以 string 类型为例,简单介绍下 hash 函数的内部实现:

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

假设我们使用的是 64 bit 位的机器的话,在执行 hash(a) 方法时,就会调用 string_hash() 的方式并返回一个结果如 12416037344.

如果存储 key/value 的数组长度是 x,就可以用 x-1 作为掩码来计算相应的数组下标。假设数组的长度是 8 的话,'a' 对应的索引值的计算方法是:hash(a) & 7=0 . 类似的,'b' 的 index 是 3. 'c' 的 index 结果是 2. 'z' 的结果是 3. 这时 'z' 和 'b' 的 hash 值相同,也就遇到了常说的 hash 冲突。

译者注:使用 Python 3.6/64 bit 对上面的情况进行了模拟:

print(hash('a') & 7)
print(hash('b') & 7)
print(hash('c') & 7)
print(hash('z') & 7)
# first result
2
2
4
3
###################
print(hash('a') & 7)
print(hash('b') & 7)
print(hash('c') & 7)
print(hash('z') & 7)
# second result
6
0
4
6
# and so on

可以发现对同一数据多次执行 hash 时,运算的结果和冲突不同,这时由于每次调用 hash 函数式,引入了随机数。通常来说,在 key 值是连续的情况下,hash 冲突发生的几率会小些。反之,冲突发生的几率会增大。

这里可以使用链表来存储具有相同 hash 值的键值对,但是它会增加查找时间,时间花费的平均值不再是O(1)。接下来就简单介绍下,Python 中解决 hash 冲突的方法。

Hash 冲突的解决

Open addressing

开放寻址是一种使用探测的冲突解决方法。在例子中 'z' 的情况下,index 3 位置已被占用,所以需要重新探测一个未被占用的位置。对于增加和查询来说,平均花费 O(1) 的时间。

二次探测序列用于找到空闲的位置。代码如下:

j = (5*j) + 1 + perturb;
perturb >>= PERTURB_SHIFT;
use j % 2**i as the next table index;

在定期的执行5*j+1 时,会放大一些细小的不同,但不会影响初始化的索引位置。perturb 的作用是获取其他 bits 的 hash 值。

出于好奇心,下面是当 table 的大小是 32, j 的值是 3 时,探索值的大小:

3 -> 11 -> 19 -> 29 -> 5 -> 6 -> 16 -> 31 -> 28 -> 13 -> 2…

这里是实现的源码 dictobject.c 。对于探索序列的一个详细的解释可以在文件的顶部找到。下面简单介绍下字典的具体结构。

Dictionary C structures

下面是在 C 中用于描述字典的条目,key/value, hash 值。PyObject 是 Python 对象的基类。

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

下面的结构用于表示字典对象:

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];
};
  • ma_fill 是已用位置和 dummy 位置之和。
  • ma_used 表示已经被使用的位置。
  • ma_mask 表示数组的数量,最小值是 1. 在计算数组的索引时会被用到。
  • ma_table 是一个数组。
  • ma_smalltable 表示初始化的数组,大小是 8.

dummy :当一个 key/value 对象被移除时,会被标记成 dummy.

Dictionary initialization

当创建字典时,函数 PyDict_New() 会被调用。这里移除了一些 python 的源代码,并用伪代码进行代替。

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

Add items

当新的键值对被增加时,PyDict_SetItem() 函数会被调用. 该函数使用指向字典对象的指针和对应的键值对作为参数。它会检查 key 是否为 string 类型,并且计算 hash 值并判断是否有缓存可以使用。insertdict() 会被用来增加一个键值对,并且当 ma_fill (使用位置的数量加上被标记 dummy 位置的数量)超过 2/3 时,字典会被重新调整大小。 2/3 的原因是保证探索序列可以足够快的找到一个未被使用的位置。

arguments: dictionary, key, value
returns: 0 if OK or -1
function PyDict_SetItem:
if key's hash cached:
use hash
else:
calculate hash
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

inserdict() 使用 lookdict_string() 来查询可以使用的位置。这和使用查找 key 时是一样的。lookdict_string() 根据 hash 值和掩码值来计算空闲的位置。如果使用 index=hash&mask 求出的位置被占用,它会在循坏中一直探索,直到找到一个空闲的位置。如果在第一次查询的过程中 key 为空,会返回一个带有 dummy 标记的位置。这就保证了可以优先的重新使用之前删除的位置。

下面的来看具体的例子:

在字典中增加 {‘a’: 1, ‘b’: 2′, ‘z’: 26, ‘y’: 25, ‘c’: 5, ‘x’: 24} :

一个字典的结构被分配,其内部表的大小是 8 

* 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

到目前为止,总共 8 个位置中 6 个位置已经被占用,超过了数组 2/3 的容量。dictresize() 会被调用重新分配一个更大的数组。它还会将过去的字典项复制到新分配的数组中。

在这个例子中,dictresize() 被调用时,会带有 minused=24 的参数,这是因为分配的原则是 4 * ma_used. 但当 ma_used 的数量超过 50000 时,原则改成 2 * ma_used.

为什么在分配时的 4 倍,是因为这样做会减小重新设置的步骤并且让数组变得更稀疏。

新的 hash 表的大小需要大于 24 ,所以可以通过左移一位的方式进行,直到最后的结果大于 24.(8 -> 16 -> 32).

下面是重新调整表的结果,一个大小是 32 的新表被分配。过去表的数据被插入到新的表中。插入的方式通过与新的掩码 31 做与操作得到。

Removing items

PyDict_DelItem() 被用于删除一个字典项。key 的哈希值被计算出来作为查找函数的参数,之后被删除的位置被标记成 dummy.

假如我们想从字典中,移除 key c

注意删除元素操作并不会触发重置数组大小的操作,即使使用的位置数量远远小于总共的位置数量。重置数组的操作基于,在增加 key/value 时,ma_fill 的数量(使用的数量+标记 dummy 的数量)。

Python2 中字典实现的分析【翻译】的更多相关文章

  1. python3中替换python2中cmp函数的新函数分析(lt、le、eq、ne、ge、gt)

    本文地址:http://blog.csdn.net/sushengmiyan/article/details/11332589 作者:sushengmiyan 在python2中我们经常会使用cmp函 ...

  2. 详解:Python2中的urllib、urllib2与Python3中的urllib以及第三方模块requests

    在python2中,urllib和urllib2都是接受URL请求的相关模块,但是提供了不同的功能.两个最显著的不同如下: 1.urllib2可以接受一个Request类的实例来设置URL请求的hea ...

  3. python3中的 zip()函数 和python2中的 zip()函数 的区别

    python3中的 zip()函数 和python2中的 zip()函数 的区别: 描述: zip() 函数用于将可迭代对象作为参数,将对象中对应的元素打包成一个个元组,然后返回由这些元组组成的对象. ...

  4. 容器中的诊断与分析4——live diagnosis——LTTng

    官网地址 LTTng 简介&使用实战 使用LTTng链接内核和用户空间应用程序追踪 简介: LTTng: (Linux Trace Toolkit Next Generation),它是用于跟 ...

  5. python中字典的循环遍历的两种方式

    开发中经常会用到对于字典.列表等数据的循环遍历,但是python中对于字典的遍历对于很多初学者来讲非常陌生,今天就来讲一下python中字典的循环遍历的两种方式. 注意: python2和python ...

  6. python2.7字典转换成json时中文字符串变成unicode的问题:

    参考:http://blog.csdn.net/u014431852/article/details/53058951 编码问题: python2.7字典转换成json时中文字符串变成unicode的 ...

  7. php中foreach源码分析(编译原理)

    php中foreach源码分析(编译原理) 一.总结 编译原理(lex and yacc)的知识 二.php中foreach源码分析 foreach是PHP中很常用的一个用作数组循环的控制语句.因为它 ...

  8. python2中的unicode()函数在python3中会报错:

    python2中的unicode()函数在python3中会报错:NameError: name 'unicode' is not defined There is no such name in P ...

  9. AI框架中图层IR的分析

    摘要:本文重点分析一下AI框架对IR有什么特殊的需求.业界有什么样的方案以及MindSpore的一些思考. 本文分享自华为云社区<MindSpore技术专栏 | AI框架中图层IR的分析> ...

随机推荐

  1. Laradock Laravel database connection refused

    Laradock Laravel database connection refused SHARE  Laradock is a PHP development environment which ...

  2. mongodb 高级聚合查询

    mongodb高级聚合查询   在工作中会经常遇到一些mongodb的聚合操作,特此总结下.mongo存储的可以是复杂类型,比如数组.对象等mysql不善于处理的文档型结构,并且聚合的操作也比mysq ...

  3. OS创建页目录和页

    ;开始创建页目录项(PDE) .create_pde: ; 创建Page Directory Entry mov eax, PAGE_DIR_TABLE_POS ; PAGE_DIR_TABLE_PO ...

  4. Invalid JDK version in profile 'doclint-java8-disable': Unbounded range: [1.8, for project com.google.code.gson:gson 解决办法

    利用maven打包的时候遇到这个问题 在git上发现一个解决方案 问题解决

  5. linux系统普通用户设置密码

    linux系统中如何给一个普通用户grindnt设置密码 一.新密码符合规则,用以下方式修改: #root用户直接更改gridnt密码,不需要输入旧密码 [root@subsname home]# p ...

  6. Qt学习大全

    这边文章的目的是把自己之前写的关于Qt的文章整理归纳成一个Qt学习的专栏,会提供之前文章的导航,同时也会留一些坑待自己日后填. 1.Qt 元对象系统 2.Qt的信号和槽 3.Qt的插件开发 4.Qml ...

  7. Kafka 最新版配置

    当前基于kafaka最新版 kafka_2.12-2.2.1.tgz 进行配置 . 官网地址:http://kafka.apache.org/intro kafka的一些基础知识 参考:http:// ...

  8. nessus在Linux上的安装

    Nessus有三种安装方式 1.源文件安装 源文件安装是最复杂的安装方式,用此方式安装可以修改配置参数. 2.rpm安装 rpm安装比起源文件安装更简单一些,它已经把一些底层的东西写好了,用户只要按步 ...

  9. 如何交叉编译curl?

    1. 先准备一下openssl库 编译openssl库的方法在此 2. 获取curl源码 wget https://curl.haxx.se/download/curl-7.65.3.tar.gz 2 ...

  10. java 虚拟机类加载 及内存结构

    http://www.jb51.net/article/105920.htm https://www.cnblogs.com/Qian123/p/5707562.html Java类加载全过程 一个j ...