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

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

为了让你有动力研究散列表,下面先来看一个关于 dictset 效率的实验,实验对象里大概有上百万个元素,而实验结果可能会出乎你的意料。

所有的 Python 程序员都从经验中得出结论,认为字典和集合的速度是非常快的。接下来我们要通过可控的实验来证实这一点。

为了对比容器的大小对 dictsetlistin 运算符效率的影响,我创建了一个有 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

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 换成了 setlist 类型,重复了同样的增长大小的实验。对于 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

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 中还有相关的讨论。

把字典和集合的运行速度之快的事实抓在手里之后,让我们来看看它背后的原因。对散列表内部结构的讨论,能解释诸如为什么键是无序且不稳定的。

这一节笼统地描述了 Python 如何用散列表来实现 dict 类型,有些细节只是一笔带过,像 CPython 里的一些优化技巧 8 就没有提到。但是总体来说描述是准确的。

8Python 源码 dictobject.c 模块(http://hg.python.org/cpython/file/tip/Objects/dictobject.c)里有丰富的注释,另外延伸阅读中有对《代码之美》一书的引用。

 为了简单起见,这里先集中讨论 dict 的内部结构,然后再延伸到集合上面。

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

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

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

  1. 散列值和相等性

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

    为了让散列值能够胜任散列表索引这一角色,它们必须在索引空间中尽量分散开来。这意味着在最理想的状况下,越是相似但不相等的对象,它们散列值的差别应该越大。示例 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 开始,strbytesdatetime 对象的散列值计算过程中多了随机的“加盐”这一步。所加盐值是 Python 进程内的一个常量,但是每次启动 Python 解释器都会生成一个不同的盐值。随机盐值的加入是为了防止 DOS 攻击而采取的一种安全措施。在 __hash__ 特殊方法的文档(https://docs.python.org/3/reference/datamodel.html#object.__hash__) 里有相关的详细信息。

    了解对象散列值相关的基本概念之后,我们可以深入到散列表工作原理背后的算法了。

  2. 散列表算法

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

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

    {%}

    图 3-3:从字典中取值的算法流程图;给定一个键,这个算法要么返回一个值,要么抛出 KeyError 异常

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

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

    表面上看,这个算法似乎很费事,而实际上就算 dict 里有数百万个元素,多数的搜索过程中并不会有冲突发生,平均下来每次搜索可能会有一到两次冲突。在正常情况下,就算是最不走运的键所遇到的冲突的次数用一只手也能数过来。

    了解 dict 的工作原理能让我们知道它的所长和所短,以及从它衍生而来的数据类型的优缺点。下面就来看看 dict 这些特点背后的原因。

9既然提到了整型,CPython 的实现细节里有一条是:如果有一个整型对象,而且它能被存进一个机器字中,那么它的散列值就是它本身的值。

10在散列冲突的情况下,用 C 语言写的用来打乱散列值位的算法的名字很有意思,叫 perturb。详见 CPython 源码里的 dictobject.chttps://hg.python.org/cpython/file/tip/Objects/dictobject.c)。

下面的内容会讨论使用散列表给 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,相关细节在 9.8 节会谈到。

    记住我们现在讨论的是空间优化。如果你手头有几百万个对象,而你的机器有几个 GB 的内存,那么空间的优化工作可以等到真正需要的时候再开始计划,因为优化往往是可维护性的对立面。

  3. 键查询很快

    dict 的实现是典型的空间换时间:字典类型有着巨大的内存开销,但它们提供了无视数据量大小的快速访问——只要字典能被装在内存里。正如表 3-5 所示,如果把字典的大小从 1000 个元素增加到 10 000 000 个,查询时间也不过是原来的 2.8 倍,从 0.000163 秒增加到了 0.00456 秒。这意味着在一个有 1000 万个元素的字典里,每秒能进行 200 万个键查询。

  4. 键的次序取决于添加顺序

    当往 dict 里添加新键而又发生散列冲突的时候,新键可能会被安排存放到另一个位置。于是下面这种情况就会发生:由 dict([key1, value1), (key2, value2)]dict([key2, value2], [key1, value1]) 得到的两个字典,在进行比较的时候,它们是相等的;但是如果在 key1key2 被添加到字典里的过程中有冲突发生的话,这两个键出现在字典里的顺序是不一样的。

    示例 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])
    
  5. 往字典里添加新键可能会改变已有键的顺序

    无论何时往字典里添加新的键,Python 解释器都可能做出为字典扩容的决定。扩容导致的结果就是要新建一个更大的散列表,并把字典里已有的元素添加到新表里。这个过程中可能会发生新的散列冲突,导致新散列表中键的次序变化。要注意的是,上面提到的这些变化是否会发生以及如何发生,都依赖于字典背后的具体实现,因此你不能很自信地说自己知道背后发生了什么。如果你在迭代一个字典的所有键的过程中同时对字典进行修改,那么这个循环很有可能会跳过一些键——甚至是跳过那些字典中已经有的键。

    由此可知,不要对字典同时进行迭代和修改。如果想扫描并修改一个字典,最好分成两步来进行:首先对字典迭代,以得出需要添加的内容,把这些内容放在一个新字典里;迭代结束之后再对原有字典进行更新。

     在 Python 3 中,.keys().items().values() 方法返回的都是字典视图。也就是说,这些方法返回的值更像集合,而不是像 Python 2 那样返回列表。视图还有动态的特性,它们可以实时反馈字典的变化。

    现在已经可以把学到的有关散列表的知识应用在集合上面了。

setfrozenset 的实现也依赖散列表,但在它们的散列表里存放的只有元素的引用(就像在字典里只存放键而没有相应的值)。在 set 加入到 Python 之前,我们都是把字典加上无意义的值当作集合来用的。

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