Loading [MathJax]/jax/output/HTML-CSS/jax.js

1.5. 算法评价标准及分析方法

1+2+3++100=5050
100×(1+100)2=5050

1.5.1. 解决同一问题的不同算法

1.5.1.1. 将数字添加到一个列表中

让我们来做一个小实验, 简单地将一堆数字添加到一个最初为空的列表中, 然后反转该列表:

count = 10**5
nums = []
for i in range(count):
    nums.append(i)
nums.reverse()

当然我们有另一种思路: 与其到最后才将整个列表反转过来, 何不在数字出现的时候就将它插到列表头部?

count = 10**5
nums = []
for i in range(count):
    nums.insert(0,i)

看上去似乎第二种方法更好, 我们来验证下

#计算append、insert 操作的运行时间在规模从 10^3 到 10^5 的耗时
cost_time1 = []
for count in range(1, 101):
    start = time.time()
    nums = []
    for i in range(count*1000):
        nums.append(i)
    nums.reverse()
    cost_time1.append(time.time()-start)

cost_time2 = []
for count in range(1, 101):
    start = time.time()
    nums = []
    for i in range(count*1000):
        nums.insert(0,i)
    cost_time2.append(time.time()-start)


#加载 matplotlab 相关作图模块
%pylab inline

mpl.rcParams['font.sans-serif'] = ['FangSong'] # 指定默认字体
mpl.rcParams['axes.unicode_minus'] = False # 解决保存图像是负号'-'显示为方块的问题

plot(cost_time1, label = 'append')
plot(cost_time2, label = 'insert')
xlabel('$\\times 10^3$')
ylabel('Time costs (second)')
title(u'append、insert 操作的运行时间在规模从 $10^3$ 到 $10^5$ 的变化图')
legend()
../_images/output_25_1.png

1.5.1.2. 考虑斐波那契数列的第 n 项:

F0=F1=1,Fn=Fn1+Fn2
  • 求斐波那契数列的递归算法

n 看做问题的规模, 不难得到计算 Fn 的时间代价 (考虑求加法操作的次数) 大致等于

limnFn=(5+12)n

因此计算 Fn 的时间代价按 n 的指数增长. 当 n=100 时, 大约为 8×1020. 如果使用2014年全球运算速度最快计算机”天河二号”进行计算 (每秒运算 33.86千万亿次), 大约需要运算 6.5 小时.

  • 求解斐波那契数列的递推算法

用这个算法计算 Fn 的值, 循环前工作只要做一次, 循环需要做 n1 次. 因此总的工作量(基本操作执行次数)与 n 呈线性关系.

1.5.2. 算法复杂度的渐近分析

需要注意的是, 算法设计首先要担心的并不是常数级别的性能差异. 即使相关程序完成任务所需要的时间是另一程序的两倍, 甚至十倍, 但这样的速度可能依然是够快的.

  • 我们并不考虑常数单位上的差距, 这通常取决于编程语言的性能、硬件速度等
  • 我们重点关注当问题规模扩大时运行时间的增长幅度
  • 即将焦点集中在解决问题方法中那些独立于具体实现的属性

1.5.2.1. 渐近记号

f(n)=n2+100n+log10n+1000
  • 当数据规模 n 逐步增大时, f(n) 的增长趋势 当n增大到一定值以后, 计算公式中影响最大的就是n的幂次最高的项, 其他的常数项和低幂次项都可以忽略

  • O 表示法 (上界)

    • 如果存在正数 cn0, 使得对任意 n>n0, 都有 f(n)cg(n), 则称

      f(n)=O(g(n))
    • 表示函数增长的上界

    • 注意: 一个函数增长的上界不唯一

  • Ω 表示法 (下界)

    • 如果存在正数 cn0, 使得对任意 n>n0, 都有 f(n)cg(n), 则称

      f(n)=Ω(g(n))
    • 表示函数增长的下界

    • 注意: 一个函数增长的下界不唯一

  • Θ 表示法

    • 如果存在正数 c1,c2n0, 使得对任意 n>n0, 都有 c1g(n)f(n)c2g(n), 则称

      f(n)=Θ(g(n))
    • 表示函数增长的上、下界相同

  • 常用渐近复杂度

    O(1),O(logn),O(n),O(nlogn),O(n2),O(n3),O(2n),O(n!)

