有时候因为列表实在是太方便了,所以 Python
程序员可能会过度使用它。
如果你只需要处理数字列表的话,数组可能是个更好的选择。比如,要存放 1000 万个浮点数的话,数组 array
的效率要高得多,因为数组在背后存的并不是 float
对象,而是数字的机器翻译,也就是字节表述。这一点就跟 C 语言中的数组一样。
再比如说,如果需要频繁对序列做先进先出的操作,deque
双端队列的速度应该会更快。
如果在你的代码里,包含操作(比如检查一个元素是否出现在一个集合中)的频率很高,用 set
会更合适。set
专为检查元素是否存在做过优化。但是它并不是序列,因为 set
是无序的。
一. 数组
如果我们需要一个只包含数字的列表,那么 array.array
比 list
更高效。数组支持所有跟可变序列有关的操作,包括 .pop
、.insert
和 .extend
。另外,数组还提供快速从文件读取和存入文件的方法,如 .frombytes
和 .tofile
。
Python 数组跟 C 语言数组一样精简。创建数组需要一个类型码,这个类型码用来表示在底层的 C 语言应该存放怎样的数据类型。比如 b
类型码代表的是有符号的字符 signed char
,因此 array('b')
创建出的数组就只能存放一个字节大小的整数,范围从 -128
到 127
,这样在序列很大的时候,我们能节省很多空间。而且 Python 不会允许你在数组里存放除指定类型之外的数据。
演示 1 一个浮点型数组的创建、存入文件和从文件读取的过程
利用一个可迭代对象来建立一个双精度浮点数组(类型码是 d
),这里我们用的可迭代对象是一个生成器表达式:
>> from array import array
>> from random import random
>> float_arr = array('d', (random() for i in range(10**7)))
>> len(float_arr)
10000000
>> float_arr[-1]
0.21932647587464515
将数组存入二进制文件 float_arr.bin
:
>> fp = open('float_arr.bin', 'wb')
>> float_arr.tofile(fp)
>> fp.close()
从二进制文件 float_arr.bin
中读取数组:
>> float_arr2 = array('d')
>> fp = open('float_arr.bin', 'rb')
>> float_arr2.fromfile(fp, 10**7)
>> fp.close()
>> len(float_arr2)
10000000
>> float_arr2[-1]
0.21932647587464515
>> float_arr == float_arr2
True
从上面的代码我们能得出结论:array.tofile
和 array.fromfile
用起来很简单。把这段代码跑一跑,你还会发现它的速度也很快。
注:用
array.fromfile
从一个二进制文件里读出 1000 万个双精度浮点数只需要 0.1 秒,这比从文本文件里读取的速度要快 60 倍,因为后者会使用内置的float
方法把每一行文字转换成浮点数。另外,使用
array.tofile
写入到二进制文件,比以每行一个浮点数的方式把所有数字写入到文本文件要快 7 倍。另外,1000 万个这样的数在二进制文件里只占用 80 000 000 个字节(每个浮点数占用 8 个字节,不需要任何额外空间),如果是文本文件的话,我们需要 181 515 739 个字节。
如果你总是跟数组打交道,却没有听过 memoryview
,那就太遗憾了。 下面就来谈谈 memoryview
~
二. 内存视图
memoryview
是一个内置类,它能让用户在不复制内容的情况下操作同一个数组的不同切片。其中,memoryview.cast
会把同一块内存里的内容打包成一个全新的 memoryview
对象给你。
演示 2 memoryview
可以对对象进行索引或者切片
>> import array
>> arr = array.array('h', list(range(-2, 3)))
>> mem = memoryview(arr)
>> len(mem)
5
>> mem.tolist()
[-2, -1, 0, 1, 2]
>> mem[-1]
2
>> mem[1:3]
对 mem
进行切片时,返回结果为一个子 memoryview
对象。另外,mem
相当于 arr
在内存中的表示形式,但是属于不同的对象:
>> assert mem == arr
>> assert mem is not arr
演示 3 memoryview.cast
把同一块内存里的内容打包成一个全新的 memoryview
对象:
>> mem_oct = mem.cast('B')
>> mem_oct.nbytes
10
>> mem_oct.itemsize
1
>> mem.nbytes
10
>> mem.itemsize
2
我们发现 mem
再转换为 unsigned char
类型的 mem_oct
之后,所占字节数保持一致;但每个元素所占的字节,却从原来的 2
变成了 1
。那想必 mem_oct
的元素个数必然变成了 10 个:
>> len(mem_oct)
10
>> mem_oct.tolist()
[254, 255, 255, 255, 0, 0, 1, 0, 2, 0]
这是因为,我们在创建数组时使用了 h
,即 signed short
,在计算机中每个元素使用 2 个字节来表示;而 cast
使用 B
,即 unsigned char
,在计算机中每个元素使用 1 个字节来表示。
以 -2 为例,mem_oct
中的前两个元素分别为 254
和 255
,表示 -2 的低八位和高八位,表示成二进制即 1111 1111 1111 1110
。我们可以反推一下,用 2 个字节表示的 -2 ,其原码为 1000 000 0000 0010
,对应的反码即 1111 1111 1111 1101
,反码加 1 即 1111 1111 1111 1110
。
注:为了将将符号位和数值域统一处理,同时加法和减法也可以统一处理,计算机系统中数值一律用补码来表示和存储。
对于正数:
原码 = 反码 = 补码
;而对于负数:需要先求反码,再计算原码。反码即将最高位的符号位以外的数,全部取反;补码需要继续将负数的反码进行加一操作。
创建 memv_oct
时 ,把 memv
里的内容转换成 B
类型, 表示无符号字符 0~255
。
此外,由于 mem_oct
和 arr
其实是同一块内存的不同表示,因此修改 mem_oct
中的元素,arr
中的值也将发生变化:
>> mem_oct[4] = 4
>> mem_oct.tolist()
[254, 255, 255, 255, 4, 0, 1, 0, 2, 0]
>> arr
array('h', [-2, -1, 4, 1, 2])
三. Numpy
Numpy 是一个非常高效的数据分析库,并且也是 Pandas 的基础,Pandas 数据分析库以 Numpy 为基础,提供了高效的且能存储非数值类数据的数组类型。下面,我们来简单感受一下 Numpy 的强大~
演示 4 对 numpy.ndarray
的行和列进行基本的操作
>> import numpy as np
>> arr = np.arange(6)
>> arr
array([0, 1, 2, 3, 4, 5])
>> type(arr)
numpy.ndarray
>> arr.shape
(6,)
>> arr.shape = 2,3
>> arr
array([[0, 1, 2],
[3, 4, 5]])
>> arr[1]
array([3, 4, 5])
>> arr[1, 1]
4
>> arr[:, 2]
array([2, 5])
>> arr.transpose()
array([[0, 3],
[1, 4],
[2, 5]])
演示 5 Numpy 也可以对 numpy.ndarray
中的元素进行抽象的读取、保存和其它操作
构建一个包含 100 万个浮点数的 Numpy 数组:
>> floats = np.random.rand(1000000)
>> len(floats)
1000000
>> floats.dtype
dtype('float64')
把数组里的每个数都乘以 0.5,然后再看看最后 3 个数,你会发现整个过程 numpy操作 100 万个数速度非常快:
>> floats[-3:]
array([0.27139327, 0.07056907, 0.40621752])
>> floats = floats * 5
>> floats
array([4.07371211, 3.23691516, 3.33498591, ..., 1.35696635, 0.35284534,
2.03108762])
>> floats[-3:]
array([1.35696635, 0.35284534, 2.03108762])
把每个元素都除以 3,可以看到处理 100 万个浮点数所需的时间还不足 2.2 毫秒:
>> from time import perf_counter as pc
>> t0 = pc()
>> floats /= 3
>> pc() - t0
0.002136999999947875
把数组存入后缀为 .npy 的二进制文件。接着将上面的数据导入到另外一个数组里,这次 load 方法利用了一种叫作内存映射的机制,它让我们在内存不足的情况下仍然可以对数组做切片:
>> np.save('floats-10M', floats)
>> floats2 = np.load('floats-10M.npy', 'r+')
>> floats2 *= 6
>> floats2[-3:]
memmap([2.7139327 , 0.70569067, 4.06217524])
四. 双向队列和其它形式的队列
利用 .append
和 .pop
方法,我们可以把列表当作栈或者队列来用。比如,把 .append
和 .pop(0)
合起来用,就能模拟栈的 先进先出 的特点。
但是删除列表的第一个元素,或是在第一个元素之前添加一个元素,这类操作是很耗时的,因为这些操作会牵扯到移动列表里的所有元素。
collections.deque
类(双向队列)是一个线程安全、可以快速从两端添加或者删除元素的数据类型。
演示 6 使用双向队列
>> from collections import deque
>> dq = deque(range(10), maxlen=10)
>> dq
deque([0, 1, 2, 3, 4, 5, 6, 7, 8, 9])
>> dq.append(10)
>> dq
deque([1, 2, 3, 4, 5, 6, 7, 8, 9, 10])
>> dq.appendleft(0)
>> dq
deque([0, 1, 2, 3, 4, 5, 6, 7, 8, 9])
>> dq.extend([10, 11, 12])
>> dq
deque([3, 4, 5, 6, 7, 8, 9, 10, 11, 12])
>> dq.extendleft([2, 1, 0])
>> dq
deque([0, 1, 2, 3, 4, 5, 6, 7, 8, 9])
双向队列实现了大部分列表所拥有的方法,也有一些额外的符合自身设计的方法,比如 popleft
。
但是为了实现这些方法,双向队列也付出了一些代价,从队列中间删除元素的操作会慢一些,因为它只对在头尾的操作进行了优化。
append
和 popleft
都是原子操作,也就说是 deque
可以在多线程程序中安全地当作先进先出的栈使用,而使用者不需要担心资源锁的问题。
关于序列的总结
Python 序列类型最常见的分类就是 可变和不可变序列。但另外一种分类方式也很有用,那就是把它们分为 扁平序列和容器序列。前者的体积更小、速度更快而且用起来更简单,但是它只能保存一些原子性的数据, 比如数字、字符和字节。
列表推导和生成器表达式则提供了灵活构建和初始化序列的方式。
元组在 Python 里扮演了两个角色,它既可以用作无名称的字段的记录,又可以看作不可变的列表。
注:具名元组就像普通元组一样,具名元组的实例也很节省空间。同时提供了方便地通过名字来获取元组各个字段信息的方式,另外还有个实用的
._asdict()
方法来把记录变成OrderedDict
类型。
Python 里最受欢迎的一个语言特性就是序列切片,用户自定义的序列类型也可以选择支持 Numpy 中的多维切片和省略(...)。另外,对切片赋值是修改一个可变序列的捷径。
重复拼接 seq * n
在正确使用的前提下,能让我们方便地初始化含有不可变元素的多维列表。增量赋值 +=
和 *=
会区别对待可变和不可变序列。在遇到不可变序列时,这两个操作会在背后生成新的序列。但如果被赋值的对象是可变的,那么这个序列会就地修改,这也取决于序列本身对特殊方法的实现。
序列的 sort
方法和内置的 sorted
函数虽然很灵活,但是用起来都不难。这两个方法都比较灵活,是因为它们都接受一个函数作为可选参数来指定排序算法如何比较大小,这个参数就是 key
参数。
注:
key
还可以被用在min
和max
函数里。
如果在插入新元素的同时还想保持有序序列的顺序,那么需要用到 bisect.insort
;bisect.bisect
的作用则是快速查找。
collections.deque
具有灵活多用和线程安全的特性。