【数据结构与算法Python描述】——算法的优劣分析和大O表示法

一、算法分析引入

在进行算法的优劣分析时,通过运行使用算法实现的代码来直接测量其耗费时间的方式是不可行的,因为一方面编程语言、硬件配置等不同所造成的影响无法量化;另一方面,算法说白了是解决某一类问题的思想,其中包含有穷、确定的可实现步骤,是独立于语言存在的,所以很多时候人们希望能仅凭理论分析就可大致知道解决同一类问题的算法孰优孰劣,而无需每次都需要通过具体编程语言来先进行算法实现。

实际上,无论最终使用什么样的编程语言在什么样的平台来实现算法,程序都可以被分成有限类别的基本操作(对应操作系统层级的若干基本指令),如:赋值、寻址、相加、比较、判断、函数返回等。当待解决的问题规模(一般使用输入变量的维度 n n n来表示)一定时,对于不同算法,其各自基本操作的数量总和 f ( n ) f(n) f(n)便可作为衡量算法优劣的统一标准。

下面是一个找出长度为 n n n的列表中最大元素算法的Python实现:

def find_max(lst: list):
    """从非空列表中返回最大元素"""
    biggest = lst[0]
    for value in lst:
        if value > biggest:
            biggest = value
    return biggest

对于上述算法计算其基本操作的总数:

  • 初始化biggest变量需要 a a a次基本操作;
  • 迭代 n n n次,且每次迭代的基本操作数量为 b b b
  • 总的基本操作次数为: f ( n ) = a + b × n f(n)=a+b\times{n} f(n)=a+b×n

二、“大 O O O表示法”

上述find_max案例中,其算法比较简单,得出的 f ( n ) f(n) f(n)形式也较为简单,但通常在实际的算法中, f ( n ) f(n) f(n)可能十分复杂,包含很多项,而通过本节所述的大 O O O表示法可以很好的避免这个问题的同时,也能方便的比较各种算法优劣。

1. 简介

上述案例表明find_max算法的时间复杂度为 f ( n ) f(n) f(n),更一般地,通常我们说find_max的时间复杂度为 O ( n ) O(n) O(n),即使用所谓的大 O O O表示法。

大O表示法定义:假设 n n n为正整数,有两个函数 f ( n ) f(n) f(n) g ( n ) g(n) g(n),如果存在常数 c > 0 c>0 c>0以及常数 n 0 ≥ 1 n_0\ge1 n01使得:
f ( n ) ≤ c × g ( n ) , n ≥ n 0 f(n)\le{c}{\times}{g(n)}, n\ge{n_0} f(n)c×g(n),nn0
我们就说 f ( n ) f(n) f(n)的大O表示法记为 O ( g ( n ) ) O(g(n)) O(g(n))或者说 f ( n ) f(n) f(n) g ( n ) g(n) g(n)阶的。

大O表示法的一个重要性质为:

如果 f ( n ) f(n) f(n)是一个 d d d阶的多项式,即对于:
f ( n ) = a 0 + a 1 n + ⋅ ⋅ ⋅ + a d n d f(n)=a_0+a_1{n}+\cdot\cdot\cdot+a_d{n^{d}} f(n)=a0+a1n++adnd
以及 a d > 0 a_d>0 ad>0,则 f ( n ) f(n) f(n)的大O表示法为 O ( n d ) O(n^{d}) O(nd)

根据大 O O O表示法的定义及上述性质,所以我们将find_max的时间复杂度度量 f ( n ) f(n) f(n)记为 O ( n ) O(n) O(n)

2. 常见复杂度

常见的复杂度度量除了 O ( n ) O(n) O(n),还有 O ( 1 ) O(1) O(1) O ( l o g n ) O(logn) O(logn) O ( n ) O(n) O(n) O ( n l o g n ) O(nlogn) O(nlogn) O ( n 2 ) O(n^2) O(n2) O ( n 3 ) O(n^3) O(n3) O ( 2 n ) O(2^n) O(2n),且其衡量算法的优秀程度按此顺序递减。

三、算法优劣分析举例

基于上述讨论,下面是使用大 O O O表示法描述的几种简单算法:

1. O ( 1 ) O(1) O(1)时间复杂度

在Python中,使用 O ( 1 ) O(1) O(1)时间复杂度的算法之一实现的方法是列表list实例对象的len()方法,因为list类中有一个实例变量,该实例变量保存了当前列表的长度,而对列表的实例对象调用len()方法可以直接返回该保存列表长度的实例变量的值。

Python语言中,假设一个列表对象为lst,其另一个时间复杂度为 O ( 1 ) O(1) O(1)的操作是索引取值lst[j],这是因为:在后面我们将看到,Python的列表使用基于数组序列方式实现,即列表各个元素的引用在内存中以连续方式存储,所以使用lst[j]访问第 j j j个元素并不需要遍历列表,只需要将索引 j j j当作数组偏置量即可找到对应元素。

2. O ( n ) O(n) O(n)时间复杂度

