时间复杂度浅析

数据结构之简单的时间算法复杂度分析

数据结构可以说是编程的最最最基础且重要的概念之一了,也是大学本科中一项非常重要的课程。数据结构通常和算法联合起来,又称数据结构与算法。面对困难的问题时,如果选择了合适的数据结构算法,可以大大调高解决问题的效率。同时数据结构也是读懂一些底层源码的必备技能,可以说是作为码农的一项内功了。

因为要备战考研,所以在专业课复习时,每当复习到一定的阶段,我就打算把知识以文章的形式串联起来,巩固自己的知识,同时也算是给大学的课程做一个相对系统的总结。当然,如果看了这些内容,对你也有一些帮助,那就再好不过了。

数据结构部分我选取的资料有严蔚敏《数据结构C语言版》,Mark Allen Weiss的《数据结构与算法分析》与极客时间上王争老师的《数据结构与算法之美》。当然,后两个资料都有相当一部分超越了408的要求,对于超出了部分,这次因为时间关系就不复习了,仅对408涉及到的考点进行复习和拓展。

为什么要引入复杂度分析?

好啦,现在来开始我们的第一个问题,为什么要引入复杂度的分析呢?
我们在设计算法的时候,效率是考量的重要指标。这里的效率有两个含义,通俗的讲就是“多快好省”,既要让代码跑得快,又要让程序占用的空间尽量的少(当然,大多数情况下这两个目标想要兼得是很困难的)。当我们想比较两段代码之间在效率的差异上时,问题就出现了,我们要以什么标准来比较呢?

最容易想到的方法就是把代码跑一遍,通过记录程序运行的时间,检测程序内存占用的大小,这样就可以直观的看出算法的运行情况啦!这个方案的优点是简单可行,许多书籍上称这种计量方法为“事后统计法”。这种方法虽然直观,但是有两个致命的缺陷使它在理论研究中难以使用。

第一个缺陷是其严重依赖于具体硬件。同样一个算法跑,我是在I3处理上测试,你在I9处理器上测试,肯定是I9处理器跑的快啊!所以同样一个算法,在不同的机器上会体现出不同的性能,甚至还有可能因为运行时系统环境的不同而造成差异。这让我们比较不同的算法性能就出现了问题,因为没有一个统一的基准。这说明我们要找到一种方法来避免硬件对算法的影响。

第二个缺陷是测试结果受数据规模影响很大。 这里要分两点来讨论,第一点,用冒泡排序算法举例,极端情况下,完全有序的数组在排序时进行的操作很少,而全部逆序的数组进行的操作就很多,这说明测试结果是和测试数据的分布有密切关系的。第二点,如果测试数据少,有可能掩盖算法的真实性能。比如在较小的数据量下,插入排序的性能甚至可能会高于快排。这就要求我们要寻找一种方法不用具体的数据,而是可以快速地大致估略算法的运行效率的方法。

那么,有没有这样一种方法,既可以做到度量算法的性能,又能克服上面两项的缺点呢?下面我们来介绍大O复杂度表示法。

大O复杂度表示法

在介绍复杂度分析之前,我们首先要接受一个基本的假设,那就是计算机运行一条指令的时间可以近似看作是相同的。 这个假设对我们化简问题很有帮助,同时也是很合理的,因为现代计算机运行的速度非常快,在大致估算代码的需求下,将任何一条指令都看作是相同时间是合理的。现在我们设这个单位时间为 t t t

现在让我们来看一段代码,瞧瞧它的运行时间是怎样的一个规律。下面这段代码的运行时间是多少呢?

def sum(n: int)->int:
    partial_sum = 0
    for i in range(1,n+1):
        partial_sum += i*i*i
    return partial_sum

问题的答案是 ( 2 n + 1 ) ∗ t (2n+1)*t (2n+1)t (在忽略函数调用以及函数返回的开销时)。 当然,你看到这里可能会反驳说,程序在代码的第四行做了更多的工作,如乘法加法和赋值等,但是这依然是有限个运算,所以我们在粗略估计时依然将这行时间与其他代码的运行时间一并估计为 t t t,一会儿你就知道这并不影响我们最终的结果。要注意,循环语句明显也是要计算时间的(想想为什么?)。不知道你看到上面的公式,有没有什么感觉呢?如果没有也没关系,我们再来看一段代码的例子

