来来来,让咱从新认识一下算法的复杂度!


0. 前言

你们好,我是多选参数的程序锅,一个正在“研究”操做系统(主要是容器这块)、学数据结构和算法以及 Java 的硬核菜鸡。今天这篇主要是讲算法的时间、空间复杂度,参考来源主要是王争老师的专栏《数据结构与算法之美》以及程序锅去年上课时老师的课件。node

另外,程序锅整了一个关于算法的 github 仓库:https://github.com/DawnGuoDev/algorithm,该仓库除包含基础的数据结构和算法实现以外,还会有数据结构和算法的知识内容整理、LeetCode 刷题记录(多种解法、Java 实现) 、一些优质书籍整理。git

复杂度分析是整个算法学习的精髓,只要掌握了它,数据结构和算法的内容基本上就掌握了一半github

1. Motivation - 为何须要复杂度分析

过后统计法(也就是把代码运行一遍,经过统计、监控获得运行的时间和占用的内存大小)的测试结果很是依赖测试环境以及受数据规模的影响程度很是大。可是实际上更须要一个不用具体的测试数据就能够估计出算法的执行效率的方法。web

2. 大 O 复杂度表示法

算法的执行效率简单来讲就是算法的执行时间。好比下面这段代码的执行时间,假设每行代码执行的时间是同样的,都为 unit_time。在这个假设的基础之上,这段代码的总执行时间为 (2n + 2)* unit_time。算法

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

经过这个例子,能够看出总执行时间 T(n) 是与每行代码的执行次数成正比,便可以知足这个公式 T(n) = O(f(n)),其中 n 是数据规模的大小,f(n) 表示每行代码执行的总次,O() 表示一个函数,即 T(n) 与 f(n) 成正比。在这个例子中 T(n) = O(2n+2),这种方式就被称为大 O 复杂度表示法。可是实际上,大 O 时间复杂度并不具体表示代码执行真正的执行时间,而是表示代码执行时间随数据规模增加的变化趋势,也叫作渐进时间复杂度,简称时间复杂度。那么,在 2n+2 这个式子中,系数 2 和 常数 2 并不左右增加趋势,好比它是线性,并非会由于系数 2 或者常数 2 改变它线性增加的趋势,所以又能够写成T(n)=O(n)。又好比 T(n) = O(n^2),那么表示代码执行时间随数据规模 n 增加的变化趋势是 n 平方。下面这张图是不一样时间复杂度,随数据规模增加的执行时间变化数组

3. 时间复杂度分析

如何对一段代码的时间复杂度进行分析呢?能够采用如下几种方法微信

  1. 只关注循环次数最多的一段代码数据结构

    由于大 O 复杂度表示法只是表示一种趋势,因此能够忽略掉公式中的常数项、低阶、系数等,只记录一个最大的量级就能够了。所以在分析一个算法、一段代码的复杂度的时候,只须要关注循环次数最多的那一段代码就好了。好比下面这段代码,时间复杂度是 O(n)app

    int cal(int n) {
    int sum = 0;
    int i = 1;
    for (;i <= n; ++i) {
    sum = sum + i;
    }
    return sum;
    }
  2. 加法法则:总复杂度等于量级最大的那段代码复杂度数据结构和算法

    这个主要是省略掉大 O 复杂度中的低阶项。我的感受这个方法跟上面的方法有些重合。好比下面这段代码中,能够按照循环分为三个段,第一个段中有个循环,可是循环次数是个常数项,对增加趋势无任何影响,所以时间复杂度是 O(1),第二段代码的时间复杂度是 O(n),第三个段代码的时间复杂度是 O(n^2)。这三个段中量级最大的那个时间复杂度也就是整段代码的时间复杂度。

    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; ++q) {
    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;
    }
  3. 乘法法则:嵌套代码的复杂度等于嵌套内外代码复杂度的乘积

    好比下面这段代码中,f() 是一个循环操做,整个复杂度是 O(n),而 cal() 中的循环至关于外层,调用了 f(),假如把 f() 当成一个简单的操做的话,那么时间复杂度是 O(n),算上 f() 真实的复杂度以后,整个 cal() 的时间复杂度是 O(n)*O(n)=O(n*n) = O(n^2)。

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

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

3.1. 常见时间复杂度

