大O符号和算法分析与Python实例

222 阅读13分钟

简介

通常有多种方法可以使用计算机程序来解决问题。例如,有几种方法对数组中的项目进行排序--你可以使用合并排序冒泡排序插入排序等等。所有这些算法都有自己的优点和缺点,开发人员的工作是权衡这些算法,以便能够在任何用例中选择最好的算法来使用。换句话说,主要问题是在存在多种解决方案的情况下,用哪种算法来解决一个具体问题。

算法分析是指对不同算法的复杂性进行分析,并找到最有效的算法来解决手头的问题。Big-O符号是一种用于描述算法复杂性的统计措施。

注意:Big-O符号法是用于算法复杂性的衡量标准之一。其他一些包括Big-Theta和Big-Omega。Big-Omega、Big-Theta和Big-O在直觉上等同于一个算法可以达到的最佳平均最差的时间复杂度。我们通常使用Big-O作为衡量标准,而不是其他两个,因为我们可以保证一个算法在其最坏的情况下以可接受的复杂度运行,它在平均和最好的情况下也会工作,但反之亦然。

为什么算法分析很重要?

为了理解为什么算法分析很重要,我们将借助于一个简单的例子。假设一个经理给他的两个雇员一个任务,用Python语言设计一个算法,计算用户输入的一个数字的阶乘。第一个雇员开发的算法看起来像这样。

def fact(n):
    product = 1
    for i in range(n):
        product = product * (i+1)
    return product

print(fact(5))

请注意,该算法只是将一个整数作为一个参数。在fact() 函数中,一个名为product 的变量被初始化为1 。一个循环从1 执行到n ,在每次迭代过程中,product 中的值被乘以循环所迭代的数字,结果再次被存储在product 变量中。循环执行后,product 变量将包含阶乘。

同样地,第二位雇员也开发了一种算法,计算一个数字的阶乘。第二位员工用一个递归函数来计算数字的阶乘n

def fact2(n):
    if n == 0:
        return 1
    else:
        return n * fact2(n-1)

print(fact2(5))

经理必须决定使用哪种算法。为此,他们决定选择哪种算法运行更快。一种方法是通过找出在相同输入上执行代码所需的时间。

在Jupyter笔记本中,你可以使用%timeit 字面,后面跟着函数调用,以找到函数执行所需的时间:

%timeit fact(50)

这将给我们带来:

9 µs ± 405 ns per loop (mean ± std. dev. of 7 runs, 100000 loops each)

输出结果显示,该算法每个循环需要9微秒(正负45纳秒)。

同样,我们可以计算出第二种方法需要多少时间来执行:

%timeit fact2(50)

这将导致:

15.7 µs ± 427 ns per loop (mean ± std. dev. of 7 runs, 100000 loops each)

第二个涉及递归的算法需要15微秒(正负427纳秒)。

执行时间表明,与涉及递归的第二种算法相比,第一种算法更快。当处理大型输入时,性能差异会变得更加明显。

然而,执行时间并不是衡量算法复杂性的一个好指标,因为它取决于硬件。我们需要一个更客观的算法复杂度分析指标。这就是大O记号发挥作用的地方。

用大O符号法分析算法

大O符号表示算法的输入和执行算法所需的步骤之间的关系。它用一个大的 "O "表示,后面是一个开始和结束的小括号。在括号内,输入和算法所采取的步骤之间的关系用 "n "表示。

关键的启示是--Big-O对你运行算法的特定实例不感兴趣,例如fact(50) ,而是对它在输入增加的情况下的扩展程度感兴趣。这是一个比具体实例的具体时间更好的评估指标!这也是一个很好的评估指标。

例如,如果输入和算法完成执行的步骤之间存在线性关系,那么使用的Big-O符号将是O(n)。同样地,二次函数的Big-O符号是O(n²)

为了建立直观的印象:

  • O(n):在n=1 ,采取1步。在n=10 ,要走10步。
  • O(n²): 在n=1 ,走了1步。在n=10 ,要走100步。

n=1 ,这两者的表现是一样的!这就是为什么观察输入和处理该输入的步骤数之间的关系比仅仅用一些具体的输入来评估函数更好的另一个原因。

下面是一些最常见的大O函数:

名称大O
常数O(c)
线性O(n)
二次方O(n²)
二次方O(n³)
指数O(2ⁿ)
对数O(log(n))
对数线性O(nlog(n))

你可以将这些函数可视化并进行比较。

