enter image description here

第2章 算法天平:复杂度分析

在上一章中我们已经了解了算法的概念、特征及设计算法要秉持的原则!我们在最后也提示大家算法的设计原则也可以辅助我们评判算法的优劣!那么本章我会尽量详细的展示给大家具体的衡量算法优劣的方法。

首先还是让我们对本章内容做个概览。

本章内容如下:

  • 算法分析方法论
  • 算法的时间复杂度
  • 常见的时间复杂度类型
  • 时间复杂度知识扩展
  • 算法的空间复杂度
  • 小结

2.1 算法分析方法论

算法优劣的衡量即性能的分析是个很重要的课题!只有能够正确的衡量一个算法的好坏并且知道是什么原因引发了算法的性能问题,我们才能改进它,才能够设计出更好的算法!不断的改进甚至挑选出更好的算法来解决问题使我们本章的目标。

我们常用的来衡量算法优劣的方案有两种:事前评估和事后统计!其实这个好理解,现实中我们解决问题常用的无非也是这两种方案。既然算法分析本身也是一个问题,那么解决这个问题最常用的也是算法编写前的分析和算法完成后上线运行的统计。

2.1.1 事后统计方案

首先来聊聊事后统计的方案。其实事后统计无论如何是一定要做的!在算法程序的运行阶段我们一定要进行检测和统计运行的结果,这种方案能直接告诉我们算法真实的运行结果是否是我们想要的!但是事后统计这种方案也有它的弊端。

  • 首先,事后统计的时间点是算法程序完成之后。这就带来了很大的隐患。如果能达到要求还好,万一达不倒需求的效果,那就意味着之前的人力精力财力都浪费掉了,这对项目运营者可能是致命打击!再就是如果是上线运营阶段,那造成的损失就更大。
  • 第二,计算机的硬件环境也严重影响着算法的性能。在不同的硬件环境上,算法的表现不一,这会掩盖掉算法本身的优势和劣势,使我们并不能客观的说是算法太好或者太坏。
  • 第三,测试数据的选择本身也影响了算法的执行效果!数据的规模,数据的流量,数据的分布都会影响算法的执行,使我们也并不能客观的描述一个算法的效率和优劣。

对于事后统计的方案,我们必须做,但是不能完全依赖与它!这是由于它的直观的优点和事后统计的缺点共同决定的!所以,事前评估的重要性就不言而喻了!

2.1.2 事前评估方案

事前评估的方案是指在正式编写算法程序之前就对算法通过一定的方法论进行科学的评估!

我们需要一套科学的、客观的方法只是针对算法本身就能够做到比较不同算法的优劣!其实这些方法论我们的前辈已经总结成为系统的方案,我们可以直接参考这些方案来对我们的算法进行评估!这些方法论就是:时间复杂度和空间复杂度!

那么我们的前辈们是基于什么原理找到合适的方法论呢,我们这里也有必要了解一下,因为这可以作为辅助性前提来帮我们理解我们马上要学习的内容!

我们的前辈通过分析算法的程序实现尤其是高级语言例如PHP编写的程序受到的影响其实原因总结有四条:

  • 算法本身的策略和方法。
  • 编译产生的代码质量。
  • 问题的输入规模。
  • 机器指令的执行速度。

继续分析可知其中的第二条和第四条是依赖于底层的软硬件环境!也就是说这个是我们不能掌控的。即实际上算法的优劣最终归结为算法本身的设计和问题输入的数据规模!所谓的数据规模指的是算法输入数据的数量!例如要计算10000以内的数的和,那么10000就是数据规模。

那么算法本身如何衡量优劣呢,主要有两个标准:时间复杂度和空间复杂度。

2.2 算法的时间复杂度

算法的时间复杂度实际上就是算法的运行花费的时间!但是不同的机器、不同的数据规模都会造成算法时间上的不同!所以,在这里实际上要计算时间复杂度需要三个重要的前提!

  • 不考虑任何的软硬件环境!
  • 假设程序每一条语句的执行时间相同,均为t;
  • 只考虑最坏情况,即数据规模足够大,设为n。

