这篇文章描述了在Python中字典是如何实现的。

字典通过键(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'

键'd'不存在,所以抛出了KeyError异常。

哈希表

Python字典是用哈希表(hash table)实现的。哈希表是一个数组,它的索引是对键运用哈希函数(hash function)求得的。哈希函数的作用是将键均匀地分布到数组中,一个好的哈希函数会将冲突(译者注:冲突指不同键经过哈希函数计算得到相同的索引,这样造成索引重复的冲突。)的数量降到最小。Python没有这类哈希函数,它最重要的哈希函数(用于字符串和整数)很常规:

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

在这篇文章的余下部分里,我们假定用字符串作为字典的键。Python中字符串的哈希函数定义如下:

arguments: string object
return: hash
function: string_hash:
if hash cached:
return it
set len to string's length
initialize var p ponting 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
cached x as the hash so we don't need to calculate it again
return x as the hash

如果你在Python中运行 hash('a'),string_hash()函数将会被执行并返回12416037344。这里我们假定我们使用的时64位机器。(译者注:这篇文章讲述的是Python2的字典实现)

如果一个长度为x的数组被用来存储键/值对,那么我们用x-1作为掩码来计算数组中slot的索引,这使得计算slot索引的计算非常快。找到一个空slot的概率很大,原因是下述调整大小的机制。这意味着在绝大多数情况下使用简单的计算是很有意义的。如果数组的大小是8,那么'a'的索引将会是hash('a') & 7 = 0'b'的索引是3,'c'的索引是2,'z'的索引和'b'一样都是3因此便产生了冲突。

我们可以看到当键是连续的时候,Python的哈希函数能很好地运作,因为这类数据极为常见。然而,当我们添加了键'z'冲突就产生了,因为它(译者注:这里是指这些key经过哈希函数计算得到的索引)不够连续。

我们可以使用一个链表(linked list)来存储hash值相同的键/值对,但是这样会增加查找的时间复杂度(再也不是O(1)了)。下面的部分描述了Python字典中用到的冲突解决方法。

开放地址法

开放地址法是一个用探测手段来解决冲突的方法。在上述'z'键的例子中,索引为3的slot在数组中已经被使用了,因此我们需要探测出一个尚未被占用的索引。因为需要探测,所以添加一个键/值对可能需要更多的时间,但是查找却是O(1)时间复杂度的,这正是我们诉求的。

二次探查序列是用来查找空闲slot的,代码如下:

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

你可以在源代码dictobject.c中找到更多关于探测序列的内容。详细的探测机制的解释在源码的头部。

现在让我们顺着一个例子来看看Python实现的源码。

字典的C数据结构

下面的C结构被用来存储一个字典项:键/值对。哈希值、键和值都存储在这个结构中。PyObject是Python对象的基类(译者注:这里只是类的概念,CPython用C结构体抽象出了Python中的类概念)。

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

下面的结构代表了一个字典。ma_fill是使用了的slots加dummy slots的数量和。当一个键值对被移除了时,它占据的那个slot(译者注:slot一般翻译为“槽”,个人比较喜欢直接用英文,通俗地讲它就是指一个抽象的存储位置)会被标记为dummy。ma_used是被占用了(即活跃的)的slots数量。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];
};

字典的初始化

当你第一次创建一个字典,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

添加项

当添加一个新键值对时PyDict_SetItem()被调用,该函数带一个指向字典对象的指针和一个键值对作为参数。它检查该键是否为字符串并计算它的hash值(如果这个键的哈希值已经被缓存了则用缓存值)。然后insertdict()函数被调用来添加新的键/值对,如果使用了的slots和dummy slots的总量超过了数组大小的2/3则重新调整字典的大小。

为什么事2/3?这是为了保证探测数列可以快速地找到可用的空slot(译者注:个人理解是如果空闲量较大,那么每次探测产生冲突的概率就会降低,从而减少探测次数)。稍候我们再看调整大小的函数。

arguments: dictionary, key, value
return: 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 orver 2/3:
call dictresize to resize dictionary's table

insertdict()使用查找函数lookdict_string来寻找空闲的slot,这和寻找key的函数是一样的。lookdict_string()函数利用hash和mask值计算slot的索引,如果它不能在slot索引(=hash & mask)中找到这个key,它便开始如上述伪码描述循环来探测直到找到一个可用的空闲slot。第一次探测时,如果key为空(null),那么如果找到了dummy slot则返回之。这给了之前删除的slots以重用的优先级。

