Python3.6以后字典有序且高效
摘自小猿取经:03-06-08-Python3.6以后字典有序且更高效底层分析 - 小猿取经-林海峰老师 - 博客园 (cnblogs.com)
【一】引言
-
在Python 3.5(含)以前
- 字典是不能保证顺序的
- 键值对A先插入字典
- 键值对B后插入字典
- 但是当你打印字典的Keys列表时
- 你会发现B可能在A的前面。
- 字典是不能保证顺序的
-
但是从Python 3.6开始,字典是变成有顺序的了。
- 你先插入键值对A
- 后插入键值对B
- 那么当你打印Keys列表的时候
- 你就会发现B在A的后面。
- 你先插入键值对A
-
不仅如此
- 从Python 3.6开始
- 下面的三种遍历操作
- 效率要高于Python 3.5之前:
for key in 字典
for value in 字典.values()
for key, value in 字典.items()
【二】Python3.5字典底层原理
- 从Python 3.6开始
- 字典占用内存空间的大小
- 视字典里面键值对的个数
- 只有原来的30%~95%。
- Python 3.6到底对字典做了什么优化呢?
- 为了说明这个问题,我们需要先来说一说,在Python 3.5(含)之前,字典的底层原理。
- 当我们初始化一个空字典的时候
- CPython的底层会初始化一个二维数组,这个数组有8行,3列,如下面的示意图所示:
my_dict = {}
此时的内存示意图
[[---, ---, ---],
[---, ---, ---],
[---, ---, ---],
[---, ---, ---],
[---, ---, ---],
[---, ---, ---],
[---, ---, ---],
[---, ---, ---]]
- 现在,我们往字典里面添加一个数据:
my_dict['name'] = 'kingname'
此时的内存示意图
[[---, ---, ---],
[---, ---, ---],
[---, ---, ---],
[---, ---, ---],
[---, ---, ---],
[1278649844881305901, 指向name的指针, 指向kingname的指针],
[---, ---, ---],
[---, ---, ---]]
- 这里解释一下,为什么添加了一个键值对以后,内存变成了这个样子:
- 首先我们调用Python 的
hash
函数
- 计算
name
这个字符串在当前运行时的hash值:>>> hash('name') 1278649844881305901
- 特别注意
- 这里强调了『当前运行时』
- 这是因为
- Python自带的这个
hash
函数,和我们传统上认为的Hash函数是不一样的。- Python自带的这个
hash
函数计算出来的值,只能保证在每一个运行时的时候不变
- 但是当你关闭Python再重新打开,那么它的值就可能会改变
- 假设在某一个运行时里面,
hash('name')
的值为1278649844881305901
。 - 现在我们要把这个数对8取余数:
>>> 1278649844881305901 % 8
5
- 余数为5
- 那么就把它放在刚刚初始化的二维数组中
- 下标为5的这一行。
- 由于
name
和kingname
是两个字符串- 所以底层C语言会使用两个字符串变量存放这两个值
- 然后得到他们对应的指针。
- 于是
- 我们这个二维数组下标为5的这一行
- 第一个值为
name
的hash值 - 第二个值为
name
这个字符串所在的内存的地址(指针就是内存地址) - 第三个值为
kingname
这个字符串所在的内存的地址。
- 第一个值为
- 我们这个二维数组下标为5的这一行
- 现在,我们再来插入两个键值对:
my_dict['age'] = 26
my_dict['salary'] = 999999
此时的内存示意图
[[-4234469173262486640, 指向salary的指针, 指向999999的指针],
[1545085610920597121, 执行age的指针, 指向26的指针],
[---, ---, ---],
[---, ---, ---],
[---, ---, ---],
[1278649844881305901, 指向name的指针, 指向kingname的指针],
[---, ---, ---],
[---, ---, ---]]
- 那么字典怎么读取数据呢?
- 首先假设我们要读取
age
对应的值。
- 首先假设我们要读取
- 此时
- Python先计算在当前运行时下面
age
对应的Hash值是多少:
Copy>>> hash('age')
1545085610920597121
- 现在这个hash值对8取余数:
Copy>>> 1545085610920597121 % 8
1
-
余数为1
- 那么二维数组里面
- 下标为1的这一行就是需要的键值对。
-
直接返回这一行第三个指针对应的内存中的值
- 就是
age
对应的值26
。
- 就是
-
当你要循环遍历字典的Key的时候
- Python底层会遍历这个二维数组
- 如果当前行有数据
- 那么就返回Key指针对应的内存里面的值。
- 如果当前行没有数据
- 那么就跳过。
-
所以总是会遍历整个二位数组的每一行。
-
每一行有三列
- 每一列占用8byte的内存空间
- 所以每一行会占用24byte的内存空间。
-
由于Hash值取余数以后
- 余数可大可小
- 所以字典的Key并不是按照插入的顺序存放的。
- 注意,这里我省略了与本文没有太大关系的两个点:
- 开放寻址,当两个不同的Key,经过Hash以后,再对8取余数,可能余数会相同。此时Python为了不覆盖之前已有的值,就会使用
开放寻址
技术重新寻找一个新的位置存放这个新的键值对。
- 当字典的键值对数量超过当前数组长度的2/3时,数组会进行扩容,8行变成16行,16行变成32行。长度变了以后,原来的余数位置也会发生变化,此时就需要移动原来位置的数据,导致插入效率变低。
【三】Python3.6以后字典底层
- 在Python 3.6以后
- 字典的底层数据结构发生了变化
- 现在当你初始化一个空的字典以后
- 它在底层是这样的:
Copymy_dict = {}
此时的内存示意图
indices = [None, None, None, None, None, None, None, None]
entries = []
- 当你初始化一个字典以后
- Python单独生成了一个长度为8的一维数组。
- 然后又生成了一个空的二维数组。
- 现在
- 我们往字典里面添加一个键值对:
Copymy_dict['name'] = 'kingname'
此时的内存示意图
indices = [None, 0, None, None, None, None, None, None]
entries = [[-5954193068542476671, 指向name的指针, 执行kingname的指针]]
- 为什么内存会变成这个样子呢?
- 我们来一步一步地看:
- 在当前运行时
name
这个字符串的hash值为-5954193068542476671
- 这个值对8取余数是1:
Copy>>> hash('name')
-5954193068542476671
>>> hash('name') % 8
1
- 所以
- 我们把
indices
这个一维数组里面 - 下标为1的位置修改为0。
- 我们把
- 这里的0是什么意思呢?
- 0是二位数组
entries
的索引。 - 现在
entries
里面只有一行 - 就是我们刚刚添加的这个键值对的三个数据:
name
的hash值- 指向
name
的指针 - 指向
kinganme
的指针。
- 所以
indices
里面填写的数字0- 就是刚刚我们插入的这个键值对的数据在二位数组里面的行索引。
- 0是二位数组
- 好
- 现在我们再来插入两条数据:
Copymy_dict['address'] = 'xxx'
my_dict['salary'] = 999999
此时的内存示意图
indices = [1, 0, None, None, None, None, 2, None]
entries = [[-5954193068542476671, 指向name的指针, 执行kingname的指针],
[9043074951938101872, 指向address的指针,指向xxx的指针],
[7324055671294268046, 指向salary的指针, 指向999999的指针]
]
- 现在如果我要读取数据怎么办呢?
- 假如我要读取
salary
的值 - 那么首先计算
salary
的hash值 - 以及这个值对8的余数:
- 假如我要读取
Copy>>> hash('salary')
7324055671294268046
>>> hash('salary') % 8
6
- 那么我就去读
indices
下标为6的这个值。- 这个值为2.
- 然后再去读entries里面
- 下标为2的这一行的数据
- 也就是salary对应的数据了。
【四】总结
- 新的这种方式
- 当我要插入新的数据的时候
- 始终只是往
entries
的后面添加数据 - 这样就能保证插入的顺序。
- 始终只是往
- 当我们要遍历字典的Keys和Values的时候
- 直接遍历
entries
即可 - 里面每一行都是有用的数据
- 不存在跳过的情况
- 减少了遍历的个数。
- 直接遍历
- 当我要插入新的数据的时候
- 老的方式
- 当二维数组有8行的时候
- 即使有效数据只有3行
- 但它占用的内存空间还是 8 * 24 = 192 byte。
- 但使用新的方式
- 如果只有三行有效数据
- 那么
entries
也就只有3行 - 占用的空间为3 * 24 =72 byte
- 而
indices
由于只是一个一维的数组- 只占用8 byte
- 所以一共占用 80 byte。
- 内存占用只有原来的41%。
- 当二维数组有8行的时候