所以,一定要注意这三个前提下,我们的分析才能正常进行也才有意义!所以我们这里的时间复杂度实际上指的是最坏时间复杂度!

2.2.1 时间复杂度和大O表示法

这里有一个固定的公式来表示时间复杂度:

T(n) = O(f(n))

其中n表示的是数据规模,f(n)是算法执行的总时间,其计算公式是:

f(n) = 算法执行语句的数量 * t

即算法中语句的执行数量与单条语句执行时间t的积。

所以时间复杂度公式就是:

T(n) = O(算法运行总时间)

这里我们把算法运行总时间放在了一个固定的格式中: O()。 我们成这种表达方式为大O表示法,大O表示法就是表达最坏的情况的一中表示形式,后面我们会学习最坏空间复杂度,也是用大O表示法来表示!而且在时间复杂度扩展知识中我们还会了解表达最好情况的大Ω表示法和表达平均情况的大Θ表示法!大家只要记住这种特殊的表达结构就可以了!多用几次就会熟悉了!

2.2.2 时间复杂度计算实例

这里给出一个实际问题作为实例场景:

计算 1+2+3+4+5.....+n 的和!

这个场景足够简单,可以让我们把精力放在算法的分析上面!

这里我们设计多种算法来解决上面的问题,计算不同的算法的时间复杂度作为比较!

第一种思想:我们可以生成一个n个长度的数组,然后把数据存储到数组中,最后计算数组各项的和。

算法的实现:

function sum( $n ){
    // 设定返回结果
    $res = 0;                                   //{1}
    // 生成数组[1,2,3,4,5...n]
    $arr = range(1,$n);                         //{2}

    // 计算数组中各项的和
    for($i = 0; $i < $n; ++ $i){                //{3}
        $res += $arr[$i];                       //{4}
    }

    // 返回结果
    return $res;                                //{5}
}

让我们来计算一下上面这个算法的时间复杂度。

第一步:计算算法运行过程中运行的代码总的条数N:

分析代码可知,其中{1}{2}{5}所标注的代码语句整个过程每条都只执行1次,而{3}{4}标注的语句执行过程中每一条都要执行n次!所以总条数N:

    N = 2*n + 3

第二步:计算算法总运行时间f(n):

前面我们已经制定了前提,以为每一条语句执行的时间实行同的,都是t,所以:

    f(n) = N * t = (2*n + 3) * t

第三步:计算时间复杂度T(n):

我们这里的时间复杂度指的是最坏的时间复杂度,使用大O表示法表示:

    T(n) = O( f(n) ) = O( (2*n + 3) * t )

    即

    T(n) = O( (2*n + 3) * t )

第四步:简化。

实际上到这里我们已经成功的计算出了这个算法的时间复杂度,但是在实际的时间复杂度的表示中,对于上面的结果我们要做一定的简化,简化结果是:

    T(n) = O(n)

Tips:简化步骤

  • 1, 去掉常数t:

因为t代表的是每一条语句的运行时间,而每一条语句的运行时间都相同,所以t实际上是个常数,并且不影响分析结果

T(n) = O( (2*n + 3) )
  • 2, 用常数1取代运行时间中的所有加法常数:
T(n) = O( 2*n + 1 )
  • 3, 在修改后的运行次数函数中,只保留最高阶项:
T(n) = O( 2*n + 1 )
  • 4, 如果最高阶项存在且不是1,则去除与这个项相乘的常数:
T(n) = O( n + 1 )
  • 5, 如果存在数据规模n, 去掉加法常数项:
T(n) = O(n)

关于时间复杂度的计算,你会发现计算过程实际上变成了要数数整个程序一共会有多少条语句被执行的一年级数数问题!哈!开个玩笑!但,时间复杂度计算的实际结果确实也如此!

Tips

所以以后有人问你某个算法的时间发复杂度是什么,如果是直接描述,你就直接告诉他是O(XX), 如果是写表达式的话就写我们上面演示的这种T(n)=O(XX)就可以啦!

第二种思想:直接循环计算数组各项的和。

算法的实现:

