算法复杂度的基础分析

191 阅读3分钟

持续创作,加速成长!这是我参与「掘金日新计划 · 10 月更文挑战」的第8天,点击查看活动详情

开篇

数据结构与算法是前人在很多实际的操作场景中总结出来的智慧的结晶,我们可以直接拿来使用。 经典的数据结构与算法,如链表、栈、递归、动态规划等,都经过人们多方的求证与检验,最终抽象出来的存储结构或方法。数据结构与算法之间相辅相成,数据结构服务于算法,算法需要作用在特定的数据结构上。如果说,熟练掌握编程语言是外功,那么数据结构与算法可谓是内功心法了。

复杂度简述

以下两个复杂度将贯穿算法学习的始终。

时间复杂度:从时间维度衡量这个算法,可以大体看出执行这个算法耗费了多少时间

空间复杂度:通过占用了多少空间来衡量这个算法,可以预估运行这个算法会占用多少内存

我们通过以上两种算法复杂度,可以不用具体的测试数据,就能粗略估算出算法的执行效率。

大O表示法

我们通常用大O来表示一个算法的复杂度。先看看以下两个例子

def demo1(n):
    sum = 0
    i = 0
    while i <= n:
        sum += i
        i += 1
    return sum

如上所示:

假设每一行执行时间都一致,为t,对于demo1

第2,3行执行时间都为t,第4, 5, 6行执行时间内都运行了n次,那么就是3nt,因此我们可以得到这段代码的执行时间是(3n+2)t

def demo2(n):
    sum = 0
    for i in range (1, n+1):	# 可以理解为for(i = 1; i < n+1; i++)
        for j in range(1, n+1):
            sum += j
    return sum

我们再按上面的方法来研究demo2,第2行执行t,第3行执行n次,第4,5行执行了n²次,因此,总耗时为(2n² + n + 1)t

似乎每个算法的耗时都是不完全相同的,甚至同一个算法因为多几行少几行的关系,执行时间都会有所不同。

但不难发现,这里的耗时都是与n呈正相关的。大O表示法正是基于这个特性,当n很大时,前面的系数影响实际上并不大,所以,我们这里只需要记录一个最大量级, 并且忽视一行代码执行的单位时间(即t,我们将其计为1)。比如demo1,它的时间复杂度是O(n),demo2的时间复杂度是O(n²)

因此,关于算法复杂度的计算,我们只需要知道这个算法的复杂度的最大量级即可,这就是它的复杂度。

常见的复杂度量级

  • 常量级:O(1)
  • 对数阶:O(logn)
  • 线性阶:O(n)
  • 线性对数阶:O(nlogn)
  • K次方阶:O(n^k)
  • 指数阶:O(2^n)
  • 阶乘阶:O(n!)

我们再看看几个比较难理解的

O(1)

常量级,一般出现在不存在循环、递归的算法中。比如下面的代码

i = 1
j = 2
k = 3
sum = i + j + k

这种代码的最大量级一直是t,而在大O法中,t我们计为1。所以无论多少行,其时间复杂度都是O(1)

对数阶

O(logn)O(nlogn)是常见且不容易分析的情况,如下面的例子

i = 1
n = 10
while(i <= n):
    i *= 2
print(i)

我们并不易计算第4行代码运行了几次,但我可以先告诉你这个算法的时间复杂度是O(logn)。它是怎么计算的呢?

第4行代码可以理解为每次循环都会乘以2,当大于n时,结束循环,也就是i * 2 * 2 * .... = n ==> i * 2^x = n

当n很大时,忽略i,有2^x = n ==> x = log2n,这里的x就是循环的次数,通过大O法表示即O(log2n),然后我们忽略底数,即得到O(logn)

如果第4行是i *= 3,那么是O(log3n),忽略底数,也是O(logn)

那么O(nlogn)也很好理解了,就是O(logn)的算法循环跑了n次

无法确定量级

我们有时候会看到如O(m+n)、O(m*n)的算法复杂度,如果说忽略系数,为什么会出现两个参数呢,这是因为算法中出现了两个相同的量级,我们无法判断哪个量级更大,此时不能简单省略其中一个。

复杂度分析

时间复杂度和空间复杂度是如何计算的

从上面的学习中,我们对复杂度的计算有了个大体的了解,上面的部分中,基本都是以计算时间复杂度来介绍大O法和常见复杂度的

简单来说,如何确定算法的时间复杂度?

只关注算法中循环执行次数最多的一段代码,这段代码的n量级就是整个算法的时间复杂度。

那么又应该如何确定一个算法的空间复杂度呢?

这个算法申请了最大的空间有多大,那么它的时间复杂度就是多少,我们拿一个例子来看看:

def demo3(n):
    i = 0
    alist = []
    while i <= n:
        alist.append(i)
        i += 1
    for a in alist:
        print(a)

demo3中变量i占用的空间为1,又开辟了一个新空间alist用来存储数据,这个新空间占用的大小为n,这个算法的空间复杂度取其中占用最大的空间,所以是O(n)

复杂度也有最好和最坏

最好情况和最坏情况的时间复杂度

我们设计一个简单的函数,这个函数可以根据我们提供的元素,找到它在数组中出现的位置,如果查找不到,则返回-1,使用python实现如下:

def find(num, arr):
    res = -1
    for idx in range(len(arr)):
        if arr[idx] == num:
            res = idx
    return res
    
print(find(1, [1, 8, 2]))	# 0
print(find(3, [0, 1, 2]))	# -1

这里的时间复杂度是O(n),但我们一般会这么写

def find(num, arr):
	for idx in range(len(arr)):
        if arr[idx] == num:
            return idx		# 找到第一次出现的位置后就会返回退出了
    return -1

这里最好的情况是遍历数组时,第一个就是我们要找的元素,找到后程序就退出了,不再遍历后面的数据。那么此时它的复杂度只有O(1)。但是如果我们要找的元素刚好只出现在数组的末尾,我们就需要遍历完整个数组了,这就是最坏的情况,此时复杂度就变为O(n)。

所以,最好情况的时间复杂度就是在理想情况下执行这段代码所需要的时间复杂度,而最好情况的时间复杂度就是在最糟糕的情况下执行这段代码的时间复杂度。

平均情况的时间复杂度

最好和最坏都是一种极端情况,为了更好的表示平均情况下的时间复杂度,我们可以有以下的等式。

1+2+3+...+n+nn+1=n(n+3)2(n+1)\frac{1+2+3+...+n+n}{n+1} = \frac{n(n+3)}{2(n+1)}

解释下左边的式子,n是数组的长度,我们想要的元素有可能出现在0~n-1的位置上,共n个可能。另外还有一个可能,我们想要的元素不在数组中。所以共有n+1种可能性,这个是分母。分子则是元素出现位置我们需要遍历的次数,包括元素不存在数组中(遍历n次),所以有左边的式子,然后可以计算得到右边的式子

那么忽略系数,我们可以得到n²/n也就是n,所以平均情况下时间复杂度是O(n)

很多时候我们并不需要区分最好、最坏与平均时间复杂度,只有在时间复杂度有量级的差距时,才会考虑使用这三种复杂度来区分。