【数据结构与算法】->详解时间复杂度和空间复杂度

Ⅰ 前言

数据结构与算法本身解决的“”和“”的问题,即如何让代码运行得更快,如何让代码更节省存储空间。所以数据结构与算法一个重要衡量指标就是执行效率

那么如何进行执行效率的衡量呢?这就用到了我这篇要讲的时间复杂度空间复杂度分析。

并且,复杂度分析是整个算法学习的精髓,只要掌握了它,数据结构和算法的内容就掌握了半边江山。

Ⅱ 为什么需要复杂分析度分析?

在我初学数据结构与算法时,我对这部分的内容特别不感兴趣,觉得没有什么意义,而且算来算去的很麻烦,我相信和我当初抱持一个想法的人并不是少数。
但是随着我的积累,我发现复杂度分析在数据结构与算法中是十分重要的,更是一个基础。并且,仅仅只需要初中数学,就可以完全掌握。

有的人可能会很疑惑,我写完代码运行一遍,不就知道程序怎么样了嘛,我对运行结果进行监控和分析,就能得到执行时间和空间占用的内存大小了,为什么还要做时间、空间复杂度分析?
事实上,这种方法确实是对的,它还有个名字叫事后统计法。但是这种方法有很大的局限性。

——事后统计法的局限性

局限一:测试结果依赖测试环境

测试环境中的硬件的不同会对测试结果有很大的影响,一段同样的代码 i7 的处理器和 i5 的处理器都会有差异,i7 的必然会快很多。像这样影响程序快慢的因素很多,每台计算机甚至都会出现不同的结果。

局限二:测试结果受数据规模影响

拿我写过的博客来看,有两篇算法优化的博文,验证哥德巴赫猜想以及找质数,随着数据量的增加,速度是越来越慢的。
或者说排序,数据的量以及顺序性都会对结果产生很大的影响。所以我们需要一个不用具体的测试数据来测试,就可以粗略地估计算法的执行效率的方法,这就是这篇文章要讲的复杂度分析。

有兴趣的同学可以看看我算法优化的文章👇

【C语言基础】->哥德巴赫猜想验证->筛选法->算法极限优化之你不可能比我快
【C语言基础】->自幂数优化->这个算法快得像一道闪电

Ⅲ 大 O 复杂度表示法

算法的执行效率粗略来看就是算法代码的执行时间,那么要如何在不运行代码的前提下,得到代码的执行时间呢?

我在此给出一段简单的代码,求1到n的累加和。不笨的方法是用高斯定理,但我在此要通过循环来讲解,所以暂且用这个笨办法吧。我来带你估算一下这个简单代码的执行时间。

在这里插入图片描述
我们假设每一行代码的执行时间都相同,假设为 time 。

在这个假设基础上,我们来看这段代码的总执行时间。

第2,3行代码分别需要 1time 的执行时间,第5,6行都运行了 n 次,所以需要2n*time的执行时间。
所以这段代码总的执行时间就是 (2n+2)*time。

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

按照这个思路分析,我们再来看下面这段代码,也是一个简单的累加。
在这里插入图片描述
仍然假设每个语句执行时间为 time。

第2,3,4行代码每行需要1个 time 执行时间,第6,7行代码循环执行了 n 次,需要 2n * time 的执行时间,第8,9行代码循环执行了 n2 遍,所以需要 2n2 * time 的执行时间。
所以整段代码的总执行时间 T(n) = (2n2 + 2n + 3) * time。

尽管我们不知道 time 的具体值,但是通过这两段代码的推导过程,我们可以得到一个非常重要的规律,那就是,所有代码的执行时间 T(n) 与每行代码的执行次数 n 成正比。 我们可以把这个规律总结成一个公式👇

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

在这里插入图片描述
其中 T(n) 就是总的代码执行时间,n 表示数据规模的大小,f(n) 表示每行代码执行的次数总和,因为这是个公式,所以用f(n)表示。公式中的 O,表示代码的执行时间T(n)与f(n)表达式成正比。

所以,第一个例子可写为 T(n) = O(2n + 2)。
第二个可写为 T(n) = O(2n2 + 2n + 3)。

这就是大 O 时间复杂度表示方法