function sum( $n ){
    // 设定返回结果
    $res = 0;                                   //{1}

    // 计算数组中各项的和
    for($i = 1; $i < $n; ++ $i){                //{2}
        $res += $i;                             //{3}
    }

    // 返回结果
    return $res;                                //{4}
}

让我们来计算一下上面这个算法的时间复杂度。

第一步:计算算法运行过程中运行的代码总的条数N:

N = 2*n + 2

第二步:计算算法总运行时间f(n):

f(n) = N * t = (2*n + 2) * t

第三步:计算时间复杂度T(n):

T(n) = O( f(n) ) = O( (2*n + 2) * t )

第四步:简化。

T(n) = O(n)

你会发现我们上面这两种算法的时间复杂度都是O(n), 难道它们运行的效率一样吗?不是这样的,这就涉及到时间复杂度到底是什么!它描述的实际上是一种“级别”,就像手机一样!1000以下是一个级别,1000到2000是一个级别,2000-3000是一个级别!但是每个级别里手机有很多款!位于同一个级别里的手机,无论是质量还是功能都是大体相似的,但并不代表完全一致!时间复杂度的代表的也是这个意思!同一算法时间复杂度级别中的算法的效率不会差异非常大!

第三种思想:利用高斯公式,f(n) = (1+n)*n/2。

算法的实现:

function sum( $n ){
    // 设定返回结果
    $res = 0;                                   //{1}

    $res = ( 1 + $n ) * $n / 2;                 //{2}

    // 返回结果
    return $res;                                //{3}
}

让我们来计算一下上面这个算法的时间复杂度。

第一步:计算算法运行过程中运行的代码总的条数N:

N = 3

第二步:计算算法总运行时间f(n):

f(n) = N * t = 3 * t

第三步:计算时间复杂度T(n):

T(n) = O( f(n) ) = O( 3 * t )

第四步:简化。

T(n) = O(1)

这个算法的时间复杂度是O(1),这个时间复杂度级别是最好的级别!也是我们设计算法的一个目标!这个级别的算法从理论上说应该是效率最高的算法!但是别忘了,我们还要考虑空间复杂度!

通过计算以上几个算法的时间复杂度,我们可以得出这样的一个结果:

假如计算机一秒钟可以运行一条语句,如果计算的是从1到100的和,那么前两种时间复杂度是O(n)级别的算法应该在100秒完成计算(当然现实中要快的多), 而第三种算法需要1秒钟!如果计算的数量是从1到1000000的和,第一种和第二种O(n)级别的算法要运行1000000秒,而第三种需要1秒!

这说明随着数据规模的增加,O(n)级别的算法和O(1)级别的算法的差距越来越大!实际上从侧面这也说明了时间复杂度的另外一个本质特征:时间复杂度描述的是算法动态渐变的状态!通过时间复杂度能看出一个算法随着数据规模n的变化而变化的情况! 所以通过时间复杂度我们要明白很重要的两点:

  • 1,同一问题的不同算法可以通过时间复杂度来比较效率优劣!
  • 2,对于一个算法可以通过时间复杂度来了解随着数据规模渐变而影响算法的效率变化情况!

ok, 下面就让我们来看看有哪些常见的时间复杂度级别,并且让我们类对比这些级别的时间复杂度来更深入的学习时间复杂度的本质!

2.3 常见的时间复杂度

上一小节已经学会了如何计算时间复杂度,那么你就可以立刻去尝试一下计算你之前接触到的算法,然后总结一下你会有一个惊奇的发现。我们常见的算法的时间复杂度的种类并不是无穷的!而是非常有限的几种!我们的前人也为我们总结出了一些常见的时间复杂度类型,那下面就让我们一起看看常见的时间复杂度级别。这里我给大家列出了一张表:

enter image description here

这些都是常见的时间复杂度,以后慢慢学当你遇到时,权当参考就可以了!不过如果你得到了算法的时间复杂度要比较算法的效率,你需要记住这些时间复杂度之间的关系。

按照递增排列:

 O(1) < O(logn) < O(n) < O(nlogn) < O(n^2) < O(n^3) < O(2^n) < O(n!) < O(n^n)

2.4 时间复杂度知识扩展

