算法分析的一般方法

算法分析

算法简单点来说就是有限的输入经过有限的步骤到明确的结果。需要注意的是算法的步骤一定是有限的,否则,基于该算法所写的程序一定会进入死循环从而宕机。本文主要介绍在给定一个算法时,我们如何评估它的时间效率,也称时间复杂度。

必要的数学背景

在算法分析中常常要评估它的运行时间,通常的做法是将算法的行为抽象为一个函数,然后找一个理想的函数与之近似从而确定上下限。因此,主要使用如下4个定义

定义1

\(T(N) = \Omicron(f(N))\),如果存在常数\(c\)\(n_0\)\(N \ge n_0\)时使得\(T(N) \le cf(N)\)

定义2

\(T(N) = \Omega(g(N))\),如果存在常数\(c\)\(n_0\)\(N \ge n_0\)时使得\(T(N) \ge cg(N)\)

定义3

\(T(N) = \Theta(h(N))\)当且仅当\(T(N) = \Omicron(h(N))\)\(T(N) = \Omega(h(N))\)

定义4

\(T(N) = \omicron(p(N))\),如果存在常数\(c\)\(n_0\)\(N > n_0\)时使得\(T(N) < cp(N)\)

这四个定义表明了增长关系,虽然在小量输入时不明显,但是算法解决的问题是在大量输入的情况下居多。

此外由这四个定义派生出来的一些结论如下

结论1

如果\(T_1(N) = \Omicron(f(N))\)\(T_2(N) = \Omicron(g(N))\),那么\(T_1(N) + T_2(N) = \Omicron(f(N)+g(N))\)\(T_1(N)*T_2(N) = \Omicron(f(N)*g(N))\)

结论2

如果\(T(N)\)是一个\(k\)次多项式,那么\(T(N) = \Theta(N^k)\)

结论3

\(log^kN = \Omicron(N)\),对所有的常数\(k\)

在我们的算法分析中,通常采用第一个定义所规定的形式,尽管在事实上这四个定义在逻辑上是一致的。同时,我们一般会忽略常数与决定项的常参数。

模型

本文主要是介绍时间复杂度的算法分析,所以我们应当假设内存是无限的。除此之外,我们的模型还具有以下特征

按序执行指令,也即按序执行逻辑代码

具有一套简单的操作,如加减乘除

忽略复杂的操作,如矩阵

每一个简单的操作所执行的时间单元是相同的

大小是固定的,如32比特或者64比特

忽略给程序的输入

与现实的计算机相比,简单操作所执行的时间单元相同,事实上,计算机在执行诸如像加减乘除这样的操作时要比其它的操作快上几个数量级。此外,给予程序的输入有时会占据大量,而这是在程序或者说算法执行之前。

基于上面的考虑,从另一个角度来看,算法是对数据的处理,我们不考虑数据是如何输入到程序中,对于这样的时间一般不计算到算法的时间复杂度的分析中。此外,我们的模型是用计算数代替时间,因此,时间被抽象为一个单元。而且我们常常使用的是大O估算,所以计算时间复杂度是找出最能影响时间的阶项,把它抽离出来作为我们的拟合。

一般法则

1、for循环

迭代时间乘以测试时间,例如

for(i=1; i<=n; i++)
    c=a+b;

初始化被认为占用一个时间单位。其它的基础操作也占用一个时间单位,所以这条for循环的时间为\(4N+1\)。即for循环的规则是迭代次数乘以内容。

2、嵌套循环

由内到外计算,即每层循环的迭代次数乘以该层的内容,最后再全部相乘。

3、选择语句

选择语句在执行时会根据各个分支的内容有长有短,我们在分析时应当根据最长的内容来计算,这样就不会违背时间复杂度中对最坏情形的原则。例如

if(condition)
    S1;
else
    S2;

如果S1时间多,则整个选择语句时间复杂度按S1算,否则按S2算。

4、顺序语句

将各个语句的时间复杂度做加法。需要注意的是,我们通常采用的是大O估算,所以取最高阶。

至于函数调用,它是基于上述规则所作的封装。接下来着重讨论一下递归。

递归

另一个单独陈述的模型是递归,递归事实上与归纳法证明在逻辑上一致,基于归纳法拓展的递归具有以下4个性质

1、基准情形:对于某些小量输入,在不使用递归的前提下得到解

2、要有进展:随着输入的推进,最终会终于于某些情形,通常是基准情形

