对比上篇的文章,通常对于数值型的操作,通常可以用数值的本身来反映问题的规模。对于大多数的关于数组或者vector的算法,通常参与运算的数的多少本身就代表了问题的规模。在评估算法效率过程中,我们通常用字母N来表示问题规模的大小,当N逐渐增大时,N与算法性能之间的关系称为算法复杂度(The relationship between N and the performance of an algorithm as N becomes large is called the computational complexity of that algorithm)。
通常,算法重要的性能指标就是执行时间,尽管算法分析的时候,也要考虑到运行时所需的内存空间,但是因为随着科技的发展,现在的空间已经不是首要的考虑因素。所以我们考虑问题首先考虑时间复杂性。 通常我们使用一种称为大O符号的特殊速记符号来表示算法的计算复杂度。该符号是德国数学家保罗·巴赫曼(Paul Bachmann)在1892年前在计算机开发之前提出的。符号本身很简单,由字母O组成,后跟括号括起来的公式。当用于指定计算复杂度时,公式通常是涉及问题大小N的简单函数。比如下面的表示方法:
记为O(N^2),(英文读作:big-oh of N squared)
Big-O的简化
使用大O符号来估计算法的计算复杂度时,其目的是提供一个对问题的定性观察,以便观察当N中的变化(如N变大)是怎么影响算法性能的。因为大O表示法并不是一个量化措施,所以要简化括号内的公式,以便以简单的形式捕获算法的定性行为。使用big-O表示法时可以使用的常见的简化如下:
1. 去除当N变大时,任何对算法性能的影响不再显著的部分(Eliminate any term whose contribution to the total ceases to be significant as N becomes large)。当一个公式涉及多个部分时,其中一个部分通常比其他部分增长得更快,并且随着N变大而终导致影响整个表达式。对于N值较大的值,此部分单独控制算法的运行时间,你可以完全忽略公式中的其他部分。(类似求极限,N->无穷,可以忽略低次幂部分)。
2. 去除常数因素。(Eliminate any constant factors.) 当计算复杂度时,主要关注的是运行时间如何随着问题大小N的变化而变化。常数因子对整体模式没有影响。
例如:
可以简化为:
此表达式抓住了选择排序的性能的本质。随着问题的大小增加,运行时间的增长趋向于问题规模的平方。 因此,如果你将vector的大小加倍,则运行时间将增加四倍。如果你将输入值的数量乘以10,则运行时间将会增加100倍。
计算算法复杂度
根据之前的算法,算法的复杂度肯定是越小越好的。那么如何从代码中降低算法的复杂度呢? 我们先看一段代码,这段代码是用来求vector中的数据的平均值的:
double average(vector<double> & vec) {
int n = vec.size();
double total = ;
for (int i = ; i < n; i++) {
total += vec[i];
}
return total / n;
}
现在我们来分析一下这个算法的复杂度。 当调用此函数时,代码的某些部分只会执行一次,例如初始化total为0,并在返回语句中进行除法运算。这些计算需要一定的时间,但是他们不依赖于vector大小的意义上(即无论vector多大,他们运行的时间和次数都是确定的),运行时间是恒定的。对于这种执行时间不依赖于问题大小的代码被称为常数时间(constant time),以大O表示法表示为O(1)。 有些人可能会对O(1)的心存疑惑,因为O记法是要依赖于N的。但是实际上,这种不依赖于N的符号,是O(1)符号的整体。因为增加问题的大小时,这部分代码的运行时间根本不增加(因为它是确定的,就像数学函数中的Y = 1的一条线,它斜率为0,表示随着问题规模的增大,运行时间不变。)。
现在再来分析一下for循环里面的语句:
for (int i = 0; i < n; i++) {
total += vec[i];
}
对于for循环来说,的每个循环都是执行 一次。这些部分包括在for循环语句中的表达式i ++还有下面的语句,它们构成循环体:
total += vec[i];
虽然这里这部分操作的每执行一次执行都花费了固定的时间t(假设这部分操作为 i 自增一次,total加一次),那么具体要执行多少次就由for循环中的n决定。也就是说这些语句执行n次的意味着它们的总执行时间T与vector的大小N成正比。也就是T = O(tN),根据大O记法,这里应该省略常数。即该函数的算法复杂度为O(N),这种复杂度通常称为线性时间(linear time)。
当然我们还可以通过查看代码的循环结构来估算算法复杂度。在大多数情况下,单个表达式和语句,除非它们涉及必须单独考虑的函数调用,否则会在固定的时间里运行。在计算复杂性方面重要的是这些语句的执行频率。对于许多程序,可以通过找到常执行的代码片段来确定计算复杂度,并确定其作为N的函数被执行了多少次。 例如在average函数的情况下,循环体是执行n次 因为代码的任何部分都不会比这更频繁地执行,所以我们可以预测计算复杂度将为O(N)。 这个时候我们可以用同样的方式对选择排序函数进行分析。代码中常执行的部分是语句中的比较两个数的大小:
if (vec[i] < vec[rh]) rh = i;
该语句嵌套在两个for循环中,并且都受N的值的限制。内循环运行的次数跟外循环一样频繁,也就意味着内循环中的语句的运行时间为O(N^2).