【数据结构与算法Python描述】——Python列表实现原理探究及常用操作时间复杂度分析

由文章【数据结构与算法Python描述】——字符串、元组、列表内存模型简介中的讨论可知,对于Python的strlisttuple三种序列类型,不论连续内存空间是直接存储对象数据(如:str)还是对象数据的引用(如:listtuple),连续内存的大小都需要在序列创建时根据计划保存的元素数量就指定。

然而,Python的解释器内部一定针对列表的实现做了特殊处理,否则,如果后续希望使用append()extend()方法向列表中添加元素就可能会有问题,因为系统可能已经将列表序列临近的内存空间分配用于存储其他数据了,这对于字符串或元组则不会有问题,因为二者均为不可变类型,这意味着二者也不用支持类似列表中方法来实现容量的增长。

针对上述疑问,本文将探究Python中列表的实现原理,以及列表支持的常见操作如append()insert()等的时间复杂度。

一、使用动态数组实现列表

1. 动态数组概念引入

实际上,Python底层实现列表的算法为动态数组,通过该算法实现的列表特点为:

  • 列表实例的底层维护了一个容量大于当前列表长度的数组,如:开发者可能创建的是一个含有5个元素的列表,但实际上支持列表的数组容量实际为8;
  • 当数组容量已满时,系统会先创建一个新的、更大的数组,然后使用容量已满的旧数组中的元素来初始化新数组,最后旧数组会被解释器垃圾回收。

2. 验证列表实现策略

为了验证列表底层的确是基于动态数组实现,现使用下列代码。需要说明的是,下列代码中使用的getsizeof(object)方法来自sys模块,其功能是以字节为单位返回对象占用的内存大小,但对于采用引用型数组的列表而言,该方法的特殊性在于:只会返回列表底层数组以及列表对象实例属性的字节数,而不会考虑数组每一个元素所引用具体数据对象的字节数。

import sys
lst = list()
for each in range(27):
    lst_length = len(lst)
    lst_size = sys.getsizeof(lst)
    print('列表长度:{0:2d}; 占用字节大小:{1:4d}'.format(lst_length, lst_size))
    lst.append(None)

上述代码的执行结果为:

列表长度: 0; 占用字节大小: 56
列表长度: 1; 占用字节大小: 88
列表长度: 2; 占用字节大小: 88
列表长度: 3; 占用字节大小: 88
列表长度: 4; 占用字节大小: 88
列表长度: 5; 占用字节大小: 120
列表长度: 6; 占用字节大小: 120
列表长度: 7; 占用字节大小: 120
列表长度: 8; 占用字节大小: 120
列表长度: 9; 占用字节大小: 184
列表长度:10; 占用字节大小: 184
列表长度:11; 占用字节大小: 184
列表长度:12; 占用字节大小: 184
列表长度:13; 占用字节大小: 184
列表长度:14; 占用字节大小: 184
列表长度:15; 占用字节大小: 184
列表长度:16; 占用字节大小: 184
列表长度:17; 占用字节大小: 256
列表长度:18; 占用字节大小: 256
列表长度:19; 占用字节大小: 256
列表长度:20; 占用字节大小: 256
列表长度:21; 占用字节大小: 256
列表长度:22; 占用字节大小: 256
列表长度:23; 占用字节大小: 256
列表长度:24; 占用字节大小: 256
列表长度:25; 占用字节大小: 256
列表长度:26; 占用字节大小: 336

分析上述代码的执行结果:

  • 由第一行可知,即使列表长度为0时,该列表对象就已经占用了一定的字节数(在笔者的系统上为56个字节),实际上这是因为每一个Python中的对象都会保存一些状态信息,如:
    • 表明创建该对象的类的引用;
    • 表明当前列表元素个数的长度_array_length
    • 表明当前列表底层数组的容量_array_capacity
    • 代表当前列表底层数组的引用_array
  • 由第二行可知,当插入第一个元素时,底层数组的容量增长了32个字节,在本64位的机器上(即内存地址占8个字节),这意味着此时列表底层的数组可保存4个对象的引用,这和插入第2,3,4个元素后并未见容量变化这一现象是一致的;
  • 由第六行可知,插入第5个元素后,底层数组的容量又增长了32个字节,从而使得其又可以多存储4个对象的引用;
  • 后续底层数组的容量继续增长,区别是增长得更快了,即从单次增长32个字节到64个字节,再到72个字节,进而到80个字节。

