算法分析
概念:算法分析是关于计算机程序性能和资源利用的理论研究。主要关注点在于程序性能,如何让程序运行的更快。同时也会涉及其他问题,例如通信,存储器等。
问题1:什么比性能更重要?
正确性,可维护性,简洁性,程序员的时间成本,健壮性,功能性,模块化,安全性,可扩展性,用户友好性等等
问题2:如果算法和性能和都不重要,为什么还要学习它们?
算法能够将不可行变成可行
如:在实时需求中,程序不够快等同于不可行;如果程序占用过多的内存,也是不可行的。算法已经成为一种广泛应用于计算机科学领域的描述程序行为的语言。
性能处于最底层,其角色和经济中的货币相似,就像你可以用货币买来水(水对你的生命来说比货币更重要),你也可以“支付”性能来换取良好的用户体验,或换取安全性。
一个例子:有人希望有更多的功能,因此用Java来写程序,尽管它比C写的程序慢的多(可能会损失三倍的性能),但考虑到Java的优点(面向对象,异常处理等),人们愿意“支付”三倍性能。算法很有趣
下面以排序问题为例引入算法分析。
问题描述:一组输入序列
<a1,a2,...an>
<script type="math/tex" id="MathJax-Element-1">
</script>,按照需求重新排列后输出
<a′1,a′2,...a′n>
<script type="math/tex" id="MathJax-Element-2">
</script>
满足
a′1⩽a′2⩽...⩽a′n
插入排序
伪代码
与编程语言很类似,只是经常包含一些英语,能够让我们更容易理解算法所要表达的意思。
插入排序伪代码:
Insertion-Sort(A, n) // Sorts A[1...n]
for j ← 2 to n
do key ← A[j]
i ← j-1
while i > 0 and A[i] > key
do A[i+1] <- A[i]
i ← i-1
A[i+1] ← key
伪代码解析:
A为待排序的数组。在外层for循环中,j从2递增到n,在算法任意一步,取出数组A中的第j个值(之前的部分已经有序),把这个值存在key中,并依次把前一个元素向后移一位,直到前一个元素不大于key为止,从而让“有序”的部分长度增加1。如下图所示:
举例:
给定数组 8,2,4,9,3,6,并设定j从2开始(即从第二个元素“2”开始)
排序过程如下:
算法运行时间:
- 与输入本身有关(假如输入已经有序,则运行时间将会很短)
- 与输入规模有关(上面的例子输入规模为6,如果变为
6×109
,运行时间将会变长)
——我们将把运行时间看成是输入规模的函数 - 我们想知道运行时间的上限
算法分析的种类:
最坏情况分析(最经常用到)
T(n) = 输入规模为n时算法的最长运行时间平均情况分析(有时用到)
T(n) = 输入规模为n时算法的期望运行时间
期望运行时间 = 所有输入按概率加权平均的运行时间
每种输入出现的概率是未知的,所以要先假设每种输入出现的概率分布最好情况分析(假象)
可以给慢速算法选择特定的输入,从而使运行时间很短,但是,这种信息没什么用。
问题:插入排序算法的最坏情况运行时间是多少?
思考:
算法的运行时间与计算机的配置有关(超级计算机?腕表计算机?)
——“相对运行时间”(不同算法在同一台计算机上运行的时间)
——“绝对运行时间”(有没有一种算法在任何计算机上运行时间都很短?)
当讨论软件相关的算法的最坏运行时间而不涉及硬件时,会造成困惑。
如何解决这种复杂的局面?如何把问题简化到可以用数学手段来处理?
那就是“大局观”——渐近分析
渐近分析
- 忽略掉那些依赖于机器的常量
- 关注运行时间的增长性(而不是实际运行时间) T(n):n→∞
为了更好的解释渐近分析,首先引入渐近符号:
Θ
:丢掉低阶项,并忽略前面的常数因子
例:
3x3+90n2−5n+6046=Θ(n3)
因此我们知道,当n
→∞
时,
Θ(n2)
的算法总比
Θ(n3)
的算法快,即使在较慢的计算机上运行
Θ(n2)
的算法,在较快的计算机上运行
Θ(n3)
的算法,这个性质仍然满足。因为不同的平台上我们可能只差一个常数因子。
下图展示了
Θ(n3)
的算法与
Θ(n2)
的算法运行时间与输入规模的关系。
可以看出,总有一个点
n0
,当输入规模大于
n0
时,
Θ(n2)
的算法都比
Θ(n3)
的算法有更小的开销。
然而,从工程的角度来看,仍存在一个问题:有时
n0
太大了,大到计算机无法运行这样的算法,因此我们有时也对低速算法感兴趣。尽管从渐近的观点来看它们的运行速度比较慢,但它们在合理规模的输入下运行得更快。
结论:要在数学理解与工程直觉之间做出权衡(仅仅会做算法分析是不够的,还需要学习怎样编程以及在实践中运用这些工具)
插入排序算法分析
最坏情况:输入序列为逆序
进行操作数目计数的一种方法:内存引用计数(实际上访问了某个变量多少次)
T(n)=∑nj=2Θ(j)=Θ(n2)
(算数级数)
插入排序快吗?
如果输入规模很小,比较快
如果输入规模很大,并不快
下面引入一个更快的排序方法,归并排序。
归并排序
算法步骤包括以下3点:
1. 如果n = 1,排序完毕
2. 递归排序
A[1,⌈n/2⌉]
和
A[⌈n/2⌉+1,n]
3. 合并两个排好序的序列
关键的子程序:归并
假设现在已经有两个有序的子序列
A[1,⌈n/2⌉]
:2,7,13,20
A[⌈n/2⌉+1,n]
:1,9,11,12
想要将这两个序列合并成一个大的目标序列,具体做法如下:
1. 找出两个序列中最小值,作为目标序列的第一个值,并将这个最小值从原序列中“划掉”
由于两序列都已经有序,因此最小值只能是第一个序列或第二个序列的第一个值,比较“1”和“2”,”选择“1”,将“1”从第二个序列中删除
2. 找出两序列中剩余值中的最小值,作为目标序列的第二个值,并将这个最小值从原序列中“划掉”
由于“1”已被删除,此时比较“2”和“9”,选择“2”,将2从第一个序列中删除
3. 重复上述步骤直到两序列为空
由于每一步操作的运行时间都与输入规模无关(比较两个值,取最小值,删除最小值,将指针前移一位),因此每一步的运行时间都是 Θ(1) 的。遍历两序列所有元素,需要n次,因此归并操作是 Θ(n) 的。
归并排序时间复杂度分析
设总体时间复杂度为
T(n)
,分别考虑以下3个步骤的时间复杂度:
1. 如果n = 1,排序完毕 —— 判断n是否为1,与输入规模无关,
Θ(1)
(并不严格,为了简化问题,后续介绍)
2. 递归排序
A[1,⌈n/2⌉]
和
A[⌈n/2⌉+1,n]
——
2T(n/2)
(并不严格,后续介绍)
3. 合并两个排好序的序列 ——
Θ(n)
递归式
具体求解方法将在下节讲解,这里用图形化方法——递归树,进行直观的理解。
递归树
一直递推下去,会出现如下情形:
树高:n折半 log2n 次以后为1
叶子节点数:第i层有 2i−1 个节点,第 log2n+1 层有n个节点
计算总体时间复杂度:
第一层:cn
第二层:cn
…..
共有lgn个cn相加
最后一层:
Θ(n)
T(n)=(cn)lgn+Θ(n)=Θ(nlgn)
从渐近的角度来开,在充分大的输入规模下(n>30),归并排序优于插入排序。