程序的运行时间

目录

算法的选择

度量运行时间

基准测试

90-10法则

 对程序的分析

运行时间

不同运行时间的比较

大 O 运行时间和近似运行时间

大O的定义

证明大O关系

 大O证明的模版

简化大O表达式

大O关系的传递率

多项和指数大O表达式

描述程序的运行时间

紧凑性

简单性

运行时间中的对数

求和规则

不相称函数

分析程序运行时间

简单语句的运行时间

 简单for循环的运行时间

 选择语句的运行时间

程序块的运行时间

边界运行时间的递归规则

后面的内容超过我的理解能力了,跳过


算法的选择

  如果需要编写的程序只是一次性处理少量数据后就弃之不用的,就应该选择自己所知的最容易实现的算法,编写并调试程序,然后就不用多管了。不过,如果需要编写在很长一段时间里由很多人使用和维护的程序,就会出现其他问题了。其一就是底层算法的可理解性,或者说是简单性。要求算法简单的原因有不少,不过最重要的也许在于,与复杂的算法相比,简单的算法实现起来不容易出错。用简单算法实现的程序,哪怕在使用相当长一段时间后,遇到一些意外输入时曝出奇怪bug的可能性也较小。

  应该将程序写得清晰明确,并仔细地记下文档,这样可便于他人维护这些程序。如果算法简单且易于理解,就更易于描述。有了好的文档,原作者之外的程序员就能方便地对原始程序加以修改(原作者经常不会做这些);或者,如果程序完成得比较早,原作者也会对其加以修改。有很多程序员写出巧妙高效的算法后就从公司拍屁股走人了,结果后续的代码维护者只能放弃他们的算法,转而用更慢但更好理解的算法来代替,这种情况屡见不鲜。

  当程序要重复运行时,它的效率以及其底层算法的效率就很重要了。我们通常会将效率与程序运行所花的时间挂钩,虽然有时程序也必须占用一些其他资源,比如:

  (1) 程序变量占用的存储空间;

  (2) 程序在计算机网络中产生的流量;

  (3) 必须出入磁盘的数据量。

  不过,对大的问题来说,对给定程序是否堪用起着决定性作用的是运作时间,而本章的主题就是运行时间。我们所要讲的程序的效率,其实就是它耗费的时间,是用程序输入大小的函数来衡量的。

  通常,可理解性和效率是相互矛盾的目标。例如,比较过选择排序程序和归并排序程序的肯定都会认同,后者不仅更长,而且难理解得多。就算我们总结了解释,在程序中添加了经过深思熟虑的注释,结果依然如此。不过,也正如我们将要了解的,只要待排序的元素个数过百,归并排序的效率就会比选择排序的效率高得多。不巧的是,这种情况太普遍了——对大数据量来说有效率的算法,编写和理解起来往往比那些相对低效的算法更加复杂。算法的可理解性,或者说是简单性,是有些主观的概念。我们可以在某种程度上克服算法不够简单的问题,即在注释和程序文档中对算法进行到位的解释。编写文档的人始终要考虑阅读这些代码及其注释的人:一般人能明白这是在说什么吗?是否需要进一步的解释、细节、定义和示例?

  另一方面,程序的效率是个客观的问题:程序所花的时间就是那么多,没什么争议的余地。不过我们没办法用所有可能的(通常是无数的)输入来运行程序。因此,我们要对程序运行时间加以度量,因为它总结了程序处理所有输入的性能,通常是用一个诸如“ n2 ”这样的简单表达式来度量的。


度量运行时间

  一旦我们认同可以通过度量程序的运行时间对程序加以评估,就要面对确定实际运行时间的问题。总结运算时间的两种主要方法是:

  (1) 基准测试;

  (2) 分析。

基准测试

  在比较用于完成相同任务的两个或多个程序时,制定一小组可用作基准的典型输入是一种惯例。也就是说,我们愿意接受基准输入作为这些任务组合的代表,并假设能顺利处理基准输入的程序能顺利处理所有输入。

  例如,评估排序算法的基准可能包含一小组数字(比如圆周率的前20位数字)、一个中等规模的输入组(比如得克萨斯州的邮政编码集合),以及一个大规模输入组(比如布鲁克林区电话目录中的电话号码集合)。我们可能还想知道,在对空集、单元素集以及已排序表排序时,程序是否能有效及正确地工作。有趣的是,有些排序算法在处理已排序表时的性能惨不忍睹。

