Jay Wengrow - A Common-Sense Guide to Data Structures and Algorithms【自译】第5章

本文讨论了大O优化在选择排序中的应用,以及它如何帮助我们区分看似效率相同的算法。作者通过详细解释选择排序的过程,展示了大O表示法在衡量算法效率时的重要性,同时提醒读者大O分类关注的是效率趋势,而非具体数值。
摘要由CSDN通过智能技术生成

第5章使用和不使用大O优化代码

我们已经了解到,大 O 表示法是比较算法和确定在特定情况下应该使用哪种算法的强大工具。然而,它绝对不是唯一的工具。事实上,有时候会出现两个竞争算法使用大 O 表示法描述方式相同,但其中一个算法比另一个更快的情况。

在本章中,你将学习如何辨别看似具有相同效率的两个算法,以及如何选择其中更快的算法。

选择排序

在前一章中,我们探讨了一种称为冒泡排序(Bubble Sort)的排序算法,它的效率为 O(N²)。现在我们要深入研究另一种排序算法,称为选择排序(Selection Sort),并看看它与冒泡排序相比如何。

选择排序的步骤如下:

  1. 我们从左到右检查数组的每个单元格,以确定哪个值最小。当我们从单元格移动到单元格时,我们会记录到目前为止遇到的最小值(通过将其索引存储在一个变量中)。如果我们遇到一个包含比变量中值更低的值的单元格,我们将其替换,使变量指向新的索引。见下图示例:

在这里插入图片描述

  1. 一旦确定了包含最小值的索引,我们将其值与开始遍历的值进行交换。在第一次遍历中,这将是索引0,在第二次遍历中,将是索引1,依此类推。这里的图示展示了第一次遍历时进行交换的情况。

在这里插入图片描述

  1. 每次遍历都包括步骤1和步骤2。我们重复遍历直到达到一个从数组末尾开始的遍历。到这个时候,数组将已经完全排序。

选择排序操作

让我们使用示例数组[4, 2, 7, 1, 3]逐步了解选择排序的步骤。

我们开始第一次遍历:
首先检查索引为0的值。根据定义,它是到目前为止我们遇到的数组中最小的值(因为它是迄今为止唯一遇到的值),因此我们在一个变量中跟踪其索引:

在这里插入图片描述

步骤1:将2与迄今为止的最小值(也就是4)进行比较:

在这里插入图片描述

2比4还小,因此它成为了迄今为止的最小值:

在这里插入图片描述

步骤2:将下一个值 - 7与迄今为止的最小值进行比较。7大于2,所以2仍然是我们迄今为止的最小值:

在这里插入图片描述

步骤3:将1与迄今为止的最小值进行比较:

在这里插入图片描述

因为1比2还小,所以1成为了我们的新最小值:

在这里插入图片描述

步骤4:将3与迄今为止的最小值(即1)进行比较。我们已经到达数组的末尾,并且确定1是整个数组中最小的值:

在这里插入图片描述

步骤5:因为1是最小的值,我们将其与索引为0的值进行交换——这是我们开始本次遍历的索引:

在这里插入图片描述

由于我们已将最小值移动到数组的开头,这意味着最小值现在位于其正确的位置:

现在我们准备开始第二次遍历。
设置:第一个单元格(索引0)已经排序好了,因此这次遍历从下一个单元格开始,即索引1。索引1处的值是数字2,这是我们在本次遍历中遇到的最小值:

在这里插入图片描述

步骤6:将7与迄今为止的最小值(2)进行比较。2小于7,所以2仍然是我们的最小值:

在这里插入图片描述

步骤7:将4与迄今为止的最小值(2)进行比较。2小于4,所以2仍然是我们的最小值:

在这里插入图片描述

步骤8:将3与迄今为止的最小值(2)进行比较。2小于3,所以2仍然是我们的最小值:

在这里插入图片描述

我们已经到达数组的末尾。由于本次遍历的最小值已经在正确的位置上,我们无需执行交换。这结束了我们的第二次遍历,数组保持着原样。

在这里插入图片描述

现在我们开始第三次遍历。
设置:我们从索引2开始,其中包含值7。在本次遍历中,7是我们迄今为止遇到的最小值:

在这里插入图片描述

步骤9:将4与7进行比较:

在这里插入图片描述

我们注意到4是我们新的最小值:

在这里插入图片描述

步骤10:遇到数字3,它比4还小:

在这里插入图片描述

3成为了我们的新最小值:

在这里插入图片描述

步骤11:我们已经到达数组的末尾,所以将3与我们开始本次遍历的值进行交换,即7:

在这里插入图片描述

