Python的数据结构(一)
Python 的序列数据的特点:不管是哪种数据结构,字符串、列表、字节序列、数组、XML元素,抑或是数据库查询结果,它们都共用 一套丰富的操作:迭代、切片、排序,还有拼接。
深入理解 Python 中的不同序列类型,不但能让我们避免重新发明轮子,它们的 API 还能 帮助我们把自己定义的 API 设计得跟原生的序列一样,或者是跟未来可能出现的序列类 型保持兼容。
Python 标准库用 C 实现了丰富的序列类型,列举如下:
- 容器序列 list、tuple 和 collections.deque 这些序列能存放不同类型的数据。
- 扁平序列 str、bytes、bytearray、memoryview 和 array.array,这类序列只能容纳一种数据类型。
容器序列存放的是它们所包含的任意类型的对象的引用,而扁平序列里存放的是值而不 是引用。换句话说,扁平序列其实是一段连续的内存空间。由此可见扁平序列其实更加紧 凑,但是它里面只能存放诸如字符、字节和数值这种基础类型。
序列类型还能按照能否被修改来分类:
- 可变序列 list、bytearray、array.array、collections.deque 和 memoryview。
- 不可变序列 tuple、str 和 bytes。
下图显示了可变序列(MutableSequence)和不可变序列(Sequence)的差异,同时 也能看出前者从后者那里继承了一些方法。虽然内置的序列类型并不是直接从 Sequence 和 MutableSequence 这两个抽象基类(Abstract Base Class,ABC)继承而来的,但是了 解这些基类可以帮助我们总结出那些完整的序列类型包含了哪些功能。
列表
最重要也最基础的序列类型应该是列表(list)。list 是一个可变序列,并且能同 时存放不同类型的元素。
列表推导是一种构建列表的方法,它异常强 大,然而由于相关的句法比较晦涩,人们往往不愿意去用它。掌握列表推导还可以为我们 打开生成器表达式(generator expression)的大门,后者具有生成各种类型的元素并用它 们来填充序列的功能。
列表推导和生成器表达式
列表推导是构建列表(list)的快捷方式,而生成器表达式则可以用来创建其他任何类 型的序列。如果你的代码里并不经常使用它们,那么很可能你错过了许多写出可读性更好 且更高效的代码的机会。
很多 Python 程序员都把列表推导(list comprehension)简称为 listcomps,生成 式表达器(generator expression)则称为 genexps。我有时也会这么用。
列表推导和可读性
# 使用常用写法(可以让人轻易看懂)
>>> symbols = '$¢£¥€¤'
>>> codes = []
>>> for symbol in symbols:
... codes.append(ord(symbol))
...
>>> codes
[36, 162, 163, 165, 8364, 164]
# 使用列表推导式(读起来更方便)
>>> symbols = '$¢£¥€¤'
>>> codes = [ord(symbol) for symbol in symbols]
>>> codes
[36, 162, 163, 165, 8364, 164]
通常的原则是,只用列表推导来创建新的列表,并且尽量保持简短。 如果列表推导的代码超过了两行,你可能就要考虑是不是得用 for 循环重写了。
注:Python 会忽略代码里 []、{} 和 () 中的换行,因此如果你的代码里有多行的列表、 列表推导、生成器表达式、字典这一类的,可以省略不太好看的续行符 \。
列表推导可以帮助我们把一个序列或是其他可迭代类型中的元素过滤或是加工,然后再新 建一个列表。Python 内置的 filter 和 map 函数组合起来也能达到这一效果,但是可读性 上打了不小的折扣。
列表推导同filter和map的比较
filter 和 map 合起来能做的事情,列表推导也可以做,而且还不需要借助难以理解和阅 读的 lambda 表达式。如下所示:
# 使用列表推导式
>>> symbols = '$¢£¥€¤'
>>> beyond_ascii = [ord(s) for s in symbols if ord(s) > 127]
>>> beyond_ascii
[162, 163, 165, 8364, 164]
# 使用filter和map
>>> beyond_ascii = list(filter(lambda c: c > 127, map(ord, symbols)))
>>> beyond_ascii
[162, 163, 165, 8364, 164]
笛卡儿积
用列表推导可以生成两个或以上的可迭代类型的笛卡儿积。笛卡儿积是一个列 表,列表里的元素是由输入的可迭代类型的元素对构成的元组,因此笛卡儿积列表的长度 等于输入变量的长度的乘积,如下图所示。
如果你需要一个列表,列表里是 3 种不同尺寸的 T 恤衫,每个尺寸都有 2 个颜色,就可以使用列表推导式计算笛卡尔积:
>>> colors = ['black', 'white']
>>> sizes = ['S', 'M', 'L']
>>> tshirts = [(color, size) for color in colors for size in sizes]
>>> tshirts
[('black', 'S'), ('black', 'M'), ('black', 'L'), ('white', 'S'),
('white', 'M'), ('white', 'L')]
>>> for color in colors:
... for size in sizes:
... print((color, size))
...
('black', 'S')
('black', 'M')
('black', 'L')
('white', 'S')
('white', 'M')
('white', 'L')
>>> tshirts = [(color, size) for size in sizes
... for color in colors]
>>> tshirts
[('black', 'S'), ('white', 'S'), ('black', 'M'), ('white', 'M'),
('black', 'L'), ('white', 'L')]
列表推导的作用只有一个:生成列表。如果想生成其他类型的序列,生成器表达式就派上 了用场。
生成器表达式
虽然也可以用列表推导来初始化元组、数组或其他序列类型,但是生成器表达式是更好的 选择。这是因为生成器表达式背后遵守了迭代器协议,可以逐个地产出元素,而不是先建 立一个完整的列表,然后再把这个列表传递到某个构造函数里。前面那种方式显然能够节 省内存。生成器表达式的语法跟列表推导差不多,只不过把方括号换成圆括号而已。
如下例子用生成器表达式初始化元组和数组:
>>> symbols = '$¢£¥€¤'
>>> tuple(ord(symbol) for symbol in symbols) ➊ # 使用生成器表达式初始化元组
(36, 162, 163, 165, 8364, 164)
>>> import array
>>> array.array('I', (ord(symbol) for symbol in symbols)) ➋ # 使用生成器表达式初始化数组
array('I', [36, 162, 163, 165, 8364, 164])
➊ 如果生成器表达式是一个函数调用过程中的唯一参数,那么不需要额外再用括号把它 围起来。
➋ array 的构造方法需要两个参数,因此括号是必需的。array 构造方法的第一个参数 指定了数组中数字的存储方式。
下例则是利用生成器表达式实现了一个笛卡儿积,用以打印出上文中我们提到过的 T 恤衫的 2 种颜色和 3 种尺码的所有组合。与示例 2-4 不同的是,用到生成器表达式之后, 内存里不会留下一个有 6 个组合的列表,因为生成器表达式会在每次 for 循环运行时才 生成一个组合。如果要计算两个各有 1000 个元素的列表的笛卡儿积,生成器表达式就可 以帮忙省掉运行 for 循环的开销,即一个含有 100 万个元素的列表。
>>> colors = ['black', 'white']
>>> sizes = ['S', 'M', 'L']
>>> for tshirt in ('%s %s' % (c, s) for c in colors for s in sizes): ➊
... print(tshirt)
...
black S
black M
black L
white S
white M
white L
➊ 生成器表达式逐个产出元素,从来不会一次性产出一个含有 6 个 T 恤样式的列表。
元组
元组除 了用作不可变的列表,它还可以用于没有字段名的记录。
元组和记录
元组其实是对数据的记录:元组中的每个元素都存放了记录中一个字段的数据,外加这个 字段的位置。正是这个位置信息给数据赋予了意义。
如果只把元组理解为不可变的列表,那其他信息——它所含有的元素的总数和它们的位置 ——似乎就变得可有可无。但是如果把元组当作一些字段的集合,那么数量和位置信息就 变得非常重要了。
下例把元组用作记录:
>>> lax_coordinates = (33.9425, -118.408056) ➊
>>> city, year, pop, chg, area = ('Tokyo', 2003, 32450, 0.66, 8014) ➋
>>> traveler_ids = [('USA', '31195855'), ('BRA', 'CE342567'), ➌
... ('ESP', 'XDA205856')]
>>> for passport in sorted(traveler_ids): ➍
... print('%s/%s' % passport) ➎
...
BRA/CE342567
ESP/XDA205856
USA/31195855
>>> for country, _ in traveler_ids: ➏
... print(country)
...
USA
BRA
ESP
❶ 洛杉矶国际机场的经纬度。
❷ 东京市的一些信息:市名、年份、人口(单位:百万)、人口变化(单位:百分比) 和面积(单位:平方千米)。
❸ 一个元组列表,元组的形式为 (country_code, passport_number)。
❹ 在迭代的过程中,passport 变量被绑定到每个元组上。
❺ % 格式运算符能被匹配到对应的元组元素上
❻ for 循环可以分别提取元组里的元素,也叫作拆包(unpacking)。因为元组中第二个 元素对我们没有什么用,所以它赋值给“_”占位符。
元组拆包
拆包让元组可以完美地被当作记录来使用。
上一个例子中,我们把元组 (‘Tokyo’, 2003, 32450, 0.66, 8014) 里的元素分别赋值 给变量 city、year、pop、chg 和 area,而这所有的赋值我们只用一行声明就写完了。 同样,在后面一行中,一个 % 运算符就把 passport 元组里的元素对应到了 print 函数 的格式字符串空档中。这两个都是对元组拆包的应用。
元组拆包可以应用到任何可迭代对象上,唯一的硬性要求是,被可迭代对象中 的元素数量必须要跟接受这些元素的元组的空档数一致。除非我们用 * 来表示忽略多余的元素
最好辨认的元组拆包形式就是平行赋值,也就是说把一个可迭代对象里的元素,一并赋 值到由对应的变量组成的元组中。就像下面这段代码:
>>> lax_coordinates = (33.9425, -118.408056)
>>> latitude, longitude = lax_coordinates # 元组拆包
>>> latitude
33.9425
>>> longitude
-118.408056
另外一个很优雅的写法当属不使用中间变量交换两个变量的值:b, a = a, b
还可以用 * 运算符把一个可迭代对象拆开作为函数的参数:
>>> divmod(20, 8) # 20/8
(2, 4) # 商和余数为2和4
>>> t = (20, 8)
>>> divmod(*t)
(2, 4)
>>> quotient, remainder = divmod(*t)
>>> quotient, remainder
(2, 4)
下面是另一个例子,这里元组拆包的用法则是让一个函数可以用元组的形式返回多个值, 然后调用函数的代码就能轻松地接受这些返回值。比如 os.path.split() 函数就会返回以路径和最后一个文件名组成的元组 (path, last_part):
>>> import os
>>> _, filename = os.path.split('/home/luciano/.ssh/idrsa.pub')
>>> filename
'idrsa.pub'
在进行拆包的时候,我们不总是对元组里所有的数据都感兴趣,_ 占位符能帮助处理这种 情况,上面这段代码也展示了它的用法。
除此之外,在元组拆包中使用 * 也可以帮助我们把注意力集中在元组的部分元素上。
用*来处理剩下的元素
在 Python 中,函数用 *args 来获取不确定数量的参数算是一种经典写法了。 于是 Python 3 里,这个概念被扩展到了平行赋值中:
>>> a, b, *rest = range(5)
>>> a, b, rest
(0, 1, [2, 3, 4])
>>> a, b, *rest = range(3)
>>> a, b, rest
(0, 1, [2])
>>> a, b, *rest = range(2)
>>> a, b, rest
(0, 1, [])
在平行赋值中,* 前缀只能用在一个变量名前面,但是这个变量可以出现在赋值表达式的 任意位置:
>>> a, *body, c, d = range(5)
>>> a, body, c, d
(0, [1, 2], 3, 4)
>>> *head, b, c, d = range(5)
>>> head, b, c, d
([0, 1], 2, 3, 4)
另外元组拆包还有个强大的功能,那就是可以应用在嵌套结构中。
嵌套元组拆包
接受表达式的元组可以是嵌套式的,例如 (a, b, (c, d))。只要这个接受元组的嵌套结 构符合表达式本身的嵌套结构,Python 就可以作出正确的对应。如下例所示:
metro_areas = [
('Tokyo','JP',36.933,(35.689722,139.691667)), #每个元组内有 4 个元素,其中最后一个元素是一对坐标。
('Delhi NCR', 'IN', 21.935, (28.613889, 77.208889)),
('Mexico City', 'MX', 20.142, (19.433333, -99.133333)),
('New York-Newark', 'US', 20.104, (40.808611, -74.020386)),
('Sao Paulo', 'BR', 19.649, (-23.547778, -46.635833)),
]
print('{:15} | {:^9} | {:^9}'.format('', 'lat.', 'long.'))
fmt = '{:15} | {:9.4f} | {:9.4f}'
# 我们把输入元组的最后一个元素拆包到由变量构成的元组里,这样就获取了坐标。
for name, cc, pop, (latitude, longitude) in metro_areas:
if longitude <= 0: # if longitude <= 0: 这个条件判断把输出限制在西半球的城市。
print(fmt.format(name, latitude, longitude))
输出如下:
| lat. | long.
Mexico City | 19.4333 | -99.1333
New York-Newark | 40.8086 | -74.0204
Sao Paul | -23.5478 | -46.6358
元组已经设计得很好用了,但作为记录来用的话,还是少了一个功能:我们时常会需要给记录中的字段命名。namedtuple 函数的出现帮我们解决了这个问题。
具名元组
collections.namedtuple 是一个工厂函数,它可以用来构建一个带字段名的元组和一 个有名字的类——这个带名字的类对调试程序有很大帮助。
用 namedtuple 构建的类的实例所消耗的内存跟元组是一样的,因为字段名都 被存在对应的类里面。这个实例跟普通的对象实例比起来也要小一些,因为 Python 不会用 dict 来存放这些实例的属性。
如下例子所示,用具名元组来记录一个城市的信息:
>>> from collections import namedtuple
>>> City = namedtuple('City', 'name country population coordinates') ➊
>>> tokyo = City('Tokyo', 'JP', 36.933, (35.689722, 139.691667)) ➋
>>> tokyo
City(name='Tokyo', country='JP', population=36.933, coordinates=(35.689722, 139.691667))
>>> tokyo.population ➌
36.933
>>> tokyo.coordinates
(35.689722, 139.691667)
>>> tokyo[1]
'JP'
❶ 创建一个具名元组需要两个参数,一个是类名,另一个是类的各个字段的名字。后者 可以是由数个字符串组成的可迭代对象,或者是由空格分隔开的字段名组成的字符串。
❷ 存放在对应字段里的数据要以一串参数的形式传入到构造函数中(注意,元组的构造 函数却只接受单一的可迭代对象)。
❸ 你可以通过字段名或者位置来获取一个字段的信息。
除了从普通元组那里继承来的属性之外,具名元组还有一些自己专有的属性。
如下例展示了展示了几个最有用的:_fields
类属性、类方法 _make(iterable)
和实例方法 _asdict()
:
>>> City._fields ➊
('name', 'country', 'population', 'coordinates')
>>> LatLong = namedtuple('LatLong', 'lat long')
>>> delhi_data = ('Delhi NCR', 'IN', 21.935, LatLong(28.613889, 77.208889))
>>> delhi = City._make(delhi_data) ➋
>>> delhi._asdict() ➌
OrderedDict([('name', 'Delhi NCR'), ('country', 'IN'), ('population',
21.935), ('coordinates', LatLong(lat=28.613889, long=77.208889))])
>>> for key, value in delhi._asdict().items():
print(key + ':', value)
name: Delhi NCR
country: IN
population: 21.935
coordinates: LatLong(lat=28.613889, long=77.208889)
❶ _fields
属性是一个包含这个类所有字段名称的元组。
❷ 用_make()
通过接受一个可迭代对象来生成这个类的一个实例,它的作用跟 City(*delhi_data)
是一样的。
❸ _asdict()
把具名元组以 collections.OrderedDict
的形式返回,我们可以利用它 来把元组里的信息友好地呈现出来。 现在我们知道了,元组是一种很强大的可以当作记录来用的数据类型。它的第二个角色则 是充当一个不可变的列表。下面就来看看它的第二重功能。
作为不可变列表的元组
如果要把元组当作列表来用的话,最好先了解一下它们的相似度如何。在下表中可以清 楚地看到,除了跟增减元素相关的方法之外,元组支持列表的其他所有方法。还有一个例 外,元组没有 reversed 方法,但是这个方法只是个优化而 已,reversed(my_tuple) 这个用法在没有 reversed 的情况下也是合法的。
每个 Python 程序员都知道序列可以用 s[a:b] 的形式切片,但是关于切片,我还想说说它 的一些不太为人所知的方面。
切片
在 Python 里,像列表(list)、元组(tuple)和字符串(str)这类序列类型都支持切 片操作,但是实际上切片操作比人们所想象的要强大很多。
为什么切片和区间会忽略最后一个元素
在切片和区间操作里不包含区间范围的最后一个元素是 Python 的风格,这个习惯符合 Python、C 和其他语言里以 0 作为起始下标的传统。这样做带来的好处如下。
- 当只有最后一个位置信息时,我们也可以快速看出切片和区间里有几个元 素:range(3) 和 my_list[:3] 都返回 3 个元素。
- 当起止位置信息都可见时,我们可以快速计算出切片和区间的长度,用后一个数减去 第一个下标(stop - start)即可。
- 这样做也让我们可以利用任意一个下标来把序列分割成不重叠的两部分,只要写成 my_list[:x] 和 my_list[x:] 就可以了。
如下所示:
>>> l = [10, 20, 30, 40, 50, 60]
>>> l[:2] # 在下标2的地方分割
[10, 20]
>>> l[2:]
[30, 40, 50, 60]
>>> l[:3] # 在下标3的地方分割
[10, 20, 30]
>>> l[3:]
[40, 50, 60]
接下来进一步看看 Python 解释器是如何理解切片操作的。
对对象进行切片
一个众所周知的秘密是,我们还可以用 s[a:b:c]
的形式对 s 在 a 和 b 之间以 c 为间隔 取值。c 的值还可以为负,负值意味着反向取值。下面的 3 个例子更直观些:
>>> s = 'bicycle'
>>> s[::3]
'bye'
>>> s[::-1]
'elcycib'
>>> s[::-2]
'eccb'
a:b:c
这种用法只能作为索引或者下标用在 [] 中来返回一个切片对象:slice(a, b, c)
。
你还可以可以给切片命名,就像电子表格软件里给单元格区域取名字一样。
比如,要解析下例中所示的纯文本文件,这时使用有名字的切片比用硬编码的数字 区间要方便得多,注意示例里的 for 循环的可读性有多强。
>>> invoice = """
... 0.....6................................40........52...55........
... 1909 Pimoroni PiBrella $17.50 3 $52.50
... 1489 6mm Tactile Switch x20 $4.95 2 $9.90
... 1510 Panavise Jr. - PV-201 $28.00 1 $28.00
... 1601 PiTFT Mini Kit 320x240 $34.95 1 $34.95
... """
>>> SKU = slice(0, 6)
>>> DESCRIPTION = slice(6, 40)
>>> UNIT_PRICE = slice(40, 52)
>>> QUANTITY = slice(52, 55)
>>> ITEM_TOTAL = slice(55, None)
>>> line_items = invoice.split('\n')[2:]
>>> for item in line_items:
... print(item[UNIT_PRICE], item[DESCRIPTION])
...
$17.50 Pimoroni PiBrella
$4.95 6mm Tactile Switch x20
$28.00 Panavise Jr. - PV-201
$34.95 PiTFT Mini Kit 320x240
多维切片和省略
[] 运算符里还可以使用以逗号分开的多个索引或者是切片,外部库 NumPy 里就用到了这 个特性,二维的 numpy.ndarray 就可以用 a[i, j] 这种形式来获取,抑或是用 a[m:n, k:l] 的方式来得到二维切片。要正确处理这种 [] 运算符的话,对象的特殊方法 __getitem__
和 __setitem__
需要以元组的形式来接收 a[i, j]
中的索引。也就是说,如果要得到 a[i, j]
的值,Python 会调用 a.__getitem__((i, j))
。
Python 内置的序列类型都是一维的,因此它们只支持单一的索引,成对出现的索引是没有 用的。
除了用来提取序列里的内容,切片还可以用来就地修改可变序列,也就是说修改的时候不需要重新组建序列。
给切片赋值
如果把切片放在赋值语句的左边,或把它作为 del 操作的对象,我们就可以对序列进行 嫁接、切除或就地修改操作。通过下面这几个例子,你应该就能体会到这些操作的强大功 能:
>>> l = list(range(10))
>>> l
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
>>> l[2:5] = [20, 30] # 就地修改
>>> l
[0, 1, 20, 30, 5, 6, 7, 8, 9]
>>> del l[5:7] # 删除
>>> l
[0, 1, 20, 30, 5, 8, 9]
>>> l[3::2] = [11, 22] #修改
>>> l
[0, 1, 20, 11, 5, 22, 9]
# 如果赋值的对象是一个切片,那么赋值语句的右侧必须是个可迭代对象。即便只有单独一个值,也要把它转换成可迭代的序列。
>>> l[2:5] = 100
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: can only assign an iterable
>>> l[2:5] = [100]
>>> l
[0, 1, 100, 22, 9]