上述find_max算法即是 O ( n ) O(n) O(n)时间复杂度。

3. O ( l o g n ) O(logn) O(logn)时间复杂度

关于find_max算法,假定lst中的数互不相等且完全随机排列,实际上算法中,在找到最大值时,更新变量biggest次数的时间复杂度就是 O ( l o g n ) O(logn) O(logn)

原因是:在循环的一次迭代中,更新1次变量biggest的条件是当且仅当在循环的本次迭代中当前元素大于其前面的所有元素,而如果假定lst中的数互不相等且完全随机排列,则第 j j j个元素是前 j j j个元素中最大的概率是 1 / j \left. 1\middle/ j\right. 1/j,因此更新变量biggest的次数为数学期望:
1 × 1 1 + 1 × 1 2 + ⋅ ⋅ ⋅ + 1 × 1 j + ⋅ ⋅ ⋅ + 1 × 1 n = ∑ j = 1 n 1 / j 1\times{\frac{1}{1}}+1\times{\frac{1}{2}}+\cdot\cdot\cdot+1\times{\frac{1}{j}}+\cdot\cdot\cdot+1\times{\frac{1}{n}}=\sum\nolimits_{j=1}^{n}{\left. 1\middle/ j\right.} 1×11+1×21++1×j1++1×n1=j=1n1/j

∑ j = 1 n 1 / j \sum\nolimits_{j=1}^{n}{\left. 1\middle/ j\right.} j=1n1/j即为著名的调和级数,该调和级数的求和公式为 l n n + C lnn+C lnn+C,其中 C C C为常数。

4. O ( n 2 ) O(n^2) O(n2)时间复杂度

关于 O ( n 2 ) O(n^2) O(n2)时间复杂度,此处所举的例子是所谓的前缀平均值(prefix averages)序列。所谓前缀平均值序列是指,给定一个包含 n n n个数值的序列seq,希望得到一个序列pre_avg_seq,使得后者的每一个元素pre_avg_seq[j]都是seq[0],…,seq[j]的平均值,其中 j = 0 , ⋅ ⋅ ⋅ , n − 1 j=0,\cdot\cdot\cdot,n-1 j=0,,n1,即:

p r e _ a v g _ s e q [ j ] = ∑ i = 0 j s e q [ i ] j + 1 pre\_avg\_seq[j] = \frac{\sum\nolimits_{i=0}^{j}seq[i]}{j + 1} pre_avg_seq[j]=j+1i=0jseq[i]

计算前缀平均值在经济学和统计学中的应用很多。例如:现有一支基金若干年的每年收益,一名投资人可能希望了解最近一年、最近三年、最近五年等的平均年收益。

下面是计算前缀平均值的一种实现算法,其中外层循环用于计算pre_avg_seq的每一项,内层循环用于计划部分和。

def prefix_avg(seq):
    """
    返回一个列表,该列表对于每一个j,都有pre_avg_seq[j]等于seq[0],...,seq[j]的平均值
    """
    seq_len = len(seq)
    pre_avg_seq = [0] * seq_len
    for j in range(seq_len):
        total = 0
        for i in range(j + 1):
            total += seq[i]
        pre_avg_seq[j] = total / (j + 1)
    return pre_avg_seq

为什么说上述求取前缀平均值算法的时间复杂度是 O ( n 2 ) O(n^2) O(n2),原因在于:

  • 语句seq_len = len(seq)的时间复杂度是 O ( 1 ) O(1) O(1)
  • 语句pre_avg_seq = [0] * seq_len的时间复杂度是 O ( n ) O(n) O(n)
  • 对于外层循环,其执行 n n n次,所以语句total = 0pre_avg_seq[j] = total / (j + 1)各被执行 n n n次,故其时间复杂度为 O ( n ) O(n) O(n)
  • 对于内层循环,根据外层循环 j j j值每次的迭代,其中的total += seq[i]语句执行 j + 1 j+1 j+1次,因此内层循环中的语句共计执行 1 + 2 + 3 + ⋅ ⋅ ⋅ + n = ( n + 1 ) n / 2 1+2+3+\cdot\cdot\cdot+n={\left. (n+1)n\middle/ 2\right.} 1+2+3++n=(n+1)n/2,即时间复杂度为 O ( n 2 ) O(n^2) O(n2)

再根据大 O O O表示法的性质,忽略高阶项,可知上述算法的时间复杂度为 O ( n 2 ) O(n^2) O(n2)

下面代码对于使用列表切片对上述代码进行了简化,但实际上其复杂度不变:

def prefix_avg2(seq):
    """
    返回一个列表,该列表对于每一个j,都有pre_avg_seq[j]等于seq[0],...,seq[j]的平均值
    """
    seq_len = len(seq)
    pre_avg_seq = [0] * seq_len
    for j in range(seq_len):
        pre_avg_seq[j] = sum(seq[0:j+1]) / (j + 1)
    return pre_avg_seq

