1. 求复杂度的数学基础
估计算法资源消耗所需的分析一般说来是一个理论问题,因此需要一套正式的系统构架。我们先从某些数学定义开始。
定义1:如果存在正常数c和n,使得当N ≥ n时T(N) ≤ cf(N),则记为 T(N) = O(f(N))
定义2:如果存在正常数c和n,使得当N ≥ n时T(N) ≥ gf(N),则记为 T(N) = Ω(g(N))
定义3:如果仅当 T(N) = O(h(N))且 T(N) = Ω(h(n))时, T(N) = Θ(f(N))
定义4:如果 T(N) =O(p(N))且T(N) ≠ Θ(f(N)),则 T(N) = o(p(N))
这些定义的目的主要是在函数间建立一种相对的级别。给定两个函数,通常存在一些点,在这些点上一个函数的值小于另一个函数的值,因此,像f(N) < g(N)这样的声明是没有值么意义的。于是,我们比较它们的相对增长率。它是重要的度量。
虽然N比较小时1000N要比N大,但N以更快的速度增长,因此 N最终将更大。在这种情况下,N=1000 是转折点。
第一个定义是说,最后总会存在某个点,从它以后 cf(N) 总是至少与T(N)一样大,从而若忽略常数因子,则 f(N)至少与T(N)一样大。T(N)=1000N,f(N)=N,=1000而c=1。也可以让=10而c=100。因此,我们可以说1000N = O(N)(N平方级),这种记法称为大O记法。
如果用传统的不等式来计算增长率,那么第一个定义是说 T(N) 的增长率小于等于f(N)的增长率。第二个定义是说T(N)的增长率大于等于g(N)的增长率。第三个定义是说 T(N)的增长率等于h(N)的增长率。最后一个定义T(N)增长率小于p(N)增长率。
了解了以上定义后,我们应该掌握如下几个结论:
法则 1:如果 T(N) = O(f(N))且T(N) = O(g(N)),那么
- T(N) + T(N) = max(O(f(N)), O(g(N))
- T(N) * T(N) = max(O(f(N) * g(N))
法则2∶ 如果 T(N)是一个k次多项式,则 T(N) = Θ(N)。
法则3∶ 对任意常数k,logN=O(N)。它告诉我们对数增长得非常缓慢。
需要注意的是:
- 将常数或低阶项放进大O是非常坏的习惯。不要写成 T(N)= O(2N)或 T(N) = O(N + N)。在这两种情形下,正确的形式是T(N)= O(N)。
- 我们总能够通过计算极限来确定两个函数f(N)和g(N)的相对增长率,必要的时候可以使用洛必达法则。该极限可以有四种可能的值∶
-
1. 极限是0: 这意味着 f(N) = o(g(N)) -
2. 极限是c ≠ 0: 这意味着 f(N) = Θ(g(N)) -
3. 极限是∞: 这意味着g(N) =o(f(N))。 -
4. 极限摆动: 二者无关
2. 运行时间计算
下面是一个计算的一个简单的程序片段
int
Sum( int N )
{
int i, PartialSum;
PartialSum = 0; /* 1*/
for (i=1; i <= n; i++) { /* 2*/
PartialSum += i * i * i /* 3*/
return PartialSum; /* 4*/
}
我们来分析下上面的程序。声明不计时间。第1行和第4行各占一个时间单元。第3行每执行一次占用四个时间单元(两次乘法、一次加法和一次赋值),而执行N次共占用 4N 个时间单元。第二行在初始化 i、测试 i≤N 和对i的自增运算中隐含着开销。第2行的总开销是初始化1个时间单元,所有的测试 N+1个时间单元,以及所有的自增运算N个时间单元,共2N+2。我们忽略调用函数和返问值的开销,得到总量是6N+4。因此,我们说该函数是 O(N)的。
如果我们每次分析一个程序都要演示所有这些工作,那么这项任务很快就会变成不可行的工作。幸运的是,由于我们有了大 O 的结果,因此就存在许多可以采取的捷径并且不影响最后的结果。例如,第三行(每次执行时)显然是 O(1)语句,因此精确计算它究竞是二、三还是四个时间单元是愚蠢的,这无关紧要。第一行与for循环相比显然是不重要的,所以在这里花费时间也是不明智的。这使得我们得到若干一般法则。
所以我们在分析复杂度时只考虑最高复杂度的运算
2.1 for循环:
一次 for 循环的运行时间至多是该 for 循环内语句(包括测试)的运行时间桌以送代的次数。
2.2 嵌套的 for 循环
从里向外分析这些循环。在一组嵌套储环内部的一条语句总的运行时间为该语句的运行时间乘以该组所有的 for 循环的大小的乘积。 下列程序片段为O(N);
for(i = 0; i < n; i++)
for (j = 0; j< n; j++)
k++;
2.3 顺序语句
将各个语向的运行时间求和即可(这意味着,其中的最大值就是所得的运行时间)。下面的程序片段先用去 O(N),再花费O(N),总的开销也是 O(N);
for(i = 0; i < n; i++)
A[i] = 0;
for(i = 0; i < n; i++)
for (j = 0; j< n; j++)
A[i]+=A[j] + i + j
2.4 if/else 语句
对于程序片段
if (Condition)
S1
else
S2
一个if/else语句的运行时间从不超过判断的时间再加上S1和S2中运行时间较长者的总的运行时间。显然在某些情形下这么估计有些过高,但绝不会估计过低。
2.5 递归
若递归实际上只是稍加演示的for循环,则分析通常是很简单的。例如,下面的例子实际上就是一个简单的循环,从而其运行时间为 O(N)
long int
Factorial ( int N )
{
if( N <=1 )
return 1;
e1se
return N * Factorial(N - 1);
}
下面这个程序的运行时间以指数的速度增长。这大致是最坏的情况。这个程序之所以缓慢,是因为存在大量多余的工作要做,第-次调用Fib(N - 1)实际上计算了Fib(N - 2)。随后这个信息被抛弃并在递归第二次调用时又重新计算了一遍。。抛弃的信息量递归地合成起来并导致巨大的运行时间。
long int
Fib ( int N )
{
if( N <=1 )
return 1;
e1se
return Fib(N - 1) + Fib(N - 2);
}
2.6 运行时间中的对数
如果一个算法用常数时间O(1)将问题的大小削减为其一部分(通常是1/2),那么该算法就是 O(logN)。另一方面,如果使用常数时间只是把问题减少一个常数(如将问题减少1),那么这种算法就是 O(N)的。 显然,只有一些特殊种类的问题才能够呈现出 O(log N)型。例如,若输入 N个数,则一个算法只是把这些数读入就必须耗费 Ω(N) 的时间量。因此,当我们谈到这类问题的O(log N)算法时,通常都是假设输入数据已经提前读入。
对分查找
给定一个整数X和整数A0,A1,..,A,后者已经预先排序并在内存中,求使得 A = X的下标i,如果 X 不在数据中,则返回 i = -1。
明显的解法是从左到右扫描数据,其运行花费线性时间。
一个好的策略是验证 X是否是居中的元素。如果是,则答案就找到了,如果 X小于居中元素,那么我们可以应用同样的策略于居中元素左边已排序的子序列。同理,如果 X 大于居中元素,那么我们检查数据的右半部分。
ing
BinarySearch( const ElementType A[ ], ElementType X, int N )
{
int Low, Mid, High;
Low = 0; High = N - 1;
whi1e(Low < High)
{
Mid = (Low + High) / 2;
if (A[Mid] < X)
Low = Mid + 1;
else
if(A [Mid] > X)
High = Mid - 1
else
return Mid
}
return NotFound;
}
每次迭代在循环内的所有工作花费为 O(1),因此分析需要确定循环的次数。循环从 High-Low=N-1开始并在 High-Low ≥-1 结束。每次循环后 High-Low的值至少将该次循环前的值折半。于是,循环的次数最多为[log(N-1)]+2。例如,若 High - Low=128,则在各次迭代后 High-Low的最大值是 64, 32, 16, 8, 4, 2, 1, 0, -1。因此,运行时间是 O(log N)。
推荐文章:如何理解时间复杂度
3. 常见的时间复杂度比较
画成图表如下
4. 常用数据结构的时间复杂度
O(1):Constant Complexity 常数复杂度
O(log n):Logarithmic Complexity 对数复杂度
O(n):Linear Complexity 线性时间复杂度
O(n^2):N square Complexity 平方
O(n^3): N square Complexity 立方
O(2^n):Exponential Growth指数
O(n!):Factorial 阶乘
4.1 常用数据结构
4.2 图
4.3 排序算法
4.4 搜索算法
常见的算法复杂度分析:www.bigocheatsheet.com/
5. 检验分析
一种实现方法是编程并比较实际观察到的运行时间与通过分析所描述的运行时间是否相匹配。当N 扩大一倍时,线性程序的运行时间乘以因子2。二次程序的运行时间乘以因子4,而三次程序的运行时间则乘以因子8。以对数时间运行的程序当 N 增加一倍时其运行时间只是多加一个常数,而以 O(N log N) 运行的程序则花费比相同环境下运行时间的两倍稍多一些的时间
验证一个程序是含是O(f(N))的另一个常用的技巧是对 N的某个范围(通常用2的倍数隔开)计算比值 T(N)/f(N),其中T(N)是凭经验观察到的运行时间。如果 f(N)是运行时间的理想近似,那么所算出的值收敛于一个正常数。如果 f(N)估计过大,则算出的值收敛于0。如果 f(N)估计过低从而程序不是 O(f(N)) 的,那么算出的值发散。