0
点赞
收藏
分享

微信扫一扫

集合类型

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:字典子类,可以通过调用用户自定义的工厂函数来设置缺失值。

举报

相关推荐

0 条评论