算法的好坏是如何分析的?

情到浓时终转凉″ 提交于 2019-12-03 13:32:21

前言

本文以插入排序为例,结合自身学习过程中遇到的问题,介绍如何分析算法的复杂度,因为掌握此方法后,就可以对遇到的任何算法做一个形式化的评估,从而了解算法的执行效率。 本文先给出计算插入排序算法运行时间的表示方式和计算方法,再给出其最好情况、最坏情况的分析过程,最后引出Θ\Theta(读作theta)表示。相信看完本文后,就可以更清楚的明白:时间复杂度、Θ\Theta、大OO等曾经困惑过你的概念。
具体插入排序的算法的思想请见之前的博文《插入排序》,或者可参考《算法导论》P9P_9~P12P_{12},这里假设读者已经明白了插入排序的算法思路。

算法分析

算法的运行时间是指在特定输入时,所执行的基本操作数(或步数)。这里的基本操作数可这么理解,每执行一行伪代码(如下图为本文使用的伪代码)都要花一定量的时间,一般情况下各行执行时间是不同的,但这里假设每次执行第ii行所花的时间都是常量cic_i
在这里插入图片描述一般情况下,做简要分析的时候肯定知道,两层循环对于nn个数排序,最坏情况大致为n2n^2,因为想当然的nnn*n嘛!但实际上如果不深入学习里面知识,很多细节未掌握,那么理解的就不深,掌握的就不扎实。
此次分析就是要精细化分析,这个n2n^2是如何出来的,只有完全理解了它的来龙去脉后,再忽略掉次要部分后才能真正明白其中的真谛。
统计一个算法的执行时间,其实很简单,只要把每一行执行的时间和其次数相乘,然后相加即可。那么这里需要注意的几点如下:

  1. 每行消耗的时间是个常数cic_icic_i是个假设的未知参数。

  2. 执行的次数和待排序的数组长度有关,这里假设为n=length[A]n=length[A],其中A为待排序的数组名。

  3. 循环判断本身要比循环体内多执行一次(精细化分析就要知道,如果是粗略估计可忽略)。比如第1行实际上从2到length[A]length[A]是n-1次,但因为最后还有一次判断,即当j=length[A]+1j=length[A]+1时有一个判断,所以第1行执行的次数为n1+1=nn-1+1=n,而其内部代码执行次数为n1n-1次。

    同理,第5行的循环判断次数,假设为tjt_j次,那么其内部代码第6、7行执行次数为(tj1)(t_j-1)次。

  4. 回顾下求和公式Sn=12n(a1+an)=d2n2+(a1d2)nS_n=\frac {1}{2}n(a_1+a_n)=\frac{d}{2}n^2+(a_1-\frac{d}{2})n,比如x=5100x=(1005+1)(5+100)2=5040\sum_{x=5}^{100}x=\frac{(100-5+1)(5+100)}{2}=5040,其中(1005+1)(100-5+1)表示一共有这么多项,(5+100)(5+100)表示a1+ana_1+a_n。求和公式本身x=5100x\sum_{x=5}^{100}x,表示x=5,6,7100x=5,6,7……100这些数相加。

  5. 第5行是最关键的,这里有个while循环,而且使用了一个求和j=2ntj\sum _{j=2}^{n}t_j,不太好理解,其中的tjt_j表示在对应的jj时执行的次数。这是第二层循环,这层循环每次循环的次数和第4行有关系,初始状态当j=2j=2i=j1=21=1i=j-1=2-1=1,由于循环的判断要多出一次,所以当j=2j=2时,应该判断22次,即tj=j=2t_j=j=2,这是通常情况下。但是上述描述是没有考虑A[i]<keyA[i]<key这句的,如果加上这句,就会发现如果输入的数组本来就是排好序的,那么其实判断11次就够了(回想插入排序的特性),而且后续当jj自增时,while循环都只判断一次,即无论jj是多少,tj=1t_j=1,这是最好的情况;如果输入的数组正好是逆序,即最坏的情况,每次jj自增时,while循环这句都要执行i=j1+1=ji=j-1+1=j次,即对于j=2,3,,nj=2,3,\dots,n,有tj=jt_j=j,而while循环里面执行j1j-1次,即tj1t_j-1次。所以最坏情况下while循环执行的次数为:t=2ntj=t=2nj=(n2+1)(2+n)2=n(n+1)21\sum_{t=2}^{n}t_j=\sum_{t=2}^{n}j=\frac{(n-2+1)(2+n)}{2}=\frac{n(n+1)}{2}-1,内部执行:t=2ntj1=t=2nj1=n(n1)2\sum_{t=2}^{n}t_{j-1}=\sum_{t=2}^{n}j-1=\frac{n(n-1)}{2}