思考:

  1. 对数通常有各自不同的底数, 但在算法分析中, 我们往往并不会在意它. 为了明白其中的原因, 考虑以下等式

    logbn=loganlogab.

    为什么它能告诉我们并不需要操心对数的底数问题?

  2. 证明任何指数级操作 (Θ(kn), 其中 k>1) 的增长都要快于多项式级操作 (Θ(nj), 其中 j>0).

  3. 证明任何多项式级操作的渐近增长都要快于对数级操作 (Θ(logn)).

1.5.3. 算法分析

算法分析的目的是推导出算法的复杂度, 其中最主要技术是构造和求解递归方程.

  • 基本循环程序的时间复杂度

    1. 基本操作

      认为其时间复杂度为 O(1). 如果是函数调用, 应将其时间复杂度带入, 参与整体时间复杂度的计算.

    2. 加法规则 (顺序结构)

      如果算法是两个部分的顺序复合, 其复杂性是这两部分的复杂性之和 O(T1(n)+T2(n))=O(max(T1(n),T2(n)))

    3. 乘法规则 (循环结构)

      如果算法是一个循环, 循环体将执行 T1(n) 次, 每次循环执行需要时间 T2(n), 则总的时间为 O(T1(n)×T2(n))

    4. 取最大规则 (分支结构)

      如果算法是条件分支, 两个分支的时间复杂度分别是 T1(n)T2(n), 则有 O(max(T1(n),T2(n)))

练习:

  • 递归算法的时间复杂度 递归算法通常具有如下的算法模式:

    def fun(n):
    if n == 0:
        return g(...)
    somework
    for i in range(a):
        x = fun(n/b)
        somework
    somework
    

    也就是说, n=0 时直接得到结果, 否则将原问题归结为 a 个规模为 n/b 的子问题, 其中 a,b 是由具体问题决定的两个常数. 另外, 在本层递归中还需要做一些工作, 即 somework, 其时间复杂度可能与 n 有关, 设为 O(nk). 这样就得到了递归方程:

    T(n)=O(nk)+a×T(n/b):
    • 如果 a>bk, 则 T(n)=O(nlogba)
    • 如果 a=bk, 则 T(n)=O(nklogn)
    • 如果 a<bk, 则 T(n)=O(nk)

思考: 在上面的算法中, 元素是从序列每一半的尾端弹出 (pop()) 的. 或许选择从首端弹出 (pop(0))会更直观一点, 避免我们要对 res 进行反向处理 (res.reverse()). 但注意 pop(0)insert(0) 一样, 是一个线性级操作, 而 pop() 是常数级的. 那么这种调整对会对整体运行时间产生什么影响呢?

  • 三种重要情况

    到目前为止, 我们所设想的运行时间都是完全确定的, 并且只取决于输入的规模, 而与输入的实际内容无关. 但这显然不是特别现实. 例如, 如果想要构造一个排序算法, 我们或许会这样开始:

    def sort(seq):
    n = len(seq)
    for i in range(n-1) #如果for循环没有被break语句提前中止, 则执行 else 分支
        if seq[i] > seq[i+1]:
            break
    else:
        return
    ...
    ...
    

    这意味着无论我们的主排序算法效率有多低, 只要遇到一个已经排序的序列, 则其运行时间始终是线性级别的. 而在一般情况下是没有排序算法能达到线性级运行时间的.

    • 最好情况: 当算法遇上最理想输入时的运行时间.
    • 最坏情况: 可能会遇上的最糟糕的运行时间, 这通常也是最有用的情况.
    • 平均情况: 最复杂的一种情况, 大部分时间里会回避对这种情况的讨论.

1.5.4. 时间、空间权衡

  • 数据结构
    • 一定的空间来存储它的每一个数据项
    • 一定的时间来执行每一个操作
  • 代价和效益
    • 空间和时间的限制
    • 增大空间开销可能改善算法的时间开销
    • 想要节省空间, 往往需要增大运行时间
Next Section - 1.6. 作业