Python中的list作为一个常用数据结构,在很多程序中被用来当做数组使用,可能很多人都觉得list无非就是一个动态数组,就像C++中的vector或者Go中的slice一样。但事实真的是这样的吗?
我们来思考一个简单的问题,Python中的list允许我们存储不同类型的数据,既然类型不同,那内存占用空间就就不同,不同大小的数据对象又是如何"存入"数组中呢?
比如下面的代码中,我们分别在数组中存储了一个字符串,一个整形,以及一个字典对象,假如是数组实现,则需要将数据存储在相邻的内存空间中,而索引访问就变成一个相当困难的事情了,毕竟我们无法猜测每个元素的大小,从而无法定位想要的元素位置。
>>> a = "hello"; b = 42; c = {}
>>> d = [a, b, c]
>>> d
['hello', 42, {}]
是否是通过链表结构实现的呢? 毕竟链表支持动态的调整,借助于指针可以引用不同类型的数据,比如下面的图示中的链表结构。但是这样的话使用下标索引数据的时候,需要依赖于遍历的方式查找,O(n)的时间复杂度访问效率实在是太低。
同时使用链表的开销也较大,每个数据项除了维护本地数据指针外,还要维护一个next指针,因此还要额外分配8字节数据,同时链表分散性使其无法像数组一样利用CPU的缓存来高效的执行数据读写。
本文讨论的是CPython的实现,对于其他版本Python在list上实现存在区别,请注意区分。
1. list实现
Python中的list数据结构实现要更比想象的更简单一些,保留了数组内存连续性访问的方式,只是每个节点存储的不是实际数据,而是对应数据的指针,以一个指针数组的形式来进行存储和访问数据项,对应的结构如下面图示:
实现的细节可以从其Python的源码中找到, 定义如下:
typedef struct {
PyObject_VAR_HEAD
PyObject **ob_item;
Py_ssize_t allocated;
} PyListObject;
内部list的实现的是一个C结构体,该结构体中的ob_item是一个指针数组,存储了所有对象的指针数据,allocated是已分配内存的数量, PyObject_VAR_HEAD是一个宏扩展包含了更多扩展属性用于管理数组,比如引用计数以及数组大小等内容。
2. 动态数组
既然是一个动态数组,则必然会面临一个问题,如何进行容量的管理,大部分的程序语言对于此类结构使用动态调整策略,也就是当存储容量达到一定阈值的时候,扩展容量,当存储容量低于一定的阈值的时候,缩减容量。
道理很简单,但实施起来可没那么容易,什么时候扩容,扩多少,什么时候执行回收,每次又要回收多少空闲容量,这些都是在实现过程中需要明确的问题。
对于Python list的动态调整规则程序中定义如下, 当追加数据容量已满的时候,通过下面的方式计算再次分配的空间大小,创建新的数组,并将所有数据复制到新的数组中。这是一种相对数据增速较慢的策略,回收的时候则当容量空闲一半的时候执行策略,获取新的缩减后容量大小。
new_allocated = (newsize >> 3) + (newsize < 9 ? 3 : 6);
new_allocated += newsize
// 0, 4, 8, 16, 25, 35, 46, 58, 72, 88, …
假如我们使用一种最简单的策略:超出容量加倍,低于一半容量减倍。这种策略会有什么问题呢?设想一下当我们在容量已满的时候进行一次插入,随即删除该元素,交替执行多次,那数组数据岂不是会不断的被整体复制和回收,已经无性能可言了。
3. Append元素
接下来,我们来看下list数据结构的几个常见操作。首先是在list上执行append的操作, 该函数将元素添加到list的尾部。注意这里是指针数据被追加到尾部,而不是实际元素。
a = []
a.append("hello")
对于一个空的list,此时数组的大小为0,为了能够插入元素,我们需要对数组进行扩容,按照上面的计算公式进行调整大小。比如这时候只有一个元素,那么newsize = 1, 计算的new_allocated = 3 + 1 = 4 , 成功插入元素后,直到插入第五元素之前我们都不需要重新分配新的空间。
我们尝试继续添加更多的元素到列表中,当我们插入元素"abc"的时候,其内部数组大小不足以容纳该元素,执行新一轮动态扩容,此时newsize = 5 , new_allocated = 3 + 5 = 8
>>> a.append(42)
>>> a.append({})
>>> a.append(3.14)
>>> a.append("abc")
>>> a
['hello', 42, {}, 3.14, 'abc']
执行插入后的数据存储空间分布如下图所示:
4. Insert元素
insert函数接收两个参数,第一个是指定插入的位置,第二个为元素对象。中间插入会导致该位置后面的元素进行移位操作,由于是存储的指针因此实际的元素不需要进行位移,只需要位移其指针即可。
>>> a.insert(1, "world")
>>> a
['hello', 'world', 42, {}, 3.14, 'abc']
首先判断是否插入后元素需要进行数组的动态调整,由于这里我们还有三个空余位置,因此直接执行插入操作。
插入元素为一个字符串对象,创建该字符串并获得其指针(Ptr5), 将其存入索引为1的数组位置中,并将其余后续元素分别移动一个位置即可,insert函数调用完成。
5. Pop元素
执行pop操作的时候,将删除尾部元素,同时观察其容量是否需要进行缩减调整,如果此时元素的使用率低于一半,则进行空闲容量的回收。
>>> a.pop()
'abc'
>>> a
['hello', 'world', 42, {}, 3.14]
末尾位置的元素被回收,指针清空,这时候长度为5,容量为8,因此不需要执行任何的回收策略。当我们继续执行两次pop使其长度变为3后,此时使用量低于了一半的容量,需要执行回收策略。回收的方式同样是利用上面的公式进行处理,比如这里新的大小为3,则返回容量大小为3+3 = 6 ,并非回收全部的空闲空间。
6. Remove元素
remove函数会指定删除的元素,而该元素可以在列表中的任意位置。因此每次执行remove都必须先依次遍历数据项,进行匹配,直到找到对应的元素位置。执行删除可能会导致部分元素的迁移。Remove操作的整体时间复杂度为O(n)。
>>> a
['hello', 'world', 42, {}, 3.14]
>>> a.remove(42)
>>> a
['hello', 'world', {}, 3.14]
删除过程同样需要进行判断是否需要执行容量调整。删除结束后计算容量的使用率是否低于一半,从而决定是否执行调整。
其实对于Python列表这种数据结构的动态调整,在其他语言中也都存在,只是大家可能在日常使用中并没有意识到,了解了动态调整规则,我们可以通过比如手动分配足够的空间,来减少其动态分配带来的迁移成本,使得程序运行的更高效。
另外如果事先知道存储在列表中的数据类型都相同,比如都是整形或者字符等类型,可以考虑使用arrays库,或者numpy库,两者都提供更直接的数组内存存储模型,而不是上面的指针引用模型,因此在访问和存储效率上面会更高效一些。