算法复杂度分析(二)

192 阅读8分钟

有的时候,程序段中会有多个循环语句,比如:


private static long sum(int n) {   
    int sum1 = 0;   
    for (int i = 1; i <= 1000; i++) {   
        sum1 += i;   
    }  

    int sum2 = 0;   
    for (int i = 1; i <= n; i++) {   
        sum2 += i;  
    }

    int sum3 = 0;   
    for (int i = 1; i <= n; i++) {   
        for (int j = 1; j <= n; j++) {   
            sum3 += i;   
        } 
    }

    return sum1 + sum2 + sum3;   
}

以上程序段的时间复杂度是多少呢?

我们可以看出,这里是有 3 个循环语句的,我们分别来看看这 3 段循环代码。

  • 第一段循环代码的时间复杂度是 O(1),因为它和输入数据规模 n 没有关系
  • 第二段循环代码的时间复杂度是 O(n),因为循环内的语句执行了 n 次
  • 第三段循环代码的时间复杂度是 O(n^2),因为第二层循环中的语句执行了 n*n 次


总结这三段循环代码的时间复杂度,取最大量级的时间复杂度 O(n^2),所以,以上这段代码的时间复杂度就是 O(n^2)

也就是说:程序段总的时间复杂度等于量级最大的那段代码的时间复杂度。我们可以将这个规则称为加法规则,使用公式可以表达为:

图片
以上代码中的后面两个循环的数据规模都是 n,如果代码中有几个数据规模不同的循环语句呢?看如下的程序段:

private static long sum_1(int n, int m) { 
    int sum1 = 0; 
    for (int i = 1; i <= 1000; i++) { 
        sum1 += i; 
    }

    int sum2 = 0; 
    for (int i = 1; i <= n; i++) { 
        sum2 += i; 
    }

    int sum3 = 0; 
    for (int i = 1; i <= m; i++) { 
        sum3 += i; 
    }

    return sum1 + sum2 + sum3; 
}

以上代码中的后面两个循环的数据规模不一样,这个时候我们也不知道哪一个数据规模的量级大了

那么这个时候在表示时间复杂度的时候,就不能简单的利用加法法则了

从上面的程序段中,我们可以看出,第一个循环的时间复杂度是 O(1),第二个循环的时间复杂度是 O(n),第三个循环的时间复杂度是 O(m)

这个时候我们上面的程序的时间复杂度是:T(n) = O(n) + O(m) = O(n + m)

时间复杂度分析法则 - 乘法法则

先看下面的代码:

private static long sum_3(int n) {  
    int sum = 0;  
    for (int i = 1; i <= n; i++) {  
        sum += sumSquare(i);  
    }
    return sum;  
}

private static long sumSquare(int n) {  
    int res = 0;  
    for (int i = 1; i <= n; i++) {  
        res += (i * i);  
    }
    return res;  
}

我们现在来分析下方法 sum_3 的时间复杂度是多少。

方法 sum_3 实际上有嵌套循环,在循环代码中调用含有循环的方法 sumSquare,所以我们先分析方法 sumSquare 的时间复杂度,很明显,sumSquare 只有一层循环,它的时间复杂度就是 O(n)。

因为 sum_3 中含有嵌套循环的调用,所以复杂读等于嵌套内外代码复杂度的乘积,sum_3 的外层循环的时间复杂读是 O(n),那么 sum_3 总的时间复杂度是 O(n) * O(n) = O(n^2)。

这个就是复杂度的乘法法则,表达成公式就是:

图片

时间复杂度分析小结

到现在为止,我们已经掌握了如下的常用时间复杂度:

  • 常量阶:O(1)
  • 对数阶:O(logn)
  • 线性阶:O(n)
  • 线性对数阶:O(nlogn)
  • 平方阶:O(n^2)
  • 立方阶:O(n^3)


以上的时间复杂度,我们也可以称为多项式量级时间复杂度。还有另一类常用的时间复杂度就是非多项式量级时间复杂度,如下:

  • 指数阶:O(2^n)
  • 阶乘阶:O(n!)

其中指数阶时间复杂度我们会在分析递归时间复杂度的时候探讨,对于阶乘阶时间复杂度我们以后在学习回溯算法的时候再去聊,对 n 个数进行全排列的时候的时间复杂度就是 O(n!)

上面常见的时间复杂度按照量级是有一定的顺序的,如下图:

图片

可以看出,时间复杂度按数量级递增排列依次为:常数阶 O(1)、对数阶 O(logn)、线性阶 O(n)、线性对数阶 O(nlogn)、平方阶 O(n^2)、立方阶 O(n^3)、……k 次方阶 O(n^k)、指数阶 O(2^n)、阶乘阶:O(n!)

