《数据结构与算法分析》学习笔记-第二章-算法分析

算法分析

若是解决一个问题的算法被肯定下来,并用某种证实方法证实其是正确的,那么接下来就要判断该算法的运行时间,以及运行时占用的空间。这一章主要讨论html

  • 估算程序运行时间
  • 下降程序的运行时间
  • 递归的风险
  • 将一个数自乘获得其幂以及计算两个数的最大公因数的有效算法

2.1 数学基础

  1. 若是存在正常数c和n0使得当N >= n0时,T(N) <= cf(N),则记为T(N) = 0(f(N)).这里说的是T(N)的增加趋势不超过f(N)的增加趋势。咱们常说的时间复杂度就用的这里的定义,f(N)也称为T(N)的上界
  2. 若是存在正常数c和n0使得当N >= n0时,T(N) >= cg(N),则记为T(N) = Ω(f(N)).这里说的是T(N)的增加趋势不小于g(N)的增加趋势。这里是说g(N)是T(N)的下界
  3. T(N) = Θ(h(N)),当且仅当T(N) = O(h(N)),且T(N) = Ω(h(N))。这里是说T(N)和g(N)的增加趋势是同样的
  4. 若是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)自己的增加趋势的。如图所示:算法

有三条重要的法则须要记住:编程

  1. 若是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))
  2. 若是T(N)是一个k次多项式,则T(N) = Θ(N^k)
  3. 对任意常数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 要分析的问题

  1. 若是是很小输入量的情形,则花费大量的时间去设计聪明的算法就不值得
  2. 数据的读入是一个瓶颈,一旦数据读入,好的算法问题就会迅速解决。所以要使算法足够有效而不至于成为问题的瓶颈是很重要的

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个赋值
  2. 花费1+N+1+N=2N+2个时间单元:1个赋值、N+1次判断、N次加法
  3. 花费N(2+1+1)=4N个时间单元:2个乘法、1个加法、1个赋值,执行N次
  4. 花费1个时间单元:1个返回

合计花费1+2N+2+4N+1=6N+4个时间单元。设计

可是实际上咱们不用每次都这样分析,由于面对成百上千行的程序时,咱们不可能每一行都这样分析。只需计算最高阶。可以看出for循环占用时间最多。所以时间复杂度为O(N)

2.4.2 通常法则

  1. for循环:一次for循环运行时间至多应是该for循环内语句的运行时间乘以迭代次数
  2. 嵌套的for循环:从里向外分析循环。在一组嵌套循环内部的一条语句总的运行时间为该语句运行时间乘以该组全部for循环的大小的乘积
for (i = 0; i < N; i++)
    for (j=0; j < N; j++)
        k++;    // 1 * N * N = N^2,时间复杂度为O(N^2)
  1. 顺序语句:将各个语句运行时间求和便可。取最大值。
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)
  1. if-else语句:判断时间加上两个分支中较长的运行时间

咱们要避免在递归调用中作重复的工做。

2.4.3 最大子序列和问题的解

最大子序列问题:给定整数A1, A2, ... , AN(可能有负数),求任意连续整数和的最大值。若是全部整数均为负数,则最大子序列和为0

  1. 方案一,时间复杂度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;
}
  1. 方案二,时间复杂度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;
}
  1. 方案三,时间复杂度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);
}
  1. 方案四,时间复杂度为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)的

  1. 对分查找:对分查找提供了时间复杂度为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;
}
  1. 欧几里得算法:欧几里得算法这个名字听起来很高大上,其实就是咱们所说的展转相除法。当求两个整数的最大公因数时,使用其中一个整数去除另外一个整数获得余数。再用刚才的除数去除以余数获得新余数,以此类推,当新余数为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。定理得证
  1. 幂运算:求一个整数的幂。即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 检验你的分析

  1. 方法一:实际编程,观察运行时间结果与分析预测出的运行时间是否匹配。当N扩大一倍时,线性程序的运行时间乘以因子2,二次程序的运行时间乘以因子4,三次程序的运行时间乘以因子8.以对数时间运行的程序,当N增长一倍时,其运行时间只增长一个常数。以O(NlogN)运行的程序则是原来运行时间的两倍多一点时间。(NX,2N(X+1)).若是低阶项的系数相对较大,而N又不是足够的大,那么运行时间很难观察清楚。单纯凭实践区分O(N)和O(NlogN)是很困难的
  2. 方法二:对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

原始博文连接地址:http://www.javashuo.com/article/p-tggaygmo-hu.html


第二章到此结束,接下来就到第三章了,开始具体的数据结构和算法的实现讲解了,满满干货哦!以为好的话请能够点个关注 & 推荐,方便后面一块儿学习。谢谢你们的支持!

CrazyCatJack
相关文章
相关标签/搜索