量阶 表示
常量阶 O(1)
对数阶 O(logn)
线性阶 O(n)
线性对数阶 O(nlogn)
平方阶、立方阶、k次方阶 O(n^2)、O(n^3)、O(n^k)
指数阶 O(2^n)
阶乘阶 O(n!)
其余阶 O(m+n)、O(m*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 的值从 1 开始取,每循环 1 次就乘以 2,一直到 n 为止。那么当执行 x 次时到达 n,那么 ,推得  ,时间复杂度为  。假如每循环 1 次变成乘以 3,以下所示

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

    可得  ,时间复杂度为  。那么因为  ,这个时间复杂度又能够是  。所以在对数阶时间复杂度的表示方法里,能够忽略“底”,而直接统一成 O(logn)。

    O(nlogn) 的时间复杂度就至关于上面说到的“乘法法则”:一段代码的时间复杂度为O(logn) ,这段代码循环 n 次,时间复杂度就是 O(nlogn) 了。

  3. 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 是两个数据规模,没法评估 m 和 n 的量级大小。所以,不能省略其中任何一个,因此就是 O(m+n) 了。

  4. O(2^n)、O(n!)

    在上述表格中列出的复杂度量级,能够粗略的分为两类:多项式量级和非多项式量级。其中非多项式量级只有这两个。非多项式量级的算法问题也叫作 NP(Non-Deterministic Ploynomial,非肯定多项式)问题。当 n 愈来愈大时,非多项式量级算法的执行时间会急剧增长,求解问题的执行时间也会无限增加,因此是种很低效的算法。

3.2. 最好、最坏状况时间复杂度

好比下面这段代码中,是在数组中查找一个数据,可是并非把整个数组都遍历一遍,由于有可能中途找到了就能够提早退出循环。那么,最好的状况是若是数组中第一个元素正好是要查找的变量 x ,时间复杂度就是 O(1)。最坏的状况是遍历了整个数组都没有找到想要的 x,那么时间复杂就成了 O(n)。所以 O(1) 就是这段代码的最好状况时间复杂度,也就是在最好的状况下,执行这段代码的时间复杂度。O(n) 就是这段代码的最坏状况时间复杂度。

// n表示数组array的长度
int find(int[] array, int n, int x) {
int i = 0;
int pos = -1;
for (; i < n; ++i) {
if (array[i] == x) {
pos = i;
break;
}
}
return pos;
}

3.3. 平均状况时间复杂度(加权平均时间复杂度或者指望时间复杂度)

最好和最坏状况时间复杂度都是极端状况发生的时间复杂度,并不常见。所以可使用平均状况时间复杂度来表示。好比上面这段代码中查找 x 在数组中的位置有两种状况,一种是在数组中,另外一种是不在数组中。在数组中又能够在数组中的 0~n-1 位置。假设在数组中和不在数组中的几率分别为 1/2,在数组中的 0~n-1 的位置几率都同样,为 1/(2 *n)。所以,上述这段的平均状况时间复杂度(或者叫加权平均时间复杂度、指望时间复杂度)为

假如使用以下公式计算复杂度的话,那么就至关于每种状况的发生几率都是 1/(n+1) 了,没有考虑每种的状况的不一样几率,存在必定不许确性。

3.4. 均摊时间复杂度

均摊时间复杂度采用的是摊还分析法(平摊分析法)。就是把耗时多的操做,平摊到其余那些时间复杂度比较低的操做上。好比下面这段代码

// array表示一个长度为n的数组
// 代码中的array.length就等于n
int[] array = new int[n];
int count = 0;

void insert(int val) {
if (count == array.length) {
int sum = 0;
for (int i = 0; i < array.length; ++i) {
sum = sum + array[i];
}
array[0] = sum;
count = 1;
}

array[count] = val;
++count;
}

这段代码想要实现的就是往一个数组中插入数据,若是数组满了的话,那么就求和以后的 sum 放到数组的第一个位置,以后继续将数据插入到数组中。经过分析能够发现,这段代码的最好状况时间复杂度是 O(1),最坏状况时间复杂度是 O(n),平均时间复杂度是 O(1)。

那么这段代码中,在大部分状况下,时间复杂度是 O(1),只有个别状况下,复杂度才是 O(n)。而且,O(1) 和 O(n) 出现的比较规律,通常一个 O(n) 执行以后,会出现 n-1 个 O(1) 的执行。针对这种状况,可使用摊还分析法,就是把 O(n) 这个耗时多的时间复杂度均摊到接下来的 n-1 个 O(1) 的执行上,均摊下来的时间复杂度为 O(1),这个时间复杂度就是均摊时间复杂度。

那么均摊时间复杂度不怎么常见,常见的场景是:对一个数据结构进行一组连续操做,大部分状况下时间复杂度都很低,只有个别状况下时间复杂度比较高。并且这些操做之间存在先后连贯的时序关系,好比上面提到的先是一系列 O(1) 的时间复杂度操做,接下来是 O(n) 的时间复杂度操做。这个时候就能够采用摊还分析法将较高时间复杂度的那次操做的耗时平摊到其余时间复杂度比较低的操做上。

通常均摊时间复杂度等于最好状况时间复杂度。那么如何区别平均时间复杂度和均摊时间复杂度呢?我以为看你使用哪一种方法,假如使用摊还分析法算出来的时间复杂度就是均摊时间复杂度,使用加权方式、或者指望值计算方式算出来的时间复杂度就是平均时间复杂度。

4. 空间复杂度分析

空间复杂度分析方法很简单。时间复杂度的全称叫作渐进时间复杂度,表示算法的执行时间与数据规模之间的增加关系。那么空间复杂度全称叫作渐进空间复杂度,表示算法的存储空间与数据规模之间的增加关系。

好比下面这段代码中,首先 int i= 0; 申请一个空间存储变量,是常量能够忽略,int[] a = new int[n]; 申请了一个大小为 n 的 int 类型数组,剩下的代码都没有占用更多的空间,所以空间复杂度是 O(n)

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

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

对于空间复杂度分析,其实比较简单,通常看变量声明时分配的空间大小便可。

4.1. 经常使用时间复杂度

量阶 表示
常数阶 O(1)
线性阶 O(n)
平方阶 O(n^2)

经常使用的空间复杂度就上面 3 种,O(nlogn)、O(logn)这样的对数阶复杂度通常都用不到。

5. 总结

回顾一下复杂度分析,总的来讲时间复杂度的 motivation 是咱们想要一个不用具体数据就能够估算出算法的执行效率的方法。而时间复杂度采用的是大 O 表示法,大 O 表示法其实描述的是一个增加趋势。好比  n^2 中,当 n 的值愈来愈大时候,O(n^2) 这个算法的执行时间是成平方增加的,而 O(n) 这个算法的执行时间是成直线型增加的,所以 O(n^2)  的时间复杂度是更高(见第一张图)。以后是几种经常使用的时间复杂度,平均时间复杂度、最好最坏时间复杂度,均摊时间复杂度(均摊这种思想在操做系统中有必定的体现:RR 调度算法中,在时间片大小选择上,有着相似的处理方式,由于 RR 是一个抢占式调度算法,当发生调度以后会发生进程的上下文切换,而进程的上下文切换是须要额外的时间成本,而这个时间成本会均摊到时间片上,当时间片很大时,显然均摊的效果不错,所以这个额外的时间成本影响会很小)

为何说掌握时间复杂度是掌握了根本大法?去年上课的时候,记忆比较深入的是老师好像在讲一个比较难的算法问题,而后从最简单、复杂度最高的解法开始讲起,而后跟带着咱们一步一步分析每一块代码的时间复杂度,而后说这块的代码的时间复杂度是 O(n^2),咱们能不能想办法把它给降下来的呢?而后就在那思考了怎么降了,一句一句代码看过去,画图等等,最终将时间复杂度降下来了。所以我的以为掌握时间复杂度分析以后,掌握的是算法的分析方法,你能够分析出每段代码的复杂度,而后经过思考最终把相应代码的时间复杂度降下来。假如你复杂度分析掌握不熟,那么怎么降都不知道,那么算法的优化也就没了。

6. 巨人的肩膀

  1. 程序锅上课时老师的课件;
  2. 极客时间-《数据结构与算法》-王争老师


不甘于「本该如此」,多选参数 值得关注




本文分享自微信公众号 - 多选参数(zhouxintalk)。
若有侵权,请联系 support@oschina.cn 删除。
本文参与“OSC源创计划”,欢迎正在阅读的你也加入,一块儿分享。