- 前言
- 目标读者
- 非目标读者
- 本书的结构
- 以实践为基础
- 硬件
- 杂谈:个人的一点看法
- Python 术语表
- Python 版本表
- 排版约定
- 使用代码示例
- 第一部分 序幕
- 第 1 章 Python 数据模型
- 第二部分 数据结构
- 第 2 章 序列构成的数组
- 第 3 章 字典和集合
- 第 4 章 文本和字节序列
- 第三部分 把函数视作对象
- 第 5 章 一等函数
- 第 6 章 使用一等函数实现设计模式
- 第 7 章 函数装饰器和闭包
- 第四部分 面向对象惯用法
- 第 8 章 对象引用、可变性和垃圾回收
- 第 9 章 符合 Python 风格的对象
- 第 10 章 序列的修改、散列和切片
- 第 11 章 接口:从协议到抽象基类
- 第 12 章 继承的优缺点
- 第 13 章 正确重载运算符
- 第五部分 控制流程
- 第 14 章 可迭代的对象、迭代器和生成器
- 14.1 Sentence 类第1版:单词序列
- 14.2 可迭代的对象与迭代器的对比
- 14.3 Sentence 类第2版:典型的迭代器
- 14.4 Sentence 类第3版:生成器函数
- 14.5 Sentence 类第4版:惰性实现
- 14.6 Sentence 类第5版:生成器表达式
- 14.7 何时使用生成器表达式
- 14.8 另一个示例:等差数列生成器
- 14.9 标准库中的生成器函数
- 14.10 Python 3.3 中新出现的句法:yield from
- 14.11 可迭代的归约函数
- 14.12 深入分析 iter 函数
- 14.13 案例分析:在数据库转换工具中使用生成器
- 14.14 把生成器当成协程
- 14.15 本章小结
- 14.16 延伸阅读
- 第 15 章 上下文管理器和 else 块
- 第 16 章 协程
- 第 17 章 使用期物处理并发
- 第 18 章 使用 asyncio 包处理并发
- 第六部分 元编程
- 第 19 章 动态属性和特性
- 第 20 章 属性描述符
- 第 21 章 类元编程
- 结语
- 延伸阅读
- 附录 A 辅助脚本
- Python 术语表
- 作者简介
- 关于封面
3.9 dict 和 set 的背后
想要理解 Python 里字典和集合类型的长处和弱点,它们背后的散列表是绕不开的一环。
这一节将会回答以下几个问题。
Python 里的 dict 和 set 的效率有多高?
为什么它们是无序的?
为什么并不是所有的 Python 对象都可以当作 dict 的键或 set 里的元素?
为什么 dict 的键和 set 元素的顺序是跟据它们被添加的次序而定的,以及为什么在映射对象的生命周期中,这个顺序并不是一成不变的?
为什么不应该在迭代循环 dict 或是 set 的同时往里添加元素?
为了让你有动力研究散列表,下面先来看一个关于 dict 和 set 效率的实验,实验对象里大概有上百万个元素,而实验结果可能会出乎你的意料。
3.9.1 一个关于效率的实验
所有的 Python 程序员都从经验中得出结论,认为字典和集合的速度是非常快的。接下来我们要通过可控的实验来证实这一点。
为了对比容器的大小对 dict、set 或 list 的 in 运算符效率的影响,我创建了一个有 1000 万个双精度浮点数的数组,名叫 haystack。另外还有一个包含了 1000 个浮点数的 needles 数组,其中 500 个数字是从 haystack 里挑出来的,另外 500 个肯定不在 haystack 里。
作为 dict 测试的基准,我用 dict.fromkeys() 来建立了一个含有 1000 个浮点数的名叫 haystack 的字典,并用 timeit 模块测试示例 3-14(与示例 3-11 相同)里这段代码运行所需要的时间。
示例 3-14 在 haystack 里查找 needles 的元素,并计算找到的元素的个数
found = 0 for n in needles: if n in haystack: found += 1
然后这段基准测试重复了 4 次,每次都把 haystack 的大小变成了上一次的 10 倍,直到里面有 1000 万个元素。最后这些测试的结果列在了表 3-5 中。
表3-5:用in运算符在5个不同大小的haystack字典里搜索1000个元素所需要的时间。代码运行在一个Core i7笔记本上,Python版本是3.4.0(测试计算的是示例3-14里循环的运行时间)
haystack的长度 | 增长系数 | dict花费时间 | 增长系数 |
1000 | 1× | 0.000202s | 1.00× |
10 000 | 10× | 0.000140s | 0.69× |
100 000 | 100× | 0.000228s | 1.13× |
1 000 000 | 1000× | 0.000290s | 1.44× |
10 000 000 | 10 000× | 0.000337s | 1.67× |
也就是说,在我的笔记本上从 1000 个字典键里搜索 1000 个浮点数所需的时间是 0.000202 秒,把同样的搜索在含有 10 000 000 个元素的字典里进行一遍,只需要 0.000337 秒。换句话说,在一个有 1000 万个键的字典里查找 1000 个数,花在每个数上的时间不过是 0.337 微秒——没错,相当于平均每个数差不多三分之一微秒。
作为对比,我把 haystack 换成了 set 和 list 类型,重复了同样的增长大小的实验。对于 set,除了上面的那个循环的运行时间,我还测量了示例 3-15 那行代码,这段代码也计算了 needles 中出现在 haystack 中的元素的个数。
示例 3-15 利用交集来计算 needles 中出现在 haystack 中的元素的个数
found = len(needles & haystack)
表 3-6 列出了所有测试的结果。最快的时间来自“集合交集花费时间”这一列,这一列的结果是示例 3-15 中利用集合 & 操作的代码的效果。不出所料的是,最糟糕的表现来自“列表花费时间”这一列。由于列表的背后没有散列表来支持 in 运算符,每次搜索都需要扫描一次完整的列表,导致所需的时间跟据 haystack 的大小呈线性增长。
表3-6:在5个不同大小的haystack里搜索1000个元素所需的时间,haystack分别以字典、集合和列表的形式出现。测试环境是一个有Core i7处理器的笔记本,Python版本是3.4.0(测试所测量的代码是示例3-14中的循环和示例3-15的集合&操作)
haystack的长度 | 增长系数 | dict花费时间 | 增长系数 | 集合花费时间 | 增长系数 | 集合交集花费时间 | 增长系数 | 列表花费时间 | 增长系数 |
1000 | 1× | 0.000202s | 1.00× | 0.000143s | 1.00× | 0.000087s | 1.00× | 0.010556s | 1.00× |
10 000 | 10× | 0.000140s | 0.69× | 0.000147s | 1.03× | 0.000092s | 1.06× | 0.086586s | 8.20× |
100 000 | 100× | 0.000228s | 1.13× | 0.000241s | 1.69× | 0.000163s | 1.87× | 0.871560s | 82.57× |
1 000 000 | 1000× | 0.000290s | 1.44× | 0.000332s | 2.32× | 0.000250s | 2.87× | 9.189616s | 870.56× |
10 000 000 | 10 000× | 0.000337s | 1.67× | 0.000387s | 2.71× | 0.000314s | 3.61× | 97.948056s | 9278.90× |
如果在你的程序里有任何的磁盘输入 / 输出,那么不管查询有多少个元素的字典或集合,所耗费的时间都能忽略不计(前提是字典或者集合不超过内存大小)。可以仔细看看跟表 3-6 有关的代码,另外在附录 A 的示例 A-1 中还有相关的讨论。
把字典和集合的运行速度之快的事实抓在手里之后,让我们来看看它背后的原因。对散列表内部结构的讨论,能解释诸如为什么键是无序且不稳定的。
3.9.2 字典中的散列表
这一节笼统地描述了 Python 如何用散列表来实现 dict 类型,有些细节只是一笔带过,像 CPython 里的一些优化技巧 10 就没有提到。但是总体来说描述是准确的。
10Python 源码 dictobject.c 模块里有丰富的注释,另外延伸阅读中有对《代码之美》一书的引用。
为了简单起见,这里先集中讨论 dict 的内部结构,然后再延伸到集合上面。
散列表其实是一个稀疏数组(总是有空白元素的数组称为稀疏数组)。在一般的数据结构教材中,散列表里的单元通常叫作表元(bucket)。在 dict 的散列表当中,每个键值对都占用一个表元,每个表元都有两个部分,一个是对键的引用,另一个是对值的引用。因为所有表元的大小一致,所以可以通过偏移量来读取某个表元。
因为 Python 会设法保证大概还有三分之一的表元是空的,所以在快要达到这个阈值的时候,原有的散列表会被复制到一个更大的空间里面。
如果要把一个对象放入散列表,那么首先要计算这个元素键的散列值。Python 中可以用 hash() 方法来做这件事情,接下来会介绍这一点。
01. 散列值和相等性
内置的 hash() 方法可以用于所有的内置类型对象。如果是自定义对象调用 hash() 的话,实际上运行的是自定义的 __hash__。如果两个对象在比较的时候是相等的,那它们的散列值必须相等,否则散列表就不能正常运行了。例如,如果 1 == 1.0 为真,那么 hash(1) == hash(1.0) 也必须为真,但其实这两个数字(整型和浮点)的内部结构是完全不一样的。11
为了让散列值能够胜任散列表索引这一角色,它们必须在索引空间中尽量分散开来。这意味着在最理想的状况下,越是相似但不相等的对象,它们散列值的差别应该越大。示例 3-16 是一段代码输出,这段代码被用来比较散列值的二进制表达的不同。注意其中 1 和 1.0 的散列值是相同的,而 1.0001、1.0002 和 1.0003 的散列值则非常不同。
示例 3-16 在32 位的 Python 中,1、1.0001、1.0002 和 1.0003 这几个数的散列值的二进制表达对比(上下两个二进制间不同的位被 ! 高亮出来,表格的最右列显示了有多少位不相同)
32-bit Python build 1 00000000000000000000000000000001 != 0 1.0 00000000000000000000000000000001 ------------------------------------------------ 1.0 00000000000000000000000000000001 ! !!! ! !! ! ! ! ! !! !!! != 16 1.0001 00101110101101010000101011011101 ------------------------------------------------ 1.0001 00101110101101010000101011011101 !!! !!!! !!!!! !!!!! !! ! != 20 1.0002 01011101011010100001010110111001 ------------------------------------------------ 1.0002 01011101011010100001010110111001 ! ! ! !!! ! ! !! ! ! ! !!!! != 17 1.0003 00001100000111110010000010010110 ------------------------------------------------
用来计算示例 3-16 的程序见于附录 A。尽管程序里大部分代码都是用来整理输出格式的,考虑到完整性,我还是把全部的代码放在示例 A-3 中了。
从 Python 3.3 开始,str、bytes 和 datetime 对象的散列值计算过程中多了随机的“加盐”这一步。所加盐值是 Python 进程内的一个常量,但是每次启动 Python 解释器都会生成一个不同的盐值。随机盐值的加入是为了防止 DOS 攻击而采取的一种安全措施。在 __hash__ 特殊方法的文档里有相关的详细信息。
了解对象散列值相关的基本概念之后,我们可以深入到散列表工作原理背后的算法了。
02. 散列表算法
为了获取 my_dict[search_key] 背后的值,Python 首先会调用 hash(search_key) 来计算 search_key 的散列值,把这个值最低的几位数字当作偏移量,在散列表里查找表元(具体取几位,得看当前散列表的大小)。若找到的表元是空的,则抛出 KeyError 异常。若不是空的,则表元里会有一对 found_key:found_value。这时候 Python 会检验 search_key == found_key 是否为真,如果它们相等的话,就会返回 found_value。
如果 search_key 和 found_key 不匹配的话,这种情况称为散列冲突。发生这种情况是因为,散列表所做的其实是把随机的元素映射到只有几位的数字上,而散列表本身的索引又只依赖于这个数字的一部分。为了解决散列冲突,算法会在散列值中另外再取几位,然后用特殊的方法处理一下,把新得到的数字再当作索引来寻找表元。12 若这次找到的表元是空的,则同样抛出 KeyError;若非空,或者键匹配,则返回这个值;或者又发现了散列冲突,则重复以上的步骤。图 3-3 展示了这个算法的示意图。
图 3-3:从字典中取值的算法流程图;给定一个键,这个算法要么返回一个值,要么抛出 KeyError 异常
添加新元素和更新现有键值的操作几乎跟上面一样。只不过对于前者,在发现空表元的时候会放入一个新元素;对于后者,在找到相对应的表元后,原表里的值对象会被替换成新值。
另外在插入新值时,Python 可能会按照散列表的拥挤程度来决定是否要重新分配内存为它扩容。如果增加了散列表的大小,那散列值所占的位数和用作索引的位数都会随之增加,这样做的目的是为了减少发生散列冲突的概率。
表面上看,这个算法似乎很费事,而实际上就算 dict 里有数百万个元素,多数的搜索过程中并不会有冲突发生,平均下来每次搜索可能会有一到两次冲突。在正常情况下,就算是最不走运的键所遇到的冲突的次数用一只手也能数过来。
了解 dict 的工作原理能让我们知道它的所长和所短,以及从它衍生而来的数据类型的优缺点。下面就来看看 dict 这些特点背后的原因。
11既然提到了整型,CPython 的实现细节里有一条是:如果有一个整型对象,而且它能被存进一个机器字中,那么它的散列值就是它本身的值。
12在散列冲突的情况下,用 C 语言写的用来打乱散列值位的算法的名字很有意思,叫 perturb。详见 CPython 源码里的 dictobject.c(https://hg.python.org/cpython/file/tip/Objects/dictobject.c)。
3.9.3 dict的实现及其导致的结果
下面的内容会讨论使用散列表给 dict 带来的优势和限制都有哪些。
01. 键必须是可散列的
一个可散列的对象必须满足以下要求。
(1) 支持 hash() 函数,并且通过 __hash__() 方法所得到的散列值是不变的。
(2) 支持通过 __eq__() 方法来检测相等性。
(3) 若 a == b 为真,则 hash(a) == hash(b) 也为真。
所有由用户自定义的对象默认都是可散列的,因为它们的散列值由 id() 来获取,而且它们都是不相等的。
如果你实现了一个类的 __eq__ 方法,并且希望它是可散列的,那么它一定要有个恰当的 __hash__ 方法,保证在 a == b 为真的情况下 hash(a) == hash(b) 也必定为真。否则就会破坏恒定的散列表算法,导致由这些对象所组成的字典和集合完全失去可靠性,这个后果是非常可怕的。另一方面,如果一个含有自定义的 __eq__ 依赖的类处于可变的状态,那就不要在这个类中实现 __hash__ 方法,因为它的实例是不可散列的。
02. 字典在内存上的开销巨大
由于字典使用了散列表,而散列表又必须是稀疏的,这导致它在空间上的效率低下。举例而言,如果你需要存放数量巨大的记录,那么放在由元组或是具名元组构成的列表中会是比较好的选择;最好不要根据 JSON 的风格,用由字典组成的列表来存放这些记录。用元组取代字典就能节省空间的原因有两个:其一是避免了散列表所耗费的空间,其二是无需把记录中字段的名字在每个元素里都存一遍。
在用户自定义的类型中,__slots__ 属性可以改变实例属性的存储方式,由 dict 变成 tuple,相关细节在 9.8 节会谈到。
记住我们现在讨论的是空间优化。如果你手头有几百万个对象,而你的机器有几个 GB 的内存,那么空间的优化工作可以等到真正需要的时候再开始计划,因为优化往往是可维护性的对立面。
03. 键查询很快
dict 的实现是典型的空间换时间:字典类型有着巨大的内存开销,但它们提供了无视数据量大小的快速访问——只要字典能被装在内存里。正如表 3-5 所示,如果把字典的大小从 1000 个元素增加到 10 000 000 个,查询时间也不过是原来的 2.8 倍,从 0.000163 秒增加到了 0.00456 秒。这意味着在一个有 1000 万个元素的字典里,每秒能进行 200 万个键查询。
04. 键的次序取决于添加顺序
当往 dict 里添加新键而又发生散列冲突的时候,新键可能会被安排存放到另一个位置。于是下面这种情况就会发生:由 dict([key1, value1), (key2, value2)] 和 dict([key2, value2], [key1, value1]) 得到的两个字典,在进行比较的时候,它们是相等的;但是如果在 key1 和 key2 被添加到字典里的过程中有冲突发生的话,这两个键出现在字典里的顺序是不一样的。
示例 3-17 展示了这个现象。这个示例用同样的数据创建了 3 个字典,唯一的区别就是数据出现的顺序不一样。可以看到,虽然键的次序是乱的,这 3 个字典仍然被视作相等的。
示例 3-17 dialcodes.py 将同样的数据以不同的顺序添加到 3 个字典里
# 世界人口数量前10位国家的电话区号 DIAL_CODES = [ (86, 'China'), (91, 'India'), (1, 'United States'), (62, 'Indonesia'), (55, 'Brazil'), (92, 'Pakistan'), (880, 'Bangladesh'), (234, 'Nigeria'), (7, 'Russia'), (81, 'Japan'), ] d1 = dict(DIAL_CODES) ➊ print('d1:', d1.keys()) d2 = dict(sorted(DIAL_CODES)) ➋ print('d2:', d2.keys()) d3 = dict(sorted(DIAL_CODES, key=lambda x:x[1])) ➌ print('d3:', d3.keys()) assert d1 == d2 and d2 == d3 ➍
➊ 创建 d1 的时候,数据元组的顺序是按照国家的人口排名来决定的。
➋ 创建 d2 的时候,数据元组的顺序是按照国家的电话区号来决定的。
➌ 创建 d3 的时候,数据元组的顺序是按照国家名字的英文拼写来决定的。
➍ 这些字典是相等的,因为它们所包含的数据是一样的。示例 3-18 里是上面例子的输出。
示例 3-18 dialcodes.py 的输出中,3 个字典的键的顺序是不一样的
d1: dict_keys([880, 1, 86, 55, 7, 234, 91, 92, 62, 81]) d2: dict_keys([880, 1, 91, 86, 81, 55, 234, 7, 92, 62]) d3: dict_keys([880, 81, 1, 86, 55, 7, 234, 91, 92, 62])
05. 往字典里添加新键可能会改变已有键的顺序
无论何时往字典里添加新的键,Python 解释器都可能做出为字典扩容的决定。扩容导致的结果就是要新建一个更大的散列表,并把字典里已有的元素添加到新表里。这个过程中可能会发生新的散列冲突,导致新散列表中键的次序变化。要注意的是,上面提到的这些变化是否会发生以及如何发生,都依赖于字典背后的具体实现,因此你不能很自信地说自己知道背后发生了什么。如果你在迭代一个字典的所有键的过程中同时对字典进行修改,那么这个循环很有可能会跳过一些键——甚至是跳过那些字典中已经有的键。
由此可知,不要对字典同时进行迭代和修改。如果想扫描并修改一个字典,最好分成两步来进行:首先对字典迭代,以得出需要添加的内容,把这些内容放在一个新字典里;迭代结束之后再对原有字典进行更新。
在 Python 3 中,.keys()、.items() 和 .values() 方法返回的都是字典视图。也就是说,这些方法返回的值更像集合,而不是像 Python 2 那样返回列表。视图还有动态的特性,它们可以实时反馈字典的变化。
现在已经可以把学到的有关散列表的知识应用在集合上面了。
3.9.4 set的实现以及导致的结果
set 和 frozenset 的实现也依赖散列表,但在它们的散列表里存放的只有元素的引用(就像在字典里只存放键而没有相应的值)。在 set 加入到 Python 之前,我们都是把字典加上无意义的值当作集合来用的。
在 3.9.3 节中所提到的字典和散列表的几个特点,对集合来说几乎都是适用的。为了避免太多重复的内容,这些特点总结如下。
集合里的元素必须是可散列的。
集合很消耗内存。
可以很高效地判断元素是否存在于某个集合。
元素的次序取决于被添加到集合里的次序。
往集合里添加元素,可能会改变集合里已有元素的次序。
如果你对这篇内容有疑问,欢迎到本站社区发帖提问 参与讨论,获取更多帮助,或者扫码二维码加入 Web 技术交流群。
绑定邮箱获取回复消息
由于您还没有绑定你的真实邮箱,如果其他用户或者作者回复了您的评论,将不能在第一时间通知您!
发布评论