90-10法则

  与基准测试一样,确定要分析的程序在哪里花了时间通常也是很实用的。这种评估程序性能的方法称为剖析(profiling),而且多数程序设计环境都包含有剖析器(profiler)这种工具,会为程序中每条语句关联一个表示执行这条语句所花时间的数字。还有一种相关的实用程序,名叫语句计数器,用于确定对于给定的输入集,源程序中每条语句执行的次数。

  很多程序都具有这样的特性,即大部分运行时间都花在一小部分源代码上了。有这么一条非正式的法则:90%的运行时间花在了10%的代码上。尽管准确的百分比是视程序而定的,不过“90-10法则”还是表明了多数程序中运行时间主要花在了哪里。想要加快程序运行速度,最简单的一种方法就是对程序加以剖析,并对程序“热点”(也就是程序中花掉大部分运行时间的部分)的代码加以改进。例如我们中提到过,用等价的迭代函数替代递归函数是可能为程序提速的。不过,这种做法只有在递归函数正好是程序中占用大部分运行时间的部分时才奏效。

  在极端情况下,即便我们将只占用10%时间的那90%的代码所花的时间变为0,程序总的运行时间也只减少了10%。然而,如果将10%的程序所占用的那90%的时间减半,总运行时间就将减少45%

 对程序的分析

  要分析程序,首先要按大小为输入分组。用来表示输入大小的度量是因程序而异的。对排序程序来说,待排序元素的数量就是个很不错的度量。对于求解n元线性方程组的程序,拿n作为问题的大小是很平常的。其他的程序可能使用某个特定输入的值作为程序输入的表的长度,或作为输入的数组的大小,或是诸如此类的度量的组合。

运行时间

  用函数T ( n)来表示程序或算法处理大小为n的任意输入所花的时间是很方便的。我们将T (n )称为程序的运行时间。例如,某个程序的运行时间可能是T (n)=cn,其中c是某个常数。换个说法就是,该程序的运行时间,与其要处理的输入的大小是线性相关的。这样的程序或算法就是线性时间的,或者直接说成是线性的。

  我们可以将运行时间T (n )看作程序执行的C语言语句的数量,或是在某标准计算机上运行程序所花的时长。在多数情况下,我们都不会明确指出T n( )的具体单位。事实上,正如我们在下面将要看到的,在谈论程序的运行时间时,可以只用某个(未知的)常数因子乘上T ( n)来表示。

  很多时候,程序的运行时间取决于某个特定的输入,而不仅仅取决于输入的大小。在这类情况下,我们将T (n )定义为最坏情况运行时间,也就是所有大小为n的输入所能造成的最大运行时间。

  另一种常见的性能度量是Tavg (n ),即程序处理所有大小为n的输入的平均运行时间。平均运行时间有时候是对实际性能更为现实的反映,不过它往往比最坏情况运行时间更难计算。“平均运行时间”中“平均”的概念还意味着,所有大小为n的输入是等可能性的,而这在某个给定情况下既可能为真,也可能不为真。

不同运行时间的比较

  假设对某个问题,可以选择使用运行时间为

的线性时间程序A,以及运行时间为的二次幂时间程序B。假设这两个运行时间是在同一特定计算机上处理大小为n的输入所花的毫秒数。

  对大小小于50的输入来说,程序B要比程序A快。当输入的大小大于50时,程序A就要更快了,而且从50这个临界点开始,输入越大,程序A相比程序B而言优势就越大。对大小为100的输入,A要比B快上2倍,而对大小为1000的输入,A要快上20倍。程序运行时间的函数形式最终确定了我们能用该程序解决多大的问题。

  随着计算机速度的不断变快,与运行时间增长迅速的程序相比,那些运行时间增长缓慢的程序在可处理问题的规模上能取得更大的提高。


