大 O 记法(参见 http://en.wikipedia.org/wiki/Big_O_notation)是定义函数复杂度的最典
型的方法。这个度量指标定义了算法如何受输入数据大小的影响。例如,随着输入数据的
大小,算法是线性增长还是平方阶增长?
为了获取算法的性能与输入数据的大小相关的概述,手动计算它的大 O 记法是最佳的
方法。了解应用程序组件的复杂度使你能够检测并专注于会真正减慢代码速度的部件。
为了测量大 O 记法,除去所有常数和低阶项,那么当输入数据增长时,这样便于集中在
真正权重的部分。这个想法是尝试将算法按照表 12-2 的类别进行分类,即使它是一个近似。
表 12-2
符号
类型
O(1)
常量。不依赖于输入的数据
O(n)
线性。按照 n 增长
O(n log n)
对数
O(n2)
平方复杂度
O(n3)
立方复杂度
O(n!)
阶乘复杂度
例如,在第 2 章中已经讲过,在字典中查找的平均复杂度为 O(1)。不管在 dict 中有多
少个元素,它被认为是常数,而在特定元素个数的列表中进行查找的时间复杂度是 O(n)。
让我们来看下面另外一个例子:
def function(n):
… for i in range(n):
… print(i)
…
在这种情况下,打印语句将执行 n 次。循环速度将取决于 n,所以其复杂度表示使用
大 O 记法就是 O(n)。
如果函数具有条件,则正确的符号以最高的为准,如下所示:
def function(n):
… if some_test:
… print(‘something’)
… else:
… for i in range(n):
… print(i)
…
在该示例中,该函数复杂度可以是 O(1)或 O(n),这取决于测试。但最坏的情况是 O(n),
所以整个函数复杂度为 O(n)。
当讨论用大 O 记法表示复杂性时,我们通常回顾最坏的情况。在比较两个独立算法
时,虽然这是定义复杂度的最好方法,但在每个实际情况下,它可能不是最好的方法。
许多算法根据输入数据的统计特性改变运行时的性能或通过聪明的技巧来分摊最坏情况
的操作成本。这就是为什么,在许多情况下,最好根据平均复杂度或均摊复杂度来评审你
的实现。
例如,看一个这样的操作,将一个元素追加到 Python 的列表类型的实例。众所周知,
CPython 中的列表使用了过度分配的数组作为内部存储而不是链表。如果数组已满,当添
加一个新元素时,就需要分配一个新数组并将所有现有元素(引用)复制到内存中的一个
新区域。如果我们从最坏情况的复杂性的角度来看,很明显 list.append()方法具有 O(n)
复杂度。与链表结构的典型实现相比,这种实现有点昂贵。
但是我们也知道 CPython 中列表类型实现使用过度分配的数组,这种实现方式可以减轻偶
尔重新分配的复杂度。如果我们评估一系列操作的复杂度,我们将看到 list.append()的平
均复杂性是 O(1),这实际上是一个很棒的结果。
当解决问题时,我们通常会知道很多关于输入数据的细节,例如它的大小或统计分布。
当优化应用程序时,从头到尾的了解输入数据的情况,这是非常值得的。这里,最坏情况
是复杂度的另一个问题开始出现。当输入趋向于一个很大的值或无穷大时,而不是输入一
个提供可靠的性能近似的真实生活中的数据,函数的行为就会受到限制。渐近符号可以很
好地定义一些函数的增长率,这些函数不会给出一个简单问题的可靠答案:哪个实现将花
费更少的时间?最坏情况复杂度转储所有实现和数据特性的细节,以显示你的程序的行为
将如何渐近。它适用于任意大的输入,你甚至可能不需要考虑。
例如,让我们假设你有一个问题,要解决关于由 n 个独立元素组成的数据。让我们假
设你知道两种不同的方法来解决这个问题—程序 A 和程序 B。你知道程序 A 需要 100n2
次操作来完成,程序 B 需要 5n3 次操作才能解决这个问题。你会选择哪一个?当涉及到非
常大的输入时,程序 A 当然是更好的选择,因为它的行为更好的渐近。与程序 B 的 O(n3)
复杂度相比,程序 A 具有 O(n2)的复杂度。
但是通过求解一个简单的 100n2>5n3 不等式,我们可以发现,当 n 小于 20 时,程序 B
需要更少的操作。如果我们更多地了解我们的输入界限,那么我们就可以做出更好的决策。