3、假设可成立的设计原则:我们通常假设递归能够正确进行,而不去追究它在系统上的簿记开销

4、合成效益原则:对于递归过程的例程计算不可重复

对于第3点,我们得强调一下,簿记开销通常比较复杂,有时会比较耗时间。这里我们假设对于基础操作是一致的,相同的时间单元。因为通常情况下这是成立。

递归的时间复杂度可以通过两种方式计算。如果递归比较简单,则直接转化为for循环;如果递归比较复杂,则可以抽象为归纳法证明它的通项公式来计算。

分析什么

最终的是要分析程序的运行时间,也就是时间复杂度。程序的运行时间分为三种情况——最好的、平均的、最坏的。一般我们考虑最坏情形的时间复杂度。因为它包含了所有情况。下面给出一个示例。

最大子序列和问题

给定一组数字

\[A_1,A_2,A_3,\dots,A_N \]

找出最大子序列的和,即

\[\sum\limits_{k=i}^{j} A_k \]

解决这样的问题,有很多种算法,这里介绍4种。

1、穷举法

对于找到最大数或者最小数,不考虑效率,最简单的就是穷举法。使用C++描述大概如下

int maxSubSum1( const vector<int> & a)
{
    int maxSum = 0;

    for( int i = 0; i < a. size( ); i++ )
        for( int j = i; j < a.size( ); j++ )
        {
            int thisSum = 0;
            
            for( int k = i; k <= j; k++ )
                thisSum += a[k];
                  
            if( thisSum > maxSum )
                maxSum = thisSum;
        }
       
     return maxSum;
}

穷举法的特点就是“遍历”所有内容,这里的“遍历”不是一遍而是多遍,它的要求就是找到我们所期望的那个结果。为了能够做到彻底的遍历,首先给定一个锚点

for( int i = 0; i < a.size( ); i++ )

这个锚点就是我们所期望的子序列的起点,最终这个锚点会遍历内容,换言之,就是找出所有的子序列。之后,以这个锚点为基准,我们要规范所要待找子序列的范围

for( int j = i; j < a.size( ); j++ )

每个锚点后面的子序列都会被计算比较。

对于常量的开销,如int maxSum = 0;,它的时间估算为\(O(1)\)。诸如此类的,不是我们所关注的。而最内层的循环是一个变量,即\(j-i+1\)。我们的锚点是从大范围到小范围,计算稍许麻烦,但是反过来,小范围到大范围就方便多了,我们计算\(j-i+1\)便是如此。对于整个代码而言,实际开销的就是maxSum += a[k];,这是一个常数时间单位。所以

\[\sum\limits_{i=0}^{N-1} \sum\limits_{j=i}^{N-1} \sum\limits_{k=i}^{j} 1 = \frac{N^3+3N^2+N}{6} \]

2、改进的穷举法

上述穷举法的问题在于重复计算,这会浪费大量的计算资源。代码

for( int k = i; k <= j; k++ )

它是将相同起点的子序列计算出结果后在比较是否最大,终点相同。所以简化后,我们可以在一个起点与终点间每加上一个数就比较一次,就不必计算出整个子序列。如下

int maxSubSum2( const vector<int> & a )
{
    int maxSum = 0;
    
    for( int i = 0; i < a.size( ); i++ )
    {
        int thisSum = 0;
        
        for( int j = i; j < a.size( ); j++ )
        {
            thisSum += a[k];
            
            if( thisSum > maxSum )
                maxSum = thisSum;
        }
    }
    return maxSum;
}

很显然,它的时间估算为\(O(N^2)\)。若要计算,则

\[\sum\limits_{i=0}^{N-1} \sum\limits_{j=i}^{N-1} 2 \]

实际执行的代码仍然是thisSum += a[k];\(2\)可换成\(1\)。对于比较语句,通常计算很快,可忽略。

3、拆分的递归方法

前面所使用的方法都是直接的找寻最大值,一遍又一遍的遍历数组。我们对一组数据观察,就会发现通过将数组拆分为两部分,其最大子序列要么出现前一半要么出现在后一半,或者最大子序列一部分出现在前一半而另一部分出现在后一半。示例

\[4,\quad -3, \quad 5, \quad -2, \quad -1, \quad 2, \quad 6, \quad -2 \]