需要注意的是,大 O 时间复杂度表示方法并不具体表示代码真正的执行时间,而是表示代码执行时间随数据规模增长的变化趋势,所以,也叫做渐进时间复杂度(asymptotic time complexity),简称时间复杂度

当 n 很大时,你可以将其想象成100000,1000000。而公式中的低阶,常量,系数三部分并不左右增长趋势,所以都可以忽略。我们只需要记录一个最大量级。

所以用大 O 表示法表示那两段代码的时间复杂度,就可以记为:
T(n) = O(n), T(n) = O(n2)

Ⅳ 时间复杂度分析

前面已经介绍了大 O 时间复杂度的表示方法,现在我们来看如何分析一段代码的复杂度,在此我提供三个方法。

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

前面已经说过了,大 O 时间复杂度表示法只表示一种变化趋势,所以通常会忽略掉公式中的常量、低阶和系数,只需要记录一个最大阶的量级。所以在分析一个算法、一段代码的时间复杂度时,也只关注循环执行次数最多的一段代码就可以了。这段核心代码执行次数的 n 的量级,就是整段要分析代码的时间复杂度。

在这里插入图片描述
还用这段代码举例子。

其中第2,3行代码都是常量级的执行时间,与 n 的大小无关,所以对于复杂度并没有影响。循环执行次数最多的是第5,6行代码,所以关注这里的代码就可以了,就可以得出这段代码的时间复杂度为 O(n)。

② 加法法则

加法法则即为,总复杂度等于量级最大的那段代码的复杂度。

这里我给出另一个例子。
在这里插入图片描述
这段代码分成三部分,分别求sumOne,sumTwo,sumThree。我们分别分析每一部分的时间复杂度,然后将它们放在一起,再取一个量级最大的作为整段代码的复杂度。

第一段代码循环执行了1000次,所以这是一个常量的执行时间,跟 n 的规模无关。
这里需要强调的是,不论它执行了10000次甚至100000000次,它都是个常量,和 n 无关,一样是常量级的执行时间,当 n 趋近于无穷大时就可以忽略。尽管对代码的执行时间会有很大的影响,但是不要忘记了时间复杂度的概念,它表示的是变化趋势

第二段代码和第三段代码的时间复杂度分别是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)))。

③ 乘法法则

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

如果 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)).
也就是说,假设 T1(n) = O(n),T2(n) = O(n2),则 T1(n) * T2(n) = O(n3)。

我们可以将乘法法则看作是一个嵌套循环,这里我举个简单的例子。
在这里插入图片描述
单独看nest()函数,假设add()只是一个普通的操作,那么5到7行的时间复杂度就是 T1(n) = O(n);但add()不是个普通的操作,其本身的时间复杂度是 T2(n) = O(n),所以整个nest()函数的时间复杂度就是
T(n) = T1(n) * T2(n) = O(n * n) = O(n2)。

Ⅴ 几种常见的时间复杂度实例分析

代码固然多,但是其实常见的时间复杂度量级并不多,以下的几个量级几乎就是能碰到的所有了。

阶数时间复杂度
常量阶O(1)
指数阶O(2n)
对数阶O(logN)
阶乘阶O(n!)
线性阶O(n)
线性对数阶O(nlogN)
平方阶O(n2)
立方阶O(n3)
K次方阶O(nk)

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

我们把时间复杂度为非多项式量级的算法问题叫做NP (Non-Deterministic Polynomial,非确定多项式) 问题。
当数据规模 n 越来越大时,非多项式量级算法的执行时间会急剧增加,求解问题的执行时间会无限增长。所以,非多项式时间复杂度的算法其实是非常低效的算法。

下面我们主要看几个常见的多项式时间复杂度。👇

① O(1)

首先我们需要明确一个概念, O(1) 只是常量级时间复杂度的一种表示方法,并不是只执行了一行代码。
在这里插入图片描述
比如上面这个代码,即使有3行,它的时间复杂度也是 O(1) 。

所以可以总结一个规律,只要代码的执行时间不随 n 的增大而增长,代码的时间复杂度都记作 O(1)
即,只要算法中不存在循环语句和递归语句,即使有成千上万行代码,其时间复杂度也是 O(1) 。

② O(logn) & O(nlogn)