def double_sum(n: int)->int:
    sum = 0
    for i in range(1, n+1):
        for j in range(j, n+1):
            sum += i + j
    return sum

好啦,上面这段代码的运行时间又是什么呢?我知道区区两层循环肯定难不住你,但是还是稍微解释一下,第2行运行时间为 t t t, 第三行运行时间为 n t nt nt, 第4~5行运行时间共为 2 n 2 t 2n^2t 2n2t,所以总共的运行时间为
( 2 n 2 + n + 1 ) t (2n^2+n+1)t (2n2+n+1)t
怎么样,现在有没有看出点端倪来呢?我们虽然没办法知道假设的单位时间 t t t的准确值,却可以清晰的知道代码的运行时间与代码的执行次数成正比!。 大神Knuth给出了一个非常好用的记号,就是 O O O记号,我们有
T ( n ) = O ( f ( n ) ) T(n) = O(f(n)) T(n)=O(f(n))
其中 T ( n ) T(n) T(n)代表着程序的运行时间, f ( n ) f(n) f(n)是一个关于数据规模 n n n的函数,代表每行代码执行次数的总和。

大 O 计 法 大O计法 O在《数据结构与算法分析》这本书中给出了形式化的定义,优点嘛是更加精准,但是缺点是看起来有些晦涩,而且给出了四个符号定义,下面我只引用最常用的O记号的定义:

定义:如果存在正常数 c c c n 0 n_0 n0使得当 N > = n 0 N>=n_0 N>=n0时, T ( N ) < = c f ( N ) T(N)<=cf(N) T(N)<=cf(N), 则记为 T ( n ) = O ( f ( n ) ) T(n)=O(f(n)) T(n)=O(f(n))

如果你觉得上面的定义有点抽象,难以理解的话,不要着急,看我一点点的给你解释这个公式。从刚才到现在,我们想做的一个核心工作是比较。正如我们从上面两段代码观察出的结论,我们可以用函数 f ( n ) f(n) f(n)来估计程序运行的时间。但是问题是,单纯的比较两个函数的大小是没有什么意义的。这里我举个例子给你感受一下:
假设我们现在有两个算法需要比较,我们已知他们的函数是 f 1 ( n ) = 1000 n f_1(n) = 1000n f1(n)=1000n, f 2 ( n ) = n 2 f_2(n) = n^2 f2(n)=n2, 当我们有一个具体的 N N N时,你会发现在 N N N比较小时 1000 N > N 2 1000N > N^2 1000N>N2,但我们知道最终, N 2 N^2 N2会获胜,因为它增长的快。所以说两个函数 f 1 ( n ) < f 2 ( n ) f_1(n) < f_2(n) f1(n)<f2(n)是没有意义的,我们要比较这两个函数,就要比较他们的相对增长率

现在我们回头看定义,其实说的就是总存在某个常数,从它以后, c f ( n ) cf(n) cf(n)总是至少 T ( N ) T(N) T(N)一样大,换句话说,我们保证 T ( n ) T(n) T(n)是在以不快于 f ( n ) f(n) f(n)的速度增长。 f ( n ) f(n) f(n) T ( n ) T(n) T(n)的一个上界(Upper bound)。如果你翻翻同济版的高等数学,就会发现这其实和极限的定义是有相似之处的。通过 O O O记号,我们以后比较的就是两个函数的相对增长率,而不是两个函数的大小。至此,如果我们知道了两个算法的代码执行次数的函数,就可以通过 O O O记号来对他们进行比较了。

在上面两个代码段中,我们能得到 T 1 ( n ) = O ( 2 n + 1 ) T_1(n)=O(2n+1) T1(n)=O(2n+1), T 2 ( n ) = O ( 2 n 2 + n + 1 ) T_2(n)=O(2n^2+n+1) T2(n)=O(2n2+n+1)。不过千万要注意的是,我们使用大 O O O时,不代表代码真正的执行时间,而表示的是执行时间随数据规模增长的趋势,叫做渐进时间复杂度(asymptotic time complexity),简称时间复杂度

