Python 提供了许多内置的数据集合类型,如果选择明智的话,可以高效解决许多问题。
你可能已经学过下面这些集合类型,它们都有专门的字面值,如下所示。
• 列表(list)。
• 元组(tuple)。
• 字典(dictionary)。
• 集合(set)
Python 的集合类型当然不止这 4 种,它的标准库扩展了其可选列表。在许多情况下,
问题的答案可能正如选择正确的数据结构一样简单。本书的这一部分将深入介绍各种集合
类型,以帮你做出更好的选择。
1.列表与元组
Python 最基本的两个集合类型就是列表与元组,它们都表示对象序列。只要是花几小
时学过 Python 的人,应该都很容易发现二者之间的根本区别:列表是动态的,其大小可以
改变;而元组是不可变的,一旦创建就不能修改。
虽然快速分配/释放小型对象的优化方法有很多,但对于元素位置本身也是信息的数据
结构来说,推荐使用元组这一数据类型。举个例子,想要保存(x, y)坐标对,元组可能是一
个很好的选择。反正关于元组的细节相当无趣。本章关于元组唯一重要的内容就是,tuple
是不可变的(immutable),因此也是可哈希的(hashable)。其具体含义将会在后面“字典”
一节介绍。比元组更有趣的是另一种动态的数据结构 list,以及它的工作原理和高效处
理理方式。
(1)实现细节
许多程序员容易将 Python 的 list 类型与其他语言(如 C、C++或 Java)标准库中常
见的链表的概念相混淆。事实上,CPython 的列表根本不是列表。在 CPython 中,列表被
实现为长度可变的数组。对于其他 Python 实现(如 Jython 和 IronPython)而言,这种说法
应该也是正确的,虽然这些项目的文档中没有记录其实现细节。造成这种混淆的原因很清
楚。这种数据类型被命名为列表,还和链表实现有相似的接口。
为什么这一点很重要,这又意味着什么呢?列表是最常见的数据结构之一,其使用方
式会对所有应用的性能带来极大影响。此外,CPython 又是最常见也最常用的 Python 实现,
所以了解其内部实现细节至关重要。
从细节上来看,Python 中的列表是由对其他对象的引用组成的的连续数组。指向这个
数组的指针及其长度被保存在一个列表头结构中。这意味着,每次添加或删除一个元素时,
由引用组成的数组需要改变大小(重新分配)。幸运的是,Python 在创建这些数组时采用了
指数过分配(exponential over-allocation),所以并不是每次操作都需要改变数组大小。这也
是添加或取出元素的平摊复杂度较低的原因。不幸的是,在普通链表中“代价很小”的其
他一些操作在 Python 中的计算复杂度却相对较高:
• 利用 list.insert 方法在任意位置插入一个元素—复杂度为 O(n)。
• 利用 list.delete 或 del 删除一个元素—复杂度为 O(n)。
(2)列表推导
你可能知道,编写这样的代码是很痛苦的:
>>> evens = []
>>> for i in range(10):
... if i % 2 == 0:
... evens.append(i)
...
>>> evens
[0, 2, 4, 6, 8]
这种写法可能适用于 C 语言,但在 Python 中的实际运行速度很慢,原因如下。
• 解释器在每次循环中都需要判断序列中的哪一部分需要修改。
• 需要用一个计数器来跟踪需要处理的元素。
• 由于 append()是一个列表方法,所以每次遍历时还需要额外执行一个查询函数。
列表推导正是解决这个问题的正确方法。它使用编排好的功能对上述语法的一部分做
了自动化处理:
>>> [i for i in range(10) if i % 2 == 0]
[0, 2, 4, 6, 8]
这种写法除了更加高效之外,也更加简短,涉及的语法元素也更少。在大型程序中,
这意味着更少的错误,代码也更容易阅读和理解。
(3)其他习语
Python 习语的另一个典型例子是使用 enumerate(枚举)。在循环中使用序列时,这
个内置函数可以很方便地获取其索引。以下面这段代码为例:
>>> i = 0
>>> for element in ['one', 'two', 'three']:
... print(i, element)
... i += 1
...
0 one
1 two
2 three
它可以替换为下面这段更短的代码:
>>> for i, element in enumerate(['one', 'two', 'three']):
... print(i, element)
...
0 one
1 two
2 three
如果需要一个一个合并多个列表(或任意可迭代对象)中的元素,那么可以使用内置
的 zip()函数。对两个大小相等的可迭代对象进行均匀遍历时,这是一种非常常用的模式:
>>> for item in zip([1, 2, 3], [4, 5, 6]):
... print(item)
...
(1, 4)
(2, 5)
(3, 6)
注意,对 zip()函数返回的结果再次调用 zip(),可以将其恢复原状:
>>> for item in zip(*zip([1, 2, 3], [4, 5, 6])):
... print(item)
...
(1, 2, 3)
(4, 5, 6)
另一个常用的语法元素是序列解包(sequence unpacking)。这种方法并不限于列表
和元组,而是适用于任意序列类型(甚至包括字符串和字节序列)。只要赋值运算符左
边的变量数目与序列中的元素数目相等,你都可以用这种方法将元素序列解包到另一组
变量中:
>>> first, second, third = "foo", "bar", 100
>>> first
'foo'
>>> second
'bar'
>>> third
100
解包还可以利用带星号的表达式获取单个变量中的多个元素,只要它的解释没有歧义
即可。还可以对嵌套序列进行解包。特别是在遍历由序列构成的复杂数据结构时,这种方
法非常实用。下面是一些更复杂的解包示例:
>>> # 带星号的表达式可以获取序列的剩余部分
>>> first, second, *rest = 0, 1, 2, 3
>>> first
0
>>> second
1
>>> rest
[2, 3]
>>> # 带星号的表达式可以获取序列的中间部分
>>> first, *inner, last = 0, 1, 2, 3
>>> first
[1, 2]
>>> last
3
>>> # 嵌套解包
>>> (a, b), (c, d) = (1, 2), (3, 4)
>>> a, b, c, d
(1, 2, 3, 4)
2.字典
字典是 Python 中最通用的数据结构之一。dict 可以将一组唯一键映射到对应的值,
如下所示:
{
1: ' one',
2: ' two',
3: ' three',
}
字典是你应该已经了解的基本内容。不管怎样,程序员还可以用和前面列表推导类似
的推导来创建一个新的字典。这里有一个非常简单的例子如下所示:
squares = {number: number**2 for number in range(100)}
重要的是,使用字典推导具有与列表推导相同的优点。因此在许多情况下,字典推导
要更加高效、更加简短、更加整洁。对于更复杂的代码而言,需要用到许多 if 语句或函
数调用来创建一个字典,这时最好使用简单的 for 循环,尤其是它还提高了可读性。
对于刚刚接触 Python 3 的 Python 程序员来说,在遍历字典元素时有一点需要特别注意。
字典的 keys()、values()和 items()3 个方法的返回值类型不再是列表。此外,与之对应
的 iterkeys()、itervalues()和 iteritems()本来返回的是迭代器,而 Python 3 中并
没有这 3 个方法。现在 keys()、values()和 items()返回的是视图对象(view objects)。
• keys():返回 dict_keys 对象,可以查看字典的所有键。
• values():返回 dict_values 对象,可以查看字典的所有值。
• items():返回 dict_items 对象,可以查看字典所有的(key, value)二元元组。
视图对象可以动态查看字典的内容,因此每次字典发生变化时,视图都会相应改变,
见下面这个例子:
>>> words = {'foo': 'bar', 'fizz': 'bazz'}
>>> words['spam'] = 'eggs'
>>> items
dict_items([('spam', 'eggs'), ('fizz', 'bazz'), ('foo', 'bar')])
视图对象既有旧的 keys()、values()和 items()方法返回的列表的特性,也有旧
的 iterkeys()、itervalues()和 iteritems()方法返回的迭代器的特性。视图无需
冗余地将所有值都保存在内存里(像列表那样),但你仍然可以获取其长度(使用 len),
也可以测试元素是否包含其中(使用 in 子句)。当然,视图是可迭代的。
最后一件重要的事情是,在 keys()和 values()方法返回的视图中,键和值的顺序
是完全对应的。在 Python 2 中,如果你想保证获取的键和值顺序一致,那么在两次函数调
用之间不能修改字典的内容。现在 dict_keys 和 dict_values 是动态的,所以即使在
调用 keys()和 values()之间字典内容发生了变化,那么这两个视图的元素遍历顺序也
是完全一致的。
(1)实现细节
CPython 使用伪随机探测(pseudo-random probing)的散列表(hash table)作为字典的
底层数据结构。这似乎是非常高深的实现细节,但在短期内不太可能发生变化,所以程序
员也可以把它当做一个有趣的事实来了解。
由于这一实现细节,只有可哈希的(hashable)对象才能作为字典的键。如果一个对象有一
个在整个生命周期都不变的散列值(hash value),而且这个值可以与其他对象进行比较,那么这
个对象就是可哈希的。Python 所有不可变的内置类型都是可哈希的。可变类型(如列表、字典
和集合)是不可哈希的,因此不能作为字典的键。定义可哈希类型的协议包括下面这两个方法。
• __hash__:这一方法给出 dict 内部实现需要的散列值(整数)。对于用户自定义
类的实例对象,这个值由 id()给出。
• __eq__:比较两个对象的值是否相等。对于用户自定义类,除了自身之外,所有
实例对象默认不相等。
如果两个对象相等,那么它们的散列值一定相等。反之则不一定成立。这说明可能会
发生散列冲突(hash collision),即散列值相等的两个对象可能并不相等。这是允许的,所
有 Python 实现都必须解决散列冲突。CPython 用开放定址法(open addressing)来解决这一
冲突(https://en.wikipedia.org/wiki/Open_addressing)。不过,发生冲突的概率对性能有很大
影响,如果概率很高,字典将无法从其内部优化中受益。
(2)缺点和替代方案
使用字典的常见陷阱之一,就是它并不会按照键的添加顺序来保存元素的顺序。在某
些情况下,字典的键是连续的,对应的散列值也是连续值(例如整数),那么由于字典的内
部实现,元素的顺序可能和添加顺序相同:
>>> {number: None for number in range(5)}.keys()
dict_keys([0, 1, 2, 3, 4])
不过,如果使用散列方法不同的其他数据类型,那么字典就不会保存元素顺序。下面
是 CPython 中的例子:
>>> {str(number): None for number in range(5)}.keys()
dict_keys(['1', '2', '4', '0', '3'])
>>> {str(number): None for number in reversed(range(5))}.keys()
dict_keys(['2', '3', '1', '4', '0'])
如上述代码所示,字典元素的顺序既与对象的散列方法无关,也与元素的添加顺序无
关。但我们也不能完全信赖这一说法,因为在不同的 Python 实现中可能会有所不同。
但在某些情况下,开发者可能需要使用能够保存添加顺序的字典。幸运的是,Python
标准库的 collections 模块提供了名为 OrderedDict 的有序字典。它选择性地接受一
个可迭代对象作为初始化参数:
>>> from collections import OrderedDict
>>> OrderedDict((str(number), None) for number in range(5)).keys()
odict_keys(['0', '1', '2', '3', '4'])
OrderedDict 还有一些其他功能,例如利用 popitem()方法在双端取出元素或者利
用 move_to_end()方法将指定元素移动到某一端。这种集合类型的完整参考可参见
Python 文档(https://docs.python.org/3/library/collections.html)。
还有很重要的一点是,在非常老的代码库中,可能会用 dict 来实现原始的集合,以
确保元素的唯一性。虽然这种方法可以给出正确的结果,但只有在低于 2.3 的 Python 版本
中才予以考虑。字典的这种用法十分浪费资源。Python 有内置的 set 类型专门用于这个目
的。事实上,CPython 中 set 的内部实现与字典非常类似,但还提供了一些其他功能,以
及与集合相关的特定优化。
3.集合
集合是一种鲁棒性很好的数据结构,当元素顺序的重要性不如元素的唯一性和测试元
素是否包含在集合中的效率时,大部分情况下这种数据结构是很有用的。它与数学上的集
合概念非常类似。Python 的内置集合类型有两种。
• set():一种可变的、无序的、有限的集合,其元素是唯一的、不可变的(可哈希
的)对象。
• frozenset():一种不可变的、可哈希的、无序的集合,其元素是唯一的、不可
变的(可哈希的)对象。
由于 frozenset()具有不变性,它可以用作字典的键,也可以作为其他 set()和
frozenset()的元素。在一个 set()或 frozenset()中不能包含另一个普通的可变
set(),因为这会引发 TypeError:
>>> set([set([1,2,3]), set([2,3,4])])
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: unhashable type: 'set'
下面这种集合初始化的方法是完全正确的:
>>> set([frozenset([1,2,3]), frozenset([2,3,4])])
{frozenset({1, 2, 3}), frozenset({2, 3, 4})}
>>> frozenset([frozenset([1,2,3]), frozenset([2,3,4])])
frozenset({frozenset({1, 2, 3}), frozenset({2, 3, 4})})
创建可变集合方法有以下 3 种,如下所示。
• 调用 set(),选择性地接受可迭代对象作为初始化参数,例如 set([0, 1, 2])。
• 使用集合推导,例如{element for element in range(3)}。
• 使用集合字面值,例如{1, 2, 3}。
注意,使用集合的字面值和推导要格外小心,因为它们在形式上与字典的字面值和推导非
常相似。此外,空的集合对象是没有字面值的。空的花括号{}表示的是空的字典字面值。
实现细节
CPython 中的集合与字典非常相似。事实上,集合被实现为带有空值的字典,只有键
才是实际的集合元素。此外,集合还利用这种没有值的映射做了其他优化。
由于这一点,可以快速向集合添加元素、删除元素或检查元素是否存在,平均时间复
杂度均为 O(1)。但由于 CPython 的集合实现依赖于类似的散列表结构,因此这些操作的最
坏情况复杂度是 O(n),其中 n 是集合的当前大小。
字典的其他实现细节也适用于集合。集合中的元素必须是可哈希的,如果集合中用户
自定义类的实例的散列方法不佳,那么将会对性能产生负面影响。
4.超越基础集合类型—collections 模块
每种数据结构都有其缺点。没有一种集合类型适合解决所有问题,4 种基本类型(元
组、列表、集合和字典)提供的选择也不算多。它们是最基本也是最重要的集合类型,都
有专门的语法。幸运的是,Python 标准库内置的 collections 模块提供了更多的选择。
前面已经提到过其中一种(deque)。下面是这个模块中最重要的集合类型。
• namedtuple():用于创建元组子类的工厂函数(factory function),可以通过属性
名来访问它的元索引。
• deque:双端队列,类似列表,是栈和队列的一般化,可以在两端快速添加或取出
元素。
• ChainMap:类似字典的类,用于创建多个映射的单一视图。
• Counter:字典子类,由于对可哈希对象进行计数。
• OrderedDict:字典子类,可以保存元素的添加顺序。
• defaultdict:字典子类,可以通过调用用户自定义的工厂函数来设置缺失值。