算法分析之时间复杂度
数学基础
四个定义
-
定义:如果存在正常数c和n0使得当N>=n0 时T(N)<=cf(N),则记为T(N)=O(f(N));
-
定义:如果存在正常数c和n0使得当N>=n0时T(N)>=cg(N),则记为T(N)=Ω(f(N));
-
定义:当且仅当T(N)=O(f(N))且T(N)=Ω(f(N)),则记为T(N)=θ(h(N));
-
定义:T(N)=O((p(N))且T(N)≠θ((p(N)),则记为T(N)=o((p(N))。
通俗来说:
定义1:大于等于
定义2:小于等于
定义3:等于
定义4:小于
三个法则
典型的增长率
模型
我们为了在一个框架当中分析算法,假设了一个非常理想化的框架:一台标准的计算机,它里面的指令都被顺序执行,在做任何一件简单工作的时候都会是消耗相同的时间的,有固定范围的整数,并且有无限的内存。
要分析的问题
要分析的最重要资源就是时间,在这里只分析两个主要因素:使用的算法以及算法的输入。一般需要考虑的是平均的情况Tavg(N)和最坏的情况Tworst(N)。
最大子序列问题
题目:给定整数A1,A2,…AN (可能有负数),求
∑
k
=
1
j
A
k
\sum_{k=1}^{j} A_k
∑k=1jAk的最大值(为了方便起见,如果所有整数均为负数,则最大子序列和为0)。
例如:输入-2,11,-4,13,-5,-2时,答案为20(从A2到A4)。
给出四种算法
运行时间的增长率
运行时间的计算
计算的是运行的上界,也就是大O的运行时间,这就需要抛弃一些低阶项。
简单举例
计算 ∑ i = 1 N i 3 \sum_{i=1}^{N} i^3 ∑i=1Ni3的一个简单片段为:
int Sum(int N)
{
int i, Sum;
Sum=0; //第1行
for(i=1;i<=N;i++) //第2行
Sum+=i*i*i; //第3行
return Sum; //第4行
}
分析:
- 声明不计时间;
- 第1行和第4行各占一个时间单元;
- 第3行每执行一次占用4个时间单元,即两次乘法、一次加法和一次赋值),一共执行N次;
- 第2行所有的语句初始化的时候花费的是1个时间单元,所有的测试语句(i<=N)花费的是N+1个时间单元,所有的自增运算(i++)花费的是N个时间单元,共2N+2个时间单元;
- 忽略调用和返回值的开销,得到的总量是6N+4;
- 因此说该函数是O(N)。
四个一般法则
分析的基本策略是从内部(或最深部分)向外展开的;
如果有函数调用,那么这些调用要首先分析;
如果有递归过程,那么存在几种选择(以后会讨论)。
递归的例子
long int Factorial(int N)
{
if(N<=1)
return 1;
else
return N*Factorial(N-1);
}
上面这个递归的例子实际上只是一个简单的for循环,从而运行时间为O(N)。
递归转化成一个简单的循环结构是相当困难的,分析将涉及到求解一个递推关系。为了观察这种能发生的情形,考虑下列程序,实际上这个程序效率极其低下。
long int Fib(int N)
{
if(N<=1) //第一行时间需求为1
return 1; //第二行时间需求为1
else
return Fib(N-1)+Fib(N-2);//第三行时间需求为T(N)=T(N-1)+T(N-2)+2
}
T(N)=T(N-1)+T(N-2)+2中的“2”指的是第一行上的工作加上第三行的加法。
这个程序中Fib(N)=Fib(N-1)+Fib(N-2),因此由归纳法可求得T(N)>=Fib(N)>=(3/2)N。可以看出这个运行时间呈指数增长,因此效率低下。
深入探讨:
这个程序缓慢的原因是,做了很多多余的工作,违反了合成效益法则[^1],在第三行的第一次调用即Fib(N-1)实际上计算了Fib(N-2),而这个信息被抛弃而在第三行上的第二次调用时又重新计算了一遍。抛弃的信息量递归地合成起来并导致巨大的运行时间。
但有一说一,递归也可以有出色的应用,会在以后讨论。
最大子序列求和问题
现在我们将要叙述四个算法来求解早先提出的最大子序列问题。
算法1:穷举所有可能
int MaxSubsequenceSum(const int A[],int N)//传入一个总长和一个数组
{
int ThisSum,MaxSum,i,j,k;
MaxSum=0;
for(i=0;i<N;i++)//设置起点位置,从i开始加
for(j=i;j<N;j++)//设置终点位置,加到j结束
{
ThisSum=0;
for(k=i;k<=j;k++)//设置变量,让k从i到j,逐渐相加
ThisSum+=A[k];
if(ThisSum>MaxSum)
MaxSum=ThisSum;
}
return MaxSum;
}
三个for循环里面,
∑
i
=
0
N
−
1
∑
j
=
i
N
−
1
∑
k
=
i
j
1
\sum_{i=0}^{N-1} \sum_{j=i}^{N-1}\sum_{k=i}^{j}1
∑i=0N−1∑j=iN−1∑k=ij1可以算出ThisSum+=A[k]被执行的次数。
该算法O(N3)。
算法2:分而治之
int MaxSubsequenceSum(const int A[],int N)
{
int ThisSum,MaxSum,i,j;
MaxSum=0;
for(i=0;i<N;i++)
{
ThisSum=0;
for(j=i;j<N;j++)
{
ThisSum+=A[j];
if(ThisSum>MaxSum)
MaxSum=ThisSum;
}
}
return MaxSum;
}
该算法O(N2)
算法3:递归调用
static int MaxSubSum(const int A[],int Left,int Right)
{
int MaxLeftSum,MaxRightSum;
int MaxLeftBorderSum,MaxRightBorderSum;
int LeftBorderSum,RightBorderSum;
int Center,i;
if(Left==Right)//左右界相等,说明只有一个元素
if(A[Left]>0)
return A[Left];
else
return 0;
Center=(Left+Right)/2;//求中间部分
MaxLeftSum=MaxSubSum(A,Left,Center);
MaxRightSum=MaxSubSum(A,Center+1,Right);
/*这两个递归不断进入这个函数知道Left=Right*/
MaxLeftBorderSum=0;
LeftBorderSum=0;
for(i=Center;i>=Left;i--)
{
LeftBorderSum+=A[i];
if(LeftBorderSum>MaxLeftBorderSum)
MaxLeftBorderSum=LeftBorderSum;
}
/*左边子序列求和*/
MaxRightBorderSum=0;
RightBorderSum=0;
for(i=Center+1;i<=Right;i++)
{
RightBorderSum+=A[i];
if(RightBorderSum>MaxRightBorderSum)
MaxRightBorderSum=RightBorderSum;
}
/*右边子序列求和*/
return Max(MaxLeftSum,MaxRightSum,MaxLeftBorderSum+MaxRightBorderSum);
/*Max是求三个中最大值的函数*/
}
int MaxSubsequenceSum(const int A[],int N)
/*求最大数列和的函数*/
{
return MaxSubSum(A,0,N-1);
}
算法分析:
令T(N)是求解大小为N的最大子序列和问题所花费的时间。如果N=1,则算法执行的是
if(Left==Right)//左右界相等,说明只有一个元素
if(A[Left]>0)
return A[Left];
else
return 0;
花费的时间是一个常量,我们称之为一个时间单元,T(1)=1;
对于下面这段
for(i=Center;i>=Left;i--)
{
LeftBorderSum+=A[i];
if(LeftBorderSum>MaxLeftBorderSum)
MaxLeftBorderSum=LeftBorderSum;
}
/*左边子序列求和*/
MaxRightBorderSum=0;
RightBorderSum=0;
for(i=Center+1;i<=Right;i++)
{
RightBorderSum+=A[i];
if(RightBorderSum>MaxRightBorderSum)
MaxRightBorderSum=RightBorderSum;
}
/*右边子序列求和*/
花费的时间为O(N)
对于
Center=(Left+Right)/2;//求中间部分
MaxLeftSum=MaxSubSum(A,Left,Center);
MaxRightSum=MaxSubSum(A,Center+1,Right);
/*这两个递归不断进入这个函数知道Left=Right*/
这两行求解大小为N/2的子序列问题(假设N是偶数)。因此,这两行每行花费的T(N/2)个时间单位,共花费2T(N/2)个时间单元。
因此算法花费的总时间是2T(N/2)+O(N)
由递归推导可以发现规律O(N logN),推导过程如下
算法4:简单有效的算法
int MaxSubsquenceSum(const int A[],int N)
{
int ThisSum,MaxSum,j;
ThisSum=MaxSum=0;
for(j=0;j<N;j++)
{
ThisSum+=A[j];
if(ThisSum>MaxSum)
MaxSum=ThisSum;
else if(ThisSum<0)
ThisSum=0;
}
return MaxSum;
}
该算法花费的时间是O(N)
运行时间中的对数
对数最常出现的规律可以概括为以下法则
如果一个算法用常数时间(O(1))将问题的大小削减为其一部分(通常是1/2),那么该算法就是O(logN)。另一方面,如果使用常数时间只是把问题减少一个常数(如将问题减少1),那么这种运算就是O(N)的。
下面例举具有对数特点的三个例子:
对分查找
给定一个整数A0 ,A1 ,……,AN , 后者已经预先排序并在内存中,求使得Ai=X的下标i,如果X不在数据中,则返回i=-1。
int BinarySearch(const ElementType A[],ElementType X,int N)
{
int Low,Mid,High;
Low=0;High=N-1;
while(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。
欧几里得算法
计算最大公因式,也就是辗转相除法。
unsigned int
Gcd(unsigned int M,unsigned int N)
{
unsigned int Rem;
while(N>0)
{
Rem=M%N;
M=N;
N=Rem;
}
return M;
}
该算法花费的时间是O(logN)。
具体推导可根据下列定理:
如果M>N,则M mod N<M/2。
幂运算
计算XN,采用递归算法。如果N是偶数,我们有XN/2·XN/2,如果N是奇数,则XN=X(N-1)/2·X(N-1)/2·X
long int Pow(long int X,unsigned int N)
{
if(N==0)
return 1;
if(N==1)
return X;
if(IsEven(N)
return Pow(X*X,N/2);
else
return Pow(X*X,N/2)*X;
}
程序花费的时间为O(logN).