现在我们知道3在数组中的位置是正确的:

在这里插入图片描述

虽然你和我都能看到整个数组此时已经正确排序,但计算机还不知道这一点,因此它必须开始第四次遍历。

设置:我们从索引3开始本次遍历。4是到目前为止最小的值:

在这里插入图片描述

步骤12:将7与4进行比较:

在这里插入图片描述

4仍然是我们在本次遍历中遇到的最小值,因此我们无需交换它,因为它已经在正确的位置上。

由于除了最后一个单元格之外的所有单元格都已正确排序,这意味着最后一个单元格也处于正确的顺序中,因此整个数组都已经正确排序。

在这里插入图片描述

代码实现:选择排序

以下是选择排序的 JavaScript 实现:

function selectionSort(array) {
  for (let i = 0; i < array.length - 1; i++) {
    let lowestNumberIndex = i;
    for (let j = i + 1; j < array.length; j++) {
      if (array[j] < array[lowestNumberIndex]) {
        lowestNumberIndex = j;
      }
    }
    if (lowestNumberIndex !== i) {
      let temp = array[i];
      array[i] = array[lowestNumberIndex];
      array[lowestNumberIndex] = temp;
    }
  }
  return array;
}

让我们逐行分解这段代码。

首先,我们开始一个循环,代表每次遍历。它使用变量 i 指向数组的每个值,并遍历到倒数第二个值:

for (let i = 0; i < array.length - 1; i++) {

它不需要针对最后一个值运行,因为到那时数组将已完全排序。

接下来,我们开始追踪包含迄今为止最低值的索引:

let lowestNumberIndex = i;

在第一个遍历的开始,lowestNumberIndex 为 0,在第二个遍历的开始为 1,依此类推。

我们之所以特别追踪索引是因为在代码的其余部分我们需要访问最低值及其索引,并且我们可以使用索引来引用它们(通过调用 array[lowestNumberIndex] 可以查看最低值)。

在每次遍历内部,我们检查数组的剩余值,以查看是否存在比当前最低值更低的值:

for (let j = i + 1; j < array.length; j++) {
  if (array[j] < array[lowestNumberIndex]) {
    lowestNumberIndex = j;
  }
}

确实,如果我们找到了更低的值,我们将新值的索引存储在 lowestNumberIndex 中。

在内部循环结束时,我们将找到此次遍历中最低数字的索引。

如果在这次遍历中找到的最小值已经在正确的位置(当最小值是遍历中遇到的第一个值时会发生这种情况),我们无需进行任何操作。但如果最小值不在正确的位置,我们就需要执行一次交换。具体来说,我们将最小值与遍历开始的索引 i 处的值进行交换:

if(lowestNumberIndex != i) {
  let temp = array[i];
  array[i] = array[lowestNumberIndex];
  array[lowestNumberIndex] = temp;
}

最后,我们返回已排序的数组:

return array;

选择排序操作的效率

选择排序包含两种步骤:比较和交换。我们将每个值与每次遍历中遇到的最小数进行比较,然后将最小数交换到其正确的位置。

回顾我们包含五个元素的示例数组,我们需要总共进行10次比较。我们来拆分下这个表格:这总共是4 + 3 + 2 + 1 = 10次比较。

在这里插入图片描述

对于任意长度为N的数组来说,我们可以这样表述:对于N个元素,我们进行

(N - 1) + (N - 2) + (N - 3) … + 1 次比较。

至于交换,我们在每次遍历中最多只需要进行一次交换。这是因为在每次遍历中,根据最小数是否已经处于正确位置,我们要么进行一次交换,要么不进行交换。这与冒泡排序不同,在最坏的情况下(例如,数组降序排列),每次比较都需要进行一次交换。

这里是冒泡排序和选择排序的对比:

在这里插入图片描述

从这个对比来看,选择排序所需的步骤数量大约是冒泡排序的一半,表明选择排序大约是冒泡排序的两倍快。

忽略常数

但有趣的是,在大O符号表示法的世界中,选择排序和冒泡排序被以完全相同的方式描述。

再次强调,大O符号回答了一个关键问题:如果有N个数据元素,算法需要多少步骤?由于选择排序大约需要N²的一半步骤,我们似乎可以合理地将选择排序的效率描述为O(N² / 2)。也就是说,对于N个数据元素,需要N² / 2步骤。以下表格证实了这一点:

在这里插入图片描述

然而,在现实中,选择排序和冒泡排序的大O表示法都是O(N²),这是因为大O符号的一个主要规则,我现在第一次介绍:

大O符号忽略常数。

这简单地表示,大O符号永远不包括不是指数的普通数字。我们只是将这些普通数字从表达式中去掉。

在我们的情况下,尽管算法需要N² / 2步骤,但我们去掉了“/ 2”,因为它是一个普通数字,并将效率表示为O(N²)。

以下是更多的例子:

  • 对于需要N / 2步骤的算法,我们将其称为O(N)。
  • 需要N² + 10步骤的算法会被表示为O(N²),因为我们去掉了常数10。
  • 对于需要2N步骤的算法(意思是N * 2),我们去掉了常数,并称其为O(N)。

即使O(100N)比O(N)慢100倍,它也被称为O(N)。

一开始,这个规则似乎会使大O符号变得毫无用处,因为你可以有两个用大O符号完全相同方式描述的算法,但其中一个可能比另一个快100倍。这正是我们在选择排序和冒泡排序中看到的情况。两者都被描述为O(N²),但选择排序实际上比冒泡排序快两倍。

那么,这是怎么回事呢?

大O类

这引导我们进入大O符号中的下一个概念:大O符号只关心算法速度的一般类别。

作为类比,让我们谈谈物理建筑。当然,有许多不同类型的建筑。有单层的独栋家庭住宅,两层的独栋家庭住宅,还有三层的独栋家庭住宅。还有高层公寓楼,楼层数量不同。还有高度和形状各异的摩天大楼。

如果我们比较两栋建筑,其中一栋是单层住宅,另一栋是摩天大楼,提及它们各自有多少层楼几乎毫无意义。因为这两栋建筑在大小和功能上差异非常大,我们不需要说:“这是一栋两层楼的房子,而那是一栋一百层的摩天大楼。” 我们可能只需称其中一栋为房子,另一栋为摩天大楼。用它们的一般类别来称呼就足以表明它们巨大的差异。

同样的道理适用于算法效率。如果我们比较一个O(N)算法和一个O(N²)算法,这两种效率差异如此之大,以至于O(N)算法是否实际上是O(2N)、O(N / 2)甚至O(100N)已经不再重要。

现在,这就是为什么O(N)和O(N²)被视为两个不同的类别,而O(N)和O(100N)属于同一类别的原因。

记得《大O的精髓》,第37页。大O符号不仅关心算法的步骤数量,更关心算法步骤在数据增加时的长期趋势。O(N)讲述了一种直线增长的故事——步骤按照数据的某种比例直线增加。即使步骤是100N,这个说法也是正确的。而O(N²)则讲述了一个不同的故事——指数增长的故事。

指数增长与任何形式的 O(N) 完全不同。当我们考虑到在数据增长的某个点上,O(N²) 将变得比任何乘以因子的 O(N) 更慢时,这一点就更加显著了。在下面的图表中,你可以看到 O(N²) 在比较各种 N 的因子时变得更慢:

在这里插入图片描述

因此,当比较两种属于大O不同类别的效率时,通过它们的一般类别来辨别已经足够了。当将 O(2N) 与 O(N²) 进行比较时,就像在比较一栋两层的房子和一栋摩天大楼一样。我们可以直接说 O(2N) 属于 O(N) 的一般类别。

我们所遇到的各种大O类型,无论是 O(1)、O(log N)、O(N)、O(N²),或者在本书后面将遇到的类型,都是大O的一般类别,彼此之间有着巨大的差异。通过一个常规数字的乘除并不能将它们转换为另一个类别。

然而,当两个算法属于相同的大O分类时,并不意味着这两个算法速度相同。毕竟,冒泡排序的速度是选择排序的两倍,尽管两者都是 O(N²)。

因此,虽然大O适用于对比属于不同大O分类的算法,但当两个算法属于同一分类时,需要进一步分析才能确定哪个算法更快。

实际案例

让我们回到第一章的第一个代码示例,稍作更改:

def print_numbers_version_one(upperLimit):
    number = 2
    while number <= upperLimit:
        # If number is even, print it:
        if number % 2 == 0:
            print(number)
        number += 1

def print_numbers_version_two(upperLimit):
    number = 2
    while number <= upperLimit:
        print(number)
        # Increase number by 2, which, by definition,
        # is the next even number:
        number += 2

这里我们有两种完成相同任务的算法,即打印从2到某个上限(在第1章中,上限固定为100,而在这里,我们允许用户传入一个数字作为上限)的所有偶数。我在第1章指出,第一种版本比第二种版本需要两倍的步骤,但现在让我们看看在大O表示法方面如何体现这一点。

再次强调,大O表示法表示了一个关键问题的答案:如果有N个数据元素,算法将需要多少步骤?但在这种情况下,N不是数组的大小,而仅是我们传递给函数作为上限的数字。

第一种版本大约需要N步骤。也就是说,如果上限是100,该函数大约需要100步骤(实际上需要99步骤,因为它从2开始计数)。因此,我们可以确定第一个算法的时间复杂度为O(N)。

第二种版本需要N / 2步骤。当上限是100时,该函数仅需要50步骤。虽然将其称为O(N / 2)会很诱人,但你现在已经学到我们要忽略常数,并将表达式简化为O(N)。

现在,第二个版本比第一个版本快两倍,自然是更好的选择。这是另一个很好的例子,说明了两种算法可以使用大O表示法来表示,但需要进一步分析才能弄清楚哪种算法更快。

重要的步骤

让我们对前面的例子再进行一层分析。再看一次第一个版本 print_numbers_version_one,我们说它需要 N 步。这是因为循环运行了 N 次,其中 N 是上限。

但实际上,这个函数真的只需要 N 步吗?如果我们真正细致地分析,就会发现在每轮循环中会有多个步骤。

首先,我们有比较步骤(if number % 2 == 0),用于检查数字是否能被 2 整除。这个比较发生在每轮循环中。

其次,我们有打印步骤(print(number)),仅在偶数时发生。因此,这个步骤在每两轮循环中发生一次。

第三,我们有 number += 1,这个步骤在每轮循环中都运行。

在之前的章节中,我提到你将学会如何确定在表达算法的大 O 复杂度时,哪些步骤足够重要要进行计数。在我们的情况下,哪些步骤被认为是重要的?我们关心比较、打印还是数字的增加?

答案是所有的步骤都重要。只是当我们用大 O 表达步骤时,我们去除常数,并因此简化了表达式。

让我们在这里应用这个原则。如果我们计算所有步骤,有 N 次比较、N 次数字增加,和 N / 2 次打印。这加起来是 2.5N 步。但是,因为我们去除了常数 2.5,我们将其表达为 O(N)。那么,哪个步骤是重要的?它们都是,但通过去除常数,我们实际上更关注循环运行的次数,而不是循环内部发生的确切细节。

总结

我们现在有一些强大的分析工具可供使用。我们可以使用大 O 来广泛确定算法的效率,也可以比较落在一个大 O 分类内的两个算法。

然而,在比较两个算法的效率时,还必须考虑另一个重要因素。到目前为止,我们关注的是算法在最坏情况下的速度。然而,最坏情况通常不会一直发生。平均而言,发生的大多数情况是…呃…平均情况。在下一章中,你将学会如何考虑所有的情况。

练习

  1. 使用大 O 表达式描述一个需要 4N + 16 步的算法的时间复杂度。

  2. 使用大 O 表达式描述一个需要 2N^2 步的算法的时间复杂度。(

  3. 使用大 O 表达式描述下面这个函数的时间复杂度,该函数在将数组中的所有数字加倍后返回其总和:

    def double_then_sum(array)
      doubled_array = []
      array.each do |number|
        doubled_array << number *= 2
      end
      sum = 0
      doubled_array.each do |number|
        sum += number
      end
      return sum
    end
    
  4. 使用大 O 表达式描述下面这个函数的时间复杂度,该函数接受一个字符串数组并打印每个字符串的多种情况:

    def multiple_cases(array)
      array.each do |string|
        puts string.upcase
        puts string.downcase
        puts string.capitalize
      end
    end
    
  5. 下面这个函数迭代一个数字数组,对于索引为偶数的每个数字,它打印该数字加上数组中的每个数字的和。请问这个函数的时间复杂度是多少?

    def every_other(array)
      array.each_with_index do |number, index|
        if index.even? 
          array.each do |other_number|
            puts number + other_number 
          end
        end
      end
    end
    

答案

  1. 删除常数后,我们可以将表达式简化为 O(N)。
  2. 删除常数后,我们可以将表达式简化为 O(N^2)。
  3. 这个算法的时间复杂度是 O(N),其中 N 是数组的大小。虽然有两个不同的循环处理 N 个元素,但这只是 2N,删除常数后简化为 O(N)。
  4. 这个算法的时间复杂度是 O(N),其中 N 是数组的大小。在循环中,我们运行了三个步骤,这意味着我们的算法需要 3N 步。然而,删除常数后,这将简化为 O(N)。
  5. 这个算法的时间复杂度是 O(N^2),其中 N 是数组的大小。虽然我们只有一半时间运行内循环,但这只是意味着算法运行了 N^2 / 2 步。然而,除以 2 是一个常数,所以我们将其简单地表示为 O(N^2)。
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值