风林火山阴雷
其疾如风、其徐如林、侵略如火、不动如山、难知如阴、动如雷震。

前言

数据结构和算法本身解决的是“快”和“省”的问题,即如何让代码运行得更快,如何让代码更省存储空间。所以,执行效率是算法非常重要的考量指标。在这里,我们需要使用时间、空间复杂度分析来衡量算法代码的执行效率,复杂度分析是整个算法学习的精髓。

大O复杂度表示法

现在看一下下述算法的执行时间,

int cal(int n) { 
    int sum = 0;
    int i = 1;
    for(;i<=n;++i){
        sum = sum + i;
    }
    return sum;
}

从CPU的角度来看,这段代码的每一行都执行着类似的操作:读数据 - 运算 - 写数据。虽然每行代码对应的CPU执行的个数和执行的时间都不一样,但是我们先假设每行代码执行的时间都一样,为unit_time。第2、3行代码分别需要一个unit_time的执行时间,第4、5行都运行了n遍,所以需要2n * unit_time的执行时间,因此这段代码总的执行时间是(2n+2) * unit_time。可以看出来,所有代码的执行时间T(n)与每行代码的执行次数成正比。

我们把这个规律总结成一个公式,就是:

T(n)表示代码执行的时间,n表示数据规模的大小,f(n)表示代码执行的次数综合。O表示代码的执行时间T(n)与f(n)成正比。所以上述例子中T(n) = O(2n+2),这就是大O时间复杂度表示法。大O时间复杂度表示代码执行时间随数据规模增长的变化趋势,也叫做渐进时间复杂度,简称时间复杂度。当n很大时,可以把n当做10000,而公式中的低阶、常量、系数三部分并不左右增长趋势,所以都可以忽略。我们记录一个最大量级就可以了,即T(n)=O(n)。

时间复杂度分析

时间复杂度的全称是渐进时间复杂度,表示算法的执行时间与数据规模之间的增长关系。

如何分析一段代码的时间复杂度呢?有三个方法:

只关注循环执行次数最多的一段代码

大O只是表示一种变化趋势,所以一般会忽略掉公式中的常量、低阶、系数,只需要记录一个最大阶的量级就可以了。所以,我们在分析算法的时间复杂度的时候,只关注循环执行次数最多的那段代码就可以了。这段代码执行次数的n的量级,就是代码的时间复杂度。

比如,下列代码的时间复杂度。

int cal(int n) { 
    int sum = 0;
    int i = 1;
    for(;i<=n;++i){
        sum = sum + i;
    }
    return sum;
}

其中,2、3行都是常量级的执行时间,与n的大小无关,而4、5行是与n有关的,而且循环执行次数最多,这两行代码被执行了n次,所以总的时间复杂度就是O(n)。

总复杂度等于量级最大的那段代码的复杂度

int cal(int n){
    int sum_1 = 0;
    int p=1;
    for(;p<100;++p){
        sum_1 = sum_1 + p;
    }
    
    int sum_2 = 0;
    int q = 1;
    for(;q<n;++1){
        sum_2 = sum_2 + q;
    }
    
    int sum_3 = 0;
    int i=1;
    int j=1;
    for(;i<=n;++i){
        j = 1;
        for(;j<=n;++j){
            sum_3 = sum_3 + i*j;
        }
    }
    return sum_1 + sum_2 + sum_3;
}

这段代码中分为三部分,分别求出sum_1、sum_2和sum_3的时间复杂度,取一个量级最大的作为整段代码的复杂度。
首先,sum_1这段循环执行了100次,与n无关,sum_2和sum_3的时间复杂度为O(n)和O(n2),这三段代码的时间复杂度,取其中最大的量级,即O(n2)。抽象成公式也就是:
T1(n)=O(f(n)),T2(n)=O(g(n)),则T(n)=T1(n)+T2(n)=max(O(f(n)),O(g(n)))=O(max(f(n),g(n)))

嵌套代码的复杂度等于嵌套内外代码复杂度的乘积