3. 动态数组算法实现

尽管Python的list类基于动态数组已经提供了高度优化的实现,但通过实际实现一个类似的类,对于开发者大有裨益。

为了实现类似list的类,关于在于如何“扩充”底层数组 A A 的容量。实际上,当底层数组已满,可采用下列算法实现底层数组容量的“扩充”:

  1. 新分配一个容量更大的底层数组 B B ,如下图步骤(a)所示;
  2. i = 0 , , n 1 i=0, \cdot\cdot\cdot, n-1 时,设置 B [ i ] = A [ i ] B[i]=A[i] ,其中 n n A A 中元素个数,如下图步骤(b)所示;
  3. 设置 A = B A=B ,则后续底层数组 B B 将承担对象数据引用的存储任务,如下图步骤(c)所示;
  4. 至此,新的元素将会被插入更大容量的新底层数组中。

在这里插入图片描述

上述算法的Python代码实现如下所示:

import ctypes


class DynamicArray:
    """Python list类的简化版本"""

    def __init__(self):
        """初始化方法,用于创建空数组"""
        self._array_length = 0  # 实际元素数量
        self._array_capacity = 1  # 底层数组容量
        self._array = self._create_array(self._array_capacity)  # 底层数组引用

    def __len__(self):
        """返回数组已存储数据的数量"""
        return self._array_length

    def __getitem__(self, idx):
        """返回索引为idx的元素"""
        if not 0 <= idx < self._array_length:
            raise IndexError('索引越界!')
        return self._array[idx]

    @property
    def capacity(self):
        """返回动态数组容量"""
        return self._array_capacity

    def append(self, obj):
        """向数组尾部追加对象数据"""
        if self._array_length == self._array_capacity:  # 如果数组容量已满
            self._resize(2 * self._array_capacity)  # 底层数组容量翻倍
        self._array[self._array_length] = obj
        self._array_length += 1

    def _resize(self, capacity):  # 私有工具方法
        """将数组容量调整为capacity"""
        resized_array = self._create_array(capacity)  # 新的容量更大底层数组
        for idx in range(self._array_length):
            resized_array[idx] = self._array[idx]  # 将旧数组元素拷贝至新数组
        self._array = resized_array  # 使用新的数组
        self._array_capacity = capacity

    def _create_array(self, _array_capacity):  # 私有工具方法
        """返回容量为_array_capacity的新数组"""
        return (_array_capacity * ctypes.py_object)()  # 创建并返回新的数组


def main():
    dyn_arr = DynamicArray()
    for each in range(19):
        dyn_arr_length = len(dyn_arr)
        dyn_arr_capacity = dyn_arr.capacity
        print('列表长度:{0:2d}; 数组当前容量:{1:4d}'.format(dyn_arr_length, dyn_arr_capacity))
        dyn_arr.append(None)


if __name__ == '__main__':
    main()

上述代码的运行结果为:

列表长度: 0; 数组当前容量: 1
列表长度: 1; 数组当前容量: 1
列表长度: 2; 数组当前容量: 2
列表长度: 3; 数组当前容量: 4
列表长度: 4; 数组当前容量: 4
列表长度: 5; 数组当前容量: 8
列表长度: 6; 数组当前容量: 8
列表长度: 7; 数组当前容量: 8
列表长度: 8; 数组当前容量: 8
列表长度: 9; 数组当前容量: 16
列表长度:10; 数组当前容量: 16
列表长度:11; 数组当前容量: 16
列表长度:12; 数组当前容量: 16
列表长度:13; 数组当前容量: 16
列表长度:14; 数组当前容量: 16
列表长度:15; 数组当前容量: 16
列表长度:16; 数组当前容量: 16
列表长度:17; 数组当前容量: 32
列表长度:18; 数组当前容量: 32

分析算法实现的上述运行结果,可知底层数组的容量的确是在每次旧数组已满的情况下翻倍增长。

需要注意的是:

  • 由于需要遵循封装的思想,即使用者无需了解DynamicArray底层的实现原理,所以诸如底层数组扩容方法_resize(),创建新的底层数组方法_create_array()均为私有方法;
  • 由于需要仿照list类使得DynamicArray支持len()测长度,支持通过非负整数索引访问元素,故分别实现了__len__()__getitem__()方法。