所以说,一般的情况下,你的时间复杂度的量级越低性能越好,O(1) 比 O(logn) 好,O(logn) 又比 O(n) 好。

最好、最坏、平均时间复杂度

我们写一个算法实现判断一个数是否存在一个数组中,代码如下:

public boolean contains(int[] arr, int target) {  
    for (int i = 0; i < arr.length - 1; i++) {  
        if (arr[i] == target) {  
            return true;  
        } 
    }
    return false;  
}

以上代码的时间复杂度是多少呢?我们可以分以下两种情况:

  1. 如果想要找的数正好是在数组的第一个元素,那么时间复杂度就是 O(1),这个就是最好情况时间复杂度
  2. 如果想要找的数正好在数组的最后一个元素,或则根本就不在数组中,那么时间复杂度就是 o(n),这个就是最坏情况时间复杂度

最好情况时间复杂度和最坏情况时间复杂度对应的都是极端情况下的代码复杂度,发生的概率其实并不大。为了更好地表示平均情况下的复杂度,我们需要引入另一个概念:平均情况时间复杂度,后面我简称为平均时间复杂度

接下来,我们来看看怎么计算平均时间复杂度。

我们知道,对于一个数 target,要么在数组中,要么就不在数组里,我们可以假设这个数在数组中和不在数组中的概率都为 1/2。

另外,要查找的数据出现在 0~n-1 这 n 个位置的概率也是一样的,为 1/n。

所以,根据概率乘法法则,要查找的数据出现在 0~n-1 中任意位置的概率就是 1/(2n)

也就是说:

  • target 在第 1 个元素的概率是 1/(2n),这个时候需要循环执行 1 次就可以,所以总的概率是 1 * (1/(2n))
  • target 在第 2 个元素的概率是 1/(2n),这个时候需要循环执行 2 次就可以,所以总的概率是 2 * (1/(2n))
  • target 在第 3 个元素的概率是 1/(2n),这个时候需要循环执行 3 次就可以,所以总的概率是 3 * (1/(2n))
  • .........
  • target 在第 n 个元素的概率是 1/(2n),这个时候需要循环执行 n 次就可以,所以总的概率是 n * (1/(2n))
  • target 不在数组中的概率是 1/2,这个时候需要循环执行 n 次就可以,所以总的概率是 n * (1/2)

现在我们把每一种情况发生的概率都考虑进去,那么平均时间复杂度的计算过程如下:
图片

这个值就是概率论中的加权平均值,也叫作期望值,所以平均时间复杂度的全称应该叫加权平均时间复杂度或者期望时间复杂度。

前面的代码的加权平均值为 (3n+1)/4 ,那么去掉系数和常量,平均时间复杂度就是 O(n)。

平均复杂度分析还是有点复杂的,实际上,在大多数的情况下,我们并不需要区分最好、最坏、平均时间复杂度三种情况,我们只需要使用前面讲到的时间复杂度分析方法来分析得到时间复杂度即可。

只有同一段代码在不同的情况下,时间复杂度有量级的差距,我们才会使用这三种复杂度表示法来区分。

空间复杂度分析

前面我们是站在时间的层面上去评估一个算法的性能,我们可以使用时间复杂度来粗略表达一个算法的执行时间。实际上,除了时间这个维度,还有一个空间维度

对于一个算法,除了关心它的执行时间性能外,我们还会关注这个算法占用了多大的空间。相对于时间复杂度分析,我们可以使用空间复杂度分析来分析一个算法占用的空间情况。

我们看以下代码:

public boolean contains(int[] arr, int target) { 
    for (int i = 0; i < arr.length - 1; i++) { 
        if (arr[i] == target) { 
            return true; 
        }
    }
    return false; 
}

在上面的算法中,只是申请了一个局部变量 i ,所以这个算法占用的空间是数据规模没有关系,那么空间复杂度就是 O(1)。

我们再来看一个算法例子:

public int[] arrayCopy(int[] arr) {  
    int[] newArray = new int[arr.length];  

    for (int i = 0; i < arr.length - 1; i++) {  
        newArray[i] = arr[i];  
    } 

    return newArray;  
}

这个算法就是完成了简单的数组复制的功能,在算法执行的过程中,除了申请一个临时变量 i,还申请了一个长度为原始数组长度大小的新的数组,这个新的数组占用的空间大小和输入数据规模是成正比的,所以,这个算法的空间复杂度就是 O(n)。

在空间复杂度分析中,O(1) 和 O(n) 是最常见的,像 O(n^2)、O(logn) 以及 O(nlogn) 等其他的复杂度都不常见,所以,对于空间复杂度我们暂时掌握 O(1) 和 O(n) 就可以了。

一个程序员 5 年内需要的数据结构与算法知识都在这里,系统学习: 数据结构与算法视频教程