对数阶时间复杂度非常常见,同时又很难分析,我通过下面的例子来逐步讲解。
在这里插入图片描述
比如上面这个代码,我们来看这个循环。
变量 i 从1开始,每循环一次乘以2,大于 n 时就跳出循环。那么,i 的值我们就可以看成是一个等比数列。

20,21,22,23,……,2k,……,2x = n

所以,我们只需要知道 x 的值,就可以得到这段代码的执行次数。因为 2x = n,所以 x = log2n。

因此这段代码的时间复杂度就是O(log2n)。

现在我将代码稍微改动一下👇
在这里插入图片描述
同样的我们可以得到这段代码的时间复杂度为 O(log3n)。

但是其实不管是以2为底,还是以3为底,甚至以100为底,我们可以把所有的对数阶的时间复杂度都记为 O(logn)。为什么呢?

这就需要我们回忆一下高中数学的对数互换了。

log3n = log32 * log2n,所以 O(log3n) = O(C * log2n)。
其中C = log32,是个常量,因此可以忽略不记。
所以 O(log3n) = O(log2n) 。

因此,在对数阶时间复杂度的表示方法里,我们忽略对数的”底“,统一都表示为 O(logn) 。

这个如果理解了,O(nlogn)就很好理解了。就是我上节说的乘法法则,如果一段代码的时间复杂度是 O(logn),我们循环执行 n 遍,时间复杂度就是 O(nlogn) 了。而且,O(nlogn) 也是一种非常常见的算法时间复杂度。比如,归并排序、快速排序的时间复杂度都是 O(nlogn)。

③ O(m + n) & O(m * n)

这两种情况都是因为代码的时间复杂度由两个数据的规模来决定。
在这里插入图片描述
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),表示算法的存储空间与数据规模之间的增长关系

在这里插入图片描述
比如上面这段代码,第1,2,3行都是申请了一个四字节空间,都是常量阶的,所以可以忽略。
第5行申请了一个大小为 n*4 字节的空间,除此之外剩下的代码都没有再占用更多的空间,所以整段代码的空间复杂度就是 O(n) 。

在空间复杂度中,我们常见的就是 O(1),O(n),O(n2)。像 O(logn),O(nlogn)这样的对数阶复杂度平时都用不到。

Ⅶ 最好 & 最坏情况时间复杂度

我们还是来先看代码👇
在这里插入图片描述
这是一个查找函数,根据传入的数据data,要是数组arr[]中有data,就返回下标,否则返回-1。

要查找的数据data可能出现在数组中的任意位置,如果数组中第一个元素正好是要查找的变量data,那么就不需要继续变量了,时间复杂度就是 O(1) 。
但如果数组中不存在变量data,那么我们就需要将数组遍历一遍,时间复杂度就变成了 O(n) 。所以,不同情况下,这段代码的时间复杂度是不一样的。

为了表示代码在不同情况下的不同时间复杂度,我们需要引入三个概念:最好情况时间复杂度最坏情况时间复杂度平均情况时间复杂度

顾名思义,最好情况时间复杂度就是最理想的情况下,执行这段代码的时间复杂度。就比如那段代码,数组的第一个元素就是我们要找的元素data,这时候对应的时间复杂度就算最好情况时间复杂度。

同理,最坏情况时间复杂度就是在最糟糕的情况下,执行这段代码的时间复杂度。就是数组没有要查找的data,我们需要将数组整个的遍历一遍,此时的时间复杂度就是最坏情况时间复杂度。

Ⅷ 平均情况时间复杂度

最好情况时间复杂度和最坏情况时间复杂度对应的都是极端情况下的代码复杂度,为了更好地表示平均情况下的复杂度,我们需要引入另一个概念:平均情况时间复杂度

在这里插入图片描述
依然是借助于这个代码,我们来分析平均情况时间复杂度。

要查找的变量data在数组中的位置,有n + 1种情况:在数组的 0~n-1 位置中和不在数组中。我们把每种情况下,查找需要遍历的元素个数累加起来,然后再除以 n+1 ,就可以得到需要遍历的元素个数的平均值,即:
在这里插入图片描述
用大 O 表示法表示,得到的平均时间复杂度就是 O(n) 。

这个结论虽然是正确的,但是思路是有点问题的。哪里有问题嘞?