如果我们想添加这样的一些键/值对:{'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 not used 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个slots中的6个已经被使用了,这超过了数组容量的2/3,dictresize()函数会被调用来分配一个更大的数组。这个函数也负责将旧表中的数据复制到新表中。

在这里,dictresize()函数会带一个minused=24的参数,24是4 * ma_used。当已使用的slots量很大时(超过50000),minused=2 * ma_used。为什么是已使用slots量的4倍?这样会减小重新调整字典大小的步骤并使字典更稀疏(译者注:这样就可以降低探测时冲突的概率)。

新表的大小需要大于24,这是通过将当前表大小向左移位来实现的,每次左移一位直到它大于24,结果表大小变为了32(即8 -> 16 -> 32)。

这就是重新调整表大小的结果:分配了一个大小为32的新表,旧表的项利用新掩码31(即32 - 1)计算哈希从而插到新表的相应slots中。结果看起来像这样:

移除项

PyDict_DelItem()被用来删除一个字典项。key的哈希值被计算出来作为查找函数的参数,删除后这个slot就成为了dummy slot。

假设我们想从字典中移除键'c',最终我们得到下述数组:

注意删除字典项的操作不会触发数组大小的调整(即使使用slots的数量远小于总slots量)。

Python字典实现的更多相关文章

  1. Python字典和集合

    Python字典操作与遍历: 1.http://www.cnblogs.com/rubylouvre/archive/2011/06/19/2084739.html 2.http://5iqiong. ...

  2. python 字典排序 关于sort()、reversed()、sorted()

    一.Python的排序 1.reversed() 这个很好理解,reversed英文意思就是:adj. 颠倒的:相反的:(判决等)撤销的 print list(reversed(['dream','a ...

  3. python字典中的元素类型

    python字典默认的是string item={"browser " : 'webdriver.irefox()', 'url' : 'http://xxx.com'} 如果这样 ...

  4. python字典copy()方法

    python 字典的copy()方法表面看就是深copy啊,明显独立 d = {'a':1, 'b':2} c = d.copy() print('d=%s c=%s' % (d, c)) Code1 ...

  5. python 字典实现类似c的switch case

    #python 字典实现类似c的switch def print_hi(): print('hi') def print_hello(): print('hello') def print_goodb ...

  6. python字典的常用操作方法

    Python字典是另一种可变容器模型(无序),且可存储任意类型对象,如字符串.数字.元组等其他容器模型.本文章主要介绍Python中字典(Dict)的详解操作方法,包含创建.访问.删除.其它操作等,需 ...

  7. Python 字典(Dictionary)操作详解

    Python 字典(Dictionary)的详细操作方法. Python字典是另一种可变容器模型,且可存储任意类型对象,如字符串.数字.元组等其他容器模型. 一.创建字典 字典由键和对应值成对组成.字 ...

  8. Python 字典(Dictionary) get()方法

    描述 Python 字典(Dictionary) get() 函数返回指定键的值,如果值不在字典中返回默认值. 语法 get()方法语法: dict.get(key, default=None) 参数 ...

  9. Python 字典(Dictionary) setdefault()方法

    描述 Python 字典(Dictionary) setdefault() 函数和get()方法类似, 如果键不已经存在于字典中,将会添加键并将值设为默认值. 语法 setdefault()方法语法: ...

  10. python 字典内置方法get应用

    python字典内置方法get应用,如果我们需要获取字典值的话,我们有两种方法,一个是通过dict['key'],另外一个就是dict.get()方法. 今天给大家分享的就是字典的get()方法. 这 ...

随机推荐

  1. centoss7下将命令加开机服务

    https://www.cnblogs.com/hxun/p/11075755.html

  2. poj1061(扩展欧基里德定理)

    题目链接:https://vjudge.net/problem/POJ-1061 题意:在一个首位相接的坐标轴上,A.B开始时分别位于X,Y处,每个单位时间向右移动m,n米,问是否能相遇,坐标轴长L. ...

  3. locust 的几种写法及部分内容说明

    第一种 **********************************   from locust import  HttpLocust, TaskSet, task import  json ...

  4. MySQL -2- 体系结构--随笔小记

    简介与安装NoSQLRDBMS版本安装方式二进制安装,源码安装体系结构CS模型TCP/IPsocketmysql master thread 实例mysqld 的程序构成连接层 协议.验证.链接线程S ...

  5. Spark架构角色及基本运行流程

    1. 集群角色 Application:基于spark的用户程序,包含了一个Driver program 和集群中多个Executor Driver Program:运行application的mai ...

  6. MyBatis按时间排序

    测试代码 ActivityReadExample readExample = new ActivityReadExample(); readExample.setOrderByClause(" ...

  7. Linux安装rabbitmq (解决guest无法登陆问题)

    安装Erlang wget https://packages.erlang-solutions.com/erlang-solutions-1.0-1.noarch.rpm sudo rpm -Uvh ...

  8. wex5 如何导包

    wex5中 导jar包 要先把jar文件放在: E:\WeX5\runtime\BaasServer\WEB-INF\lib目录中(我wex5放的是E盘) 点击项目 --> 属性 --> ...

  9. VMware虚拟机中CentOS/redhat设置固定IP

    你的笔记本中的VMware中redhat或centOS系统,如果想在上面建站,而又如果你需要在家里和公司都能访问该站(至少希望你自己的笔记本能访问),那么就需要将虚拟机IP设置为固定IP了.以下介绍两 ...

  10. icmp, IPPROTO_ICMP - Linux IPv4 ICMP 核心模块.

    DESCRIPTION 描述 本网络核心协议模块实现了基于 RFC792 协议中定义的<互联网控制报文协议>.它针对网络主机间通讯出错的情况作出回应并给出诊断信息.用户不能直接使用本模块. ...