算法分析
如果解决一个问题的算法被确定下来,并用某种证明方法证明其是正确的,那么接下来就要判断该算法的运行时间,以及运行时占用的空间。这一章主要讨论
- 估算程序运行时间
- 降低程序的运行时间
- 递归的风险
- 将一个数自乘得到其幂以及计算两个数的最大公因数的有效算法
2.1 数学基础
- 如果存在正常数c和n0使得当N >= n0时,T(N) <= cf(N),则记为T(N) = 0(f(N)).这里说的是T(N)的增长趋势不超过f(N)的增长趋势。我们常说的时间复杂度就用的这里的定义,f(N)也称为T(N)的上界
- 如果存在正常数c和n0使得当N >= n0时,T(N) >= cg(N),则记为T(N) = Ω(f(N)).这里说的是T(N)的增长趋势不小于g(N)的增长趋势。这里是说g(N)是T(N)的下界
- T(N) = Θ(h(N)),当且仅当T(N) = O(h(N)),且T(N) = Ω(h(N))。这里是说T(N)和g(N)的增长趋势是一样的
- 如果T(N) = O(p(N)),且T(N) != Θ(p(N)),则T(N) = o(p(N))。这里是说T(N)的增长趋势总是小于p(N)的。而且没有相等的情况
上述说法实在太过晦涩了。举一个简单的例子。当g(N) = N^2时,g(N) = O(N^3),g(N) = O(N^4)都是对的。g(N) = Ω(N), g(N) = Ω(1)也都是对的。g(N) = Θ(N^2)则表示g(N) = O(N^2),g(N) = Ω(N^2)。即当前的结果时最符合g(N)本身的增长趋势的。如图所示:
有三条重要的法则需要记住:
- 如果T1(N) = O(f(N)),且T2(N) = O(g(N)),那么
- T1(N) + T2(N) = max(O(f(N)), O(g(N))),
- T1(N) * T2(N) = 0(f(N) * g(N))
- 如果T(N)是一个k次多项式,则T(N) = Θ(N^k)
- 对任意常数k,log^k N = O(N)。它告诉我们对数增长的非常缓慢
在用大O表示法的时候,要保留高阶次幂,丢弃常数项和低阶次幂。通过增长率对函数进行分类如图:
我们总能通过计算极限lim f(N) / g(N) (n->∞)来确定两个函数f(N)和g(N)的相对增长率。可以使用洛必达准则进行计算。
- 极限是0,则f(N) = o(g(N))
- 极限是c 且c != 0,则f(N) = Θ(g(N))
- 极限是∞,则g(N) = o(f(N))
- 极限摆动:两者无关
比如,f(N) = NlogN和g(N) = N^1.5的相对增长率,即可计算为f(N) / g(N) = logN / N^0.5 = log^2 N / N。又因为N的增长要快于logN的任意次幂。所以g(N)的增长快于f(N)的增长
洛必达准则:若lim f(N) = ∞ (n->∞)且lim g(N) = ∞ (n->∞).则lim f(N)/g(N) = lim f'(N)/g'(N) (n->∞)。
2.2 模型
为了便于分析问题,我们假设一个模型计算机。它执行任何一个基础指令都消耗一个时间单元,并且假设它有无限的内存。
2.3 要分析的问题
- 如果是很小输入量的情形,则花费大量的时间去设计聪明的算法就不值得
- 数据的读入是一个瓶颈,一旦数据读入,好的算法问题就会迅速解决。因此要使算法足够有效而不至于成为问题的瓶颈是很重要的
2.4 运行时间计算
2.4.1 例子
- 如果两个算法所花费的时间大致相同,那么判断哪个程序更快的最好方法是将它们编码并运行
- 为简化分析,我们采用大O表示法计算运行时间,大O是一个上界。所以分析结果是为了给程序在最坏情况下能够在规定时间内运行完成提供保障。程序可能提前结束,但不会延后
// 书上例程
// 计算i^3的累加求和
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个时间单元:1个赋值
- 花费1+N+1+N=2N+2个时间单元:1个赋值、N+1次判断、N次加法
- 花费N(2+1+1)=4N个时间单元:2个乘法、1个加法、1个赋值,执行N次
- 花费1个时间单元:1个返回
合计花费1+2N+2+4N+1=6N+4个时间单元。
但是实际上我们不用每次都这样分析,因为面对成百上千行的程序时,我们不可能每一行都这样分析。只需计算最高阶。能够看出for循环占用时间最多。因此时间复杂度为O(N)
2.4.2 一般法则
- for循环:一次for循环运行时间至多应是该for循环内语句的运行时间乘以迭代次数
- 嵌套的for循环:从里向外分析循环。在一组嵌套循环内部的一条语句总的运行时间为该语句运行时间乘以该组所有for循环的大小的乘积
for (i = 0; i < N; i++)
for (j=0; j < N; j++)
k++; // 1 * N * N = N^2,时间复杂度为O(N^2)
- 顺序语句:将各个语句运行时间求和即可。取最大值。
for (i = 0; i < N; i++)
A[i] = 0; // O(N)
for (i = 0; i < N; i++)
for (j = 0; j < N; j++)
A[i] += A[j] + i + j; // O(N^2)
// 总时间为O(N) + O(N^2),因此取最高阶,总时间复杂度为O(N^2)
- if-else语句:判断时间加上两个分支中较长的运行时间
我们要避免在递归调用中做重复的工作。
2.4.3 最大子序列和问题的解
最大子序列问题:给定整数A1, A2, ... , AN(可能有负数),求任意连续整数和的最大值。如果所有整数均为负数,则最大子序列和为0
- 方案一,时间复杂度O(N^3)
// 书上例程
int
MaxSubsequenceSum(const int A[], int N)
{
int ThisSum, MaxSum, i, j, k;
MaxSum = 0;
for (i = 0; i < N; i++) {
for (j = i; j < N; j++) {
ThisSum = 0;
for (k = i; k <= j; k++) {
ThisSum += A[k];
}
if (ThisSum > MaxSum) {
MaxSum = ThisSum;
}
}
}
return MaxSum;
}
- 方案二,时间复杂度O(N^2)。和方案一相比丢弃了最内层的循环
int
MaxSubsequenceSum(const int A[], int N)
{
int ThisSum, MaxSum, i, j, k;
MaxSum = 0;
for (i = 0; i < N; i++) {
ThisSum = 0;
for (j = i; j < N; j++) {
ThisSum += A[k];
if (ThisSum > MaxSum) {
MaxSum = ThisSum;
}
}
}
return MaxSum;
}
- 方案三,时间复杂度O(NlogN)。使用分治策略。‘分’为将数据分为左右两部分,即将问题分成两个大致相等的子问题,然后递归的将他们求解;‘治’为分别算出两部分的最大子序列和,再将结果合并。这个问题中,最大子序列和可能出现三种情况:左半部分,右半部分,跨越左半部分和右半部分(包含左半部分的最后一个元素和右半部分的第一个元素)。第三种情况的最大子序列和为包含左半部分最后一个元素的最大子序列和加上包含右半部分第一个元素的最大子序列和的总和。
// 书上例程
int
max3(int a, int b, int c)
{
int x;
x = a > b? a: b;
return (x > c? x: c);
}
int
MaxSubsequenceSum(const int A[], int Left, int Right)
{
int MaxLeftSum, MaxRightSum;
int MaxLeftBorderSum, MaxRightBorderSum;
int MaxLeftThisSum, MaxRightThisSum;
int Center;
int cnt;
if (Left == Right) {
if (A[Left] > 0) {
return A[Left];
} else {
return 0;
}
}
Center = (Left + Right) / 2;
MaxLeftSum = MaxSubsequenceSum(A, Left, Center);
MaxRightSum = MaxSubsequenceSum(A, Center + 1, Right);
MaxLeftBorderSum = 0;
MaxLeftThisSum = 0;
for (cnt = Center; cnt >= Left; cnt--) {
MaxLeftThisSum += A[cnt];
if (MaxLeftThisSum > MaxLeftBorderSum) {
MaxLeftBorderSum = MaxLeftThisSum;
}
}
MaxRightBorderSum = 0;
MaxRightThisSum = 0;
for (cnt = Center + 1; cnt <= Right; cnt++) {
MaxRightThisSum += A[cnt];
if (MaxRightThisSum > MaxRightBorderSum) {
MaxRightBorderSum = MaxRightThisSum;
}
}
return max3(MaxLeftSum, MaxRightSum, MaxRightBorderSum + MaxLeftBorderSum);
}
- 方案四,时间复杂度为O(N)。只对数据进行一次扫描,一旦读入并被处理,它就不需要被记忆。如果数组存储在磁盘上,它就可以被顺序读入,在主存中不必存储数组的任何部分。而且任意时刻,算法能对它已经读入的数据给出子序列问题的正确答案。具有这种特性的算法也叫做联机算法(在线算法)。仅需要常量空间并以线性时间运行的在线算法几乎是完美的算法
//书上例程
int
MaxSubsequenceSum(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;
}
2.4.4 运行时间中的对数
如果一个算法用常数时间(O(1))将问题的大小消减为其一部分(通常是1/2),那么该算法就是O(logN)。另一方面,如果使用常数时间只是把问题减少一个常数(如将问题减少1),那么这种算法就是O(N)的。
- 对分查找:对分查找提供了时间复杂度为O(logN)的查找操作。它的前提是数据已经排好序了,而且每当要插入一个元素,其插入操作的时间复杂度为O(N)。因为对分查找适合元素比较固定的情况。
// 书上例程,时间复杂度为O(logN)
#define NotFound -1
int BinarySearch(const ElementType A[], ElementType X, int N)
{
int low, high, mid;
low = 0;
high = N - 1;
mid = (low + high) / 2;
while (low <= high) {
if (A[mid] < X) {
low = mid + 1;
} else if (A[mid] > X) {
high = mid - 1;
} else {
return mid;
}
}
return NotFound;
}
- 欧几里得算法:欧几里得算法这个名字听起来很高大上,其实就是我们所说的辗转相除法。当求两个整数的最大公因数时,使用其中一个整数去除另一个整数得到余数。再用刚才的除数去除以余数得到新余数,以此类推,当新余数为0时,当前整式中的除数就为最大公因数。在两次迭代之后,余数最多是原始值的一半。迭代次数最多是2logN=0(logN)
// 书上例程:辗转相除法,时间复杂度O(logN)
int test(unsigned int M, ungisned int N)
{
unsigned int Rem;
while (N > 0) {
Rem = M % N;
M = N;
N = Rem;
}
return M;
}
- 定理2.1:如果M > N,则 M mod N < M / 2。
证明:如果N <= M / 2,则余数必然小于N,所以M mod N < M / 2; 如果N > M / 2,则M - N < M / 2,即M mod N < M / 2。定理得证
- 幂运算:求一个整数的幂。即X^N。所需要的乘法次数最多是2logN,因此把问题分半最多需要两次乘法(N为奇数的情况)
// 书上例程,时间复杂度O(logN)
long int Pow(long int X, unsigned int N)
{
if (N == 0) {
return 1;
} else if (N == 1) {
return X;
}
if (isEven(N)) {
return Pow(X * X, N / 2);
} else {
return Pow(X * X, N / 2) * X;
}
}
2.4.5 检验你的分析
- 方法一:实际编程,观察运行时间结果与分析预测出的运行时间是否匹配。当N扩大一倍时,线性程序的运行时间乘以因子2,二次程序的运行时间乘以因子4,三次程序的运行时间乘以因子8.以对数时间运行的程序,当N增加一倍时,其运行时间只增加一个常数。以O(NlogN)运行的程序则是原来运行时间的两倍多一点时间。(NX,2N(X+1)).如果低阶项的系数相对较大,而N又不是足够的大,那么运行时间很难观察清楚。单纯凭实践区分O(N)和O(NlogN)是很困难的
- 方法二:对N的某个范围(通常是2的倍数隔开)计算比值T(N)/f(N),其中T(N)是观察到的运行时间,f(N)则是理论推导出的运行时间。如果所算出的值收敛于一个正常数,则代表f(N)是运行时间的理想近似;如果收敛于0,则代表f(N)估计过大;如果结果发散(越来越大),则代表f(N)估计过小。
//书上例程,时间复杂度O(N^2)
void test(int N)
{
int Rel = 0, Tot = 0;
int i, j;
for( i = 1; i <= N; i++) {
for ( j = i + 1, j <= N; j++) {
Tot++;
if (Gcd(i,j) == 1) {
Rel++;
}
}
}
printf("%f", (double)Rel / Tot);
}
2.4.6 分析结果的准确性
有时分析会估计过大。那么或者需要分析的更细致,或者平均运行时间显著小于最坏情形的运行时间而又没办法对所得的界加以改进。许多算法,最坏的界实通过某个不良输入达到的,但是实践中它通常是估计过大的。对于大多数这种问题,平均情形的分析是极其复杂的,或者未解决的。最坏情形的界有些过分悲观但是它是最好的已知解析结果。
- 简单的程序未必能有简单的分析
- 下界分析不止适用于某个算法而是某一类算法
- Gcd算法和求幂算法大量应用在密码学中
敬告:
本文原创,欢迎大家学习转载_
转载请在显著位置注明:
博主ID:CrazyCatJack
原始博文链接地址:https://www.cnblogs.com/CrazyCatJack/p/12688582.html