大O复杂度表示法在实际中的应用

现在,我们终于正式进入到了正题。下面开始介绍如何在实际的代码中应用时间复杂度分析。让我们从一条基本法则开始,

如果 T ( N ) T(N) T(N)是一个 k k k次多项式,则 T ( N ) = O ( N k ) T(N)=O(N^k) T(N)=O(Nk)

这条法则告诉我们,在面对多项式时,只有最高阶是最重要的。这条道理显而易见,就不过多阐释了,你可以去一个很大的值比如100000,或者100000000,你就会发现低阶项的作用相比于高阶项就微乎其微了。这也提示我们一个找时间复杂度的便捷方法,那就是找循环!!!。 因为除了循环语句,其他的普通单条语句在代码规模增大时,都可以忽略不计。

代码段1中共有一层循环,所以我们最后的结果是 O ( N ) O(N) O(N),代码段2中共有两层循环,所以我们的最后结果是 O ( N 2 ) O(N^2) O(N2)

下面我们引用第二类法则,加法法则与乘法法则,它的形式化表述如下:

IF T 1 ( N ) = O ( f ( N ) ) T_1(N)=O(f(N)) T1(N)=O(f(N)) T 2 ( n ) = O ( g ( N ) ) T_2(n) = O(g(N)) T2(n)=O(g(N)),那么
+ T 1 ( N ) + T 2 ( N ) = m a x ( O ( f ( N ) ) , O ( g ( N ) ) ) T_1(N) + T_2(N) = max(O(f(N)),O(g(N))) T1(N)+T2(N)=max(O(f(N))O(g(N)))
+ T 1 ( N ) ∗ T 2 ( N ) = O ( f ( N ) ∗ g ( N ) ) T_1(N)*T_2(N)=O(f(N) * g(N)) T1(N)T2(N)=O(f(N)g(N))

这两条法则应用起来一样非常简单,不知道你在研究上面有点复杂的定义之后,看这个定义是不是有一点感觉了呢?下面来稍微解释一下这两条。
加法法则变向的告诉了我们,一段代码的时间复杂度和循环次数最高的代码有关,原理和只取最高项是一样的,我们快速的用代码来展示一下。