一般来说--任何比线性差的东西都被认为是不好的复杂度(即低效),应该尽可能避免。线性复杂度是可以的,通常是一种必要的邪恶。对数是好的。常数是惊人的!

注意:由于Big-O对输入到步骤的关系进行建模,我们通常从表达式中删除常数。O(2n)O(n) 是同一类型的关系--两者都是线性的,所以我们可以将两者表示为O(n) 。 常数不会改变这种关系。

为了了解Big-O是如何计算的,让我们看一下常数、线性和二次复杂性的一些例子。

恒定复杂度--O(C)

如果完成一个算法的执行所需的步骤保持不变,而不考虑输入的数量,那么这个算法的复杂性就被称为恒定。恒定复杂度用O(c)表示,其中c可以是任何常数。

让我们用Python写一个简单的算法,找到列表中第一个项目的平方,然后在屏幕上打印出来:

def constant_algo(items):
    result = items[0] * items[0]
    print(result)

constant_algo([4, 5, 6, 8])

在上面的脚本中,不管输入的大小,或者输入列表中的项目数items ,该算法只执行2个步骤:

  1. 找到第一个元素的平方
  2. 在屏幕上打印结果。

因此,复杂性保持不变。

如果你在X轴上画一个线段图,在Y轴上画出items 输入的不同大小,在Y轴上画出步骤数,你会得到一条直线。让我们创建一个简短的脚本来帮助我们直观地看到这一点。无论输入的数量是多少,执行的步骤数都是一样的。

steps = []
def constant(n):
    return 1
    
for i in range(1, 100):
    steps.append(constant(i))
plt.plot(steps)

线性复杂度--O(n)

如果完成一个算法的执行所需的步骤随着输入数的增加或减少而线性增加或减少,那么该算法的复杂性就被称为是线性的。线性复杂度用O(n)来表示。

在这个例子中,让我们写一个简单的程序,将列表中的所有项目显示到控制台。

def linear_algo(items):
    for item in items:
        print(item)

linear_algo([4, 5, 6, 8])

在上述例子中,linear_algo() 函数的复杂性是线性的,因为for-loop的迭代次数将等于输入items 数组的大小。例如,如果items 列表中有4个项目,for-loop将被执行4次。

让我们快速创建一个线性复杂度算法的图表,X轴为输入数,Y轴为步骤数:

steps = []
def linear(n):
    return n
    
for i in range(1, 100):
    steps.append(linear(i))
    
plt.plot(steps)
plt.xlabel('Inputs')
plt.ylabel('Steps')

这将导致:

需要注意的一个重要问题是,在大的输入中,常数往往会失去价值。这就是为什么我们通常把常数从Big-O符号中删除,像O(2n)这样的表达式通常被缩短为O(n)。O(2n)和O(n)都是线性的--线性关系才是最重要的,而不是具体数值。例如,让我们修改一下linear_algo()

def linear_algo(items):
    for item in items:
        print(item)

    for item in items:
        print(item)

linear_algo([4, 5, 6, 8])

有两个for-loop,在输入的items 列表上进行迭代。因此算法的复杂度变成了O(2n),然而在输入列表中有无限项的情况下,无限的两倍仍然等于无穷大。我们可以忽略常数2 (因为它最终是不重要的),算法的复杂性仍然是O(n)

让我们通过在X轴上画出输入,在Y轴上画出步骤数来直观地看到这个新算法:

steps = []
def linear(n):
    return 2*n
    
for i in range(1, 100):
    steps.append(linear(i))
    
plt.plot(steps)
plt.xlabel('Inputs')
plt.ylabel('Steps')

在上面的脚本中,你可以清楚地看到y=2n,然而,输出是线性的,看起来像这样:

二次方复杂度 -O(n²)

当执行算法所需的步骤是输入项目数量的二次函数时,算法的复杂性被称为二次型。二次方复杂度被表示为O(n²)

def quadratic_algo(items):
    for item in items:
        for item2 in items:
            print(item, ' ' ,item2)

quadratic_algo([4, 5, 6, 8])

我们有一个外循环,迭代了输入列表中的所有项目,然后是一个嵌套的内循环,再次迭代了输入列表中的所有项目。执行的总步骤数为n*n,其中n是输入数组中的项目数。

下图绘制了一个具有二次复杂性的算法的输入数与步骤:

对数复杂度--O(logn)

有些算法达到了对数复杂度,例如二进制搜索。二进制搜索在一个数组中搜索一个元素,方法是检查数组的中间部分,并修剪掉没有该元素的那一半。它对剩下的一半再做一次,继续同样的步骤,直到找到该元素。在每个步骤中,它数组中的元素数量减半