我们这样计算的 n+1 种情况,是假设每种情况发生的概率都是相同的,但是其实不是这样的,这就要用到概率论的知识了。(关于计算机需要用到的数学知识我之后会写一个专栏)

我们要查找数据 data ,它要么在数组中,要么不在,两种情况的概率我们假设是相同的,各1/2。如果data在数组中,那么它出现在 0~n-1 这 n 个位置的概率是一样的,为 1/n。

所以要查找的数据出现在0~n-1 种任意位置的概率就是 1/(2n) 。

那么,平均情况时间复杂度的计算过程就变成了这样👇
在这里插入图片描述
这个值就是概率论中的加权平均值,也叫做期望值,所以平均情况时间复杂度的全称应该叫加权平均时间复杂度期望时间复杂度

引入概率之后,这段代码的加权平均值为 (3n+1)/4。用大 O 表示法来表示,这段代码的加权平均时间复杂度仍然是 O(n)。

在大多数情况下,并不需要计算这么多,只需要一个复杂度就可以了,只有同一块代码在不同情况下,时间复杂度有量级的差距,我们才会使用这三种复杂度表示法来区分。

Ⅸ 均摊时间复杂度

前面的内容已经是算法复杂度分析的大部分内容了,接下来我要讲一个更为高级的概念,就是均摊时间复杂度,以及对它的分析方法摊还分析(或者叫平摊分析)。

对于初学者来说,均摊时间复杂度和平均时间复杂度很容易弄混。在前面我说过,大部分情况下并不需要区分最好、最坏、平均三种复杂度,平均复杂度只在某些特殊情况下才会用到,而均摊时间复杂度应用的场景比它更特殊,更有限。

我还是用一段代码来说明。需要说明一点,应该没有人会这样写代码,这个代码仅为了方便地配合这里的内容。
在这里插入图片描述
这个代码的意思是,实现数组的插入,n为数组的大小,当 n == count时,说明数组以及满了,此时将数组所有元素求和,清空数组,并将求和的结果放在数组的首元素中。如果数组没有满,就将元素data插入下标为count的地方,插入后count+1。

那么这段代码的时间复杂度我们就需要用之前讲的三个复杂度来看了。

最理想的情况就是数组有空闲空间,此时就直接插入了,所以最好情况时间复杂度为 O(1)。最糟糕的情况就是数组满了,此时需要遍历数组,将所有元素求和,所以最坏情况时间复杂度为 O(n)。

那么平均时间复杂度呢?答案是 O(1)。还是用之前说的加权平均来算,数组长度为 n ,根据插入位置的不同,可以分为 n 种情况,每种情况的时间复杂度为 O(1)。除此之外还有一种情况,就是数组满了,此时时间复杂度为 O(n)。并且,这n+1种情况发生的概率是相同的,都是 1/(n+1),计算过程为👇
在这里插入图片描述
这样理解是没有问题的,但是这个例子里的平均情况时间复杂度其实不需要这么复杂,不需要引入概率论的知识。为什么呢?

我们可以对比这个例子里的insert()函数和上个例子里的search()函数,其实差异很大。

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

所以针对这样一种特殊场景的复杂度分析,我们引入一种更加简单的分析方法:摊还分析法。通过摊还分析法得到的时间复杂度就叫均摊时间复杂度

对于这个例子,一个 O(n) 插入之后,紧跟着 n-1 个 O(1) 的插入,我们把耗时多的操作均摊到接下来的n-1次耗时少的操作上,均摊下来,这一组的连续的操作的均摊时间复杂度就是 O(1) 。

均摊时间复杂度和摊还分析应用场景比较特殊,所以用到的次数比较少。我们只需要记住它的特征就好了。

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

并且,在能够应用均摊时间复杂度分析的场合,一般均摊时间复杂度就等于最好情况时间复杂度。

说白了,均摊时间复杂度就是一种特殊的平均时间复杂度,我们不需要过度地去区分,重要的是掌握摊还分析的方法。

时间复杂度和空间复杂度我就讲解到这里了,希望大家能掌握这个方法。另,这篇文章的知识点来源于极客时间上王争的课程,《数据结构与算法之美》。

  • 4
    点赞
  • 6
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值