大 O 运行时间和近似运行时间

  假设我们编写了一个C语言程序,并选择了想要它处理的特定输入。程序处理这一输入的运行时间仍取决于以下两个因素。

  (1) 运行该程序的计算机。一些计算机执行指令的速度比其他计算机更快,最快的超级计算机与最慢的个人计算机之间的性能比远大于1000∶1。

  (2) 生成计算机可执行程序所使用的特定C语言编译器。在同一计算机上,执行不同程序所用的时间是不一样的,即便这些程序有着相同的功效。

  这样一来,我们就不能看着C语言程序及其输入,然后判断说:“这个任务要花上3.21秒。”除非知道用的什么计算机和编译器。此外,就算我们知道程序、输入、机器和编译器,要准确预计将要执行的机器指令数通常也是一项过于复杂的任务。

  出于这些原因,我们通常用大O表示法来表示程序的运行时间,该方法让我们可以不去考虑如下常数因子。

  (1) 特定编译器生成机器指令的平均数。

  (2) 特定计算机每秒执行机器指令的平均数。

   例如,我们研究的选择排序程序段处理长度为m的数组将耗时 4m-1 。不过这里我们不这么说,而是说它耗时O ( m) ,非正式的含义是“某个常数乘以m”。“某个常数乘以m”这一表述不仅能让我们忽略那些与编译器和计算机相关的未知常数,还让我们可以作出一些起简化作用的假设。O (m)表示法使我们可以在不涉及不可知或无意义常数的情况下作出这些陈述。另一方面,将程序段的运行时间表示为O ( m),也告诉我们一些非常重要的事情。它表明,执行处理逐步变大的数组的程序,所花的时间是线性增长的。因此,该程序段表示的算法,优于运行时间增长更快的算法。

大O的定义

  我们现在要给出某个函数是另一个函数的“大O”的正式定义。设有函数T (n ) ,这通常是某个程序的运行时间,以输入大小为n的函数来度量。要让函数适用于度量程序的运行时间,我们假设有:

  (1) 参数n被限定为非负整数;

  (2) 值T (n ) 对所有的参数n来说都非负。

  设 f (n ) 是某个定义在非负整数n之上的函数。如果除了对某些较小的n值之外,T ( n) 至多是某个常数乘以 f (n ),我们就可以说“T ( n) 是 O(f(n))”。

  正式地说,如果存在某个整数 n0 以及某个大于0的常数c,使得对所有大于 n0 的整数n,都有Tn≤cf(n),那么我们就说T (n ) 是 O(f(n))。

  我们把数对 n0 和c称为“T (n )是 O(f(n))”这一事实的证物(witness)。在接下来的证明中,该证物可以为T (n ) 和 f ( n) 的大O关系“作证”。

证明大O关系

  可以应用“大O”的定义证明对特定的函数T和f,T (n)就是 O(f(n))。我们会通过选择特定的证物 n0 和c,接着证明T(n)≤cf(n),从而完成这一证明。证明过程必须假设n是非负整数,且不小于我们选择的 n0 。通常,证明过程涉及一些代数和不等式的变换。

 大O证明的模版

  请记住:所有的大O证明基本遵循相同的形式,只有代数变换是各异的。要证明T (n ) 就是O(f(n)),要做的只有下面两件事。

  (1) 说明证物 n0 和c。这些证物必须是特定的常数,比如 n0 = 47 和 c = 12.5 。还有,n0 必须是非负整数,而c必须是正实数。

  (2) 通过适当的代数变换,证明对所选择的特定证物 n0 和c,如果n≥n0,则有T(n)≤cf(n)


简化大O表达式

  通过舍弃一些常数因子和低阶项,可以简化大O表达式。我们将会看到,在分析程序时,作出这样的简化有多重要。一般来说,某个程序的运行时间来源于程序中很多不同的语句或程序段,而一小部分程序占用大量运行时间的情况也很平常(由“90-10”法则可知)。通过舍弃一些低阶项,并将相等或近似相等的项结合起来,通常能大大简化表示运行时间的大O表达式。

大O关系的传递率

  如果 f (n ) 是 O(g(n)),而且 g(n) 是 O(h(n)),就有 f (n ) 是 O(h(n))。

多项和指数大O表达式

  多项式的次数是指多项式所有项中的最高指数。

  (1) 如果 p (n ) 和q (n ) 都是多项式,且q (n ) 的次数大于等于 p (n ) 的次数,就有p(n) 是 O(q(n))

  (2) 如果 q (n ) 的次数小于 p (n ) 的次数,那么 p (n ) 不是 O(q(n))。

  (3) 指数式是指形如 an 的表达式(其中 a>1)。指数式要比多项式增长得更快。也就是说,我们可以为任一多项式 p (n ) 证明, p (n ) 是 O(an)。

  (4) 反过来,对 a>1,不存在指数式 an为多项式 p (n ) 的 O(p(n))。

