数据结构与算法之美 课程笔记二 复杂度分析(上)

数据结构和算法本身解决的是“快”和“省”的问题,即如何让代码运行得更快,如何让代码更省空间。所以,执行效率是算法一个非常重要的考量指标。衡量算法的执行效率最常用的就是时间和空间复杂度分析。

一、为什么需要复杂度分析?

把代码跑一遍,通过统计、监控来得到算法执行的时间和占用的内存大小,这种做法叫做事后统计法。事后统计法有非常大的局限性:

1、测试结果非常依赖测试环境。

测试环境中硬件的不同会对测试结果有很大的影响。

2、测试结果受收据规模的影响很大。

对同一个排序算法,待排序数据的有序度不一样,排序的执行时间就会有很大的差别。此外,如果测试数据规模过小,测试结果可能无法真实地反映算法的性能。

所以,需要一个不用具体的测试数据来测试,就可以粗略估算算法执行效率的方法。这就是时间、空间复杂度分析方法。

二、大O复杂度表示法

如下面的代码,我们怎么来估算一下其执行时间呢?

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

假设每行代码执行的时间都一样,为unit_time,那么这段代码的总执行时间为(2n+2)*unit_time(第2、3行分别需要1个unit_time的执行时间,第4、5行都运行n遍,需要2n*unit_time的执行时间)。

由此看出,所有代码的执行时间T(n)与每行代码的执行次数成正比。

再看下面这边代码:

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

在这段代码中,第2、3、4行都需要1个unit_time的执行时间,第5、6行代码循环执行了n遍,需要2n*unit_time的执行时间,第7、8行代码循环了n^2遍,需要2n^2*unite_time的执行时间。所以,整段代码总的执行时间为T(n)=(2n^2+2n+3)*unit_time。

由此得到:所有代码的执行时间T(n)与每行代码的执行次数n成正比。用公式表示:

T(n) = O(f(n))

其中T(n)代表执行时间,n表示数据规模的大小,f(n)表示每行代码执行的次数总和。

第一个例子T(n)=O(2n+2),第二个例子T(n)=O(2n^2+2n+3);这就是大O时间复杂度表示法。它表示代码执行时间随数据规模增长的变化趋势,也叫做渐进时间复杂度(asymptotic time complexity),简称时间复杂度。

当n很大时,公式中的低阶、常量、系数三部分不左右增长趋势所以可以忽略,只需要记录一个最大量级就可以。前面两段代码的时间复杂度可以记为:T(n)=O(n);T(n)=O(n^2)。

三、时间复杂度分析

三个实用分析代码的时间复杂度的方法:

1、只关心循环执行次数最多的一段代码

2、加法法则:总复杂度等于量级最大的那段代码的复杂度

int cal(int n) {
    int sum_1 = 0;
    int p = 1;
    for(; p < 100; ++p) {              -----------时间复杂度:O(1)
        sum_1 = sum_1 + p;         
    }

    int sum_2 = 0;
    int q = 1;
    for(; q < n; ++q) {                -----------时间复杂度:O(n)
        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) {             -----------时间复杂度:O(n^2)
            sum_3 = sum_3 + i * j; 
        }
    }

    return sum_1 + sum_2 + sum_3;
}

上面这段代码分三个部分,我们可以分别分析每一部分的时间复杂度,然后把它们放在一块儿,取一个量级最大的作为整段代码的复杂度。所以上面这段代码的时间复杂度为O(n^2)。

抽象为公式:

如果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)))

3、乘法法则:嵌套代码的复杂度等于嵌套内外代码复杂度的乘积

int cal(int n) {
    int ret = 0;
    int p = 1;
    for(; i < n; ++i) {              -----------时间复杂度:O(n)
        ret = ret + f(i);         
    }
}

int f(int n) {
    int sum = 0;
    int i = 1;
    for(; i < n; ++i) {                -----------时间复杂度:O(n)
        sum = sum + i;               
    }
    return sum;
}

