数据结构和算法(第 2 章):复杂度分析

一、复杂度分析

首先要明确一点,数据结构和算法本质是解决“快”和“省”的问题。要描述一个算法的好坏就需要用到复杂度分析了,复杂度分析可分为如下两种。

  • 时间复杂度

  • 空间复杂度

时间复杂度就是描述算法的快,空间复杂度则是描述算法的省。一般说的复杂度都是时间复杂度,毕竟现代计算机存储空间已经不那么拮据了,时间复杂度是我们重点研究的内容。

二、大 O 复杂度表示法

首先看一段代码,求从 1~n 的累加之和。

int demo(int n) {

    int i;
    int sum = 0;

    for(i=1; i<n; i++) {
        sum += i;
    }

    return sum;
}

现在就来估算一下这段代码的执行时间(下面都是以时间复杂度为例讲解,空间复杂度最后再讲)。

CPU 的角度来看,每一行代码都执行着类似的操作读数据-运算-写数据。这里为了方便计算,假设每行代码的执行时间都是一样的,用 t 表示执行一行代码所需要的时间,n 表示数据规模的大小,T(n) 表示代码执行的总时间。

那么这段代码总执行时间是多少呢?我们来数一下。

首先,函数体内有 5 条语句,第 1、2、5 条语句总共执行了 3 次,所需时间是 3*t;第 3、4 条语句各自执行了 n 次,所需时间是 2*n*t。把这两个代码段执行的时间相加,所得到的结果就是这段代码总共所需的时间。

T(n)=(2n+3)t T(n)=(2n+3)t

通过上述公式可以得到一个规律,T(n) 随着 n 变大而变大,变小而变小。所以,T(n)n 是成正比的,用数学符号表示就可以写成。

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

其中 f(n) 是代码段执行所需的时间之和,O 表示 T(n)f(n) 之间的关系是成正比的。

由公式可得代码段执行所需的时间可表示为 T(n)=O(2n+3)T(n)=O(2n+3)。这就是O 时间复杂度表示法。大 O 时间复杂度实际上并不具体表示代码真正的执行时间,而是表示代码执行时间随着数据规模增长的变化趋势,所以,也叫做渐进时间复杂度简称时间复杂度

其实O(2n+3)O(2n+3)并不是最终时间复杂度的表示方式。在实际的复杂度分析中,一般会把公式中的常量系数低阶忽略。因为这三部分并不影响增长趋势(还记得时间复杂度其实是渐进时间复杂度吧!),所以只需要记录一个最大量级就可以了,时间复杂度的最终表示方式就是O(n)O(n)

三、复杂度的分析方法

1. 最大量阶

int demo(int n) {

    int i;
    int sum = 0;

    for(i=1; i<n; i++) {
        sum += i;
    }

    return sum;
}

在分析一个算法、一段代码的时间复杂度的时候,只关注循环执行次数最多的那一段代码即可。

2. 加法法则

int demo(int n) {

    int i;
    int sum = 0;

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

    return sum;
}

如果代码中存在着不同量级的时间复杂度,总的时间复杂度就等于量级最大的那段代码的时间复杂度。

3. 乘法法则

int demo(int n) {

    int i;
    int sum = 0;

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

    return sum;
}

如果是嵌套、函数调用、递归等操作,只需要将各部分相乘即可。

四、复杂度的量级

  • 常量阶:O(1)O(1)

  • 对数阶:O(logn)O(\log n)

  • 线性阶:O(n)O(n)

  • 线性对数阶:O(nlogn)O(n \log n)

  • 平方阶:O(n2)O(n^2)

  • 立方阶:O(n3)O(n^3)

  • k次方阶:O(nk)O(n^k)

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

  • 阶乘阶:O(n!)O(n!)

对于上述不同的量级可以分为两类:多项式量级非多项式量级。其中,非多项式量级只有两个:O(2n)O(2^n)O(n!)O(n!),非多项式也叫做 NP问题。

一般情况下,我们常见的复杂度只有O(1)O(1)O(logn)O(\log n)O(n)O(n)O(nlogn)O(n \log n)O(n2)O(n^2) 这五个,常用的分析方法有最大量阶、加法法则、乘法法则这三个。只要把这些掌握,基本上就没有太大问题了。

五、时间复杂度

我们已经分析了时间复杂度,但是还是有一点儿小问题,比如我们要查找某个元素在长度为 n 的数组中的下标。如果按照顺序遍历,最理想的情况是第一个就是我们要找的,所以时间复杂度是 O(1);如果最后一个才找到我们要的数据,那么它的时间复杂度是 O(n)

为了解决同一段代码在不同情况下时间复杂度出现量级差异,我们就需要对时间复杂度进一步细化分类,为了更准确、更全面的描述代码的时间复杂度,引入了一下 4 个概念。

1. 最好情况时间复杂度

代码在最理想情况下执行的时间复杂度。

2. 最坏情况时间复杂度

代码在最坏情况下执行的时间复杂度。

3. 平均情况时间复杂度

上面两个最好、最坏情况都是小概率事件,平均情况时间复杂度才是最能代表一个算法的时间复杂度。因为平均情况时间复杂度需要引入概率进行分析,所以也叫做加权平均时间复杂度

4. 均摊时间复杂度

正常情况下,代码在执行过程中都处于低阶的复杂度,极个别情况会出现高阶的复杂度,这是我们就可以将高阶的复杂度均摊到每个低阶的复杂度上,这种分析使用的是摊还分析法的思想。

其实我们只需要知道时间复杂度就够了。这四种方法都是对时间复杂度的一些特殊情况的补充,也没必要花大力气去研究它,大概知道有这种时间复杂度分类就可以了,如果你自己想学或者有脑残面试官要问这些,那你就自己去查找资料研究研究,这里不会展开讲解。

六、空间复杂度

前面讲解过,时间复杂度是渐进时间复杂度,表示算法的执行时间与数据规模之间的增长关系。那么空间复杂度就是渐进空间复杂度,表示算法的存储空间与数据规模之间的增长关系。

看一段代码,定义一个新数组,赋值后遍历输出。

void demo(int n) {

    int i;
    int data[n];

    for(i=0; i<n; i++) {
        data[i] = i * i;
    }

    for(i=0; i<n; i++) {
        printf("%d\n", data[i]);
    }
}

跟时间复杂度分析一样,函数体内第 1 条语句是常量阶,直接忽略;第 2 条语句申请了一个大小为 nint 类型数组,所以整段代码的空间复杂度就是O(n)O(n)

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章