描述程序的运行时间

  我们之前对程序的运行时间T(n)的定义是,程序处理大小为n的任意输入所耗费时间单位的最大值。我们还说过,要确定T(n)的准确公示,就算不是不可能,也将非常困难。通常,可以用大O表达式O(f(n))作为T(n)的上限,从而将问题大大简化。

  例如,选择排序运行时间T(n)的上限是an2,其中a是某个常数,而且n≥1,我们将在后面展示这个事实。然后可以说选择排序的运行时间是O(n2)。从直觉上讲,这一陈述是最为实用的,因为n2是个非常简单的函数,而且有关其他简单函数的更强陈述(比如“T(n)是O(n)”)都为假。

  不过,因为大O表示法的本性,还可以说运行时间T(n)是O(0.01n2),或O(7n2-4n+26),或者是任何二次多项式的大O。原因在于,n2是任意二次式的大O,而根据传递律,就可以从T(n)是O(n2)这一事实得出T(n)是任意二次式的大O。

  更糟的是,n2还是任意三次或更高次多项式,或者是任意指数式的大O。因此,再次利用传递性,T(n)是O(n2),O(2n+n4),等等。不过我们将会解释,为什么O(n2)是表示选择排序程序的运行时间的首选。

紧凑性

  首先,我们一般都想要达到可以证明“最紧”大O上界。也就是说,如果T(n)是O(n2),我们就想做出这一表述,而不是作出“T(n)是O(n3)”这种技术上正确却更弱的表述。不过,因为在大O表达式中,常数因子是不产生影响的,所以通过缩小常数因子让预估运行时间“更紧凑”的尝试是没有意义的。因此,只要有可能,我们就会试着使用常数因子为1的大O表达式。

  更精确地讲,如果同时满足如下两点:

  (1)T(n)是O(f(n))

  (2)如果T(n)是O(g(n)),那么f(n)是O(g(n))也为真(通俗地讲,我们照不出这样一个函数g(n),他至少与T(n)增长的一样快,却又比f(n)增长得慢)。

  那么我们就说f(n)是T(n)的紧大O边界。

简单性

  在我们选择大O边界时,另一个目标就是函数表达式的简单性。与紧凑性不同,简单性有时候是种偏好问题。不过,一般还是可以按照如下标准认定函数 f (n )是简单的:

  (1) 它只有一项;

  (2) 这项的系数是1。

运行时间中的对数

  通常会把“log n ”考虑为log2 n ,而不是ln n 和lg n 。log2 n 就是将n除以2直到得到1为止的次数,或者换句话说,是为了得到n,相乘的2的个数。对数在对分治算法的分析中会频繁地出现。如果我们一开始有大小为n的输入,那么将输入对半分,直到大小为1的阶段数是 log2 n 。或者,如果n不是2的乘方,就是比 log2 n 大的最小整数。

求和规则

  假设某个程序由两部分组成,一部分耗费的时间是O(n2),而另一部分消耗的时间为O(n3)。可以将这两个大O边界“相加”,从而得出整个程序的运行时间。在很多情况下,通过应用如下求和规则,可以将大O表达式“相加”。

  假设已知T1(n)是O(f1(n)),而T2(n)是O(f2(n))。此外,假设 f2 的增长率不大于 f1的增长率,也就是说, f2(n)是O(f1(n))。那么就可以得出“T1(n)+T2(n)是O(f1(n))”的结论。

不相称函数

  任意两个函数f(n)和g(n)可由大O相比较。也就是说,要么f(n)是O(g(n)),要么g(n)是O(f(n))。或者二者互为对方的大O。但也有一些不相称函数对,他们之间不存在任何大O关系。


分析程序运行时间

