前言
在python中字典是一种很重要的数据类型,我们在开发中经常使用。在python中,Python 3.6以前,字典是不能保证顺序的,数据A先插入字典,数据B后插入字典,但是当你打印字典的Keys列表时,你会发现B可能在A的前面。但是从Python 3.6开始,字典是变成有顺序的了。接下来我们了深入解下python 字典 这个数据类型。
字典的结构
python中dict的底层结构可以简单的理解成如下代码所示
typedef struct {
# me_key的散列值
Py_hash_t me_hash;
# 键
PyObject *me_key;
# 值
PyObject *me_value;
} PyDictKeyEntry;
python3.6前的字典结构
在3.6之前python中dict的储存结构如下:
dict_temp = {}
# 内部结构 第一位hash值 第二位键的指针 第三位值的指针
entries = [['--', '--', '--'],
['--', '--', '--'],
['--', '--', '--'],
['--', '--', '--'],
['--', '--', '--'],
['--', '--', '--'],
['--', '--', '--'],
['--', '--', '--']
我们插入一个数据后,内部则变为
dict_temp['name'] = "帅气的读者"
# 内部结构
entries = [['--', '--', '--'],
[-8522787127447073497, '指向name的指针', '指向帅气的读者的指针'],
['--', '--', '--'],
['--', '--', '--'],
['--', '--', '--'],
['--', '--', '--'],
['--', '--', '--'],
['--', '--', '--']]
当要添加了一个数据到字典中时,python首先通过自带的hash函数对值进行hash,然后取余数,这样可以决定添加的数据放置的位置,然后在把键的hash值,键的指针,值的指针写入。
python3.6之后的字典结构
在python3.6开始,借鉴 PyPy 字典设计,采用更紧凑的存储结构.内存效率更高。
当你初始化一个字典以后,Python单独生成了一个长度为8的一维数组(索引列表)。然后又生成了一个空的二维数组(数据列表)
dict_temp = {}
# 内部结构
indices = [None, None, None, None, None, None, None, None]
entries = []
我们插入一个数据后,内部则变为
dict['name'] = "帅气的读者"
# 内部结构
indices = [None, 0, None, None, None, None, None, None]
entries = [[-700611245610185527, '指向name的指针', '指向帅气的读者的指针']]
当要添加一个数据的时候,操作和3.6之前一样,不过他在得到位置时候时会现在indices对应的位置插入要写入entries的位置.从这里我们知道python3.6后保持插入有顺序是因为索引位置放在
indices列表,数据插入是采取追加的方式。
结论
- python3.6之后字典是更节约内存的:3.6之前初始化的时候分配了8个列表的内存,但是在3.6之后是按需分配。
- 有序:python3.6之后数据的插入是有顺序的
- 效率更快:indices就是哈希表,改变的只是布局, 哈希表算法将不变,更能节省空间, 新的布局迭代更快,
keys() values() items()
直接在关联容器entries
获取, 无需判断['--', '--', '--']
空关联容器, 移动或者复制hash表,只需操作indices即可。
字典怎么读取数据呢
从上面我们已经知道字典的内部结构和插入数据的时候底层做什么操作,接下来我们深入了解下python字典是怎么查询数据的。
python3.6之前读取数据
首先对键通过自带的hash函数对值进行hash,取余数得到下标,然后到entries对应的位置,进行比对键是否一致,如果一致则返回数据,否则返回None
python3.6之后读取数据
当我们要读取数据的时候,先对键进行hash取余数, 然后先去 indices 找到对应的索引,再去entries进行查询数据。
插入时的键冲突
看上面得例子有人肯定会问,那肯定会出现相同的索引值,那不就是数据覆盖了吗?
我们上面说过插入数据的位置取决于两个因素,一个是键的哈希值比较,另外一个是第二位键的数据与其比较。如果索引值相同那么会进行比较,如果是键的数据是相同的那么就不需要插入,反之掩码数据(列表长度或索引列表长度)会使用高比特位进行寻找新的索引,这一个过程被称为嗅探。在这个过程中仅使用了哈希值最后3个字节作为初计算索引但没有考虑其他字节,如果发生哈希值碰撞,在python中会使用哈希值中更多的比特位来解决这个问题。索引上面求索引值实际上是901& 0b111得出索引值的
字典的大小改变
当字典的键值对数量超过当前数组长度的2/3时,数组会进行扩容,8行变成16行(源码中通过newsize <<= 1)。长度变了以后,求余的除数变了,所以需要重新将数据重新hash放入。2/3扩充的原因是:一般不超过大小的三分之二具有最佳空间节约的同时依然具有不错的哈希碰撞避免的概率。
扩展
我之前看了一本书,再介绍字典的时候他有一段代码例子实现了一个查询单词在的文档内容位置的需求,这段代码有点类似倒排索引,所以我把代码放在这里
docs = [
"the cat is under the table",
"the dog is under the table",
"cats and dogs smell roses",
"carla eat an apple"
]
index = {}
for i, doc in enumerate(docs):
for word in doc.split():
if word not in index:
index[word] = [i]
else:
index[word].append(i)
res = index["table"]
res_d = [docs[i] for i in res]
作者寄语
字典的操作可以看下菜鸟redis操作,本人大部分内容来自python的高性能编程这本书,知识的梳理参考了为什么Python 3.7以后字典有序并且效率更高?这篇博客。其实有时间建议还是看看官方文档或者买那本书,自己理解总是不会错的。另外注意的是当字典插入冲突的时候有说开放寻址法(散列表是通过一定的函数将需搜索的键值映射为一个整数,将这个整数视为索引去访问某片连续的内存区域. 一般情况下,hash table会申请一块较大的连续内存通过映射函数f(n)得到所对应的索引,当产生冲突时,python会通过一个二次探测函数f,计算下一个候选索引, 如果索引不可用,就再次用f探测.直到找到一个可用的位置)