前一半最大子序列最大值为\(6\),后一半最大子序列最大值为\(7\)。若要将二者合并,需要将前一半最大子序列加上尾元素,后一半最大子序列加上前置元素,因此其值为\(4+7=11\)

使用c++描述,其代码大致如下

int maxSumRec( const vector<int> & a, int left, int right )
{
     if( left == right )
         if(a[left] > 0 )
             return a[left];
         else
             return 0;
            
      center = (left + right) / 2;
      int maxLeftSum = maxSumRec( a, left, center );
      int maxRightSum = maxSumRec( a, center+1, right );
      
      int maxLeftBorderSum = 0,   leftBorderSum = 0;
      for( int i = center; i >= left; --i )
      {
            leftBorderSum += a[i];
            if( leftBorderSum > maxLeftBorderSum )
                maxLeftBorderSum = leftBorderSum;
       }
       
       int maxRightBorderSum = 0,   rightBorderSum = 0;
       for( int i = center+1; i <= right; ++i )
       {
             rightBorderSum += a[i];
             if( rightBorderSum > maxRightBorderSum )
                  maxRightBorderSum = rightBorderSum;
        }
        
        return max3( maxLeftSum, maxRightSum,
                 maxLeftBorderSum+maxRightBorderSum );  //一个比较例程,返回参数中最大值
}                                  

这个代码的一个不好理解的地方在于比较的三者是哪三者,在所写的代码中即maxLeftSummaxRightSummaxLeftBorderSum+maxRightBorderSum。前两者是下一次递归的结果,后者是当前的左右界和的最大值。例某一次递归的父本是

\[4, \quad -3, \quad 5, \quad -2 \]

maxLeftBorderSum+maxRightBorderSum是当前父本的最大子序列的值,而maxLeftSummaxRightSum是依据这个父本递归到下一层的左右部分的最大子序列的值。当然,当前父本最大子序列的值也是由上一层传递而来,这里的父本是相对来说。

现在计算这个算法的时间复杂度,除去10,11行以及两个for循环外,其余的均为常量时间单位。两个for循环,随着递归的进行,由多到少,最坏情形不过是\(O(N)\)。而递归是每次拆分两半,所以

\[T(N) = 2T(N/2) + O(N) \]

\(N\)最终可近似表示为

\[N = 2^k \]

所以

\[T(N) = N(k+1) = NlogN + N = O(NlogN) \]

此外,这种二分的做法需要注意的是临界点,这里是center,一定要让左边与右边不可重复,否则会陷入到无限递归的泥潭中去。

4、前置判断的线性算法

这种算法是基于前两个算法优化而来,其中优化的逻辑便是对最大子序列的前若干个数字做一些判断。假设有如下一组数字

\[-4,\quad 5,\quad 12,\quad 22,\quad -10,\quad 11 \]

如果这一组数字是某个全序列中的最大子序列,这意味着我们不能找到比这个序列还要大的子序列。但是显然这是矛盾的,因为在这个序列当中,我们可以去除第一个数字\(-4\)得到的新的序列一定大于原来的序列。这意味着最大子序列的第一个数字不可能是一个负数。

现在我们可以扩展这一想法,一个全序列可以分成若干个子序列,这些子序列彼此连接、延伸,且长度可以互相制约。假设一个全序列表示如下

\[A_1, \quad A_2, \quad A_3, \quad A_4, \quad A_5, \quad A_6, \quad A_7, \quad A_8, \quad A_9, \quad A_{10} \]

我们先假设该序列为最大子序列

\[A_3, \quad A_4, \quad A_5, \quad A_6, \quad A_7, \quad A_8 \]

这个子序列仍然可以分成若干个子序列,其长度最小为\(1\)

\[A_3, \quad A_4 \quad | \quad A_5, \quad A_6, \quad A_7, \quad A_8 \]

如果

\[A_3 + A_4 < 0 \]

则有

\[A_3 + A_4 + A_5 + A_6 + A_7 + A_8 < A_5 + A_6 + A_7 + A_8 \]

此时我们可以得到最大子序列为

\[A_5 + A_6 + A_7 + A_8 \]

这与我们的假设相悖。

通过以上示例,我们知道最大子序列的前置——数字或者序列不可能为负数。基于这样的思想,我们可以得到一个新的算法,描述大概如下

int maxSubSum4( const vector<int> & a )
{
    int maxSum = 0, thisSum = 0;
    
    for( int j = 0; j < a.size( ); ++j )
    {
        thisSum += a[j];
        
        if( thisSum > maxSum )
            maxSum = thisSum;
        if( thisSum < 0 )
            thisSum = 0;
    }
    
    return maxSum;
}