二、摊销法分析时间复杂度

在这里插入图片描述

三、列表常见操作时间复杂度

1. 非修改类操作

操作 时间复杂度
len(data) O ( 1 ) O(1)
data[j] O ( 1 ) O(1)
data.count(value) O ( n ) O(n)
data.index(value) O ( k + 1 ) O(k+1)
value in data O ( k + 1 ) O(k+1)
data1 == data2 O ( k + 1 ) O(k+1)
data[j:k] O ( k j + 1 ) O(k-j+1)
data1 + data2 O ( n 1 + n 2 ) O({n_1}+{n_2})
c * data O ( c n ) O(cn)

2. 修改类操作

操作 时间复杂度
data[j] = value O ( 1 ) O(1)
data.append(value) O ( 1 ) O(1) 1
data.insert(k, value) O ( n k + 1 ) O(n−k + 1) 1
data.pop() O ( 1 ) O(1) 1
data.pop(k)
del data[k]
O ( n k ) O(n-k) 1
data.remove(value) O ( n ) O(n) 1
data1.extend(data2)
data1 += data2
O ( n 2 ) O(n_2) 1
data.reverse() O ( n ) O(n)
data.sort() O ( n l o g n ) O(nlogn)

2.1 添加元素

在这里插入图片描述

def insert(self, position, value):
    """
    在指定位置插入值,并将后续值向右平移
    :param position: 指定位置
    :param value: 待插入的值
    :return: None
    """
    if self._array_length == self._array_capacity:  # 当前底层数组容量已满
        self._resize(2 * self._array_capacity)  # 创建容量翻倍的底层数组
    for i in range(self._array_length, position, -1):  # 从最右边的元素开始移动
        self._array[i] = self._array[i - 1]
    self._array[position] = value  # 将value插入指定位置
    self._array_length += 1

2.2 删除元素

a. pop()

Python的list类提供了若干个从列表中删除元素的方式,其中调用pop()可以将列表最后一个元素删除。这种删除方式最高效,因为所有其他元素的位置都保持不变,显然这是 O ( 1 ) O(1) 时间复杂度的操作,但需要注意的是该复杂度是经摊销后的,因为Python解释器可能会缩小底层数组的容量以节省内存。

该方法还可以接收一个非负整数作为参数,调用pop(k)会删除索引为 k k 的元素值,然后将该元素所有右边的值左移一个单元(如下图所示),这种形式的方法调用,其时间复杂度为 O ( n k ) O(n-k) ,因为左移的操作量取决于 k k ,这也意味着pop(0)的效率最低。

在这里插入图片描述

b. remove()

Python的list类还提供了另外一个删除用操作remove(value),该方法可以直接指定想要删除的值(而非通过索引先找到value再对其删除),该方法会删除其找到的第一个value值,如果列表中不存在value,则抛出ValueError异常,基于此,下面是算法的Python实现。

值得注意的是,对于任何value值,该方法的时间复杂度都是 O ( n ) O(n) ,因为:

  • 如果列表中存在value,则对于value值,该方法的调用都分为两部分:
    • 从左至右找到位于 k k 处的value,复杂度为 O ( k ) O(k)
    • 将位置 k k 右边所有元素向左移动一个单元,复杂度为 O ( n k + 1 ) O(n-k+1)
  • 如果列表中不存在value,则算法依然要遍历列表的所有元素。
def remove(self, value):
    """
	删除左起第一个出现的value,如不存在则抛出ValueError异常
	:param value: 待删除的值
    :return: None
    """
    for i in range(self._array_length):
        if self._array[i] == value:  # 在动态数组中找到了待删除的value
            for j in range(i, self._array_length - 1):  # 将value右边每个元素左移一个位置
                self._array[j] = self._array[j+1]
            self._array[self._array_length - 1] = None  # 协助进行垃圾回收
            self._array_length -= 1
            return 
    raise ValueError('不存在指定要删除的', value)

2.3 扩充列表

a. extend()

2.4 创建列表


  1. 经摊销后的时间复杂度。 ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎

猜你喜欢

转载自blog.csdn.net/weixin_37780776/article/details/107896229