我们上面谈到的时间复杂度是指“最坏时间复杂度”!这个我们在这里再次强调一遍!“最坏时间复杂度”的确是我们通常说的默认的情况!但是有“最坏”就有“最好”吧!没错,不光有“最好”,还有“平均”!这些知识我想还是有必要了解一下!

2.4.1 最好情况下的算法时间复杂度

最好情况下的时间复杂度又叫做 最好时间复杂度,使用大Ω表示法表示!

其实“最好时间复杂度”好理解,它描述了一个算法的最好的情况下花费的时间,也可以看做是一个算法花费时间的下限!就比如我们生活中要找一个东西,最好的情况是他就摆在你眼前,你一下就能找到它!在算法里呢,如果要设计一个搜索算法,比如要在一个包含100个元素的数据中搜索指定目标,最好的情况就是第一次就找到了!

但是“最好时间复杂度”的实际意义并不大,除非我们要求一个算法只要最好的状态能满足需求就可以使用,但是这样的要求并不多见,一般都是要求最坏的情况下如果能接受那算法就可以使用!所以我们主要研究的和使用的都是“最坏时间复杂度”!

2.4.2 平均情况下的算法时间复杂度

平均情况下的时间复杂度又叫作 平均时间复杂度, 使用大Θ表示法表示!

“平均时间复杂度”也有一定的实用性,它描述了一个算法在处理数据过程中单个数据元素花费的平均时间!但是“平均时间复杂度”有一个问题就是并不好测量,因为平均时间是在真正运行过程中统计并且计算出来的!但也更贴合实际效果!所以这种表示方法很难通过直接评估得到结果!这个更加接近事后统计的一种方案。

不过我们可以举一个比较好统计的例子,仍然是搜索100个排好序的数据中的某个元素!我们就使用直接遍历对比并且找到就立刻停下的形式来搜索!那么最好情况下找到的第一个元素就是我们要找的,也就是1次就找到了!最坏情况呢?是第100次才找到!那么平均呢?如果每一个元素都要找一遍的话,按照从头到尾找并且找到就停下的算法你可以计算一下应该是

( 1+2+3+4+...100 ) / 100 = 50.5

平均要找50.5次!

好了,以上我们了解了原来时间复杂度是分为3种情况的!而我们日常应用中的实际上是"最差时间复杂度"!当然我们也应该更加清晰为什么要是用“最坏时间复杂度”而不是其它两个!

2.5 算法的空间复杂度

2.5.1 空间复杂度的表示

算法的空间复杂度实际上描述的就是算法程序运行时占用的内存大小!记作:

S(n)=O( f(n) )

其中n指的是数据规模!

我们设计算法的原则中“环保性”针对的就是算法的空间复杂度!但是现代设计算法和程序的时候往往忽略掉算法的空间复杂度!因为硬件已经足够多足够便宜了!但是存在就有意义!在一些地方我们还是要考虑空间复杂度的!特别是存储器很小的地方,例如物联网芯片,打印机芯片上。

那么是不是算法的空间复杂度越小越好呢?并不是这样的!因为实际是上算法的性能主要由两个元素制约:时间复杂度和空间复杂度!而且这两个往往还是冤家!时间复杂度低了那么可能就要牺牲掉空间复杂度!要降低空间复杂度,可能就要牺牲算法的时间复杂度!

空说无凭,举个例子!

先举一个生活中的例子:杯子和水。

假如你们家有无数个杯子而你又有洁癖,你是不是就可以任性一些,每次喝水都可以拿过一个新的干净的杯子倒水喝!但现实是,你可能就只有一个杯子,如果你习惯用干净杯子喝水,那你是不是就每次都要先洗干净再倒水喝呢?这个例子中,水杯就是内存空间!水就是数据!如果有足够的杯子就不用每次都洗干净直接用,是不是降低了时间复杂度?如果空间不够用,水杯越少,你是不是就要更多次的先洗干净杯子再喝水,空间复杂度降低了但是你要花掉很多时间从而牺牲了时间复杂度!

其实在编程过程中我们无数次牺牲掉了空间复杂度来降低时间复杂度?只不过,你或许从不知道这些概念而已!举个例子!

