第3章对极了O!大O符号
我们在前面的章节中看到,确定算法效率的主要因素是它所需的步数。
然而,我们不能简单地将一个算法标记为“22步算法”另一个为“400步算法”。这是因为算法所需的步数不能归结为一个单一的数字。以线性搜索为例。线性搜索所需的步数是不确定的,因为它需要与数组中元素数量相同的步数。如果数组包含22个元素,线性搜索就需要22步。但是,如果数组包含400个元素,线性搜索将需要400步。
因此,更有效的方式是用“线性搜索在N个元素数组中需要N步”来量化线性搜索的效率。也就是说,如果一个数组有N个元素,线性搜索需要N步。现在,这种表达方式比较啰嗦。
为了更便于描述时间复杂度,计算机科学家们借鉴了数学领域的一个概念,用一种简洁和一致的语言描述数据结构和算法的效率。这个概念被称为“大O符号”,它是对这些概念的形式化表达,允许我们轻松地对给定算法的效率进行分类,并传达给其他人。
一旦你理解了“大O符号”,你就会掌握分析每个算法的工具,并能以一种一致而简洁的方式进行分析,这也是专业人士的做法。
虽然“大O符号”来自数学领域,但我将忽略所有的数学术语,将其解释为与计算机科学相关的概念。另外,我将从简单的角度解释“大O符号”,然后在接下来的三章中继续深入讨论。这不是一个难懂的概念,但如果我分章节解释,它会更容易理解。
大O符号:相对于N个元素的步数是多少?
大O符号通过特定的方式关注算法所需步骤的数量。让我们先用大O符号来分析线性搜索算法。
在最坏的情况下,线性搜索将花费与数组中元素数量相等的步骤数。正如我们之前所说的:对于数组中的N个元素,线性搜索最多需要N步。用大O符号来表示这一点的适当方式是:
O(N)
有些人将其发音为“大O的N”。其他人称之为“阶数N”。而我个人更喜欢“O的N”。
这个符号代表了一个关键问题的答案。这个关键问题是:如果有N个数据元素,算法会花费多少步骤?再读一遍那句话。然后将它铭记于心,因为这是我们将在本书其余部分使用的大O符号的定义。
对于关键问题的答案,隐藏在我们的大O表达式的括号中。O(N)表示这个关键问题的答案是,该算法将花费N步。
让我们快速回顾一下使用大O符号表达时间复杂度的思维过程,再次以线性搜索为例。首先,我们问一个关键问题:如果数组中有N个数据元素,线性搜索会花费多少步骤?因为答案是线性搜索将花费N步,所以我们表示为O(N)。值得一提的是,O(N)的算法也被称为具有线性时间。
让我们将其与大O符号如何表示从标准数组中读取数据的效率进行对比。正如你在“为什么数据结构很重要”中学到的,无论数组有多大,从数组中读取数据只需要一步。为了弄清楚如何用大O符号来表示这一点,我们再次问关键问题:如果有N个数据元素,从数组中读取数据需要多少步骤?答案是只需要一步。因此,我们将其表示为O(1),我发音为“O的1”。
O(1)很有趣,因为尽管我们的关键问题围绕N展开(“如果有N个数据元素,算法需要多少步骤?”),但答案与N无关。而这其实正是重点所在。也就是说,无论数组有多少个元素,从数组中读取数据总是只需一步。
这就是为什么O(1)被认为是“最快”的算法。即使数据增加,O(1)算法也不需要额外的步骤。无论N是多少,该算法始终需要恒定数量的步骤。实际上,O(1)算法也可以称为具有常数时间。
[!所以数学在哪?]
在本书的前面,我提到了采用简单易懂的方式来讲解大O符号这个主题。这并不是唯一的方式;如果你参加传统的算法课程,你可能会从数学的角度介绍大O。大O最初是数学中的一个概念,因此通常以数学术语来描述。例如,描述大O的一种方式是它描述了函数增长率的上界,或者如果函数g(x)的增长速度不超过函数f(x),那么就说g是O(f)的一个成员。根据你的数学背景,这可能说得通也可能没什么帮助。我写这本书是为了让你不需要太多数学知识就能理解这个概念。如果你想深入了解大O背后的数学知识,可以参考《算法导论》(Thomas H. Cormen, Charles E. Leiserson, Ronald L. Rivest, and Clifford Stein 著,MIT Press出版,2009年)获取完整的数学解释。Justin Abrahms在他的文章中也提供了一个相当不错的定义:https://justin.abrah.ms/computer-science/understanding-big-o-formal-definition.html。
大O的本质
现在我们已经了解了O(N)和O(1),我们开始意识到Big O符号所做的不仅仅是描述算法所需的步骤数量,比如22或400这样的硬性数字。相反,它回答了你额头上的那个关键问题:如果有N个数据元素,算法需要多少步骤?
虽然这个关键问题确实是对Big O的严格定义,但实际上Big O所涵盖的远不止这些。
假设我们有一个算法,无论有多少数据,它总是需要三步。也就是说,对于N个元素,该算法始终需要三步。你会怎样用Big O来表达它呢?
根据你到目前为止学到的一切,你可能会说它是O(3)。
但实际上,它是O(1)。这是因为Big O还有一个更深层次的理解。
虽然Big O是对算法相对于N个数据元素的步骤数量的表达,但这单单还不足以解释Big O背后更深层次的原因,这就是我称之为“Big O的灵魂”的东西。
Big O的灵魂是Big O真正关心的事情:随着数据增长,算法的性能会如何变化?
这就是Big O的本质。Big O并不只是想告诉你一个算法需要多少步骤。它想要讲述的是随着数据的变化,步骤数量的增长情况。
用这种视角来看,我们并不太在乎算法是O(1)还是O(3)。因为这两种算法都是不受增加数据影响的类型,它们的步骤数量始终保持不变,本质上是相同类型的算法。它们都是那种不论数据如何,步骤数量始终保持不变的算法,我们并不在乎它们之间的区别。
[!NOTE]
也就是加速度为0?
而另一方面,O(N)的算法则是一种不同类型的算法。它是一种随着数据增长而受影响的算法。更具体地说,它是那种随着数据增加,其步骤数量与数据直接成比例增加的算法。这就是O(N)所讲述的故事。它告诉了你数据和算法效率之间的比例关系,准确描述了随着数据增加,步骤数量是如何增加的。
看一下这两种类型的算法在图表上的表现:
O(N)呈现出完美的对角线。这是因为每增加一条数据,算法就需要增加一步。因此,数据越多,算法执行的步骤也就越多。相比之下,O(1)则是一条完美的水平线。无论数据有多少,步骤数量都保持不变。
深入大O的本质
为了理解“Big O的本质”为何如此重要,让我们深入挖掘一层。假设我们有一个常数时间的算法,无论数据量多少,它总是需要100步。你会认为这个算法比O(N)的算法表现得更好还是更差呢?看一下下面的图表:
正如图表所示,对于少于100个元素的数据集,O(N)算法比O(1)的100步算法需要更少的步骤。当元素数量正好为100时,这两种算法的曲线相交,意味着它们花费相同数量的步骤,即100步。但关键在于:对于所有大于100的数组,O(N)算法花费的步骤更多。
因为总会存在某个数据量,在那一点上潮水会转向,从那一点开始直到无穷大,O(N)都需要比O(1)更多的步骤。因此,总体上来说,无论O(1)算法实际需要多少步骤,O(N)都被认为是不如O(1)的。
即使对于总是需要一百万步的O(1)算法也是如此。随着数据量的增加,必然会达到一个点,O(N)变得不如O(1)高效,并且在接近无限的数据量上依然如此。
同样的算法,不同的情况
你在前面章节学到的线性搜索并非总是O(N)。确实,如果我们要查找的项在数组的最后一个单元格中,那么找到它将需要N步。但是,当我们搜索的项在数组的第一个单元格中时,线性搜索将在一步内找到该项。所以,线性搜索的这种情况可以描述为O(1)。如果要总体描述线性搜索的效率,我们可以说,在最佳情况下,线性搜索是O(1),在最坏情况下是O(N)。
虽然Big O有效地描述了给定算法的最佳和最坏情况,但Big O表示法通常指最坏情况,除非另有说明。这就是为什么大多数参考资料会将线性搜索描述为O(N),即使在最佳情况下它也可以是O(1)。 这是因为“悲观”的方法可能是一个有用的工具:了解算法在最坏情况下的低效程度,使我们做好最坏的准备,并可能对我们的选择产生重大影响。
第三种算法
在上一章中,你学到了在有序数组上进行的二分搜索比同一数组上的线性搜索要快得多。现在让我们来看看如何用大O表示法描述二分搜索。
我们不能将二分搜索描述为O(1),因为随着数据的增加,步数也会增加。它也不适用于O(N)的范畴,因为步数远少于N个数据元素。正如我们所见,对于包含100个元素的数组,二分搜索只需要七步。
因此,二分搜索似乎介于O(1)和O(N)之间。那它到底是什么呢?
用大O表示法来描述二分搜索的时间复杂度是:
O(log N)
我将其发音为“大O对数N”。这种类型的算法也被称为具有对数时间的时间复杂度。
简而言之,O(log N)是用大O表示法描述的一种算法,每当数据加倍时,步骤就增加一步。就像你在前一章中学到的那样,二分搜索就是这样做的。接下来你会立即看到为什么要将其表示为O(log N),但让我们先总结一下你到目前为止学到的内容。
到目前为止,你学到的三种类型的算法可以按照效率从高到低进行排序,如下所示:
O(1)
O(log N)
O(N)
让我们看一下比较这三种类型的算法的图表:
请注意,O(log N)的曲线稍微向上弯曲,使其效率低于O(1),但比O(N)要高得多。
要理解为什么这个算法被称为“O(log N)”,你需要先了解什么是对数。如果你已经熟悉这个数学概念,可以跳过接下来的部分。
对数
现在让我们看看为什么像二分查找这样的算法被描述为 O(log N)。究竟什么是对数呢?
“Log”是“logarithm(对数)”的简称。首先要注意的是,尽管这两个词看起来和发音听起来很相似,但对数与算法没有任何关系。
**对数是指数的倒数。**以下是指数的一个简要回顾:
2^3
等价于:
2 * 2 * 2
刚好等于8。
现在,log2 8 就是反过来。它意味着:你需要将2乘以自身多少次才能得到8?
因为你需要将2乘以自身3次才能得到8,所以log2 8 = 3。
再举一个例子:
2^6
相当于:
2 * 2 * 2 * 2 * 2 * 2 = 64
因为我们需要将2乘以自身6次才能得到64,所以:
log2 64 = 6。
虽然上述解释是“教科书式”的对数定义,我喜欢用另一种方式来描述相同的概念,因为许多人发现这种方法更容易理解,特别是在涉及大O表示法时。
另一种解释 log2 8 的方式是:如果我们将8反复除以2直到最后得到1,我们的等式中会有多少个2?
8 / 2 / 2 / 2 = 1
换句话说,**我们需要将8减半多少次才能最终得到1呢?**在这个例子中,需要3次。因此,
log2 8 = 3。
同样地,我们可以解释 log2 64 为:我们需要将64减半多少次才能最终得到1呢?
64 / 2 / 2 / 2 / 2 / 2 / 2 = 1
因为有六个2,所以 log2 64 = 6。
现在你了解了什么是对数,O(log N)的含义也就清楚了。
解释O(log N)
让我们把这一切都联系到大O表示法。在计算机科学中,每当我们说O(log N)时,实际上是简写为O(log2
N)。我们只是为了方便省略了那个小2。
回顾一下,大O表示法解决了一个关键问题:如果有N个数据元素,算法需要多少步骤?
O(log N)意味着对于N个数据元素,算法需要log2
N步。如果有8个元素,算法需要三步,因为log2
8 = 3。
换句话说,如果我们不断将这8个元素减半,直到最终得到1个元素,那就需要三步。
这正是二分查找所发生的情况。当我们搜索特定项时,我们不断将数组的单元格减半,直到找到正确的数字。
简单来说:O(log N)意味着算法需要多少步来将数据元素减半,直到最终剩下1个元素。
以下表格展示了O(N)和O(log N)效率之间的显著差异:
把数据元素的数量视为步数的O(N)算法,每个数据元素需要一步操作,而O(log N)算法在数据翻倍时只需增加一步操作。在未来的章节中,你将遇到更多不同于你目前学到的三种Big O符号的算法分类。但与此同时,让我们将这些概念应用到一些日常代码的例子中。
实践案例
这里有一些典型的 Python 代码,可以打印列表中的所有项:
things = ['apples', 'baboons', 'cribs', 'dulcimers']
for thing in things:
print("Here's a thing: %s" % thing)
我们要如何用大O表示法来描述这个算法的效率呢?
首先要意识到,这是一个算法示例。**尽管可能不是很复杂,但任何能够实现某种功能的代码在技术上都是一个算法——它是解决问题的特定过程。**在这种情况下,问题是我们想要打印列表中的所有项。我们用来解决这个问题的算法是一个包含打印语句的for循环。
为了进行分析,我们需要计算这个算法需要多少步骤。在这个例子中,算法的主要部分——for循环——需要四步。
在这个例子中,列表中有四个元素,我们将每个元素打印一次。
然而,步骤数量并不是恒定的。如果列表包含10个元素,for循环将需要10步。由于这个for循环所需的步骤数量与元素数量一样多,我们可以说这个算法的效率是O(N)。
下一个示例是一个简单的基于 Python 的算法,用于确定一个数字是否为质数:
def is_prime(number):
for i in range(2, number):
if number % i == 0:
return False
return True
上述代码接受一个数字作为参数,并开始一个for循环,其中我们将这个数字除以从2到该数字的每个整数,看是否有余数。如果没有余数,我们知道该数字不是质数,并立即返回False。如果我们一直循环到该数字并始终找到余数,那么我们知道该数字是质数,并返回True。
在这种情况下,关键问题与之前的示例略有不同。在之前的示例中,我们的关键问题是,如果数组中有N个数据元素,算法需要多少步骤。在这里,我们不是处理数组,而是处理一个我们传递给该函数的数字。根据我们传递的数字不同,函数的循环次数也会不同。
因此,在这种情况下,我们的关键问题将是:当传递数字N时,算法需要多少步骤?
如果我们将数字7传递给is_prime函数,for循环大约运行了七次。(实际上运行了五次,因为它从2开始,直到实际数字前止。)对于数字101,循环大约运行了101次。**由于步骤数量与传递给函数的数字成正比增加,这是一个经典的O(N)示例。
在这里,关键问题涉及到不同类型的N,因为我们的主要数据是一个数字,而不是一个数组。随着我们在未来的章节中不断学习,我们会更多地练习识别不同类型的N。
总结
用大O表示法,我们有一个一致的系统,可以比较任何两个算法。有了它,我们将能够检查现实生活中的场景,并在竞争的数据结构和算法之间进行选择,使我们的代码更快,并能够处理更重的负载。
在下一章中,我们将遇到一个真实的例子,我们将使用大O表示法显著加速我们的代码。
练习
这里是一些练习,让你练习使用大O表示法。这些练习的解答在第三章的第440页。
- 使用大O表示法描述下面的函数的时间复杂度,该函数用于确定给定年份是否为闰年:
function isLeapYear(year) {
return (year % 100 === 0) ? (year % 400 === 0) : (year % 4 === 0);
}
- 使用大O表示法描述下面的函数的时间复杂度,该函数对给定数组中的所有数字进行求和:
function arraySum(array) {
let sum = 0;
for(let i = 0; i < array.length; i++) {
sum += array[i];
}
return sum;
}
- 下面的函数基于一个古老的类比来描述复利的力量:假设你有一个棋盘,在一个方格上放置一粒米。
在第二个方格上,你放置2粒米,因为它是前一个方格上米的两倍。在第三个方格上,你放置4粒米。在第四个方格上,你放置8粒米,在第五个方格上,你放置16粒米,以此类推。下面的函数计算需要在哪个方格放置一定数量的米粒。例如,对于16粒米,函数将返回5,因为你将在第五个方格上放置16粒米。使用大O表示法描述此函数的时间复杂度:
function chessboardSpace(numberOfGrains) {
let chessboardSpaces = 1;
let placedGrains = 1;
while (placedGrains < numberOfGrains) {
placedGrains *= 2;
chessboardSpaces += 1;
}
return chessboardSpaces;
}
- 下面的函数接受一个字符串数组,并返回一个只包含以字符"a"开头的字符串的新数组。使用大O表示法描述函数的时间复杂度:
function selectAStrings(array) {
let newArray = [];
for(let i = 0; i < array.length; i++) {
if (array[i].startsWith("a")) {
newArray.push(array[i]);
}
}
return newArray;
}
- 下面的函数从一个有序数组中计算中位数。使用大O表示法描述其时间复杂度:
function median(array) {
const middle = Math.floor(array.length / 2);
// 如果数组中数字的数量为偶数:
if (array.length % 2 === 0) {
return (array[middle - 1] + array[middle]) / 2;
} else { // 如果数组中数字的数量为奇数:
return array[middle];
}
}
答案
-
这是O(1)。我们可以将N视为传入函数的年份。但无论年份是什么,算法在所花的步骤上没有变化。
-
这是O(N)。对于数组中的N个元素,循环将运行N次。
-
这是O(log N)。在这种情况下,N是传递到函数中的numberOfGrains。循环运行只要placedGrains < numberOfGrains,但placedGrains从1开始,每次循环都会加倍。例如,如果numberOfGrains是256,我们会将placedGrains加倍9次直到达到256,意味着我们的循环将对N为256运行9次。如果numberOfGrains是512,我们的循环将运行10次,如果numberOfGrains是1024,循环将运行11次。因为我们的循环每次N加倍只运行一次,所以这是O(log N)。
-
这是O(N)。N是数组中的字符串数量,循环将执行N步。
-
这是O(1)。我们可以将N视为数组的大小,但是无论N是多少,算法都需要固定数量的步骤。算法考虑了N是偶数还是奇数,但无论哪种情况,所花的步骤都是相同的。