数据结构与算法之美 - 02复杂度分析(二)

数据结构与算法之美-学习大纲

上一节,学习了什么是大O复杂度分析有哪些复杂度分析技巧什么是时间复杂度什么是空间复杂度以及有哪些常见的复杂度(O(1)、O(logn)、O(n)、O(nlogn))。

如果你对以上提到的内容还不了解,请点击这里:数据结构与算法之美 - 复杂度分析(一)

最好、最坏情况时间复杂度

上例子:

int find(int[] arr, int x) {
    int position = -1;
    int n = arr.length;
    for (int i = 0; i < n; i++) {
        if (arr[i] == x) {
            position = i;
            break;
        }
    }
    return position;
}

显然,这是一个从数组arr中查询x的索引position,如果不存在返回-1。我们尝试使用之前的方法先标注出每行代码的单位时间:

int position = -1;               // 1 * unitTime
int n = arr.length;              // 1 * unitTime
for (int i = 0; i < n; i++) {    // ???
    if (arr[i] == x) {           // ???
        position = i;            // ???
        break;
    }
}

发现循环体中的代码执行次数是不固定的,比如当x在数组索引0的位置,那么它的时间复杂度是O(1),当x不存在数组中那么就是O(n),这时候就产生了两个极端情况下的时间复杂度,我们称之为:最好情况时间复杂度、最坏情况时间复杂度

find()方法的最好情况时间复杂度为O(1),最坏情况时间复杂度为O(n)。

(加权)平均情况时间复杂度

既然有最好和最坏,那么在这两者之间肯定有很多我们叫不上名的情况,我们怎么更好的表示这个算法的复杂度呢?

随着x所在的arr数组索引位子不同需要的循环次数也就不同,我们尝试把所有的循环次数累加出来,并除以总的可能性,来计算下平均某种可能性下的循环次数。

循环总次数累加:1+2+3+…+n+n (多+n是因为当x不在数组里依旧需要循环n遍)

可能出现的情况累加:n+1 (+1是x不在数组中也是一种情况)

计算: f(n) = (1+2+3+…+n+n) / (n+1)

1+2+3+…+n 中学数学里的等差数列还有印象吗?我们套入等差数列求和公式:

等差数列求和公式.png

得到:1+2+3+…+n+n = n/2*(n+1)+n = n(n+3)/2

则: f(n) = n(n+3)/2 / (n+1) = (n²+3n) / (2n+2)

套用大O复杂度表示法得到: T(n) = O((n²+n) / n) = O(n+1) = O(n)

通过求平均数并且套用大O复杂度表示法,我们的到这个例子的平均情况时间复杂度为:O(n)。

然而,这个平均情况时间复杂度我们在推算过程中并不完全正确,我们忘了什么呢?

试想一下,我们总循环次数除以n+1表示的是所有情况出现的概率一致时的平均时间复杂度,那每种情况出现的概率真的都一致吗?

这里就需要引入一点高中数学概率论的知识了:

简单解释下,虽然我们已经统计出来会出现n+1中情况,但每种情况出现的概率其实是不同的。x存在或者不存在arr数组中的概率简单看作1/2;x存在arr数组中时,存在arr数组索引的某个位置的概率又是1/n;那么x存在arr数组中的某个索引的概率其实是1/2n。

那么我们把概率重新套入到之前的推算过程中:

x在arr数组中情况循环次数加入概率:(1+2+3+…+n)/2n = (n+1)/4

x不在arr数组中情况循环次数加入概率:n/2

套入f(n)= (n+1)/4 + n/2 = (3n+1)/4

大O复杂度表示法:T(n) = O((3n+1)/4)=O(n)

这个平均值,在概率论中称之为加权平均值,这种时间复杂度我们称之为加权平均时间复杂度

显然这个例子的加权平均时间复杂度也是O(n)。

总结下,一般同一段代码,在不同的情况下出现多种不同的时间复杂度,这时候我们会用最好时间复杂度、最坏时间复杂度、(加权)平均时间复杂度来表示。

均摊时间复杂度

到这里复杂度分析的内容基本总结完了,而现在要学的内容可以看作为平均时间复杂度的一种特殊情况。

class IntArr {

    private final int DEF_SIZE = 10;
    private int[] arr = new int[DEF_SIZE];
    private int len = DEF_SIZE;
    private int index = 0;

    public void add(int num) {
        if (index >= len) {
            //空间不足,扩充数组大小,比之前多一半
            int newLen = len + len / 2;
            int[] newArr = new int[newLen];
            for (int i = 0; i < len; i++) {
                newArr[i] = arr[i];
            }
            arr = newArr;
            len = newLen;
        }
        arr[index] = num;
        index++;
    }

    ...
}

上面我创建了一个整形数组容器,它的好处是可以自动扩展空间大小,我们尝试计算出它的时间复杂度。

通过代码,我们看到:

  • 没当数组大小不够用时,会扩展之前大小的一半,并且进行遍历copy

开始分析:

第一遍:

当 index < len 时,时间复杂度是 O(1)

当 index >= len 时,时间复杂度是O(n)

扩展后第二遍:

当 index < len 时,时间复杂度是 O(1)

当 index >= len 时,时间复杂度是O(n)

扩展后第三遍:

一眼看出,它的最好时间复杂度是O(1),最坏时间复杂度是O(n),那它的平均时间复杂度是多少呢?

假设当前数组长度是n,填加一个数进入数组的所需时间分两种情况:当前添加位置小于n,那么所需单位时间为1,这种情况下的总时间就是1+1+1+…+1=n;当添加的位置大于等于n时,所需的时间就为n。

总时间就为:1+1+1+…+1+n = 2n

无论是小于n里的每一次情况还是大于等于n时的情况,它们的发生的概率都是1/(n+1)

f(n) = 2n/(n+1) = 1

则最终加权平均时间复杂度为O(1)

这个例子我们也通过概率的形式算出了加权平均时间复杂度,当然这个算法是肯定没问题的。但是,其实有更简单的方法算出它的平均时间复杂度:

可以看到上面的每一遍分析规律,都是出现n次O(1)复杂度后,再出现一次O(n)复杂度,我们假设把O(n)复杂度分为n份,每份的时间单位也就是1,把这些拆分的时间单位平摊到前面的n次O(1)中,尽管前面的n次O(1)复诊度时间单位变成了2,但它的时间复杂度依旧是O(1)。

上面这种分析方法,称为平摊分析法,分析出的时间复杂度称为均摊时间复杂度

对于这种随着数据的不断改变,出现了某种固定规律的算法,我们可以尝试使用平摊分析法快速得到均摊时间复杂度

小结

到此,复杂度分析就全部学完了,这节学习了最好情况时间复杂度最坏情况时间复杂(加权)平均时间复杂度均摊时间复杂度

要想学好数据结构和算法,那么复杂度分析就是重中之重,学会复杂度分析就相当于学会了一半。学好复杂度分析还是那句话:

复杂度分析并不难,贵在勤于练习!

铭记于心!