假设f()只是一个普通的操作,那么4~6行的时间复杂度T1(n)=O(n)。但f()函数本身不是一个简单的操作,它的时间复杂度T2(n)=O(n),所以整个cal()函数的时间复杂度T(n)=T1(n)*T2(n)=O(n)*O(n)=O(n^2)。

四、几种常见时间复杂度实例分析

按数量级递增:

常数阶 O(1)

对数阶 O(logn)

线性阶 O(n)

线性对数阶 O(nlogn)

平方阶 O(n^2)、立方阶 O(n^3)...k次方阶 O(n^k)

指数阶 O(2^n)

阶乘阶 O(n!)

上述复杂度量级可分为两类:多项式量级和非多项式量级。其中非多项式量级只有两个:指数阶和阶乘阶。

当数据规模n越来越大时,非多项式量级算法的执行时间会急剧增加,求解问题的执行时间会无限增长。所以,非多项式时间复杂度的算法其实是非常低效的算法。

1、O(1)

O(1)只是常量级时间复杂度的一种表示方法。只要代码的执行使劲不随n的增大而增长,这样代码的时间复杂度都记作O(1)。一般情况下,只要算法中不存在循环语句、递归语句,即使有成千上万行,其时间复杂度也是O(1)。

2、O(logn)、O(nlogn)

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

在这段代码中i的值分别为2^02^12^22^3......2^x,当2^x > n时, 循环结束。通过2^x=n求解,x=log_2n。所以这段代码的时间复杂度为log_2n

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

同理,在这段代码中i的值分别为3^03^13^23^3......3^x,当3^x > n时, 循环结束。通过3^x=n求解,x=log_3n。所以这段代码的时间复杂度为log_3n

实际上,不管是以2为底,还是以3为底,我们可以把所有对数阶的时间复杂度都记为O(logn)。这是因为对数之间是可以相互转换的(log_3n=log_32*log_2n),而在采用大O表示复杂度的时候,可以忽略系数,即O(Cf(n))=O(f(n))。

如果一段代码的时间复杂度为O(logn),循环执行n遍,那么总体的时间复杂度就是O(nlogn)。

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

int cal(int m, int n) {
    int sum_1 = 0;
    int p = 1;
    for(; p < m; ++p) {              -----------时间复杂度:O(m)
        sum_1 = sum_1 + p;         
    }

    int sum_2 = 0;
    int q = 1;
    for(; q < n; ++q) {              -----------时间复杂度:O(n)
        sum_2 = sum_2 + q;               
    }

    return sum_1 + sum_2;
}

在上面代码中,m和n表示两个数据规模,我们无法事先评估m和n谁的量级大,所以,在表示复杂度时,不能简单地利用加法规则,省略掉其中一个。由此,上面代码的时间复杂度为O(m+n)。

针对这种情况,加法规则需要变更为T1(m)+T2(n)=O(f(m)+g(n))。但乘法法则继续有效:T1(m)*T2(n)=O(f(m)*g(n))。

五、空间复杂度分析

空间复杂度全称渐进空间复杂度(asymptotic space complexity),表示算法的存储空间与数据规模之间的增长关系。

void print(int n) {
    int i = 0; 
    int[] a = new int[n];
    for (; i < n; i++) {
        a[i] = i * i;    
    }

    for (i = n-1; i >= 0; --i) {
        print out a[i];
    }
}

上面代码中。第2行申请了一个存储变量i,但它是常量阶的,可以忽略。第3行申请了一个大小为n的数组,除此之外,剩下的代码都没有占用更多的空间,所以整段代码的空间复杂度为O(n)。

常见的空间复杂度为O(1)、O(n)、O(n^2)。

极客时间版权所有: https://time.geekbang.org/column/article/40011

猜你喜欢

转载自blog.csdn.net/LiuQQu/article/details/84426449
今日推荐