Python 序列:列表 (list),元组(tuple),字符串(str)深入分析(包括扩容和摊销)。

1. 概述

Python 中的序列类:列表 (list),元组(tuple),字符串(str)最主要的共性:支持下标访问序列元素,例如 list[1], tuple[0], str[1]。每个类都是使用数组这种低层次的概念来表示序列。

2. 数组

2.1 存储机制

计算机是以字节(1 字节 = 8 位)为单位存储和访问数据的,并且存储器的任一单元被存储或检索的运行时间为 O(1)。
数组:一组相关变量能够一个接一个地存储在计算机存储器的一块连续的区域内。
数组的每个单元必须占据相同数量的字节!Why:这样可以允许使用索引值在常量时间内访问数组的任一单元!

地址 = 起始地址 + 索引 * 单元大小

例如:

  • 起始地址为 #2100,每个单元为 2 字节,那么 0 索引的元素地址为:
    2100 + 0 * 2 = 2100
  • 起始地址为 #2100,每个单元为 2 字节,那么 2 索引的元素地址为:
    2100 + 2 * 2 = 2104

这个时候有个问题了:例如我们的列表元素是这样的呢:

a_list = ['James', 'Kobe', 'Jordan', 'Wade']

在 Python 中,每个字符都用 Unicode 字符集表示,即一个字符需要 2 个字节,那么我们 a_list 的每个元素都是一个字符串,包含不同数量的字符,那么我们每个单元的大小应该如何设置啊。
一种 Naive 的方法:单元大小为最大元素(包括未来添加的元素)的大小。这显然太浪费空间了,比如有的元素就是一个长度为 1 的字符串(即,字符)。

Python 使用数组内部存储机制,即对象引用:数组每个元素存储的是内存地址,这些地址指向数组的元素。在我们上面的例子中,a_list 的第一个元素实际上不是 ‘James’,而是这个字符串对象所在的地址,由于每个地址的大小都是一样的,这样就保证了上面的要求。这样还有一个好处就是每个对象不一定是固有的数据类型,还可以自己定义的类对象。这里可以加深浅拷贝和深拷贝的理解 Python:赋值,浅拷贝(copy)和深拷贝(deepcopy)

具体到数组实现的序列类中:列表 (list),元组(tuple),字符串(str)

  • 列表 (list),元组(tuple)是上面说的对象引用的方式,因为每个元素可以是任意对象。
  • 字符串(str)则是一开始那种直接的方式,称为紧凑数组,因为字符串的每个元素都是字符,是固定的。(一个地址 64 位,占 8 字节,而一个字符只需要 2 字节,更不用说除了数组本来的内存之外,还有地址指向对象所占用的内存,直接存放字符可以节省空间)

如果你可以确定你的 list 和 tuple 每个元素都是简单的整型,浮点型,字符型,那你可以使用 array 模块里面的紧凑数组来节约内存。

2.2 动态数组

学过 C 语言都知道,C 语言中数组一般的初始化形式是需要指定大小的,但是 Python 则不需要。
在元组(tuple),字符串(str)似乎没有什么问题,这是不可变对象,如果要添加则需要新建一个对象(申请新的空间)然后变量重新指向。但是如果是可变对象 list 呢?
我们考虑这种情形:假如我们 list 大小为 8 个字节:#2100 ~ #2107,我们添加元素的时候肯定不可以简单的使用 #2108 位置,因为可能它已经被使用了,那咋办呢。
Python 的列表关联着一个底层数组,并且这个数组比列表的长度更长。例如用户创建一个长为 5 的列表,其实实际上关联的数组长度可能为 8。这样子添加元素似乎就可以了。但是预留的也是有限的啊,如果多次添加之后,预留的也没了,那怎么办啊。
列表类向系统请求一个新的更大的数组,并用原列表的已有元素初始化该数组,使其前面部分和原来的小数组一样,并且回收原来的数据。

import sys

data = []


def init_n_list(n):
    for k in range(n):
        a = len(data)
        b = sys.getsizeof(data)
        print('Length: {0:3d}; Size in bytes: {1:4d}'.format(a, b))
        data.append(None)


init_n_list(20)

结果如下:
在这里插入图片描述
我们发现:

  1. data 的大小并不是和长度一起线性增加的。这说明了确实是有预留的。
  2. 每次获取新的数组的时候,增加量是不同的。比如 4 - 5,新增了 32 字节,8 - 9 新增了 64 字节,16 - 17 新增了 64 字节。
  3. data 长为 0 的时候就已经占有空间了,这是因为需要自身维护一些变量,比如这个数组的最大容量,数组中元素的长度等。
  4. 我们看 4 - 5,新增了 32 字节,然后到 8 又新增了字节。可见一个元素占 8 个字节。这是由于列表引用结构,存的是一个 64 位的地址。