简单语句的运行时间

  某些对数据的简单操作可以在O(1) 时间内完成,也就是说,这个时间是和输入大小无关的。C语言中的这些基本操作包括:

  (1) 算术运算(比如+或%);

  (2) 逻辑运算(比如&&);

  (3) 比较运算(比如<=);

  (4) 结构体存取操作(比如A[i]这样的数组索引,或者跟在指针后的->运算符);

  (5) 简单的赋值(比如将某个值复制到某个变量中);

  (6) 对库函数(比如scanf、printf)的调用。

  对这一原则的验证需要对常见计算机的机器指令(初始步骤)进行详细研究。我们很容易看出,之前描述的每种操作都只需要少量机器指令便可完成,通常只需要1条或2条指令。

  因此,在C语言中有好几种语句都能在O(1) 时间内执行完,也就是说,可以在与输入无关的某个时间段内执行完。这些简单语句包括:

  (1) 表达式中不涉及函数调用的赋值语句;

  (2) 读语句;

  (3) 不需要调用函数确定参数值的写语句;

  (4) 跳转语句break、continue、goto和return表达式,其中表达式不含函数调用。

 简单for循环的运行时间

  在for循环中,最终值和初始值之间的差,除以指标变量每次递增的量,就可以得出循环了多少次。这种计数是精确的,除非还存在一些通过跳转语句退出循环的方式,否则这在任何情况下都是迭代次数的上界。

  要为for循环的运行时间找出边界,必须先找到循环体进行一次迭代所花时间的上界。进行一次迭代的时间包括递增循环指标所花的时间O(1) ,以及比较循环指标与上限所花的时间O(1) 。除了循环体为空的异常情况,其他所有情况下的这些O(1) 都可以根据求和规则舍弃掉。

  在最简单的情况,也就是循环体每次迭代所花的时间均相同的情况下,可以用循环体的大O上界乘上循环的次数。严格地说,还必须加上初始化循环指标的时间O(1) ,以及第一次比较循环指标和上限的时间O(1) 。不过,除非有可能不执行循环,否则初始化循环和测试上限的时间都是根据求和规则可被舍弃的低阶项。

 选择语句的运行时间

  if-else选择语句具有如下形式:

if(<condition>)
  <if-part>
else
  <else-part>

  其中

  (1) 条件是待评估的表达式;

  (2) if部分的语句只有在条件为真(表达式的值不为0)时才执行;

  (3) else部分的语句只有在条件为假(评估为0)时才执行,else后的<else-part>是可选的。

  只要条件中没有函数调用,不管条件多么复杂,都只需要计算机执行一定量的基本操作。因此,条件评估所花的时间为O(1) 。

  假设在条件中没有函数调用,而且if部分和else部分分别具有大O上界f(n)和g(n),还假设f(n)和g(n)不会都为0,也就是说,尽管else部分可能不存在,但if部分是不会为空的。

  如果f(n)是O(g(n)),那么可以将O(g(n))作为选择语句运行时间的上界。原因包括:

  (1) 可以忽略条件所花的时间O(1) ;

  (2) 如果else部分执行,就可知 g( n) 是运行时间的边界;

  (3) 如果if部分(而不是else部分)执行,那么运行时间将是O(g(n))。

  类似的,如果g(n)是O(f(n)),就可以通过O(f(n))确定选择语句运行时间的边界。当else部分不存在时,g(n)为0,就肯定是O(f(n))。

  当f和g之间不存在大O关系时,问题出现了。我们知道if部分或else部分肯定有一种要执行,但不可能都执行,所以运行时间的安全上界就是f(n)和g(n)中的较大者。因此,要将选择语句的运行时间表示为O(max(f(n),g(n)))。

程序块的运行时间

  一般的情况是,必须能将一系列语句(其中有一些是复合语句,也就是选择语句或循环)组合起来。这样一系列简单的复合语句就是程序块。要计算程序块的运行时间,需要对程序块中每条(可能是复合的)语句的大O上界求和。


边界运行时间的递归规则

  要将一些构建C语言语句的句法规则表述为递归定义。这些规则符合经常出现在C语言教材中的那些定义C语言的语法规则。语法可以用作简洁递归表示法,来指明编程语言句法。

  我们可以用如图所示的树表示程序的结构。树叶(那些圆圈)是简单语句,而其他的节点则表示复合语句。节点会被标记上它们所表示结构的种类,以及构成该节点所表示简单语句或复合语句的代码行。从每个表示复合语句的节点N都会向下引出到达其“子节点”的连线。节点N的子节点表示构成N所表示复合语句的那些子语句。这样的树就称为程序的结构树。

        

 攀爬结构树以确定运行时间

后面的内容超过我的理解能力了,跳过

  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值