def three_loop(data_size:int):
    for i in range(data_size):
        print("hi!~~~")
    for i in range(data_size):
        print("hello~~~)
    for i in range(data_size):
        print("Surprise!")

这段代码的时间复杂度是 O ( d a t a _ s i z e ) O(data\_size) O(data_size),相信你可以轻松理解的。接下来是一个稍微综合一点的例子,

def say_hello(N:int):
    for m in range(N)
        print("hello")

def harder_example(N:int):
    for i in range(N):
        print("happy_time")  
    for i in range(N):
        for j in range(N):
            say_hello(N)

上面这段代码的时间复杂度是多少呢?乘法法则告诉我们循环里面的次数要相乘,加法法则告诉我们只看最高的次数。 记得函数内部的循环也要考虑在内哦,上段代码的时间复杂度为 O ( N 3 ) O(N^3) O(N3),不知道你有没有算对呢?

经过了上面的讨论之后,我们来继续讨论当代码中出现不同的数据规模时,我们如何处理问题。废话少说,先看例子

def sum_in_two_size(N:int, M:int)-> int:
    total = 0
    for i in range(N):
        total += i
    for j in range(M):
        total += j
    print(total)

上面这段代码的时间复杂度是多少呢?正确的答案是 O ( N + M ) O(N+M) O(N+M). 这么做的道理是因为我们无法实现估计 M M M N N N的量级,所以只能将他们加在一起,而不能单纯的合并。当然,在这里乘法法则还是继续适用的,将两个不同量级的按以前乘法的规则乘起来就好了,没有什么变化,这里就不展示了。

常见时间复杂度的比较与介绍

在掌握了基本的复杂度计算的法则之后,你已经可以对自己写下的一些代码做时间复杂度分析啦!让我们对常见的时间复杂度做一个分类,然后对他们的效率进行比较。

大体上来说,复杂度就分为两类,多项式量级的和非多项式量级的。

在非多项式的量级里,最常见的两位大哥是 O ( n ! ) O(n!) O(n!) O ( a n ) O(a^n) O(an),如果你的代码写出来时间复杂度属于这两位大哥的话,那就很尴尬了,因为无论是阶乘还是指数,随着数据规模的增大,它们的运行时间会急剧膨胀,就像指数爆炸一样,运行时间会随数据规模的增大趋向于无穷,这在实际中是很难接受的算法,算法的效率相当之差,这类NP的问题也是很多研究的焦点。

剩下的一类就是我们的老朋友多项式量级了,也是我们复习时研究的重点。让我们来看下面的这张图:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-QKtCyEbe-1581445571568)(https://ws3.sinaimg.cn/large/005BYqpgly1g1wo3u4l41j30i50daq3d.jpg)]

从这张图片中我们可以清晰的看到两位“大哥”的增长非常恐怖,数据稍微增大一点,运行时间就近乎以直线的速度上升,这几乎是不可接受的。然后我们可以清晰看到这样的一个比较级
O ( 1 ) < O ( l o g n ) < O ( n l o g n ) < O ( n 2 ) O(1) < O(log n) < O(nlogn) < O(n^2) O(1)<O(logn)<O(nlogn)<O(n2)
一般来讲, l o g n logn logn n l o g n nlogn nlogn都是非常理想的量级, n 2 n^2 n2可以勉强接受,如果是 n 3 n^3 n3那就要小心了,当数据规模很大时,它的表现并不是很好。

这里我们对这些数量级做一些解释,首先是 O ( 1 ) O(1) O(1)。还要再次强调一遍的是,大O记号只与数据规模有关,与单独的若干条语句都无关,所以即使你的代码有一千行,一万行,只要没有循环,在计算复杂度是都是 O ( 1 ) O(1) O(1),这点要特别注意。

接下来是对于 O ( l o g n ) O(logn) O(logn)的讲解,一开始可能大家会很费解,这个对数是怎么出来的呢?我也是这样的,知道看了王争老师对这段的讲解才豁然开朗,还是来直接看代码

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

这段代码的时间复杂度如何分析呢?我们依然可以发现,分析这段代码复杂度的重点依然落脚在循环体上,让我们详细的研究一下循环体的这段代码。每当循环进行了一次,i的值就会平方一次,所以我们可以看到这样的一个数列
2 0 , 2 1 , 2 2 , . . . , 2 x = n 2^0,2^1,2^2,...,2^x=n 20,21,22,...,2x=n
那么这段代码会运行多少次呢? 答案当然就是两边取对数, x = l o g 2 n x = log_2n x=log2n,这样就推导出来了 l o g n log_n logn这个量级,同时我们通过换底公式可以知道, l o g log log的底数是可以换掉的,所以底数是多少无关紧要,不同底数之间只差一个常数,所以我们知道这种类型的时间复杂度可以被同一记为 O ( l o g n ) O(log_n) O(logn)

当然,知道了 O ( l o g n ) O(log_n) O(logn),那么 O ( n l o g n ) O(nlog_n) O(nlogn)怎么推导出来也很明确了,只是外面多套了一层循环而已。

中场休息

好了,我们到现在可以喘口气稍微放松一下,聊一个轻松一点的话题,换换脑子。我们前面用很长的篇幅介绍了时间复杂度,现在我们可以骄傲的回答篇首中的一个问题了,通过时间复杂度的分析,我们可以摆脱具体硬件的限制来考量算法的效率,这是一个很大的突破。接下来,我要提出的一个问题是,当我们说一个算法在时间复杂度上优于另一个算法,可不可以认为复杂度低的算法在运行时的时间一定快于复杂度高的算法呢?,再来一个问题,如果两个算法的时间复杂度相同,是不是这两个算法在真实的机器上运行效率相仿呢?
这两个问题的答案都是错误的。这里的错误十分的微妙,如果不能从源头上理解时间复杂度定义,就很难准确的回答这两个问题,很多时候在做算法题时,也会因为把时间复杂度好不容易推向了一个很好的结果,最后程序跑出来还是很别人的运行速度相距甚远的事情,源头都是在于对时间复杂度的理解。

我们一定要明确,时间复杂度本质上只是一个数学模型。 模型在这里只起到一个估算的作用,时间复杂度只比较了算法的相对增长率,是一个理论上的估计而已。面对两个复杂度不同的算法,我们只能估计复杂度更优的算法大概率会优于复杂度差的算法,而不是一个绝对的值。

讲一个极端的例子,假设有一段代码,代码里只有一个for循环,但是这个for循环之前有足足十万条语句,根据我们前面的分析,这段代码的时间复杂度确实是 O ( n ) O(n) O(n),但是同样是一个 O ( n ) O(n) O(n)级别的另一个算法,可能它前面只有几十行语句,在真实的环境中,这两个算法又怎么可能会有近似的运行时间呢?

针对第二个问题,我们再来举一个例子。两个算法的时间复杂度相同,同时不向上面的诡辩一样,两个算法解决的是同样的问题,除了循环区域外其他的地方代码差异不大。两个算法要解决的问题是从硬盘中存放的一组数据中找出一个数。
算法一的策略是一个一个的从硬盘中往出拿,然后比对
算法二的策略是一次性的把数据都从硬盘中拿出来放到内存里,然后在内存里一个一个的比较。

这两种策略在代码层面上或许都拥有同样的复杂度量级,但是我们可以明显看出,算法二要远远快于算法一因为利用了缓存的特性。

希望通过这两种例子,可以帮你避开一些算法复杂度理解上的一些误区。

棘手的数据分布问题

额呀!中场休息时间结束,学到这里你是不是以为关于时间复杂度已经结束了呢?虽然我们已经接近尾声了,但是还是要打起精神 攻克掉初级阶段的最后一个堡垒,最好时间复杂度,最坏时间复杂度和平均时间复杂度。

这里的核心问题是,我们虽然解决了不同的硬件设施对比较算法的干扰,却依然没有搞定数据分布给算法分析带来的麻烦,让我们分析下面的一个例子。

def search(to_search:int, array:list)->int:
    pos = -1
    for i in range(len(array)):
        if(array[i] == to_search):
            pos = i
            break
    return pos

这段代码的目的是在一个无序的list中找到某个数的位置(当然这里只是为了演示,真正运用python时可不用这么麻烦)。 怎么分析这段代码的运行时间呢?现在我们发现碰到了个大麻烦,这段代码的运行时间不单单和数据的规模有关,还和数据在list中的分布情况密切相关。如果我们要找的数据元素在list中的第一项,那么时间复杂度就是 O ( 1 ) O(1) O(1),而如果我们要找的数据元素在list中的最后一项,那么我们算法的时间复杂度是 O ( l e n ( a r r a y ) ) O(len(array)) O(len(array))

最好时间复杂度说明了一个算法在最理想的情况下的时间复杂度,对应上面的 O ( 1 ) O(1) O(1),而最坏时间复杂度对应算法在最不理想的情况下的时间复杂度,对应上面的 ( l e n ( a r r a y ) ) (len(array)) (len(array))。最好时间复杂度和最坏时间复杂度告诉我们了一个算法的上界与下界的情况,但是并不能准确的反映一个算法的性能,因为毕竟这些都属于极端情况,我们更需要的是在一般情况下算法性能的度量,所以接下来引用了平均时间复杂度

平均时间复杂度与加权时间复杂度分析

假设:我们要查找的数据元素在List是等概率分布的

当然,这个假设不是很严谨,不过没关系,我们后面会把这条假设解决掉,为了简单起见,请先接受这条假设。

下面我们来看我们要查找的数据在list中都可能出现在哪些位置呢?它可以出现在下标为0到 n − 1 n-1 n1的位置上,也可能压根就不在list中,所以共有 l e n ( a r r a y ) + 1 len(array) + 1 len(array)+1种可能的情况。

那么对应这些位置,需要查找的次数又是多少呢?稍作思考我们就可以得到:
1 + 2 + 3... + n + n 1+2+3...+n+n 1+2+3...+n+n
l e n ( a r r a y ) = n len(array) = n len(array)=n,则查找次数的平均值为:
1 + 2 + 3... + n + n n + 1 = n ( n + 3 ) 2 ( n + 1 ) \frac{1+2+3...+n+n}{n+1} = \frac{n(n+3)}{2(n+1)} n+11+2+3...+n+n=2(n+1)n(n+3)

请确保你知道等式右边是怎么算出来的,务必动笔快速计算一下。现在我们知道该算法的平均复杂度为 O ( n ) O(n) O(n)

当然,这个结论建立在一个美好的假设,等概率分布的情况下,现实世界可能就没有这么美好了,为了让我们的模型更加精准,接下来我们来讨论非等概率分布的情况。

加权时间复杂度

我们知道,要查找的元素要么在list中,要么不在,所以这是一个概率分布的问题,我们将这个概率值记为 P e x i s t P_{exist} Pexist

我们设元素在下标i出现的概率为 P i P_i Pi,那么就可以推导得出平均查找次数为:
P e x i s t ∗ ( ∑ i = 0 n − 1 ( i + 1 ) P i ) + ( 1 − P e x i s t ) ∗ n P_{exist} * (\sum_{i=0}^{n-1}(i+1)P_i) + (1 - P_{exist}) * n Pexist(i=0n1(i+1)Pi)+(1Pexist)n

其中, ∑ i = 0 n − 1 P i = 1 \sum_{i=0}^{n-1}P_i = 1 i=0n1Pi=1

额,好吧,这个公式稍微有那么一丢丢复杂,但是只是基本概率论知识的应用,我这里只解释一下和式的部分,其他的部分相信你稍作思考就能搞明白。我们知道查找的次数与数据元素的位置息息相关,在数据元素出现的概率不同的情况下,我们只需要将对应的概率与对应的查找次数相乘,在乘上出现的概率,就能够得到期望了,当然,你也可以把它叫平均值。要注意的是即使是在下标0的位置,依然有1次查找,所以有公式中的 i + 1 i+1 i+1

为了让大家能够看到一个直观的结果,下面我们试着代入一些数值计算一下。
P e x i s t = 1 2 P_{exist} = \frac{1}{2} Pexist=21,即我们要查找的元素有一半的可能性在数组中,有一半的可能性不在数组中。
∀ i ∈ [ 0 , n ) , P i = 1 n \forall i \in [0,n),P_i=\frac{1}{n} i[0,n),Pi=n1,即数据元素在数组中等概率分布。我们有,
1 2 × 1 n × n × ( n + 1 ) 2 + ( 1 − 1 2 ) × n = 3 n + 1 4 \frac{1}{2} \times \frac{1}{n} \times \frac{n\times(n+1)}{2} +(1-\frac{1}{2})\times n =\frac{3n+1}{4} 21×n1×2n×(n+1)+(121)×n=43n+1

老规矩,动手算一算,确保你能算的出来。我们现在知道这个简单的查找算法的加权时间复杂度为 O ( n ) O(n) O(n)

Summary

介绍到这里,今天的文章基本就完毕了。掌握了这些内容,九成的代码都是可以直接上手分析的。现在想想真的是自己给自己找坑,光是总结这篇文章就花费了很多时间,期间图床乱起八糟的问题也是相当的烦人,不过好在还是把这篇写完了。

我个人认为时间复杂度这个概念可以说是数据结构的一个重点了,很多时候有些算法我们可能做不到上手就能实现,实现出来就没有问题,但是起码要马上反应过来一个算法的时间复杂度是多少,算法的优点缺点有哪些,在哪些场景应用哪些算法最合适,只要知道了这些问题,算法的实现一般交给库就好了。

根据我文章片头给出的三个资料,在这方面的介绍中,最好的应该是极客时间上面王争老师的那份资料了,相比于教科书上面过于简略的解释以及国外那本教材充斥着一堆数学符号的解释,极客时间上的数据结构基本上做到了通俗易懂与深度的平衡,大家如果有时间的话,还是很推荐一看的,可以有很多收获。这篇文章在写的时候我也反复琢磨了里面的内容。

你可能感兴趣的:(数据结构,时间复杂度,算法)