数据结构与算法(Python版) | (2) 算法分析

本专栏主要基于北大的数据结构与算法教程(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)
  • 2
    点赞
  • 10
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值