2.3 成倍增长

这时候又有一个问题,每次申请的新数组到底多大呢?
答案是成倍增长,Why,为什么不是固定一个增加量呢?

假设初始数组为空,进行 n 个 append 操作:

  1. 每次调整数组大小时为固定增量 c,那么需要申请 m = n/c 次(向上取整),第一次需要 c 的操作时间(原数组赋值过来),第二次需要 2c 次,所以总的时间为:
    ∑ 1 m c i = c ∑ 1 m i = c m ( m + 1 ) / 2 \sum_{1}^{m} ci = c \sum_{1}^{m}i =cm(m + 1)/2 1mci=c1mi=cm(m+1)/2
    把 m = n/c 次(向上取整)可以知道时间复杂度为 n * n,分到 n 个 append 操作,每次 append 操作的时间复杂度为 n
  2. 每次调整数组大小时为原来的两倍,那么需要申请 m = logn - 1次(向上取整),第一次需要 2^0 = 1 次,第二次需要 2^1 = 2 次,所以总的时间为:
    ∑ 0 m − 1 2 i = 2 m − 1 − 1 \sum_{0}^{m-1} 2^{i}=2^{m-1}-1 0m12i=2m11
    把 m = logn - 1 带入可知时间复杂度为 n,分到 n 个 append 操作,每次 append 操作的时间复杂度为 1。最后数组大小也能正比于元素的总个数,也就是说这样的空间复杂度也是 O(n)。

可见成倍增长是一个较好的选择,但是具体的倍数涉及一个权衡为题,倍数太大空间浪费太多,但是摊销时间更少,反之节约空间但是花费更多时间。

我们看一下 Python 内部到底是不是成倍增长的,以及时间复杂度是不是个常数:

def f(n):
    data = []
    start = time.time()
    for _ in range(n):
        data.append(None)
    end = time.time()
    print('The times of append: {0:3d}; Time cost of total append: {1:4f}'.format(n, (end - start)))


f(10000)
f(100000)
f(1000000)
f(10000000)
f(100000000)

结果如下:
在这里插入图片描述
可以清楚看到总时间是随着 n 线性增长的,那么摊销到 n 则每次时间确实是一个常数。

3. 列表时间复杂度

操作时间复杂度
len(data)O(1)
data[j]O(1)
data[j] = valueO(1)
data.append(value)O(1)
data.insert(k, value)O(n - k + 1)
data.count(value)O(n)
data.pop()O(1)
data.pop(k)O(n - k)
del data(k)O(n - k)
data.remove(val)O(n)
data.index(value)O(k + 1) (k 为存在是最左边的索引,不存在则为 n)
data1.extend(data2)O(n2)
data1 += (data2)O(n2)
value in dataO(k)
data1 == data2O(k + 1) (k 为不相等时最左边的索引)
data[j:k]O(k - j + 1)
data1 + data2O(n1 + n2) (n1 和 n2 为两个列表的长度)
c * dataO(cn)
data.sort()O(nlogn)
data.reverseO(n)

4. 其他:二维数组的创建

一维数组的创建(假设初始化长度为 10,每个元素为 0):

a1 = [0] * 10
print(a1)

结果如下:
在这里插入图片描述
这是 OK 的!

那么二维数组呢(5 行 10 列,每个元素为 0)?
第一种:

a2 = [0] * 10 * 5

在这里插入图片描述
还是一维,只不过成了 50 个元素。
这样呢:

a2 = [[0] * 10] * 5
print(a2)

在这里插入图片描述
表面上对了,实际上有问题:

a2 = [[0] * 10] * 5
print(a2)
a2[0][0] = 1
print(a2)

在这里插入图片描述
这是由于我们是将一行复制了多次导致的,即其实每一行指向同一地址。

a2 = [[0] * 10] * 5
print(a2)
a2[0][0] = 1
for i in a2:
    print(id(i))
print(a2)

在这里插入图片描述
那应该怎么弄呢?答案是列表生成式

a2 = [[0] * 10 for _ in range(5)]
print(a2)
a2[0][0] = 1
for i in a2:
    print(id(i))
print(a2)

在这里插入图片描述

END

  • 0
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值