时间复杂度

时间复杂度实际上并不具体表示代码真正的执行时间,而是表示代码执行时间随数据规模增长的变化趋势,所以,也叫作渐进时间复杂度(asymptotic time complexity),简称时间复杂度

分析方法

  1. 重点分析循环:执行次数最多的代码片段。我们在分析一个算法、一段代码的时间复杂度的时候,也只关注循环执行次数最多的那一段代码就可以了。
  2. 加法法则:总复杂度等于量级最大的那段代码的时间复杂度。但是有一点需要注意,一个循环,只要循环次数跟 n 无关,哪怕执行成千上万次,时间复杂度我们也是用一个常量 O(1) 来衡量。
  3. 乘法法则:嵌套的代码时间复杂度等于嵌套内外代码复杂度的乘积。典型的冒泡排序,选择排序就是这样的。

常见的复杂度量级

  • 多项式量级
    • 常量阶 O(1)
    • 对数阶 O(log):二分查找
    • 线性阶 O(n):
    • 线性对数阶 O(nlog):归并排序,快速排序等
    • 平方阶 O(n) 立方阶 O(n)…… k 次方阶 O(n)
  • 非多项式量级
    • 指数阶 O(2)
    • 阶乘阶 O(n!)

复杂度分析 - 图1

其中Ο(log2n)、Ο(n)、 Ο(nlog2n)、Ο(n2)和Ο(n3) 称为多项式量级,而 Ο(2n) 和 Ο(n!) 称为指数时间,也叫非多项式量级。计算机科学家普遍认为前者(即多项式时间复杂度的算法)是有效算法,把这类问题称为P(Polynomial,多项式)类问题。而把后者(即指数时间复杂度的算法)称为NP(Non-Deterministic Polynomial,非确定多项式)问题。

O(1)
一般情况下,只要算法中不存在循环语句、递归语句,即使有成千上万行的代码,其时间复杂度也是O(1)

O(log)、O(nlog)
在对数阶时间复杂度的表示方法里,通常会忽略对数的“底”,统一表示为 O(log)
归并排序、快速排序时间复杂度都是 O(nlog)

时间复杂度分类

**

  • 最好情况时间复杂度(best case time complexity)
  • 最坏情况时间复杂度(worst case time complexity)
  • 平均情况时间复杂度 (average case time complexity)
  • 均摊时间复杂度(amortized time complexity)。

最好、最坏时间复杂度

最好,最坏时间复杂度比较好理解,都是相对极端的情况下的复杂度。比如我们插入排序,最好情况就是一个已经排好序的数组,此时时间复杂度是O(n);最坏就是刚好一个倒序的数组,时间复杂度就是O(n^2)。还有快速排序,如果基准时没找好,或者一个倒叙的数组排序,造成左右两边严重不均衡,最坏情况能退化到O(n^2)。

平均情况时间复杂度

由于最好,最坏时间复杂度概率并不大,所以才有了平均复杂度这个概念。那么如何分析平均时间复杂度呢?

以从一个数组中查找一个数n的位置为例。总共有n+1中情况:在数组的0到n-1位置中和不在数组中。把所有情况累加,除以n+1,得到了需要遍历的平均次数:

  1. (1+2+3+...+n+n) / (n+1) = n*(n+3) / 2*(n+1)

简化上面的公式后得到的是:O(n)。

上面的结论看似正确,其实有个问题,就是我们忽视了每种情况发生的概率。那这个概率是多少呢?我们看查找的变量 x,要么在数组里,要么就不在数组里。这两种情况对应的概率统计起 来很麻烦,为了方便你理解,我们假设在数组中与不在数组中的概率都为 1/2。另外,要查找的 数据出现在 0~n-1 这 n 个位置的概率也是一样的,为 1/n。所以,根据概率乘法法则,要查找的数据出现在 0~n-1 中任意位置的概率就是 1/(2n)。

于是平均时间复杂度的计算变成了:

复杂度分析 - 图2

这个值就是概率论中的加权平均值,也叫作期望值,所以平均时间复杂度的全称应该叫加权平均时间复杂度或者期望时间复杂度

均摊时间复杂度

均摊时间复杂度应用的场景比较特殊,了解概念就好。

看个例子,我们往一个数组中插入数据,数组满后,我们用 for 循环遍历数组求和,并清空数组,将求和 之后的 sum 值放到数组的第一个位置,然后再将新的数据插入。但如果数组一开始就有空闲空 间,则直接将数据插入数组。