知道这些问题后,就可以计算算法消耗的总体时间T(n)T(n)了.
T(n)=c1n+c2(n1)+c4(n1)+c5j=2ntj+c6j=2n(tj1)+c7j=2n(tj1)+c8(n1) T(n)=c_1n+c_2(n-1)+c_4(n-1)+c_5\sum_{j=2}^n t_j+c_6\sum_{j=2}^n (t_j-1) \\ +c_7\sum_{j=2}^n (t_j-1)+c_8(n-1)
如果输入数组是已经排好序的,那么第5行就永远不会成立,第6、7行就不会执行,那么此时就是最佳运行时间
T(n)=c1n+c2(n1)+c4(n1)+c5(n1)+c8(n1)=(c1+c2+c4+c5+c8)n(c2+c4+c5+c8) T(n) = c_1n+c_2(n-1)+c_4(n-1)+c_5(n-1)+c_8(n-1) \\ =(c_1+c_2+c_4+c_5+c_8)n-(c_2+c_4+c_5+c_8)

这一运行时间可表示为an+ban+b,它是n的一个线性函数,常量a和b依赖于语句的代价cic_i,从后面的学习可以看到cic_i的影响很少,可以忽略。
如果输入的数组是按逆序排序的,那么就是最坏情况,此时必须将每个待插入的元素A[j]A[j](A[j]A[j]表示每次内部循环中待排序的数,可参考P10正确性验证一段)和已排序的部分A[1j1]A[1…j-1]中的每个元素做比较,而且还要将已排序部分每个元素挨个后移一个位置。此时最坏情况下的时间T(n)为:
T(n)=c1n+c2(n1)+c4(n1)+c5(n(n+1)21)+c6(n(n1)2)+c7(n(n1)2)+c8(n1)=(c52+c62+c72)n2+(c1+c2+c4+c52c62c72+c8)n(c2+c4+c5+c8) T(n) = c_1n+c_2(n-1)+c_4(n-1)+c_5(\frac{n(n+1)}{2}-1) \\ +c_6(\frac{n(n-1)}{2})+c_7(\frac{n(n-1)}{2})+c_8(n-1) \\ =(\frac{c_5}{2}+\frac{c_6}{2}+\frac{c_7}{2})n^2 +(c_1+c_2+c_4+\frac{c_5}{2}-\frac{c_6}{2}-\frac{c_7}{2}+c_8)n -(c_2+c_4+c_5+c_8)
最后可简化为:an2+bn+can^2+bn+c,常量a,b,ca,b,c之前提过,不管,所以这是一个关于nn的二次函数。接着书上还分析了平均情况,得出的运行结果也是关于nn的二次函数。

为了简化对插入排序的分析,做简化抽象(此部分内容都是非形式化描述,所以不是特别精确,记住就好):

  1. 忽略每条语句的真实代价,而用常量cic_i表示。
  2. 更进一步忽略抽象代码cic_i,用a,b,ca,b,c表示,即可表示为:an2+bn+can^2+bn+c
  3. 再进一步抽象,使用运行时间的增长率(rate of growth),或称增长的量级(order of growth)。这样仅考虑公式中的最高次项,因为当nn很大时,低阶项相对来说不太重要(这句话书上是这么说的,但具体的数学证明或者实验暂时没有参考)。
  4. 另外还忽略最高次项前面的系数,因为在大规模输入时,相对于增长率来说,系数是次要的

所以,插入排序最坏情况时间代码为Θ(n2)\Theta(n^2)Θ\Theta符号在本节并未给出准确定义,在《导论》第3章"函数的增长"会给出具体的定义,实际上把Θ\Theta理解 为大OO即可。

到此为止,一个算法的运行时间,即效率是如何分析的已经很清楚了。

总结

本文参考《算法导论》对插入算法做了分析,主要加入了自己的理解和思考,为后续的进一步学习打下基础,另外也期待本文能给在算法入门过程中遇到和我一样类似困惑的朋友有点帮助。

易学教程内所有资源均来自网络或用户发布的内容,如有违反法律规定的内容欢迎反馈
该文章没有解决你所遇到的问题?点击提问,说说你的问题,让更多的人一起探讨吧!