算法分析
算法(algorithm)是为求解一个问题需要遵循的、被清楚地指定的简单指令的集合。
数学基础
定义: 如果存在正常数和使得当时,则记为。
定义: 如果存在正常数和使得当时,则记为。
定义:当且仅当且。
定义: 如果且,则。
这些定义的目的是要在函数之间建立一种相对的级别。给定两个函数,通常存在一些点,在这些点上一个函数的值小于另一个函数的值,因此,像这样的声明是没有什么意义的。于是,我们比较它们的相对增长率(relative rate of growth)。
如果用传统的不等式来计算增长率,那么第一个定义是说的正常率小于等于的增长率。第二个定义(念成“Omega”)是说的增长率大于等于的增长率。第三个定义(念成“Theta”)是说的增长率等于增长率。最后一个定义(念成“小o”)说的则是的增长率小于的增长率。它不同于大O,因为大O包含增长率相同这种可能性。
当我们说时,我们是在保证函数是在不快于的速度增长;因此是的一个上界(upper bound)。与此同时,意味着是的一个下界(lower bound)。
作为一个例子,的增长比快,因此我们说或。和以相同的速率增长时,从而和都是正确的。直观地说,如果,那么从技术上都是成立的,但最后一个选择是最好的答案。
法则1
如果且,那么
- ,
法则2
如果是一个次多项式,则。
法则3
对任意常数。它告诉我们对数增长得非常缓慢。
函数 | 名称 |
---|---|
常数 | |
对数级 | |
对数平方根 | |
线性级 | |
平方级 | |
立方级 | |
指数级 |
我们总能通过计算极限来确定两个函数和的相对增长率,必要的时候可以使用洛必达法则。该极限可以有四种可能的值:
- 极限是:这意味着
- 极限是:这意味着
- 极限是:这意味着
- 极限摆动:二者无关
运行时间计算
一个简单的例子
这里是计算的一个简单的程序片段:
int Sum(int N) {
int i, PartialSum;
/* 1*/ PartialSum = 0;
/* 2*/ for (i = 1; i <= N; i++)
/* 3*/ PartialSum += i * i * i;
/* 4*/ return PartialSum;
}
对这个程序的分析很简单。声明不计时间,第1行和第4行各占一个时间单元。第3行每执行一次占用四个时间单元(两次乘法、一次加法和一次赋值),每执行次共占用个时间单元。第二行在初始化、测试和对的自增运算中隐含着开销。所有这些的总开销是初始化1个时间单元,所有的测试个时间单元,以及所有的自增运算个时间单元,共。我们忽略调用函数和返回值的开销,得到总量是。因此,我们说该函数是。
如果我们每次分析一个程序都要演示所有这些工作,那么这项任务很快就会变成不可行的工作。幸运的是,由于我们有了大的结果,因此就存在许多可以采取的捷径并且不影响最后的结果。这使得我们得到若干一般法则。
一般法则
法则1——for循环
一次for循环的运行时间至多是该for循环内语句(包括测试)的运行时间乘以迭代的次数。
法则2——嵌套的for循环
从里向外分析这些循环。在一组嵌套循环内部的一条语句总的运行时间为该语句的运行时间乘以该组所有的for循环的大小的乘积。
作为一个例子,下列程序片段为:
for (i=0; i<N; i++)
for(j=0; j<N; i++)
k++;
法则3——顺序语句
将各个语句的运行时间求和即可(这意味着,其中的最大值就是所得的运行时间)。
作为一个例子,下面程序片段先用去,再花费,总的开销也是:
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;
法则4——if/else语句
对于程序片段:
if(Condition)
S1
else
S2
一个if/else语句的运行时间从不超过判断再加上S1和S2中运行时间长者的总的运行时间。
其他的法则都是显然的,但是,分析的基本策略是从内部(或最深层部分)向外展开的。如果有函数调用,那么这些调用要首先分析。如果有递归过程,那么存在几种选择。若递归只是被薄面纱遮住的for循环,则分析通常是很简单的。例如,下面的函数实际上就是一个简单的循环,从而其运行时间为:
long int Factorial(int N) {
if (N <= 1)
return 1;
else
return N * Factorial(N - 1);
}
这个例子中对递归的使用实际上并不好。当递归被正常使用时,将其转换成一个简单的循环结构是相当困难的。在这种情况下,分析将涉及求解一个递推过程。为了观察到这种可能发生的情形,考虑下列程序,实际上它对递归使用的效率低得令人诧异。
long int Fib(int N) {
/* 1*/ if (N <= 1)
/* 2*/ return 1;
else
/* 3*/ return Fib(N - 1) + Fib(N - 2);
}
令为函数Fib(N)
的运行时间。如果或,则运行时间是某个常数值,即第一行上做判断以及返回所用的时间。若,则执行该函数时间是第一行上的常数工作加上第三行上的工作。第三行由一次加法和两次函数调用组成。由于函数调用不是简单的运算,必须通过它们本身来分析。第一次函数调用是Fib(N - 1)
,从而按照的定义,它需要个时间单元。类似的论证指出,第二次函数调用需要个时间单元。此时总的时间需求为,其中"2"指的是第一行上的工作加上第三行上的加法。于是对于我们有下列关于Fib(N)
的运行时间公式:
但是,因此由归纳法容易证明。而,类似的计算可以证明(对于),可见,这个程序的运行时间以指数的速度增长。
最大子序列和问题的解
给定整数(可能有负数),求的最大值(为方便起见,如果所有整数均为负数,则最大子序列和为0)。
我们将要叙述四个算法来求解最大子序列和问题。
第一个算法为:
int MaxSunSequenceSum(coonst int A[], int N) {
int ThisSum, MaxSum, i, j, k;
/* 1*/ MaxSum = 0;
/* 2*/ for(i=0; i<N; i++)
/* 3*/ for(j=i; j<N; j++) {
/* 4*/ ThisSum = 0;
/* 5*/ for(k=i; k<=j; j++)
/* 6*/ ThisSum += A[k];
/* 7*/ if(ThisSum > MaxSum)
/* 8*/ MaxSum = ThisSum;
}
/* 9*/ return MaxSum;
}
它只是穷举式地尝试所有的可能。再有,本算法不计算实际的子序列;实际的计算还要添加一些额外的程序。
该算法肯定会正确运行。运行时间为,这完全取决于第5行和第6行,第6行由一个含于三重嵌套for循环中的语句组成。第2行上的循环大小为。
第2个循环大小为,它可能要小,但也可能是。我们必须假设最坏的情况,而这可能会使得最终的界有些大。第3个循环的大小为,我们也要假设它的大小为。因此总数为。语句1总共的开销只是,而语句7和8总共开销也只不过,因为它们只是两层循环内部的简单表达式。
事实上,考虑到这些循环的实际大小,更精确的分析指出答案是,而我们上面的估计高出个因子6(不过这并无大碍,因为常数不影响数量级)。一般来说,在这类问题中上述结论是正确的。精确的分析由得到,该和指出程序的第6行被执行的次数。经过计算我们得到。
下面指出一种改进的算法,该算法显然是:
int MaxSunSequenceSum(const int A[], int N) {
int ThisSum, MaxSum, i, j;
/* 1*/ MaxSum = 0;
/* 2*/ for(i=0; i<N; i++) {
/* 3*/ ThisSum = 0;
/* 4*/ for (j=i; j<N; j++) {
/* 5*/ ThisSum += A[j];
/* 7*/ if(ThisSum > MaxSum)
/* 8*/ MaxSum = ThisSum;
}
}
return MaxSum;
}
对于这个问题有一个递归和相对复杂的解法,要是真的没出现(线性的)解法,这个算法就会是体现递归威力的极好的范例了。
该算法采用一种“分治(divide-and-conquer)”策略。其想法就是把问题分成两个大致相等的子问题,然后递归地对它们求解,这是“分”部分。“治”阶段将两个子问题的解合并到一起并可能再做些少量的附加工作,最后得到整个问题的解。
在这个问题中,最大子序列和可能在三处出现。或者整个出现在输入数据的左半部,或者整个出现在右半部,或者跨越输入数据的中部从而占据左右两半部分。前两种情况可以递归求解。第三种情况的最大和可以通过求出前半部分的最大和(包含前半部分的最后一个元素)以及后半部分的最大和(包含后半部分的第一个元素)而得到。然后将这两个和加在一起。作为一个例子,考虑下列输入:
前半部分 | 后半部分
4 -3 5 -2 -1 2 6 -2
其中前半部分的最大子序列和为6而后半部分的最大子序列和为8。
前半部分包含其最后一个元素的最大和是4,而后半部分包含其第一个元素的最大和是7。因此,横跨这两部分且通过中间的最大和为。
下面提出了实现这种策略的一种实现手段。
int Max3(int a, int b, int c)
{
return a > b ? a > c ? a : c : b > c ? b : c;
}
int MaxSubSum(const int A[], int Left, int Right)
{
int MaxLeftSum, MaxRightSum;
int MaxLeftBorderSum, MaxRightBorderSum;
int LeftBorderSum, RightBorderSum;
int Center, i;
/* 1*/ if (Left == Right) /* Base Case */
/* 2*/ if (A[Left] > 0)
/* 3*/ return A[Left];
else
/* 4*/ return 0;
/* 5*/ Center = (Left + Right) / 2;
/* 6*/ MaxLeftSum = MaxSubSum(A, Left, Center);
/* 7*/ MaxRightSum = MaxSubSum(A, Center + 1, Right);
/* 8*/ MaxLeftBorderSum = 0; LeftBorderSum = 0;
/* 9*/ for (i = Center; i >= Left; i--)
{
/* 10*/ LeftBorderSum += A[i];
/* 11*/ if (LeftBorderSum > MaxLeftBorderSum)
/* 12*/ MaxLeftBorderSum = LeftBorderSum;
}
/* 13*/ MaxRightBorderSum = 0; RightBorderSum = 0;
/* 14*/ for (i = Center + 1; i <= Right; i++)
{
/* 15*/ RightBorderSum += A[i];
/* 16*/ if (RightBorderSum > MaxRightBorderSum)
/* 17*/ MaxRightBorderSum = RightBorderSum;
}
/* 18*/ return Max3(MaxLeftSum, MaxRightSum, MaxLeftBorderSum + MaxRightBorderSum);
}
int MaxSubSequnceSum3(const int A[], int N)
{
return MaxSubSum(A, 0, N - 1);
}
第1行至第4行处理基准情况。如果Left == Right
,那么只有一个元素,并且当该元素非负时它就是最大子序列和。Left > Right
的情况是不可能出现的,除非是负数。第6行和第7行执行两次递归调用。我们可以看到,递归调用总是小于原问题的问题进行。第8行至12行以及第13行至第17行计算达到中间分界处的两个最大和的和数。这两个最大和的和为扩展到左右两边的最大和。
令是求解大小为的最大子序列和问题所花费的时间。如果,则算法3执行程序第1行到第4行花费某个时间常量,我们称之为一个时间单元。于是,。否则,程序必须运行两次递归调用,即在第9行和第17行之间的两个for循环,还需某个小的簿记量,如在第5行和第18行。这两个for循环总共接触到到的每一个元素,而在循环内部的工作量是常量,因此,在第9到17行花费时间为。第1行到第5行,第8、13和18行上的程序的工作量都是常量,从而与相比可以忽略。其余就是第6、7行上运行的工作。这两行求解大小为的子序列问题(假设是偶数)。因此 ,这两行每行花费个时间单元,共花费个时间单元。算法3花费的总的时间为。我们得到方程组:
为了简化计算,可以用代替上面方程中的项;由于最终还是要用大表示的,因此这么做并不影响答案。如果,且,那么,以及。其形式是显然并且可以得到,即若,则。
下面是第4种算法的代码:
int MaxSubSequenceSum(const int A[], int N)
{
int ThisSum, MaxSum, j;
/* 1*/ ThisSum = MaxSum = 0;
/* 2*/ for (j = 0; j < N; j++)
{
/* 3*/ ThisSum += A[j];
/* 4*/ if (ThisSum > MaxSum)
/* 5*/ MaxSum = ThisSum;
/* 6*/ else if (ThisSum < 0)
/* 7*/ ThisSum = 0;
}
/* 8*/ return MaxSum;
}
该算法的一个附带优点是,它只对数据进行一次扫描,一旦A[i]
被读入并被处理,它就不再需要被记忆。因此,如果数组在磁盘或磁带上,它就可以被顺序读入,在主存中不必存储数组的任何部分。不仅如此,在任意时刻,算法都能对它已经读入的数据给出子序列问题的正确答案(其他算法不具有这个特性)。具有这种特性的算法叫做联机算法(online algorithm)。仅需要常量空间并以线性时间运行的联机算法几乎是完美的算法。
运行时间中的对数
分析算法最混乱的方面大概集中在对数上面。我们已经看到,某些分治算法将以时间运行。除分治算法外,可将对数最常出现的规律概括为下列一般法则:如果一个算法用常数时间()将问题的大小削减为其一部分(通常是),那么该算法就是。另一方面,如果使用常数时间只是把问题减少一个常数(如将问题减少1),那么这种算法就是的。
显然,只有一些特殊种类的问题才能够呈现除型。例如,若输入个数,则一个算法只是把这些数读入就必须耗费的时间量。因此,当我们谈到这类问题的算法时,通常是假设输入数据已经提前读入。我们提供具有对数特点的三个例子。
对分查找(binary search)
给定一个整数和整数,后者已经预先排序并在内存中,求使得的下标,如果不再数据中,则返回*
下面给出代码实现:
int BinarySearch(const ElementType A[], ElementType X, int N)
{
int Low, Mid, High;
/* 1*/ Low = 0; High = N - 1;
/* 2*/ while (Low <= High)
{
/* 3*/ Mid = (Low + High) / 2;
/* 4*/ if (A[Mid] < X)
/* 5*/ Low = Mid + 1;
/* 6*/ else if (A[Mid] > X)
/* 7*/ High = Mid - 1;
else
/* 8*/ return Mid; /* Found */
}
/* 9*/ return NotFound; /* NotFound is defined as -1 */
}
显然,每次迭代在循环内的所有工作花费为,因此分析需要确定循环的次数。循环从开始并在结束。每次循环后的值至少将该次循环前的值折半;于是,循环的次数最多为。(例如,若,则在各次迭代后的最大值是64,32,16,8,4,2,1,0,-1。)因此,运行时间是。
欧几里得算法
计算最大公因数的欧几里得算法。两个整数的最大公因数()是同时整数二者的最大整数。于是,。下面代码实现这个算法,假设。(如果,则循环的第一次迭代将它们互相交换)。
unsigned int Gcd(unsigned int M, unsigned int N)
{
unsigned int Rem;
/* 1*/ while (N > 0)
{
/* 2*/ Rem = M % N;
/* 3*/ M = N;
/* 4*/ N = Rem;
}
return M;
}
算法通过连续计算余数直到余数是-为止,最后的非零余数就是最大公因数。因此,如果和,则余数序列是399,393,6,3,0。从而,。
在两次迭代以后,余数最多是原始值的一半。这就证明了,迭代次数至多是从而得到运行时间。
定理2.1
如果,则。
欧几里得算法在平均情况下的性能需要大量篇幅的高度复杂的数学分析,其迭代的平均次数约为。
幂运算
将用乘法的次数作为运行时间的度量。
计算的常见的算法是使用次乘法自乘。下面的 递归算法更好:
long int Pow(long int X, unsigned int N)
{
/* 1*/ if (N == 0)
/* 2*/ return 1;
/* 3*/ if (N == 1)
/* 4*/ return X;
/* 5*/ if (IsEven(N))
/* 6*/ return Pow(X * X, N / 2);
else
/* 7*/ return Pow(X * X, N / 2) * X;
}
第1行至第4行处理基准情形。如果是偶数,我们有,如果是奇数,则。
例如,为了计算,算法将如下进行,它只用到9次乘法:
显然,所需要的乘法次数最多是,因为把问题分半最多需要两次乘法(如果是奇数)。
第3行到第4行实际上不是必须的,因为如果是1,那么第7行将做同样的事情。第7行还可以写成
/* 7*/ return Pow(X, N - 1) * X;
而不影响程序的正确性。事实上,程序仍将以运行,因为乘法的序列同以前一样。
检验你的分析
一旦分析进行过后,则需要看一看答案是否正确,是否尽可能地好。一种实现方法是编程并比较实际观察到的运行时间与通过分析所描述的运行时间是否相匹配。当扩大一倍时,则线性程序的运行时间乘以因子,二次程序的运行时间乘以因子,而三次程序则乘因子。以对数时间运行的程序当增加一倍时其运行时间只是多加一个常数,而以运行的程序则花费比在相同环境下运行时间的两倍稍多一些的时间。如果低阶项的系数相对地大,并且又不是足够地大,那么运行时间的变化量很难观察清楚。
检验一个程序是否时的另一个常用的技巧是对的某个范围(通常用2的倍数隔开)计算比值,其中是凭经验观察到的运行时间。如果是运行时间的理想近似,那么所算出的值收敛于一个正常数。如果估计过大,则算出的值收敛于。如果估计过低从而程序不是的,那么算出的值发散。