那这段代码的时间复杂度是多少呢?最理想的情况下,数组中有空闲空间,我们只需要将数据插入到数组下标为 count 的位置就可 以了,所以最好情况时间复杂度为 O(1)。最坏的情况下,数组中没有空闲空间了,我们需要先做 一次数组的遍历求和,然后再将数据插入,所以最坏情况时间复杂度为 O(n)。

那平均时间复杂度是多少呢?答案是 O(1)。我们还是可以通过前面讲的概率论的方法来分析。

假设数组的长度是 n,根据数据插入的位置的不同,我们可以分为 n 种情况,每种情况的时间复 杂度是 O(1)。除此之外,还有一种“额外”的情况,就是在数组没有空闲空间时插入一个数据,这 个时候的时间复杂度是 O(n)。而且,这 n+1 种情况发生的概率一样,都是 1/(n+1)。所以,根据 加权平均的计算方法,我们求得的平均时间复杂度就是:

复杂度分析 - 图3

至此为止,前面的最好、最坏、平均时间复杂度的计算,理解起来应该都没有问题。但是这个例 子里的平均复杂度分析其实并不需要这么复杂,不需要引入概率论的知识。这是为什么呢?我们 先来对比一下这个 insert() 的例子和前面那个 find() 的例子,你就会发现这两者有很大差别。

  • 查找函数在极端情况下,复杂度才为 O(1)。但 插入 在大部分情况下,时间复杂度都 为 O(1)。只有个别情况下,复杂度才比较高,为 O(n)。
  • 对于 insert() 函数来说,O(1) 时间复杂度的插入和 O(n) 时间复 杂度的插入,出现的频率是非常有规律的,而且有一定的前后时序关系,一般都是一个 O(n) 插 入之后,紧跟着 n-1 个 O(1) 的插入操作,循环往复。

所以,针对这样一种特殊场景的复杂度分析,我们并不需要像之前讲平均复杂度分析方法那样, 找出所有的输入情况及相应的发生概率,然后再计算加权平均值。

针对这种特殊的场景,我们引入了一种更加简单的分析方法:摊还分析法,通过摊还分析得到的 时间复杂度我们起了一个名字,叫均摊时间复杂度

均摊时间复杂度思路:每一次 O(n) 的插入操作,都会跟着 n-1 次 O(1) 的插入操作,所以把耗时多的那次操作均摊到接下来的 n-1 次耗时少的操作上,均摊下来,这一 组连续的操作的均摊时间复杂度就是 O(1)。这就是均摊分析的大致思路。

对一个数据结构进行一组连续操作中,大部分情况下时间复杂度都很低,只有个别情况下时间复 杂度比较高,而且这些操作之间存在前后连贯的时序关系,这个时候,我们就可以将这一组操作 放在一块儿分析,看是否能将较高时间复杂度那次操作的耗时,平摊到其他那些时间复杂度比较 低的操作上。而且,在能够应用均摊时间复杂度分析的场合,一般均摊时间复杂度就等于最好情
况时间复杂度。其实均摊时间复杂度就是一种特殊的平均时间复杂度。

空间复杂度

空间复杂度全称就是渐进空间复杂度(asymptotic space complexity),表示算法的存储空间与数据规模之间的增长关系,可以理解成除了程序本身运行时所需的内存大小,在算法过程中还需要用到的额外的存储空间。比如归并排序,空间复杂度就是O(n)。

时间复杂度与空间复杂度的关系

对于一个算法,这两者是互相影响的。当追求一个较好的时间复杂度时,可能会使空间复杂度的性能变差,即可能导致占用较多的存储空间,比如双向链表相比于单链表,双向链表在很多操作上都比单链表上方便很多,但是双向链表前后两个指针需要更多的内存空间。这就是所谓的空间换时间的思想(缓存,其实也是空间换时间的思想)。

反之,如果我们追求一个较好的空间复杂度时,可能会使时间复杂度的性能变差,即可能导致占用较长的运行时间,即时间换空间。

所以当内存空间充足的时候,如果我们更加追求代码的执行速度,我们就可以选择空间复杂度相对较高、但时间复杂度相对很低的算法或者数据结构。相反,如果内存比较紧缺,比如代码跑在手机或者单片机上,这个时候,就要反过来用时间换空间的设计思路。