int cal(int n){
    int ret = 0;
    int i = 1;
    for(;i<n;++1){
        ret = ret + f(i);
    }
}
int f(int n){
    int sum = 0;
    int i = 1;
    for(;i<n;++1){
        sum = sum + i;
    }
    return sum;
}

这段代码中,cal函数中有调用f函数,先把f函数看做普通的操作,那么cal的复杂度就是O(n),然后看f函数的复杂度为O(n),因为f函数不是一个简单的操作,所以放在一起看就是cal的复杂度为O(n2)。抽象成公式为:
T1(n)=O(f(n)),T2(n)=O(g(n)),则T(n)=T1(n) * T2(n)=O(f(n)) * O(g(n))=O(f(n) * g(n)) 。

常见的复杂度量级

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

注:其中^代表平方。

复杂度量级分为两类,多项式量级和非多项式量级,非多项式量级只有两个:O(2^n)和O(n!)。

O(1)

O(1)只是常量级时间复杂度的一种表示方法,并不是指只执行了一行代码。一般情况下,只要算法中不存在循环语句、递归语句,即使有很多行代码,它的时间复杂度也是O(1)。

O(logn)、O(nlogn)

对数阶的时间复杂度非常常见,也是最难分析的一种时间复杂度。举个例子

int i=1;
while(i<=n){
    i = i*2;
}

上述代码中,第三行代码是循环执行次数最多的,所以要计算第三行代码执行了多少次即可。
首先,变量i的值从1开始取,每循环一次就乘以2,i的值就是一个2的等比数列,当大于n时,循环停止,
所以2的x次方等于n,那么x为log2(n),2为对数底,所以这段代码的时间复杂度为O(log2(n))。由于对数是可以相互转换的,所以,log2(n) 可以拆成Log2(4)* log4(n),在采用大O标记复杂度的时候,可以忽略系数,所以O(Cf(n))=O(f(n)),所以O(log2(n))就等于O(log3(n)),因此,在对数阶时间复杂度的标示方法里,我们忽略对数的底,统一表示为O(logn)。

O(m+n)、O(m * n)

有的时候,代码的复杂度由两个数据规模决定的,例如:

int cal(int m,int n){
    int sum_1 = 0;
    int i=1;
    for(;i<m;++i){
        sum_1 = sum_1 + i;
    }
    
    int sum_2 = 0;
    int j=1;
    for(;j<n;++j){
        sum_2 = sum_2 + j;
    }
    return sum_1 + sum_2;
}

由于m和n,无法评估两个数据规模谁大,所以这个复杂度就不能省略掉一个,而是相加。
T1(m)+T2(n)=O(f(m)+g(n))。乘法的规则和之前一样,T1(m) * T2(n)=O(f(m) * g(n))。

空间复杂度分析

空间复杂度全称就是渐进空间复杂度,表示算法的存储空间与数据规模之间的增长关系。
例如:

void create(int n){
 int i=0;
 int[] a = nw int[n];
 for(i;i<n;++i){
     a[i] = i * i;
 }
 for(i = n-1;i>=0;--i){
     print out a[i];
 }
}

与时间复杂度分析类似,第二行定义了变量i,此时相当于申请了一个空间存储变量i,是常量阶的,和数据规模没有关系,第三行申请了一个n大小的数组,所以这段代码的空间复杂度为)(n)。

常见的空间复杂度由O(1)、O(n)、O(n2),n2为n的平方。

最好、最坏时间复杂度

最好情况时间复杂度就是,在最理想的情况下,执行这段代码的时间复杂度。
最坏情况时间复杂度就是,在最糟糕的情况下,执行这段代码的时间复杂度。
举个例子来说,当我们遍历数组寻找元素的时候,要查找的元素可能出现在数组的任意位置,如果这个元素恰好在第一个位置,那就不需要遍历以后的元素了,所以时间复杂度为O(1),这是最好情况时间复杂度。但是如果数组中不存在我们要查找的元素,那么就需要把整个数组都遍历一遍,时间复杂度就为O(n),这是最坏情况时间复杂度。

  转载请注明: 码出世界 复杂度分析

  目录