第3章:字典和集合- dict 和 set 的背后

想要理解 Python 里字典和集合类型的长处和弱点,它们背后的散列表是绕不开的一环。

这一节将会回答以下几个问题。

  • Python 里的 dict 和 set 的效率有多高?

  • 为什么它们是无序的?

  • 为什么并不是所有的 Python 对象都可以当作 dict 的键或 set 里的元素?

  • 为什么 dict 的键和 set 元素的顺序是跟据它们被添加的次序而定的,以及为什么在映射对象的生命周期中,这个顺序并不是一成不变的?

  • 为什么不应该在迭代循环 dict 或是 set 的同时往里添加元素?

3.9.1 一个关于效率的实验

Python 中的 dictset 的速度是非常快的。接下来我们要通过可控的实验来证实这一点:

示例 3-14 在 haystack 里查找 needles 的元素,并计算找到的元素的个数

import array
import time
from random import random


needles = array.array('d', (random() for _ in range(10 ** 3)))

# 数组
haystack = array.array('d', (random() for _ in range(10 ** 6)))
# # 列表
# haystack = list(haystack)
# # 字典
# haystack = dict.fromkeys(haystack, 10000000)
# 集合
haystack = set(haystack)


# 在 haystack 字典里查找 needles 的元素,并计算找到的元素的个数
now_time = time.time()
count = 0
for n in needles:
    if n in haystack:
        count += 1
end_time = time.time()
total_time = end_time - now_time
print('总用时:', total_time)

表格3-15:用 in 运算符在5个不同类型和大小的 haystack 里搜索 1000 个元素所需要的时间。
        从表格的数据上看,在 setdict 数据类型中,in 运算符遍历不同大小数据花费的时间没有太大变化,而在 listarray 中,花费的时间是和数据大小等比例增长;

haystack 的长度

dict 花费的时间

set 花费的时间

list 花费的时间

array 花费的时间

1000

0.0003778

0.0001032

0.0128400

0.0184147

10 000

0.0004606

0.0001280

0.1115570

0.1622700

100 000

0.0003881

0.0001912

1.1210200

1.5445446

1 000 000

0.0003950

0.0002031

11.2484440 

15.8610751

10 000 000

0.0004899

0.0003800

108.1884930

161.1305637

dictset 的查询性能高,是因为其背后是有散列表支持,而 listarray 每次搜索都需要扫描一个完整的列表,导致花费时间和数据大小成线性增长;

3.9.2 字典中的散列表

        散列表其实是一个稀疏数组(总是有空白元素的数组称为稀疏数组)。在一般的数据结构教材中,散列表里的单元通常叫作表元(bucket)。在 dict 的散列表当中,每个键值对都占用一个表元,每个表元都有两个部分,一个是对键的引用,另一个是对值的引用。因为所有表元的大小一致,所以可以通过偏移量来读取某个表元。

        而且 Python 会设法保证大概还有三分之一的表元是空的,所以在快要达到这个阈值的时候,原有的散列表会被复制到一个更大的空间里面。

        如果要把一个对象放入散列表,那么首先要计算这个元素键的散列值。Python 中可以用 hash() 方法来做这件事情,接下来会介绍这一点:

  1. 散列值和相等性

            内置的 hash() 方法可以用于所有的内置类型对象。如果是自定义对象调用 hash() 的话,实际上运行的是自定义的 __hash__。如果两个对象在比较的时候是相等的,那它们的散列值必须相等,否则散列表就不能正常运行了。例如,如果 1 == 1.0 为真,那么 hash(1) == hash(1.0) 也必须为真,虽然这两个数字(整型和浮点)的内部结构是完全不一样的。

            为了让散列值能够胜任散列表索引这一角色,它们必须在索引空间中尽量分散开来。这意味着在最理想的状况下,越是相似但不相等的对象,它们散列值的差别应该越大。

  2. 散列表算法

            为了获取 dict[key] 背后的值,Python 首先会调用 hash(key) 来计算 key 的"散列值",把这个值最低的几位数字当作偏移量,在散列表里查找表元(具体取几位,得看当前散列表的大小)。若找到的表元是空的,则抛出 KeyError 异常。若不是空的,则表元里会有一对 found_key:found_value。这时候 Python 会检验 key == found_key 是否为真,如果它们相等的话,就会返回 found_value

            如果 key 和 found_key 不匹配的话,这种情况称为"散列冲突"。发生这种情况是因为,散列表所做的其实是把随机的元素映射到只有几位的数字上,而散列表本身的索引又只依赖于这个数字的一部分。为了解决散列冲突,算法会在散列值中另外再取几位,然后用特殊的方法处理一下,把新得到的数字再当作索引来寻找表元。若这次找到的表元是空的,则同样抛出 KeyError;若非空,或者键匹配,则返回这个值;或者又发现了散列冲突,则重复以上的步骤。

