Python中常用的内建序列类型主要有list
,str
和tuple
。实际上,在Python中序列可以指任何1一个可迭代对象(关于可迭代对象的概念请见Python中for循环运行机制探究以及可迭代对象、迭代器详解),只要该可迭代对象通过实现__getitem__()
方法支持使用索引方式访问元素,且实现__len__()
方法返回序列的长度。
尽管list
,str
和tuple
支持的操作十分类似,但实际上其内部的实现区别很大,鉴于后续的数据结构和算法学习将大量使用这三种内建序列类型,因此很有必要对这三个序列类型的底层原理有一个较为深刻的理解。
进一步地,如果想要了解如何自己手动实现一个与列表功能类似的类,并借此分析列表常见操作的时间复杂度,请见文章【数据结构与算法Python描述】Python列表实现原理深入探究及其常用操作时间复杂度分析。
一、计算机内部存储模型简介
1. 内存地址
为了能够准确的描述Python实现序列类型的方式,首先需要简单了解一下计算机的内存用以存储信息的方式:计算机以二进制位(bit
)来表达信息,最小的信息存储单位是字节(byte
),一个字节一般等于8位。
在计算机的内存中,以字节为单位的存储单元数量众多,为了能够快速地访问这些存储单元,计算机为这些内存单元进行了编号,这些编号即所谓的内存地址,因此可以通过如#2150
或#2152
来访问内存中的数据,且毫无疑问该基本操作的时间复杂度为
O
(
1
)
O(1)
O(1)。
2. 变量本质
在实际的计算机中,内存地址的编号一般很长,不便于人类记忆,因此一般的高级编程语言都支持给内存地址起一个别名,这个别名就是变量名,代表了内存地址,即变量名本质是内存地址的别名。
但是,对于Python需要特别注意的是,变量名虽然是内存地址的别名,但是对于语句a = 8
,变量名代表的内存地址处并不直接存放数据8,而是存放了数据8所在内存的地址,具体请见Python中变量赋值的本质——“引用”的概念。
3. 数组概念
对于一般的高级语言如Python,其很大的一部分功能就是维护一系列变量,进而对其进行相关操作,例如:我们可能希望一个电脑保存由26个英文字符组成的字母表。
当然,可以使用26个不同的变量来实现这个需求,但是如果可以使用一个变量名来表示一个字母表,该变量名代表了一段编号连续的内存地址,进而可结合索引值来访问字母表中对应的字母字符,这样可能更加简洁优雅,这种使用一段连续内存来表示一系列相同大小数据的方式即为数组。
二、字符串元组列表内存模型
1. 字符串的内存模型
实际上,在Python中,字符串就是典型的采用了上述数组内存模型的序列类型,一个Python的字符串使用2个字节表示一个Unicode字符,因此一个6字符的字符串如'SAMPLE'
在内存中以占用12个连续字节的方式存储:
上述的字符串即可称为一个6字符的数组,数组中的每个存储位置可称为单元,且可使用起始于0的整数索引来描述其在数组中的位置,例如:上述通过数组表示的字符串中,索引为4的单元存储了字符'L'
,该字符占用了内存地址#2154
和#2155
处的2个字节。
需要注意的是,数组模型的每一个单元占用相同数量的字节,这是访问数组中的单元具有 O ( 1 ) O(1) O(1)时间复杂度的基础。
2. 列表元组内存模型
在Python中,对于列表
list
和元组tuple
而言,解释器采用了所谓对象引用型数组(关于引用的概念请见Python中变量赋值的本质——“引用”的概念)数组(array of object references)的机制来表示二者的实例对象在内存中的模型。
上述概念较为晦涩,这里通过一个例子来对其进行解释:假设现在有一套医院的医疗信息系统,该系统需要记录病人所对应的床号,如果假设医院有200张床,且床的编号为从0到199,在Python中我们知道可以简单的用列表表示为:
[Rene, Joseph, Janet, Jonas, Helen, Virginia, ... ]
如果我们假设Python依然使用了和字符串一样的数组模型来表示上述列表,那么必然地,Python解释器必须要满足数组每个单元占用相同数量字节的要求。
问题是,列表的每一个元素都是字符串,而字符串天然地就有不同的长度。当然,解释器可以为每一个保存字符串的单元都分配足够的空间,使之可以保存最大长度的字符串(比如为数组的每个单元都分配100个字节),但是这样会产生很大的内存浪费。
为了避免上述内存浪费的问题,Python使用了如下图所示的对象引用型数组机制,即连续的数组内存空间中并不直接保存每个字符串,而是保存了每个字符串所在的内存地址,这是因为对于同一种位数(如:64位、32位)硬件而言,内存地址的长度一般都是一样的,如对于64位的机器,内存地址长度为8个字节。
Python中的列表和元组均采用了上述对象引用型数组的内存模型,因此基于上述分析易知:二者支持通过索引方式访问每个元素的时间复杂度也为 O ( 1 ) O(1) O(1)。
使用对象引用型数组的好处在于:更进一步地,如果上述医疗信息系统希望通过列表保存关于每个病人更加详尽的信息,可以考虑使用一个Patient
类的实例来实现,从上述列表的实现原理来看,这也同样适用,即列表中保存了每一个Patient
实例的引用。实际上,None
对象的引用同样可以作为列表的一个元素,来表示空置的床位。
3. 两种内存模型比较
针对Python中三种常见的序列类型
str
,list
和tuple
,上述分别介绍了两种内存模型,为后面讨论简单起见,后续将str
对应的内存模型称为紧凑型数组,list
和tuple
对应的内存模型称为引用型数组:
- 紧凑型数组:数组的单元中保存了实际的数据,如:字符串的每一个字符;
- 引用型数组:数组的单元中保存的是实际数据所处的内存地址。
关于紧凑型数组和引用型数组二者的优劣:
- 紧凑型数组在存储时比较节省内存,因为引用型数组需要额外保存每一个元素所在的内存地址;
- 紧凑型数组在计算时效率较高,因为数据以连续的方式存储在一片内存中,数据存取较为直接,而对于引用型数组,通过每个单元中的内存地址存取具体数据的过程还需额外由缓存等部分处理;
- 引用型数组比较通用,因为紧凑型数组的一条限制是,每一个单元所保存的元素大小必须要求一致。
4. 自定义类字符串序列
鉴于紧凑型数组序列的优势,Python为开发者提供了自定义紧凑型数组序列的模块array
。如:
from array import array
primes = array('i', [2, 3, 5, 7, 11, 13, 17, 19])
上述通过array
模块中的array
类即创建了一个紧凑型数组的列表,其中的每一个单元保存了一个素数:
上述的primes
变量所表示的对象所支持的各种方法和列表基本一致,只是需要注意的是:array
类的__init__()
方法还必需一个类型码(type code)作为第一个参数,该参数指明了保存在数组单元中的数据类型,例如:上述'i'
表示数组单元中将且仅将用于保存有符号的整数。
类型代码允许解释器可以准确地提前确定数组中的每个元素将占用多少个字节。对于array模块,其中常见的类型码如下表所示,该表的类型主要基于C语言(Python使用范围最广的一类解释器就是用C语言写的)的原生数据类型,虽然C语言的数据类型确切大小和系统相关,但典型的大小如下边所示:
类型代码 | Python数据类型 | C语言数据类型 | 内存大小 |
---|---|---|---|
'b' | int | signed char | 1 |
'B' | int | unsigned char | 1 |
'u' | Unicode character | Py_UNICODE | 2 |
'h' | int | signed short | 2 |
'H' | int | unsigned short | 2 |
'i' | int | signed int | 2 |
'I' | int | unsigned int | 2 |
'l' | int | signed long | 4 |
'L' | int | unsigned long | 4 |
'q' | int | signed long long | 8 |
'Q' | int | unsigned long long | 8 |
'f' | float | float | 4 |
'd' | float | double | 8 |
需要注意的是,array
模块不支持为用户自定义的数据类型(如Patient
类创建的病人实例)创建紧凑型数组,要实现这样的需求,需要使用更底层的模块ctypes
。
需要注意的是,虽然字典也支持
__getitem__()
和__len__()
方法,但是字典通常被视为映射而不是序列,因为字典的查找机制是通过任意不可变类型的键而不是整数。 ↩︎