原因在于,虽然sum(seq[0:j+1]) / (j + 1)看起来是一条指令,实际上这是函数调用和数组切片:

  • 首先,语句seq[0:j+1]实际上会重新创建一个列表对象,然后将前 j j j个元素拷贝至新列表对象中,这步操作的时间复杂度是 O ( j + 1 ) O(j+1) O(j+1)
  • 其次,对于切片得到的列表求和,时间复杂度也是 O ( j + 1 ) O(j+1) O(j+1)

因此,循环 n n n次的时间复杂度为 O ( 1 ) + ⋅ ⋅ ⋅ + O ( j + 1 ) + ⋅ ⋅ ⋅ + O ( n ) = O ( ( n + 1 ) n / 2 ) O(1)+\cdot\cdot\cdot+O(j+1)+\cdot\cdot\cdot+O(n)={\left. O((n+1)n\middle/ 2\right.}) O(1)++O(j+1)++O(n)=O((n+1)n/2),依然是 O ( n 2 ) O(n^2) O(n2)

实际上,下列求取前缀平均值的算法,其时间复杂度可以实现 O ( n ) O(n) O(n)

def prefix_avg3(seq):
    """
    返回一个列表,该列表对于每一个j,都有pre_avg_seq[j]等于seq[0],...,seq[j]的平均值
    """
    seq_len = len(seq)
    pre_avg_seq = [0] * seq_len
    total = 0
    for j in range(seq_len):
        total += seq[j]
        pre_avg_seq[j] = total / (j + 1)
    return pre_avg_seq

原因在于,上述两种算法为了得到第 j j j个前缀平均值,每次都要重新计算前 j j j个元素和,而上述第三种算法第 j j j次迭代都只需进行一次求和,因为第 j − 1 j-1 j1次迭代已经得到了累加至第 j − 1 j-1 j1项的和。

具体地:

  • 初始化变量seq_lentotal的时间复杂度为 O ( 1 ) O(1) O(1)
  • 初始化列表pre_avg_seq的时间复杂度为 O ( n ) O(n) O(n)
  • 循环体中累加和除法的时间复杂度都是 O ( 1 ) O(1) O(1),而循环执行 n n n次,故循环体的时间复杂度为 O ( n ) O(n) O(n)

因此,上述算法的时间复杂度是 O ( n ) O(n) O(n)

5. O ( n 3 ) O(n^3) O(n3)时间复杂度

对于 O ( n 3 ) O(n^3) O(n3)时间复杂度的算法,这里给的案例是求解所谓三集不相交的一种算法:假设给定三个长度为 n n n序列为seq1seq2seq3,每个序列中的元素均不重复,但是两两序列之间可能会有相同的元素,所谓三集不相交是指这三个序列各自对应的元素集合没有三者共同的交集,即没有元素 x x x能同时满足 x ∈ s e q 1 x\in{seq1} xseq1 x ∈ s e q 2 x\in{seq2} xseq2 x ∈ s e q 3 x\in{seq3} xseq3

下面是一种 O ( n 3 ) O(n^3) O(n3)时间复杂度的算法:

def disjoint1(seq1, seq2, seq3):
    """如果三个序列seq1,seq2和seq3不想交,则返回True"""
    for a in seq1:
        for b in seq2:
            for c in seq3:
                if a == b == c:
                    return False
    return True

显然,上述算法有三层嵌套循环,因此最坏的情况下算法的时间复杂度为 O ( n 3 ) O(n^3) O(n3)

实际上,上述算法可以稍加改进就变成 O ( n 2 ) O(n^2) O(n2)时间复杂度:很容易看出上述算法可以改进的一点是,当每次迭代至第二层循环时,如果此时a != b,则没有必要再进入第三层循环,基于这一点,下面改进的算法为:

def disjoint2(seq1, seq2, seq3):
    """如果三个序列seq1,seq2和seq3不想交,则返回True"""
    for a in seq1:
        for b in seq2:
            if a == b:
                for c in seq3:
                    if a == c:
                        return False
    return True

上述算法的时间复杂度之所以为 O ( n 2 ) O(n^2) O(n2),是因为:

  • 前面两层循环在最坏情况下会执行 n 2 n^2 n2次,故语句if a == b会被执行 n 2 n^2 n2次,但由于各个序列内部的元素互不相等,因此最坏情况下
    • 语句if a == b判断为真的情况为 n n n次,此时进入第三层循环迭代 n n n次,故语句if a == c最多会被执行 n 2 n^2 n2次;
    • 语句if a == b判断为假的情况为 n 2 − n n^2-n n2n次,此时不会进入第三层循环,于是此时仅有if a == b语句执行 n 2 − n n^2-n n2n次。

故最坏情况下,程序执行的基本操作次数(再加上程序返回1次)为 n 2 + ( n 2 − n ) + 1 = 2 n 2 − n + 1 n^2+(n^2-n)+1=2n^2-n+1 n2+(n2n)+1=2n2n+1,即程序的时间复杂度为 O ( n 2 ) O(n^2) O(n2)

©️2020 CSDN 皮肤主题: Age of Ai 设计师:meimeiellie 返回首页