图 3-3 中展示了散列算法和解决散列冲突的示意图:

{%}

 

        添加新元素和更新现有键值的操作几乎跟上面一样。只不过对于前者,在发现空表元的时候会放入一个新元素;对于后者,在找到相对应的表元后,原表里的值对象会被替换成新值。

        另外在插入新值时,Python 可能会按照散列表的拥挤程度来决定是否要重新分配内存为它扩容。如果增加了散列表的大小,那散列值所占的位数和用作索引的位数都会随之增加,这样做的目的是为了减少发生散列冲突的概率。

3.9.3 dict 的特点

下面的内容会讨论使用散列表给 dict 带来的优势和限制都有哪些。

  1. 键必须是可散列的

    一个可散列的对象必须满足以下要求。

    (1) 支持 hash() 函数,并且通过 __hash__() 方法所得到的散列值是不变的。

    (2) 支持通过 __eq__() 方法来检测相等性。

    (3) 若 a == b 为真,则 hash(a) == hash(b) 也为真。

    所有由用户自定义的对象默认都是可散列的,因为它们的散列值由 id() 来获取,而且它们都是不相等的。

         如果你实现了一个类的 __eq__ 方法,并且希望它是可散列的,那么它一定要有个恰当的 __hash__ 方法,保证在 a == b 为真的情况下 hash(a) == hash(b) 也必定为真。否则就会破坏恒定的散列表算法,导致由这些对象所组成的字典和集合完全失去可靠性。另一方面,如果一个含有自定义的 __eq__ 依赖的类处于可变的状态,那就不要在这个类中实现 __hash__ 方法,因为它的实例是不可散列的。

  2. 字典在内存上的开销巨大

            由于字典使用了散列表,而散列表又必须是稀疏的,这导致它在空间上的效率低下。举例而言,如果你需要存放数量巨大的记录,那么放在由元组或是具名元组构成的列表中会是比较好的选择;最好不要根据 JSON 的风格,用由字典组成的列表来存放这些记录。用元组取代字典就能节省空间的原因有两个:其一是避免了散列表所耗费的空间,其二是无需把记录中字段的名字在每个元素里都存一遍。

            在用户自定义的类型中,__slots__ 属性可以改变实例属性的存储方式,由 dict 变成 tuple

  3. 键值查询很快

        dict 的实现是典型的空间换时间:字典类型有着巨大的内存开销,但它们提供了无视数据量大小的快速访问———只要字典能被装在内存里。

  4. 删除了原书部分内容,因为在 Python3.6 以后,字典变为有序,此部分内容不再适用;

3.9.4 set 的特点

set 和 frozenset 的实现也依赖散列表,但在它们的散列表里存放的只有元素的引用(就像在字典里只存放键而没有相应的值)。

之前提到的字典和散列表的几个特点,对集合来说几乎都是适用的。为了避免太多重复的内容,这些特点总结如下。

  • 集合里的元素必须是可散列的。

  • 集合很消耗内存。

  • 可以很高效地判断元素是否存在于某个集合。

  • 元素的次序取决于被添加到集合里的次序。

  • 往集合里添加元素,可能会改变集合里已有元素的次序。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值