这需要数组被排序,并且需要我们对数据做出假设(比如说它被排序)。

当你能对传入的数据做出假设时,你就可以采取一些措施来降低算法的复杂性。对数复杂度是令人向往的,因为它即使在高度扩展的输入情况下也能达到良好的性能。

寻找复杂函数的复杂度?

在前面的例子中,我们在输入上有相当简单的函数。但是,我们如何计算那些在输入端调用(多个)其他函数的函数的大O呢?

让我们来看一下:

def complex_algo(items):

    for i in range(5):
        print("Python is awesome")

    for item in items:
        print(item)

    for item in items:
        print(item)

    print("Big O")
    print("Big O")
    print("Big O")

complex_algo([4, 5, 6, 8])

在上面的脚本中,有几个任务正在执行,首先,使用print 语句在控制台打印了5次字符串。接下来,我们在屏幕上打印输入列表两次,最后,在控制台打印另一个字符串三次。为了找到这样一个算法的复杂性,我们需要将算法代码分解成若干部分,并尝试找到各个部分的复杂性。标记下每一块的复杂性。

在第一部分,我们有:

for i in range(5):
	print("Python is awesome")

这一部分的复杂度是O(5),因为在这一段代码中,无论输入是什么,都要执行五个恒定的步骤。

接下来,我们有:

for item in items:
	print(item)

我们知道上面这段代码的复杂性是O(n)。同样地,下面这段代码的复杂度也是O(n)

for item in items:
	print(item)

最后,在下面这段代码中,一个字符串被打印了三次,因此其复杂度为O(3)

print("Big O")
print("Big O")
print("Big O")

为了找到总体复杂度,我们只需将这些单独的复杂度相加:

O(5) + O(n) + O(n) + O(3)

简化上面的内容,我们得到:

O(8) + O(2n) = O(8+2n)

我们之前说过,当输入(本例中长度为n)变得非常大时,常数变得无足轻重,也就是说,两倍或一半的无穷大仍然是无穷大。因此,我们可以忽略这些常数。该算法的最终复杂度将是O(n)!

最坏情况与最好情况的复杂度

通常,当有人问你一个算法的复杂性时--他们对最坏情况下的复杂性(Big-O)感兴趣。有时,他们也可能对最佳情况下的复杂度感兴趣(Big-Omega)。

为了理解这两者之间的关系,让我们看一下另一段代码:

def search_algo(num, items):
    for item in items:
        if item == num:
            return True
        else:
            pass
nums = [2, 4, 6, 8, 10]

print(search_algo(2, nums))

在上面的脚本中,我们有一个函数,它接受一个数字和一个数字列表作为输入。如果在数字列表中找到所传递的数字,它就返回真,否则就返回None 。如果你在列表中搜索2,它将在第一次比较中被找到。这是算法的最佳情况下的复杂度,即搜索到的项目在第一个搜索到的索引中找到。在这种情况下,最佳情况下的复杂性O(1)。另一方面,如果你搜索10,它将在最后搜索的索引中被找到。该算法将不得不搜索列表中的所有项目,因此最坏情况下的复杂度变为O(n)

**注意:**即使你试图在一个列表中找到一个不存在的元素,最坏情况下的复杂度也是一样的--它需要n个步骤来验证列表中没有这样的元素。因此,最坏情况下的复杂度仍然是O(n)

除了最佳和最坏情况下的复杂度,你还可以计算一个算法的平均复杂度(Big-Theta),它告诉你 "给定一个随机输入,算法的预期时间复杂度是多少"?

空间复杂度

除了时间复杂度,即计算完成一个算法的执行所需的步骤数,你还可以找到空间复杂度,它指的是在程序执行过程中你需要在内存中分配的空间量。

请看下面的例子:

def return_squares(n):
    square_list = []
    for num in n:
        square_list.append(num * num)

    return square_list

nums = [2, 4, 6, 8, 10]
print(return_squares(nums))

return_squares() 函数接受一个整数列表,并返回一个包含相应方块的列表。该算法必须为输入列表中相同数量的项目分配内存。因此,该算法的空间复杂度为O(n)

结论

Big-O符号是用来衡量算法复杂性的标准指标。在本指南中,我们研究了什么是Big-O符号,以及如何用它来衡量各种算法的复杂性。我们还借助于不同的Python例子研究了不同类型的Big-O函数。最后,我们简要回顾了最坏情况和最好情况的复杂性以及空间复杂性。