第6章乐观情景优化
到目前为止,我们主要关注算法在最坏情况下会执行多少步骤。这种做法的理念很简单:如果你为最坏情况做好了准备,事情就会变得顺利。
然而,在本章中,你将发现最坏情况并非唯一值得考虑的情况。能够考虑所有情景是一项重要技能,可以帮助你为每种情况选择合适的算法。
插入排序
我们之前遇到过两种不同的排序算法:冒泡排序和选择排序。它们的效率都是O(N^2),但选择排序实际上是快两倍。现在你将了解到第三种排序算法,称为插入排序,它将展示出超越最坏情况分析的威力。
插入排序包括以下步骤:
- 在第一次遍历中,我们暂时移除索引为1(第二个单元格)的值,并将其存储在临时变量中。这将在该索引处留下一个空隙,因为它不包含任何值:
在随后的遍历中,我们移除随后索引处的值。
2. 然后开始移动阶段,我们将左侧每个值与临时变量进行比较:
- 如果空隙左侧的值大于临时变量,则将该值向右移动:
- 随着值向右移动,空隙自然而然地向左移动。一旦遇到比暂时移除的值更小的值,或者到达数组的左端,移动阶段就结束了。
- 接着,我们将临时移除的值插入当前的空隙中。
- 步骤1到3代表了一次遍历。我们重复这些遍历,直到遍历开始于数组的最后一个索引。到那时,数组将完全排序。
插入排序实操
让我们将插入排序应用到数组 [4, 2, 7, 1, 3] 上。
我们开始第一次遍历,检查索引为1的值。这个位置的值是2:
步骤1:我们暂时移除2,并将其保存在名为 temp_value 的变量中。我们通过将其移到数组的上方表示这个值:
步骤2:我们将4与temp_value(2)进行比较:
步骤3:因为4大于2,我们将4向右移动:
没有东西可以继续移动了,因为间隙现在在数组的左端。
步骤4:我们将temp_value 插入间隙中,完成了第一次遍历:
接下来,我们开始第二次遍历:
步骤5:在第二次遍历中,我们暂时移除索引2处的值,并将其保存在 temp_value 中。在这种情况下,temp_value 是7:
步骤6:我们将4与 temp_value 进行比较:
4小于7,所以我们不会将其移动。由于我们到达了比temp_value更小的值,这个移动阶段结束了。
步骤7:我们将temp_value 再次插入间隙中,结束了第二次遍历:
现在,我们开始第三次遍历:
步骤8:我们暂时移除1并将其存储在temp_value中:
步骤9:我们将7与temp_value进行比较:
步骤10:7大于1,所以我们将7向右移动:
步骤11:我们将4与temp_value进行比较:
步骤12:4大于1,所以我们也将其移动:
步骤13:我们将2与temp_value进行比较:
步骤14:2大于1,所以我们移动2:
步骤15:间隙已经到达数组的左端,所以我们将temp_value插入间隙中,结束了这一遍历:
现在,我们开始第四次遍历:
步骤16:我们暂时移除索引4处的值,作为temp_value。这个值是3:
步骤17:我们将7与temp_value进行比较:
步骤18:7大于3,所以我们将7向右移动:
步骤19:我们将4与temp_value进行比较:
步骤20:4大于3,所以我们将4移动:
步骤21:我们将2与temp_value进行比较。2小于3,所以我们的移动阶段完成了:
步骤22:我们将temp_value插入间隙中。
我们的数组完全排序了:
代码实现:插入排序
这是一个Python实现的插入排序算法:
def insertion_sort(array):
for index in range(1, len(array)):
temp_value = array[index]
position = index - 1
while position >= 0:
if array[position] > temp_value:
array[position + 1] = array[position]
position = position - 1
else:
break
array[position + 1] = temp_value
return array
让我们逐步解释这段代码。
首先,我们从索引1开始一个循环,遍历整个数组。每一轮循环代表一次遍历:
for index in range(1, len(array)):
在每次遍历中,我们将要“移除”的值保存在一个名为temp_value
的变量中:
temp_value = array[index]
接下来,我们创建一个名为position
的变量,它将从temp_value
的索引左侧开始。这个位置会代表我们将与temp_value
进行比较的每一个值:
position = index - 1
随着我们通过遍历移动,每次比较值与temp_value
时,position
会向左移动。
然后,我们开始一个内部的while
循环,只要position
大于或等于0就会运行:
while position >= 0:
接着进行比较,也就是检查position
处的值是否大于temp_value
:
if array[position] > temp_value:
如果是的话,我们将左侧的那个值向右移动:
array[position + 1] = array[position]
position = position - 1
然后我们将position
减1,以便在下一轮while
循环中与temp_value
进行比较的下一个左侧值:
else:
break
如果在任何时候我们遇到了一个在position
处大于或等于temp_value
的值,那么我们就准备结束我们的遍历,因为现在是时候将temp_value
移动到间隙中了:
array[position + 1] = temp_value
在完成所有遍历后,我们返回排好序的数组:
return array
插入排序的效率
四种类型的步骤在插入排序中发生:移除、比较、移动和插入。要分析插入排序的效率,我们需要统计每种步骤的数量。
首先,让我们来分析比较步骤。每次我们将一个值与间隙左侧的值(即temp_value
)进行比较时,就会发生一次比较。在最坏的情况下,即数组按相反顺序排序时,我们需要在每次遍历中将temp_value
与间隙左侧的每个数字进行比较。这是因为temp_value
左侧的每个值都将始终大于temp_value
,因此遍历只有在间隙达到数组的最左侧时才会结束。
在第一次遍历中,temp_value
是索引为1的值时,最多只进行了一次比较,因为temp_value
左侧只有一个值。在第二次遍历中,最多进行了两次比较,依此类推。在最后一次遍历中,我们需要将temp_value
与除了它本身之外的数组中的每个值进行比较。换句话说,如果数组中有N个元素,那么在最后一次遍历中最多进行了N - 1次比较。
因此,我们可以得出总的比较次数公式为:
1 + 2 + 3 + … + (N - 1) 次比较。
对于包含五个元素的示例数组,最多为:
1 + 2 + 3 + 4 = 10 次比较。
对于包含10个元素的数组,将会有:
1 + 2 + 3 + 4 + 5 + 6 + 7 + 8 + 9 = 45 次比较。
对于包含20个元素的数组,将会有总共190次比较,依此类推。
在检查这种模式时,可以发现对于包含N个元素的数组,大约会有N2 / 2次比较。(10² / 2 是50,20² / 2 是200。我们将在下一章节更详细地研究这种模式。)
让我们继续分析其他类型的步骤。
移位发生在每次将一个值移动到右侧一个单元格时。当数组按相反顺序排序时,将会有与比较次数相同数量的移位,因为每次比较都会强制我们将一个值向右移动。
让我们为最坏情况下的比较和移位进行累加:
N² / 2 次比较
- N² / 2 次移位
N² 次步骤
从数组中移除和插入 temp_value
每次遍历发生一次。由于始终存在 N - 1 次遍历,我们可以得出有 N - 1 次移除和 N - 1 次插入。
所以,现在我们有了:
N² 的比较和移位总和
N - 1 次移除
- N - 1 次插入
N² + 2N - 2 步骤
你已经了解了 Big O 的一个主要规则:Big O 忽略常数。
有了这个规则,乍一看,我们可以简化为 O(N² + N)。
然而,还有另一个主要的 Big O 规则我现在会透露:
当我们将多个阶数相加时,Big O 只考虑 N 的最高阶数。
也就是说,如果我们有一个算法,它需要 N⁴ + N³ + N² + N 步骤,我们只会认为 N⁴ 是重要的,并将其称为 O(N⁴)。为什么会这样呢?
看下面的表格:
随着 N 的增加,N⁴ 将比其他阶数的 N 更为显著,因此较小的阶数被认为是微不足道的。例如,看表格的最后一行,当我们将 N⁴ + N³ + N² + N 相加时,得到的总数是 101,010,100。但是我们可以将其近似为 100,000,000,通过忽略那些较低阶数的 N 而得到这个结果。
我们可以将这个概念应用到插入排序上。即使我们已经将插入排序简化为 N² + N 步骤,我们通过舍弃较低阶数进一步简化表达式,将其降低为 O(N²)。
从最坏的情况来看,插入排序与冒泡排序和选择排序的时间复杂度是相同的。它们都是 O(N²)。
平均情况
确实,在最坏情况下,选择排序比插入排序要快。然而,我们也必须考虑平均情况。
为什么呢?
**按定义,最频繁发生的情况是平均情况。**看看这个简单的钟形曲线:
最好和最坏的情况相对较少发生。在现实世界中,平均情况是最常见的。
拿一个随机排序的数组来说吧。这些值以完美的升序或降序出现的概率有多大?更有可能的情况是这些值是杂乱无章的。
让我们考虑插入排序在各种情况下的表现。
我们已经看过插入排序在最坏情况下的表现——数组按降序排序。在最坏情况下,我们看到在每次遍历中,我们都要比较并移动我们遇到的每个值。(我们计算出总共需要 N² 次比较和移位。)
在最佳情况下,即数据已按升序排序时,我们每次遍历只需进行一次比较,并且不需要移动任何值,因为每个值都已经在其正确的位置上。
然而,在数据随机排序的情况下,我们会在遍历中比较和移动所有数据,一些数据或可能根本不移动数据。如果你看看在“插入排序示例”(页面 80)中的示例,你会注意到在第一和第三次遍历中,我们比较和移动了遇到的所有数据。在第四次遍历中,我们只比较和移动了其中一部分数据,而在第二次遍历中,我们只进行了一次比较,没有移动任何数据。
(这种差异是因为有些遍历比较了 temp_value
左侧的所有数据,而其他遍历由于遇到一个小于 temp_value
的值而提前结束。)
因此,在最坏情况下,我们会比较和移动所有数据;而在最佳情况下,我们不会移动任何数据(每次遍历只进行一次比较)。对于平均情况,我们可以说在总体上我们可能会比较和移动大约一半的数据。因此,如果插入排序在最坏情况下需要 N² 步骤,我们可以说在平均情况下需要大约 N² / 2 步骤。(不过在大 O 表示法中,这两种情况都是 O(N²)。)
让我们深入一些具体的例子。
数组 [1, 2, 3, 4] 已经是预先排序好的,这是最好的情况。相同数据的最坏情况将是 [4, 3, 2, 1],而一个平均情况的例子可能是 [1, 3, 4, 2]。
在最坏情况下([4, 3, 2, 1]),有六次比较和六次移动,总共 12 步骤。在平均情况下([1, 3, 4, 2]),有四次比较和两次移动,总共六步骤。在最佳情况下([1, 2, 3, 4]),有三次比较和零次移动。
现在我们可以看到插入排序的性能根据情况有很大的差异。在最坏情况下,插入排序需要 N² 步骤。在平均情况下,它需要 N² / 2 步骤。在最佳情况下,它大约需要 N 步骤。
你可以在以下图表中看到这三种性能表现:
与选择排序相比,情况就不同了。选择排序在所有情况下都需要 N² / 2 步骤,无论是最坏情况、平均情况还是最佳情况。这是因为选择排序没有在任何时候结束遍历的机制。每次遍历都会比较所选索引右侧的每个值,不管怎样都是如此。
这是一个比较选择排序和插入排序的表格。
那么,哪个更好:选择排序还是插入排序?答案是:嗯,这取决于情况。在一个平均情况下——也就是数组是随机排序的情况下,它们的性能相似。如果你有理由认为你将处理的数据大多是已经排序好的,那么插入排序会是一个更好的选择。如果你有理由认为你将处理的数据大多是以相反顺序排序好的,那么选择排序会更快。如果你不知道数据会是什么样子,那本质上就是一个平均情况,那么它们两者性能会相等。
实际案例
在写一个 JavaScript 应用程序时,你发现在代码的某处需要获取两个数组之间的交集。交集是两个数组中都存在的所有值的列表。例如,如果你有数组 [3, 1, 4, 2] 和 [4, 5, 3, 6],那么交集将是第三个数组 [3, 4],因为这两个值在这两个数组中都存在。
这是一个可能的实现方式:
function intersection(firstArray, secondArray){
let result = [];
for (let i = 0; i < firstArray.length; i++) {
for (let j = 0; j < secondArray.length; j++) {
if (firstArray[i] == secondArray[j]) {
result.push(firstArray[i]);
}
}
}
return result;
}
在这里,我们运行了嵌套循环。在外部循环中,我们遍历了第一个数组中的每个值。当我们指向第一个数组中的每个值时,我们运行了一个内部循环,检查第二个数组的每个值,看它是否与第一个数组中的值匹配。
这个算法中有两种类型的步骤:比较和插入。我们将两个数组的每个值相互比较,并将匹配的值插入到结果数组中。让我们先看看有多少比较操作。
如果两个数组的大小相等,并假设 N 是任一数组的大小,则执行的比较次数为 N²。这是因为我们将第一个数组的每个元素与第二个数组的每个元素进行了比较。因此,如果我们有两个包含五个元素的数组,最终将进行 25 次比较。所以,这个求交集的算法的效率为 O(N²)。
插入操作最多需要 N 步(如果两个数组碰巧是相同的)。与 N² 相比,这是一个较低的阶次,因此我们仍然认为算法的复杂度是 O(N²)。如果数组大小不同——假设分别为 N 和 M,那么这个函数的效率将是 O(N * M)。(在《日常代码中的大O表示法》中会有更多内容。)
有没有什么方法可以改进这个算法呢?
这就是考虑除了最坏情况之外的情况非常重要的地方。在当前的交集函数实现中,无论这两个数组是否相同或者根本没有共同的值,我们都进行了N²次比较。
然而,在这两个数组共享公共值的情况下,我们实际上不应该必须检查第二个数组中的每个值是否与第一个数组的某个值匹配。让我们来看看原因:
在这个例子中,一旦我们发现了一个共同的值(8),实际上就没有理由继续第二个循环了。此时我们在检查什么呢?我们已经确定第二个数组包含了第一个数组的那个8,并且可以将其添加到结果中。我们在执行一个不必要的步骤。
为了解决这个问题,我们可以在实现中添加一个单词:
function intersection(firstArray, secondArray){
let result = [];
for (let i = 0; i < firstArray.length; i++) {
for (let j = 0; j < secondArray.length; j++) {
if (firstArray[i] == secondArray[j]) {
result.push(firstArray[i]);
break; // 在这里添加 break
}
}
}
return result;
}
通过添加 break,我们可以提前结束内部循环,节省步骤(因此节省时间)。
在最坏情况下,即这两个数组不包含任何共享值时,我们仍然不得不执行N²次比较。但是现在,在最佳情况下,即这两个数组完全相同时,我们只需要执行N次比较。在平均情况下,即这两个数组不同但共享一些值时,性能将介于N和N²之间。
这对于我们的交集函数是一个重要的优化,因为我们的第一个实现在所有情况下都要进行N²次比较。
总结
能够区分最佳情况、平均情况和最坏情况是选择最适合需求的最佳算法的关键技能,同时也是进一步优化现有算法以显著提速的重要技能。记住,虽然为最坏情况做好准备是好事,但平均情况才是大多数发生的情况。
现在我们已经涵盖了与大 O 表示法相关的重要概念,让我们将知识应用到实际算法中。在下一章中,我们将看一下各种可能出现在真实代码库中的日常算法,并根据大 O 表示法确定每个算法的时间复杂度。
练习
以下练习让您有机会练习优化最佳和最坏情况。这些练习的答案可在第 6 章的第 442 页找到。
- 使用大 O 表示法描述一个需要 3N² + 2N + 1 步的算法的效率。
- 使用大 O 表示法描述一个需要 N + log N 步的算法的效率。
- 以下函数检查数字数组是否包含两个数的配对,使它们的总和为 10。
function twoSum(array) {
for (let i = 0; i < array.length; i++) {
for (let j = 0; j < array.length; j++) {
if (i !== j && array[i] + array[j] === 10) {
return true;
}
}
}
return false;
}
- 以下函数返回字符串中是否存在大写字母 “X”。
function containsX(string) {
let foundX = false;
for (let i = 0; i < string.length; i++) {
if (string[i] === "X") {
foundX = true;
}
}
return foundX;
}
这个函数的时间复杂度是多少?然后修改代码以提高最佳和平均情况下的算法效率。
答案
-
在大 O 表示法中,2N² + 2N + 1 简化为 O(N²)。去除所有常数后,剩下 N² + N,但我们也去掉了 N,因为它是比 N² 更低阶的。
-
log N 是比 N 更低阶的,所以它简化为 O(N)。
-
这里需要注意的重要事项是,一旦找到总和为 10 的一对数字,函数就会结束。因此,最佳情况是前两个数字的总和为 10,因为我们可以在循环开始之前结束函数。平均情况可能是两个数字在数组中间的某处。最坏情况是没有两个数字的总和为 10,这种情况下我们必须完全遍历两个循环。这个最坏情况的时间复杂度是 O(N²),其中 N 是数组的大小。
-
这个算法的效率是 O(N),因为数组的大小是 N,循环遍历了所有 N 个元素。
这个算法即使在找到字符串结尾之前也会继续循环。如果我们找到 “X” 就立即返回 true,可以使代码更高效:
function containsX(string) {
for(let i = 0; i < string.length; i++) {
if (string[i] === "X") {
return true;
}
}
return false;
}