时间复杂度是显而易见的——\(O(N)\),其中唯一影响时间的是一个只遍历一遍全序列的for循环。

时间复杂度为对数的简单算法实例

在上述所提及的递归方法中,其时间复杂度是\(O(NlogN)\)。对于这样的算法具有明显的特征,如下

经过一个常量时间单位即可将问题的大小减小

假设数据已经读取到内存中

比如在上述的递归方法中,是通过语句center = (left + right) / 2;来将问题拆分的,这一个语句的时间复杂度是\(O(1)\)。至于第二点,我们在算法分析中一般都假设数据在内存中。因为至少就硬件来说,机械硬盘与固态硬盘的数据传递差别很大。下面给出三个具有对数时间复杂度的算法实例。

1、二分搜索

给定一个数\(X\)和一组已排序的数序列\(A_0,A_1,A_2,\dots,A_{N-1}\),假设数序列已经存储在内存中,若存在\(i\)使得\(A_i = X\)则返回\(i\),否则返回\(-1\)

template <typename Comparable>
int binarySearch( const vector<Comparable> & a, const Comparable X )
{
    int low = 0, high = a.size( )-1;
    for( low <= high )
    {
        int mid = (low + high) / 2;
        
        if( X < a[mid] )
            high = mid-1;
        else if( X > a[mid] )
            low = mid+1;
        else
            return mid;
    }
    
    return NOT_FOUND;  //NOT_FOUND定义为-1
}

只有一个for循环会影响时间复杂度,整个序列的长度被lowhigh限制,同时在每一次迭代的过程中,low或者high都会在原有序列长度中缩短\(1/2\)。而在每一次迭代的内容中,即for循环每次执行的内容,其时间复杂度为常量\(O(1)\)。所以整个时间复杂度为\(O(logN)\)

2、欧几里得算法

欧几里得算法也是一个快速算法,其效率也是相当之高。该算法主要是寻找两个整数的最大公因子。

long long gcd( long long m, long long n )
{
    while( n != 0 )
    {
        long long rem = m % n;
        m = n;
        n = rem;
    }
    return m;
}

通过不断求模,找出余数为\(0\)时的前一个状态的数——这里是m,这个数即为最大公因子。时间复杂度分析的难点是在于迭代多少次,直觉告诉我们每次迭代会折半,这是一个很好的感觉,现在我们需要证明最多连续迭代多少次,其余数会是原来除数的一半或以下。即

\[如果M>N,则M \bmod N < \frac{M}{2} \]

证明:有两种情况需要讨论。

\((1)\):假设\(N \le \frac{M}{2}\),此时一定有\(M \bmod N \le N \le \frac{M}{2}\),故符合定理。

\((2)\):假设\(N \ge \frac{M}{2}\),只要\(M\)除去\(N\)一次就有\(M \bmod N \le M - N \le \frac{M}{2}\),故符合定理。

综上,证毕。

对于证明中的第二点,需要补充的一个知识是——除法是加法累计的逆运算。通过这个定理,我们知道该算法在经过最多两次迭代以后,m或者n就会变成原来的\(\frac{1}{2}\),其时间复杂度就可以总结为\(2logN = O(logN)\)

3、幂运算

幂运算是常见的数学运算,在各类程序当中计算幂也是经常必须要做的,因此,设计一个高效的幂运算的算法是必须的。同时这也是一个很好的递归例子。

long long pow( long long x, 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;
}

使用蛮力算法是一件麻烦的事,因为幂的增长是比较快的,如果以幂作为基准,每次迭代就乘上一个底数会大大消耗时间。我们可以对幂做观察得出以下结论

\[如果幂N是偶数,则有X^N = X^{N/2} \times X^{N/2}; \\ 如果幂N是奇数,则有X^N = X^{(N-1)/2} \times X^{(N-1)/2} \times X。 \]

这里我们明显能够得到,当幂\(N\)是偶数时,每一次都会是原来的一半,因此时间复杂度是\(O(logN)\);而当幂\(N\)是奇数时,最多迭代两次变为原来的一半,因此是\(2logN\)。总结来看,其时间复杂度是\(O(logN)\)

posted @ 2025-03-17 10:28  永恒圣剑  阅读(62)  评论(0)    收藏  举报