2.算法分析
2.1 目标
- 理解算法分析的重要性
- 能够使用 大O 符号描述算法执行时间
- 理解 Python 列表和字典的常见操作的 大O 执行时间
- 理解 Python 数据的实现是如何影响算法分析的。
- 了解如何对简单的 Python 程序做基准测试( benchmark )。
2.2 什么是算法分析
-
什么是算法?
算法
是解决问题的方法
-
算法分析可以分很多种方向:比如
可读性
,有穷性
,可行性
,健壮性
,时间效率和储存量
啊等等,但最重要的就是时间 空间.
通俗来讲就是基于每种算法使用的计算资源量来比较算法,要么看运行时间
,要么看占用空间
. -
运行时间的测量方法:
- 利用
time模块的time函数
它可以在任意被调用的地方返回系统时钟的当前时 间(以秒为单位),
像奥利奥夹心把执行函数
夹在中间,时间差就是函数执行的时间.
- 利用
import time
def sumOfN2(n):
start = time.time()
theSum = 0
for i in range(1,n+1):
theSum = theSum + i
end = time.time()
return theSum, end-start
也可以用 装饰器
来实现:
装饰器 ?
def set_func(func):
def get_func(n):
start = time.time()
func(n)
end = time.time()
return end - start
return get_func
@set_func # sum = set_func(sum)
def sum(n):
Sum = 0
for i in range(1, n+1):
Sum += i
return Sum
但是, 这种方法有很大的局限性,因为不同的电脑要受配置
,当前操作系统调度优化
等影响,不够抽象,因此需要另一种更通用的方法。
2.3 大O符号
在进行算法分析时,语句总的执行次数T(n)是关于问题规模n的函 数,进而分析T(n)随n的变化情况并确定T(n)的数量级。
数量级通常称为大O符号,写为 O(f(n))
.随着时间的增加,时间变化表达式中的某些项的作用
就会越来越明显
.
推导大O阶方法:
1.用常数1取代运行时间中的所有加法常数。
2.在修改后的运行次数函数中,只保留最高阶项。
3.如果最高阶项存在且不是1,则去除与这个项相乘的常数。 得到的结果就是大O阶。
f(n) | Name | 名称 |
---|---|---|
1 | constant | 常数 |
log(n) | Logarithmic | 对数 |
n | Linear | 线性 |
nlog(n) | Log Liner | 对数线性 |
n^2 | Quadratic | 平方 |
n^3 | Cubic | 立方 |
2^n | Exponential | 指数 |
举例:
-
常数阶O(1) : 顺序结构的时间复杂度。
int sum = 0,n = 100; /* 执行一次 */ sum = (1 + n) * n / 2; /* 执行一次 */ printf("%d", sum); /* 执行一次 */
实际上,这种单独一个语句(除函数外),都可以看作
O(1)常数阶
.而且只要可数,无论是执行一百次一千次一万次都看作O(1)
.注意:没有
O(13)
,O(250)
,O(1000)
这样的写法! -
线性阶O(n) : 线性阶的循环结构会复杂很多。要确定某个算法的阶次,我们常常 需要确定某个特定语句或某个语句集运行的次数。
n = 100 for i in range(n): print("Aha!") # 时间复杂度为O(1)的程序步骤序列
-
对数阶O(logn)
count = 1 while count < n: count = count * 2
由于每次count乘以2之后,就距离n更近了一分。也就是说,有多少个 2 相乘后大于n,则会退出循环。由2 x =n得到x=log 2 n。所以这个循 环的时间复杂度为O(logn)。
扩展:为什么算法渐进复杂度中对数的底数总为2?
其实无论是log3n还是log2n都是差不多的,因为换底公式的存在
l o g 2 n = l n ( n ) / l n 2 l o g 3 n = l n ( n ) / l n 3 ∴ l o g 2 n / l o g 3 n = l n 3 / l n 2 = l o g 2 3 log_{2}^n = ln(n)/ln2\\ log_{3}^n = ln(n)/ln3\\ ∴ log_{2}^n/log_{3}^n = ln 3/ln2 = log_{2}^3 log2n=ln(n)/ln2log3n=ln(n)/ln3∴log2n/log3n=ln3/ln2=log23
所以:无论是log2n还是logkn都是相差常数倍的,在数量级分析中,可以忽略不计。
-
平方阶O(n^2)
for i in range(n): for j in range(n): print("I am Quadratic.")
如果其中的一个n变成了m,那么就变成了O(m*n).
-
nlogn阶O(nlogn)
在以后可以见到(归并排序) -
至于 n^3 n! n^n 后两个哪怕是n只有100都是噩梦般的计算时间,所以一般不考虑。而
n^3
在数据量大的时候开始力不从心,所以设计算法时是不会采用的。
2.4 一个乱序字符串检查的例子
分析算法时,存在几种可能的考虑:
- 算法完成工作最少需要多少基本操作,即最优时间复杂度
- 算法完成工作最多需要多少基本操作,即最坏时间复杂度
- 算法完成工作平均需要多少基本操作,即平均时间复杂度
2.5 Python数据结构的性能
现在你对 大O 算法和不同函数之间的差异有了了解。本节的目标是告诉你 Python 列表和字 典操作的 大O 性能。
然后我们将做一些基于时间的实验来说明每个数据结构的花销和使用这 些数据结构的好处。
重要的是了解这些数据结构的效率,因为它们是本书实现其他数据结构 所用到的基础模块。
本节中,我们将不会说明为什么是这个性能。
在后面的章节中,你将看 到列表和字典一些可能的实现,以及性能是如何取决于实现的。
2.6 列表
对于列表,索引操作时间复杂度是O(1);
append方法是O(1);
拼接是O(K),其中K是要拼接的列表的大小
之前用的time.time()简单方便.但是如果用于某些性能好的电脑上执行时间复杂度很小的函数就会很尴尬,比如我的电脑…
所以我们现在用timeit模块下的Timer首先设置一个定时器,然后用返回的类的timeit方法执行.默认执行一百万次.你也可以手动设置number参数.
要使用 timeit,你需要创建一个 Timer 对象,其参数是两个 Python 语句。第一个参数是一个 你想要执行时间的 Python 语句; 第二个参数是一个将运行一次以设置测试的语句。然后 timeit 模块将计算执行语句所需的时间。默认情况下,timeit 将尝试运行语句一百万次。 当它完成时,它返回时间作为表示总秒数的浮点值。由于它执行语句一百万次,可以读取结果作为执 行测试一次的微秒数。你还可以传递 timeit 一个参数名字为 number,允许你指定执行测试语 句的次数。以下显示了运行我们的每个测试功能 1000 次需要多长时间
import timeit
def test1():
l = []
for i in range(1000):
l = l + [i]
def test2():
l = []
for i in range(1000):
l.append(i)
def test3():
l = [i for i in range(1000)]
def test4():
l = list(range(1000))
def main():
t1 = timeit.Timer("test1()","from __main__ import test1")
print("test1",t1.timeit(number=1000),"milliseconds")
t2 = timeit.Timer("test2()","from __main__ import test2")
print("test2",t2.timeit(number=1000),"milliseconds")
t3 = timeit.Timer("test3()","from __main__ import test3")
print("test3",t3.timeit(number=1000),"milliseconds")
t4 = timeit.Timer("test4()","from __main__ import test4")
print("test4",t4.timeit(number=1000),"milliseconds")
if __name__ == '__main__':
main()
测试结果:
注意:
from __main__ import test1
从__main__
命名空间导入到timeit
设置 的命名空间中。timeit 这么做是因为它想在一个干净的环境中做测试,而不会因为可能有你创 建的任何杂变量,以一种不可预见的方式 干扰 你 函数的性能。
从上面的试验清楚的看出,append 操作比拼接快得多。其他两种方法,列表生成器的速度是 append 的两倍。
最后一点,你上面看到的时间都是包括实际调用函数的一些开销,但我们可以假设函数调用 开销在四种情况下是相同的,所以我们仍然得到的是有意义的比较。因此,拼接字符串操作 需要 1.03 毫秒并不准确,而是拼接字符串这个函数需要 1.03 毫秒。你可以测试调用空函数 所需要的时间,并从上面的数字中减去它。
下面是常用列表操作的O阶:
值得注意的是,pop()
操作,pop()
时间复杂度是O(1)
,而pop(0)
是O(n)
2.7 字典
字典的 get 和 set 操作都是 O(1)。
另一个重要的操作是 contains / in ,检查一个键是否在字典中也是 O(1)。
以上都是平均时间复杂度,在某些特殊情况下,contains,get item 和 set item 操作可以退化为 O(n).
常见字典操作的时间复杂度:
import timeit
t = timeit.Timer("1000 in x", "from __main__ import x")
# 测试列表in时间复杂度
x = list(range(5000))
lst_time = t.timeit(number=100000)
# 测试字典in时间复杂度
x = {j:None for j in range(5000)}
dic_time = t.timeit(number=100000)
print("%10.3f, %10.3f" % (lst_time, dic_time))
测试结果如下,我们可以看到,字典的 in 明显要比 列表的 in 快的多,
实际上 字典 的in时间复杂度是 O(1) ,而 列表 的in时间复杂度是 O(n) .
引申: 为什么字典索引要快与列表的索引?
2.8 总结
我们学了什么
- 算法分析概念
- O阶的分类,计算,比较
- 列表,字典的常用操作的O阶
- 使用TIME模块分析时间复杂度