请问你对下面的==代码片段1==有什么想法?

//代码片段1

$arr = [1,2,3,4,5];

for( $i=0; $i < count( $arr ); $i ++ ){
    echo $arr[ $i ];
}

相信你从便开始编程很多人就告诉你如上的代码效率低,要优化,优化方法如下面的==代码片段二==:

//代码片段2

$arr = [1,2,3,4,5];
$len = count( $arr );

for( $i=0; $i < $len; $i ++ ){
    echo $arr[ $i ];
}

相信大家这个可以看明白,我只是把计算数组长度的代码放在了for循环的外面并且新增加了一个变量 ==$len== 来接受数组的长度!这个操作相信你一定做过!结果就是我们使用了一个新的变量从而省去了for循环内每次都要进行数组长度运算的时间!程序如何占用内存的,实际上一个变量就对应着一个空间!所以我们使用了空间复杂度换取了时间复杂度!我们通过计算能得到明确的结果!

2.5.2 空间复杂度的计算

要计算算法中的空间复杂度实际上就是计算算法占用内存的大小!要计算占用内存空间的大小,我们就要知道算法中有多少变量,每种变量对应了多少内存空间!虽然PHP是弱类型的语言,但不代表PHP没有类型!所以,PHP每种数据类型占用的实际内存大小也不相同,不过我们在计算空间复杂度时,这些具体的值都会被简化掉!要研究PHP数据类型的大小需要阅读PHP内核代码,这里不是我们的重点,相关的内容我们制作专门的文章来说明。在这里就那我们最好理解的形式来计算,按照标准C语言的类型大小就好了。

我们就以上面的两个代码片段为例打造一个遍历数组元素的算法来计算空间复杂度。

由于内存空间会受到所在机器及计算机系统影响,这里假设是在64位下的linux系统环境下的数据。

代码片段一的空间复杂度的计算

function arrIterator( $arr ){                   {1}

    for( $i=0; $i < count( $arr ); $i ++ ){     {2}
        echo $arr[ $i ];                        {3}
    }

}

第一步:统计算法中变量的个数,类型和对应的内存数量:

分析代码可知,其中用到的变量如下:
    $arr    数组    存储有n个整型元素  
    $i      整型

第二步:计算算法总内存空间f(n):

    f(n) = n * 8 + 8 = 8*n + 8

第三步:计算时间复杂度T(n):

    S(n) = O( f(n) ) = O( 8*n + 8 )

    即

    S(n) = O( 8*n + 8 )

第四步:简化。

简化方法和时间复杂度相同:

    S(n) = O(n)

Tips

其实算法的空间复杂度计算结果你会发现对应数据类型的空间大小都被简化没了,变成了数一数算法中变量个数的游戏!

代码片段二的空间复杂度的计算

function arrIterator( $arr ){                   {1}

    $len = count( $arr );                       {2}

    for( $i=0; $i < $len; $i ++ ){              {3}
        echo $arr[ $i ];                        {4}
    }

}

第一步:统计算法中变量的个数,类型和对应的内存数量:

分析代码可知,其中用到的变量如下:
    $arr    数组    存储有n个整型元素
    $len    整型
    $i      整型

第二步:计算算法总内存空间f(n):

    f(n) = n * 8 + 8 + 8 = 8*n + 16

第三步:计算时间复杂度T(n):

    S(n) = O( f(n) ) = O( 8*n + 16 )

    即

    S(n) = O( 8*n + 16 )

第四步:简化。

    S(n) = O(n)

通过比较两种实现方式的空间复杂度都是O(n)级别的,但是第二种方案使用了代码片段二优化之后反而空间复杂度比使用第一个代码片段增加了整型数据的大小,但是我们公认为第二种更快了!而其中原因就是用空间换取了时间!

2.6 小结

本章我们学习了算法性能的分析方法,主要学习了时间复杂度的意义和计算,这是重点!我们也学习了空间复杂度的意义和计算!我们要明白时间复杂度和空间复杂度的关系!对于算法而言是要更注重效率还是空间的节约,这个要在目标的基础上达到一个平衡!