python 程序的计算代价(复杂度)
要点概论
1. 时间开销
2. 空间开销
3. Python 程序的时间复杂度实例
4. 程序实现和效率陷阱
1. 时间开销
在考虑 python 程序的时间开销时,有一个问题特别需要注意:python 程序中的很多基本操作不是常量时间的。
下面是一些情况:
1)基本算术运算时常量时间操作【注:】,逻辑运算时常量时间运算。
2)组合对象的操作有些是常量时间的,有些不是,例如:
① 复制和切片操作通常需要线性时间(与长度有关,是 O(n))时间操作。
② list 和 tuple 的元素访问和元素赋值,是常量时间的。
③ dict 操作的情况比较复杂【补充一个链接地址】。
3)字符串也应该看作组合对象,但许多操作不是常量时间的。
4)创建对象也需要付出空间和时间,空间和时间代价都与对象大小有关。
对于组合对象,这里可能有需要构造的一个个元素,元素有大小问题,
整体看还有元素个数问题。通常应看作线性时间和线性空间操作(以元素个数作为规模)。
Python 结构和操作的效率问题:
1)构造新结构,如构造 list ,set 等。构造新的空结构(空表,空集合等)是常量时间操作,
而构造一个包含 n 个元素的结构,则至少需要 O(n) 时间。统计说明,分配长度为 n 个
元素的存储块的时间代价是 O(n)。
2)一些 list 操作的效率:表元素访问的元素修改是常量时间操作,但一般的加入、
删除元素操作(即使只加入一个元素)都是 O(n) 时间操作。
3) 字典 dict 操作的效率:主要操作是加入新的键值对和基于键查找关联值。
它们的最坏情况复杂度是 O(n) ,但平均复杂度是 O(l) 。
也就是说,一般而言字典操作的效率很高,但偶然也会出现效率低的情况。
2. 空间开销
在程序里使用任何类型的对象,都需要付出空间的代价。建立一个表或者元组,至少要占用元素个数那么多的空间。
如果一个表的元素个数与问题规模线性相关,建立它的空间付出至少为 O(n) (如果元素也是新创建的,还需考虑元素本身的存储开销)。
需要注意两点:
1)Python 的各种组合数据对象都没有预设的最大元素个数。在实际使用中,这些结构能根据元素个数的增长自动扩充存储空间。
从空间占用的角度看,其实际开销在存续期间可能变大,但通常不会自动缩小(即使后来元素变得很少了)。
2)还应该注意 Python 自动存储管理系统的影响。举个例子:如果在程序里建立了一个表,此后一直将其作为某个全局变量的值,
这个对象就会始终存在并占用存储空间。如果将其作为某个函数里局部变量的值,或者虽然作为全局变量的值,但后来通过
赋值将其抛弃,这个表对象就可以被回收。
3. Python 程序的时间复杂度实例
一个简单的例子。假设需要把得到的一系列数据存入一个表,其中得到一个数据是 O(l) 常量时间操作。代码如下:
data = [] whille 还有数据: x = 下一数据 data.insert(0,x) #把所有数据加在表的前面 或者写为: data = [] while 还有数据: x = 下一数据 data.insert(len(data),x) #新数据加在最后,或写append(x)
前一程序段需要 O(n) 的时间才能完成工作,而后一个程序段只需要 O(n) 时间。造成这种情况与 list 的实现方式有关。
另一个例子,建立一个表,其中包含从 0 到 10000 X n - 1 的整数值:
# 先编写一个计算时间的装饰器 import time def timer(test): def inner(*args): start = time.time() test(*args) end = time.time() - start print(end) return inner @timer def test1(n): lst = [] for i in range(n*10000): lst = lst + [i] return lst test1(5) # 3.522050380706787 @timer def test2(n): lst = [] for i in range(n*10000): lst.append(i) return lst test2(5) # 0.004010200500488281 @timer def test3(n): return [i for i in range(n*10000)] test3(5) # 0.0020058155059814453 @timer def test4(n): return list(range(n*10000)) test4(5) # 0.001033782958984375
测试环境为小米 pro 15.6 笔记本【i5处理器】。
试着尝试变化 n 值查看不同函数的增长趋势。
4. 程序实现和效率陷阱
设计一个算法,通过对它的分析可能得到抽象算法的时间与空间复杂度。
进而,采用某个变成语言可以做出该算法的实现。
那么问题来了,算法的实现(程序)的时间开销与原算法的时间复杂度之间有什么关系?
理想情况是:
作为算法的实现,相应程序的时间开销增加趋势应该达到原算法的时间复杂度。但是,如果实现做得不好,其实现程序也可能比这差。
前一话题就有这样的例子,例如函数 test1 :最常见的错误做法就是毫无必要地构造一些可能很大的复杂结构。
例如在递归定义的函数里的递归调用中构造复杂的结构(如 list 等),而后只使用其中的个别元素。
从局部看,这样做使得常量时间的操作变成了线性时间操作。
但从递归算法的全局看,这种做法经常会使多项式时间算法变成指数时间算法。
也就是说,把原来有用的算法变成了基本无用的算法。