本专栏主要基于北大的数据结构与算法教程(Python版)进行整理,包括课程笔记和OJ作业。
1. 什么是算法分析
对比程序,还是算法?
如何对比两个程序?
看起来不同,但解决同一个问题的程序,哪个“ 更好”?
程序和算法的区别
算法是对问题解决的分步描述;
程序则是采用某种编程语言实现的算法,同一个算法通过不同的程序员采用不同的编程语言,能
产生很多程序。
累计求和问题
- 我们来看一段程序,完成从1到n的累加, 输出总和:
设置累计变量=0 从1到n循环,逐次累加到累计变量 返回累计变量
def sumOfN(n):
theSum = 0
for i in range(1,n+1): #1~n
theSum = theSum + i
return theSum
print(sumOfN(10))
- 再看第二段程序,是否感觉怪怪的?
实际上本程序功能与前面那段相同,这段程序失败之处在于:变量命名词不达意,以及包含了无用的垃圾代码:
def foo(tom):
fred = 0
for bill in range(1,tom+1): #1~tom
barney = bill #无用
fred = fred + barney
return fred
print(foo(10))
算法分析的概念
- 比较程序的“好坏”,有更多因素:代码风格、可读性等等
- 我们主要感兴趣的是算法本身特性
- 算法分析主要就是从计算资源消耗的角度 来评判和比较算法:更高效利用计算资源,或者更少占用计算资源的 算法,就是好算法。从这个角度,前述两段程序实际上是基本相同的 ,它们都采用了一样的算法来解决累计求和问题。
代码风格和可读性
-
为什么Python的强制缩进是好的?
语句块功能和视觉效果是统一的。java、c++等编程语言没有强制缩紧,就算你把所有代码写在一行中,编译也是可以通过的。 -
苹果公司的一个低级Bug
由于C语言源代码书写缩进对齐的疏忽,造成SSL连接验证被跳过。
上面的第12行,看起来是缩进,但是实际上这行代码并不属于前面的if语句,在C语言中需要用一对{}来包裹语句块。这样 这行代码无论在什么情况下都会被执行,直接goto fail,那么第15行的SSL验证代码就被跳过了。
计算资源指标
计算资源:
- 一种是算法解决问题过程中需要的存储空间或内存。但存储空间受到问题自身数据规模的变化影响,要区分哪些存储空间是问题本身描述所需,哪些是算法占用,不容易。
- 另一种是算法的执行时间
我们可以对程序进行实际运行测试,获得真实的 运行时间
运行时间检测
- Python中有一个time模块,可以获取计算机系统当前时间:该时间以秒为单位,从1970年1月1日0点0分开始。
我们可以在算法开始前和结束后分别记录系统时间,做差即可得到运行时间。
- 累计求和程序的运行时间检测
用time检测总运行时间, 返回累计和,以及运行时间(秒).
def sumOfN2(n):
start = time.time()
theSum = 0
for i in range(1,n+1): #1~n
theSum = theSum + i
end = time.time()
return theSum,end - start
for i in range(5): #连续运行5次
print("Sum is %d required %.7f seconds" % sumOfN2(10000))
无迭代累计算法
- 利用求和公式
1 + 2 + . . . + n = n ( n + 1 ) 2 1+2+...+n = \frac{n(n+1)}{2} 1+2+...+n=2n(n+1)
def sumOfN3(n):
start = time.time()
theSum = n*(n+1)/2
end = time.time()
return theSum,end-start
- 采用同样的方法检测运行时间
- 这种算法的运行时间比前面都短很多。运行时间与累计对象n的大小没有关系(前种算法是倍数增长关系)
- 新算法运行时间几乎与需要累计的数目n无关
运行时间检测的分析
- 第一种迭代算法,包含了一个循环,可能会执行更多语句。这个循环运行次数跟累加值n有关系,n增加,循环次数也增加。
- 但关于运行时间的实际检测,有点问题。他和具体使用的编程语言(C>Python)和运行环境(超级计算机>>单片机)有关。
- 同一个算法,采用不同的编程语言编写, 放在不同的机器上运行,得到的运行时间会不一样,有时候会大不一样:比如把非迭代算法放在老旧机器上跑,甚至可能 慢过新机器上的迭代算法。
- 我们需要更好的方法来衡量算法运行时间:这个指标与具体的机器、程序、运行时段都无关。
2. 大O表示法
算法时间度量指标
- 一个算法所实施的操作数量或步骤数可作为独立于具体程序/机器的度量指标。需要一种通用的基本操作(和算法的具体实现无关)来作为运行步骤的计量单位。
- 赋值语句是一个合适的选择
一条赋值语句同时包含了**(表达式)计算和(变量) 存储**两个基本资源
仔细观察程序设计语言特性,除了与计算资源无关的定义语句外,主要就是三种控制流语句和赋值语句, 而控制流仅仅起了组织语句的作用,并不实施处理。
赋值语句执行次数
对于“问题规模”n,赋值语句数量(n的函数):T(n)=1+n
问题规模影响算法执行时间
- 问题规模:影响算法执行时间的主要因素
- 在前n个整数累计求和的算法中,需要累计的整数个数合适作为问题规模的指标。前100,000个整数求和对比前1,000个整数求和 ,算是同一问题的更大规模
- 算法分析的目标是要找出问题规模会怎么影响一个算法的执行时间(执行时间表示为问题规模的函数)
数量级函数 Order of Magnitude
- 基本操作数量函数T(n)的精确值并不是特别重要,重要的是T(n)中起决定性因素的主导部分
用动态的眼光看,就是当问题规模n增大的时候, T(n)中的一些部分会盖过其它部分的贡献 - 数量级函数描述了T(n)中随着n增加而增加速度最快的主导部分
称作“大O”表示法,记作O(f(n)),其中f(n) 表示T(n)中的主导部分
确定运行时间数量级大O的方法
-
T(n) = n + 1
当n增大时,常数1在最终结果中显得越来越无足轻重.所以可以去掉1,保留n作为主要部分,运行时间数量级就是O(n) -
T ( n ) = 5 n 2 + 27 n + 1005 T(n) = 5n^2+27n+1005 T(n)=5n2+27n+1005
当n很小时,常数1005其决定性作用;但当n越来越大, n 2 n^2 n2项就越来越重要,其它两项 对结果的影响则越来越小;同样 n 2 n^2 n2中的系数5,对于 n 2 n^2 n2的增长速度来说影响不大。所以可以在数量级中去掉27n+1005,以及系数5,确定为 O ( n 2 ) O(n^2) O(n2)
影响算法运行时间的其它因素
- 有时决定运行时间的不仅是问题规模
- 某些具体数据也会影响算法运行时间
分为最好、最差和平均情况,平均状况体现了算法的主流性能; 对算法的分析要看主流,而不被某几种特定的运行状况所迷惑。
常见的大O数量级函数
- 通常当n较小时,难以确定其数量级
- 当n增长到较大时,容易看出其主要变化 量级
O(1)问题规模对运行时间无影响(非迭代累计求和)。
从代码分析确定执行时间数量级函数
代码赋值语句可以分为4个部分
- 仅保留最高阶项 n 2 n^2 n2,去掉所有系数
- 数量级是
O
(
n
2
)
O(n^2)
O(n2)
其它算法复杂度表示法
- 大O表示法
表示所有上限中最小的那个上限。 - 大
O
m
e
g
a
Omega
Omega表示法
表示所有下限中最大的下限
- 大
Θ
\Theta
Θ表示法
如果上下限相同可以使用大 Θ \Theta Θ表示法
我们主要是用大O表示法。
3. 变位词判断
-
问题描述
所谓“变位词”是指两个词之间存在组成字母的 重新排列关系。如:heart和earth,python和typhon 为了简单起见,假设参与判断的两个词仅由小写字母构成,而且长度相等。 -
解题目标:写一个bool函数,以两个词作 为参数,返回这两个词是否变位词
-
可以很好展示同一问题的不同数量级算法
解法1:逐字检查
- 思路:将词1中的字符逐个到词2中检查是否存在存在就“打勾”标记(防止重复检查) 如果每个字符都能找到,则两个词是变位词 只要有1个字符找不到,就不是变位词。
- 程序技巧
实现“打勾”标记:将词2对应字符设为None。由于字符串是不可变类型,需要先复制到列表中:
def anagramSolution(s1,s2):
alist = list(s2) #字符串复制/转换为列表形式
stillOK = True
pos1 = 0
while pos1<len(s1) and stillOK:
pos2 = 0
found = False
while pos2<len(alist) and not found:
if s1[pos1]==alist[pos2]:
found=True
else:
pos2 = pos2 + 1
if found:
alist[pos2] = None
else:
stillOK = False
pos1 = pos1 + 1
return stillOK
- 问题规模:词中包含的字符个数n
- 主要部分在于两重循环:外层循环遍历s1每个字符,将内层循环执行n次 而内层循环在s2中查找字符,每个字符的对比次数,分别是1、2…n中的一个,而且各不相同
- 所以总执行次数是1+2+3+…+n
解法2:排序比较
- 思路:将两个字符串都按照字母顺序排好序,再逐个字符对比是否相同,如果相同则是变位词
有任何不同就不是变位词
def anagramSolution2(s1,s2):
#把字符串转换为列表
alist1 = list(s1)
alist2 = list(s2)
#对列表进行排序
alist1.sort()
alist2.sort()
pos = 0
matches = True
while pos<len(s1) and matches:
if alist1[pos]==alist2[pos]:
pos = pos + 1
else
matches = False #提前终止
return matches
- 粗看上去,本算法只有一个循环,最多执 行n次,数量级是O(n)
但循环前面的两个sort并不是无代价的,如果查询下后面的章节,会发现排序算法采用不同的解决方案,其运行时间数量级差不多是 O ( n 2 ) O(n^2) O(n2)或者O(n log n),大过循环的O(n). - 所以本算法时间主导的步骤是排序步骤
- 本算法的运行时间数量级就等于排序过程的数量级O(n log n)
解法3:暴力法
- 暴力法解题思路为:穷尽所有可能组合
- 将s1中出现的字符进行全排列,再查看s2是否出现在全排列列表中
- 这里最大困难是产生s1所有字符的全排列.根据组合数学的结论,如果n个字符进行全排列 ,其所有可能的字符串个数为n!
- 我们已知 n! 的增长速度甚至超过
2
n
2^n
2n
例如,对于20个字符长的词来说,将产生 20!=2,432,902,008,176,640,000个候选词,如果每微秒处理1个候选词的话,需要近8万年时间来做完所有的匹配。 - 结论:暴力法恐怕不能算是个好算法
解法4:计数比较
- 思路::对比两个词中每个字母出现的次数,如果26个字母出现的次数都相同的话,这两个字符串就一定是变位词
- 具体做法:为每个词设置一个26位的计数 器,先检查每个词,在计数器中设定好每 个字母出现的次数
- 计数完成后,进入比较阶段,看两个字符 串的计数器是否相同,如果相同则输出是 变位词的结论
def anagramSolution4(s1,s2):
c1 = [0]*26 #长度为26 全0的列表 代表每个字母出现的次数
c2 = [0]*26
for i in range(len(s1):
pos = ord(s1[i])-ord('a') #ord返回字符的unicode编码 字母的编码是连续的
c1[[pos] = c1[pos] + 1
for i in range(len(s2):
pos = ord(s2[i])-ord('a') #ord返回字符的unicode编码 字母的编码是连续的
c2[[pos] = c2[pos] + 1
j = 0
stillOK = True
while j<26 and stillOK:
if(c1[j]==c2[j])
j = j + 1
else
stillOK = False
return stillOK
- 计数比较算法中有3个循环迭代,但不象 解法1那样存在嵌套循坏
前两个循环用于对字符串进行计数,操作次数等 于字符串长度n;第3个循环用于计数器比较,操作次数总是26次. - 所以总操作次数T(n)=2n+26,其数量级为O(n)
这是一个线性数量级的算法,是4个变位词判断 算法中性能最优的 - 值得注意的是,本算法依赖于两个长度为26的计数器列表,来保存字符计数,这相比前3个算法需要更多的存储空间. 如果考虑由大字符集构成的词(如中文具有上万不同字符),还会需要更多存储空间。
- 牺牲存储空间来换取运行时间,或者相反 ,这种在时间空间之间的取舍和权衡,在选择问题解法的过程中经常会出现。
4. Python数据类型的性能
下面来讨论下Python两种内置数据类型上各种操作的大O数量级。
列表list和字典dict。这是两种重要的Python数据类型,后面的课程会用来实现各种数据结构。 通过运行试验来估计其各种操作运行时间数量级
对比list和dict的操作
List列表数据类型
-
list类型各种操作(interface)的实现方 法有很多,如何选择具体哪种实现方法?
-
总的方案就是,让最常用的操作性能最好 ,牺牲不太常用的操作.80/20准则:80%的功能其使用率只有20%.
-
最常用的是:按索引取值和赋值(v= a[i], a[i]= v)
由于列表的随机访问特性,这两个操作执行时间 与列表大小无关,均为O(1) -
另一个是列表增长,可以选择append() 和__add__()“+”
lst.append(v),执行时间是O(1)
lst= lst+ [v],执行时间是O(n+k),其中k是被加的列表长度(把两个列表复制一遍,拼接起来返回一个大列表) 。选择哪个方法来操作列表,决定了程序的性能
4种生成前n个整数列表的方法
- 首先是循环连接列表(+)方式生成
def test1():
l = []
for i in range(1000):
l = l + [i]
- 用append添加元素生成
def test2():
l = []
for i in range(1000):
l.append(i)
- 列表推导式来做
def test3():
l = [i for i in range(1000)]
- range函数转成列表
def test4():
l = list(range(1000))
使用timeit模块对函数计时
- 创建一个Timer对象,指定需要反复运行的语句和只需要运行一次的“安装语句”
- 然后调用这个对象的timeit方法,其中可以指定反复运行多少次(返回多次运行时间之和,只运行一次太快了 不明显)
- 我们看到,4种方法运行时间差别很大
列表连接(concat)最慢,List range最快, 速度相差近200倍。append也要比concat快得多。另外,我们注意到列表推导式速度是append两 倍的样子。
List基本操作的大O数量级
pop()删除最后一个元素,pop(i)删除第i个元素。
list.pop的计时试验
- 我们注意到pop这个操作
pop()从列表末尾移除元素,O(1)
pop(i)从列表中部移除元素,O(n) - 原因在于Python所选择的实现方法
从中部移除元素的话,要把移除元素后面的元素全部向前挪位复制一遍,这个看起来有点笨拙。但这种实现方法能够保证列表按索引取值和赋值的操作很快,达到O(1)。 这也算是一种对常用和不常用操作的折衷方案。 - 为了验证表中的大O数量级,我们把两种 情况下的pop操作来实际计时对比。相对同一个大小的list,分别调用pop()和 pop(0)
- 对不同大小的list做计时,期望的结果是:pop()的时间不随list大小变化,pop(0)的时间随着list变大而变长。
import timeit
popzero = timeit.Timer("x.pop(0)","from __main__ import x")
popend = timeit.Timer("x.pop()","from __main__ import x")
- 对比
对于长度2百万的列表,执行1000次
pop()时间是0.0007秒
pop(0)时间是0.8秒
相差1000倍
- 我们通过改变列表的大小来测试两个操作 的增长趋势
- 将试验数据画成图表,可以看出增长趋势
pop()是平坦的常数;pop(0)是线性增长的趋势
dict数据类型
- 字典与列表不同,根据关键码(key)找 到数据项,而列表是根据位置(index)
最常用的取值get和赋值set,其性能为O(1),另一个重要操作contains(in)是判断字典中是 否存在某个关键码(key),这个性能也是O(1)
- 设计一个性能试验来验证list中检索一个 值,以及dict中检索一个值的计时对比
生成包含连续值的list和包含连续关键码key的 dict,用随机数来检验操作符in的耗时。
- 可见字典的执行时间与规模无关,是常数O(1)
- 而列表的执行时间则随着列表的规模加大 而线性上升 O(n)
更多Python数据类型操作复杂度
- [Python官方的算法复杂度网站](https://wiki.python.org/moin/TimeCompl exity)