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

本文探讨了算法在编程中的关键作用,尤其是选择合适的数据结构(如有序数组)和算法(如线性搜索与二分查找)对代码效率的影响。文章通过实例展示了有序数组在插入和搜索上的性能差异,强调算法选择需考虑具体应用场景的需求。
摘要由CSDN通过智能技术生成

第2章 为什么算法重要

在上一章中,我们看了看我们的第一个数据结构,并了解了选择合适的数据结构如何影响我们代码的性能。即使是两个看似相似的数据结构,比如数组和集合,它们的效率也可能有很大不同。

在这一章中,我们将发现,即使我们决定了使用特定的数据结构,另一个重要因素也会影响我们代码的效率:选择合适的算法。

虽然“算法”这个词听起来有些复杂,但实际上并不是。算法只是完成特定任务的一组说明。甚至像准备一碗麦片这样简单的过程从技术上讲也是一个算法,因为它涉及遵循一组定义好的步骤来完成手头的任务。至少对我来说,准备麦片的算法遵循以下四个步骤:

  1. 拿一个碗。
  2. 把麦片倒进碗里。
  3. 把牛奶倒进碗里。
  4. 用勺子舀一些出来。

通过按照这些特定的步骤,我们现在可以享受我们的早餐了。

在计算机领域,算法指的是为了完成特定任务而给计算机的一组指令。当我们编写任何代码时,实际上就是在为计算机创建要遵循和执行的算法。

我们也可以用普通英语来表达算法,以阐明我们计划为计算机提供的指令的细节。在本书中,我将同时使用普通英语和代码来展示各种算法的工作原理。

有时,可能会有两种不同的算法来完成同样的任务。我们在《为什么数据结构很重要》的开头就看到了这样的一个例子,我们有两种不同的方法来打印偶数。在那种情况下,一个算法的步骤是另一个算法的两倍。

在这一章中,我们将遇到另外两个解决同一问题的算法。但在这种情况下,一个算法将比另一个快上好几个数量级。

为了探索这些新算法,我们需要看一看一个新的数据结构。

有序数组

有序数组几乎与我们在前一章节中看到的“经典”数组相同。唯一的区别在于有序数组要求值始终保持——你猜对了——有序。也就是说,每次添加一个值,都会被放置在正确的单元格中,以便数组中的值保持排序状态。

例如,让我们看看数组 [3, 17, 80, 202]:

在这里插入图片描述

假设我们想要将值 75 插入数组。如果这个数组是经典数组,我们可以将 75 插入到末尾,如下所示:
在这里插入图片描述

正如我们在上一章中看到的,计算机可以在一步中完成这个操作。 另一方面,如果这是一个有序数组,我们别无选择,只能将 75 插入到正确的位置,以保持值的升序排列:
在这里插入图片描述

现在,说起来容易做起来难。计算机不能简单地在单个步骤中将 75 放入正确的插槽,因为它首先必须找到插入 75 的正确位置,然后移动其他值以为其腾出空间。让我们逐步分解这个过程。

让我们重新开始我们的原始有序数组:

在这里插入图片描述

步骤 1:我们检查索引 0 处的值,以确定我们想要插入的值——75——应该放在它的左侧还是右侧:
在这里插入图片描述

因为 75 大于 3,我们知道 75 将被插入到其右侧的某个位置。但是,我们还不知道确切的插入位置,所以我们需要检查下一个单元格。 我们将这种步骤称为比较,即我们将要插入的值与有序数组中已经存在的数字进行比较。

步骤 2:我们检查下一个单元格中的值:
在这里插入图片描述

75 大于 17,所以我们需要继续往后查找。

步骤 3:我们检查下一个单元格中的值:

在这里插入图片描述

我们遇到了值为 80,它大于我们希望插入的 75。由于我们已经到达了第一个大于 75 的值,我们可以得出结论:75 必须立即插入到这个 80 的左侧,以保持这个有序数组的顺序。为了做到这一点,我们需要移动数据以为 75 腾出空间。

步骤 4: 将最后一个值向右移动

在这里插入图片描述

步骤 5: 将倒数第二个值向右移动:

在这里插入图片描述

步骤 6: 我们终于可以将 75 插入到它的正确位置:

在这里插入图片描述

**在向有序数组插入值时,需要在实际插入之前始终进行查找,以确定插入的正确位置。**这是传统数组和有序数组之间性能差异的一个方面。

从这个例子中可以看出,最初有四个元素,插入操作需要六个步骤。用 N 来表示,对于有序数组中的 N 个元素,插入总共需要 N + 2 步。

有趣的是,不论新值最终在有序数组中的位置如何,插入所需的步骤数都相似。如果新值最终位于有序数组的开始,我们会进行较少的比较和更多的移动。如果新值位于接近结尾的位置,我们会进行更多的比较但较少的移动。当新值最终位于最后时,步骤最少,因为不需要进行移动。在这种情况下,我们需要对所有 N 个现有值进行 N 次比较,加上一步插入操作本身,总共需要 N + 1 步。

虽然对于有序数组而言,插入操作比传统数组低效一些,但在搜索方面,有序数组却有着一个隐藏的超能力。

搜索有序数组

在上一章中,我描述了在经典数组中查找特定值的过程:我们逐个检查每个单元格,从左到右,直到找到所需的值。我提到这个过程被称为线性搜索。

我们来看看线性搜索在经典数组和有序数组之间的区别。假设我们有一个普通数组,如[17, 3, 75, 202, 80]。如果我们要搜索值为22的元素——这个值恰好在数组中不存在——我们需要检查每一个元素,因为22可能在数组的任何位置。唯一能够提前停止搜索的情况是在达到数组末尾之前就找到了我们正在寻找的值。

然而,在有序数组中,即使值不在数组中,我们也可以提前停止搜索。假设我们在有序数组[3, 17, 75, 80, 202]中搜索22。一旦到达75,我们就可以停止搜索,因为22不可能出现在它的右侧。

以下是有序数组上线性搜索的 Ruby 实现:

def linear_search(array, search_value)
  array.each_with_index do |element, index|
    if element == search_value
      return index
    elsif element > search_value
      break
    end
  end
  return nil
end

这个方法接受两个参数:array 是我们要搜索的有序数组,search_value 是我们要查找的值。例如:

p linear_search([3, 17, 75, 80, 202], 22)

linear_search 方法遍历数组的每个元素,查找 search_value。当它遍历的当前元素大于 search_value 时,搜索就会停止,因为我们知道 search_value 不会出现在数组的后面。

因此,在某些情况下,相比于经典数组,线性搜索在有序数组中可能需要的步骤较少。不过,如果我们要搜索的值恰好是最后一个值,或者根本不在数组中,我们仍然需要搜索每一个单元格。

乍看之下,标准数组和有序数组在效率上并没有明显的区别,至少在最坏情况下是这样的。对于两种数组来说,如果它们包含 N 个元素,线性搜索最多可能需要 N 步。

但我们即将介绍一个极其强大的算法,它将使线性搜索相形见绌。

到目前为止,我们一直假设在有序数组中搜索值的唯一方法是线性搜索。然而,事实并非如此,线性搜索只是搜索值的一种可能算法,但不是唯一可用的算法。

有序数组相对于经典数组的一个重要优势在于它可以使用另一种搜索算法。这个算法称为二分查找,它比线性搜索快得多得多!。

二分查找

这可能是你小时候玩过的猜数字游戏:我心里想着一个介于1到100之间的数字。继续猜我心里想的是哪个数字,我会告诉你需要猜的数字是高还是低。

你可能直觉地知道如何玩这个游戏。你不会从猜1开始。相反,你可能会从50开始,这个数字正好处于中间位置。为什么?因为选择50,无论我告诉你要猜高还是低,你都自动排除了一半的可能数字!

如果你猜50,我告诉你要猜高,那么你会选择75,来排除剩余数字的一半。如果猜75后,我告诉你要猜低,你会选择62或63。你会继续选择中间数字,以便不断排除剩余数字的一半。

让我们以1到10之间猜数字的过程可视化,如下图所示。

简而言之,这就是二分查找。

在这里插入图片描述

让我们看看二分查找如何应用到一个有序数组中。假设我们有一个包含九个元素的有序数组。计算机并不知道每个单元格包含的确切值,所以我们将数组描述为这样:

在这里插入图片描述

假设我们想在这个有序数组中搜索值为7的元素。这是二分查找的工作原理:

步骤1:我们从中间的单元格开始搜索。我们可以立即跳转到这个单元格,因为我们可以通过取数组的长度并除以2来计算其索引。我们检查这个单元格的值:

在这里插入图片描述

因为发现的值是9,我们可以得出结论,7在它的左边某处。我们刚刚成功地消除了数组一半的单元格,也就是9右边的所有单元格(包括9本身):

在这里插入图片描述

步骤2:在9左边的单元格中,我们检查中间位置的值。有两个中间位置的值,所以我们随意选择左边的一个:

在这里插入图片描述

是一个4,所以7必须在它的右边某处。我们可以消除4及其左边的单元格:

在这里插入图片描述

步骤3:还有两个单元格可能包含7。我们随意选择左边的一个,如下图所示。

在这里插入图片描述

步骤4: 我们检查最后剩下的单元格。(如果不在那里,这意味着在这个有序数组中没有7。)

在这里插入图片描述

我们在四个步骤中找到了7。虽然这与在这个例子中使用线性搜索所需的步骤数相同,但我们很快会看到二分查找的威力。

请注意,二分查找仅适用于有序数组。在经典数组中,值可以以任何顺序出现,我们永远无法确定是向左还是向右查找给定值。这就是有序数组的优势之一:我们可以使用二分查找。
代码实现:二分查找

以下是 Ruby 中二分查找的实现:

def binary_search(array, search_value)
  # 首先,我们确定要搜索的值可能在的下限和上限位置。首先,下限是数组中的第一个值,而上限是最后一个值:
  lower_bound = 0
  upper_bound = array.length - 1
  
  # 我们开始一个循环,在循环中不断检查上限和下限之间的最中间值:
  while lower_bound <= upper_bound do
    # 我们找到上限和下限之间的中点:
    # (我们不必担心结果不是整数,因为在 Ruby 中,整数的除法结果总是四舍五入到最近的整数。)
    midpoint = (upper_bound + lower_bound) / 2
    
    # 我们检查中点的值:
    value_at_midpoint = array[midpoint]
    # ...
  end
end
# If the value at the midpoint is the one we're looking for, we're done.
# If not, we change the lower or upper bound based on whether we need
# to guess higher or lower:
if search_value == value_at_midpoint
return midpoint
elsif search_value < value_at_midpoint
upper_bound = midpoint - 1
elsif search_value > value_at_midpoint
lower_bound = midpoint + 1
end
end
# If we've narrowed the bounds until they've reached each other, that
# means that the value we're searching for is not contained within
# this array:
return nil
end

让我们逐步分解这段代码。与 linear_search 方法一样,binary_search 方法接受数组和搜索值作为参数。

这是调用该方法的示例:

p binary_search([3, 17, 75, 80, 202], 22)

该方法首先确定了搜索值可能被找到的索引范围。我们使用以下方式来做到这一点:

lower_bound = 0
upper_bound = array.length - 1

因为当开始搜索时,搜索值可能位于整个数组中的任何位置,所以我们将 lower_bound 设为第一个索引,upper_bound 设为最后一个索引。

搜索的核心发生在 while 循环中:

while lower_bound <= upper_bound do

该循环在我们仍有可能存在搜索值的元素范围时运行。稍后我们将看到,随着搜索的进行,算法将不断缩小这个范围。当 lower_bound <= upper_bound 条件不再满足时,就意味着没有剩余的范围了,我们可以得出结论,搜索值不在数组中。

在循环内部,我们的代码检查范围的中点的值。这通过以下方式完成:

midpoint = (upper_bound + lower_bound) / 2
value_at_midpoint = array[midpoint]

value_at_midpoint 是在范围中心找到的项。

现在,如果 value_at_midpoint 就是我们正在寻找的 search_value,我们找到了目标,并且可以返回搜索值所在的索引:

if search_value == value_at_midpoint
  return midpoint

如果 search_value 小于 value_at_midpoint,这意味着 search_value 必须在数组中的某个较早位置上。因此,我们可以缩小搜索范围,将 upper_bound 设为中点左边的索引,因为 search_value 不可能出现在更左侧的位置:

elsif search_value < value_at_midpoint
  upper_bound = midpoint - 1

相反,如果 search_value 大于 value_at_midpoint,这意味着 search_value 只能出现在中点右侧的某个位置,所以我们将 lower_bound 调整为相应的值:

elsif search_value > value_at_midpoint
  lower_bound = midpoint + 1

一旦范围缩小到 0 个元素,我们就会返回 nil,这意味着我们可以肯定地得知搜索值在数组中不存在。

二分查找VS线性搜索

有序数组的大小较小时,二分搜索算法并没有比线性搜索算法有太大优势。但让我们看看在较大数组中会发生什么情况。

对于包含 100 个值的数组,以下是每种搜索方式可能的最大步数:

  • 线性搜索:100 步
  • 二分搜索:7 步

对于线性搜索,如果我们要搜索的值在最后一个单元格中,或者大于最后一个单元格中的值,我们必须检查每个元素。对于大小为 100 的数组,这将需要 100 步。

然而,使用二分搜索时,每次猜测都会消除一半的可能搜索单元。在我们的第一个猜测中,我们就能消除 50 个单元。

让我们换个角度看,我们将会看到一个模式出现。

对于大小为 3 的数组,二分搜索最多需要两步。如果我们将数组大小翻倍(为了简单起见,添加一个额外的值以保持数字奇数),数组中将有 7 个单元。对于这样的数组,使用二分搜索的最大步数是三步。

如果再次将数组大小翻倍(并添加一个额外值),使有序数组包含 15 个元素,二分搜索的最大步数是四步。

出现的模式是每次将有序数组的大小翻倍时,使用二分搜索所需步数增加一次。这是有道理的,因为每次查找都会排除搜索范围内一半的元素。

这个模式异常高效:每次将数据大小翻倍时,二分搜索算法只增加一步。

与线性搜索相比,这显得非常高效。如果有 3 个元素,最多需要三步。对于 7 个元素,最多需要七步。对于 100 个值,最多需要 100 步。因此,对于线性搜索,每次将数组大小翻倍,搜索步骤就会加倍。但对于二分搜索,每次将数组大小翻倍时,只需要增加一步。

让我们看看这对于较大的数组是如何发挥作用的。对于包含 10,000 个元素的数组,线性搜索最多需要 10,000 步,而二分搜索最多只需要 13 步。对于大小为一百万的数组,线性搜索最多需要一百万步,而二分搜索最多只需要 20 步。

我们可以用这张图来展示线性搜索和二分搜索之间的性能差异:

在这里插入图片描述

我们将分析一堆类似于这样的图表,让我们花点时间理解其中的内容。X 轴表示数组中的元素数量。也就是说,从左到右移动时,我们处理的数据量在增加。

Y 轴表示算法所需的步骤数。随着图表向上移动,我们看到的步数也越多。

如果你看线性搜索的线条,你会发现随着数组元素增加,线性搜索的步数成比例增加。实际上,对于数组中的每个额外元素,线性搜索都需要增加一步。这产生了一条直线。

而对于二分搜索,随着数据量的增加,算法的步数只是略微增加。这符合我们所知道的情况:要使二分搜索增加一步,你必须将数据量翻倍。

请记住,有序数组并不在所有方面都更快。正如你所见,有序数组中的插入比标准数组要慢。但这里是一个权衡:使用有序数组,插入速度略慢,但搜索速度要快得多。同样,你必须始终分析你的应用程序,看哪种更适合。你的软件会做很多插入操作吗?搜索在你正在构建的应用中是否是一个重要特性?

做个小测验

问题:我们说对于包含 100 个元素的有序数组,二分查找需要 7 步。对于包含 200 个元素的有序数组,二分查找需要多少步?

答案:8 步。
直觉上,我经常听到的回答是 14 步,但这是错误的。二分查找的美妙之处在于每次检查都会排除剩余元素的一半。因此,每次我们将数据量加倍,只增加一步。毕竟,这个数据量的加倍在第一次检查时就被完全消除了!

值得注意的是,现在我们已经将二分查找加入到了工具包中,有序数组内的插入速度也可以变得更快。在实际插入之前,需要进行搜索,但我们现在可以将该搜索升级为二分查找。然而,在有序数组中的插入仍然比在普通数组中的插入慢,因为普通数组的插入根本不需要搜索。

总结

常常有多种方法可以实现特定的计算目标,而你选择的算法可以严重影响代码的速度。

同样重要的是要认识到,通常并不存在一种适用于每种情况的单一数据结构或算法。例如,仅仅因为有序数组允许进行二分查找,并不意味着你应该总是使用有序数组。在你不预期需要频繁搜索数据、只需添加数据的情况下,标准数组可能是更好的选择,因为它们的插入速度更快。

正如我们所见,分析竞争算法的方法是计算每个算法所需的步数。在下一章中,我们将研究一种形式化的方法来表达竞争数据结构和算法的时间复杂度。拥有这种共同的语言将为我们提供更清晰的信息,使我们能够更好地决定选择哪种算法。

练习

以下练习为您提供了练习二分查找的机会。这些练习的答案在第2章的第440页找到。

  1. 在有序数组[2, 4, 6, 8, 10, 12, 13]中执行线性搜索数字8需要多少步?
  2. 前一个示例中二分查找需要多少步?
  3. 在大小为100,000的数组上执行二分查找的最大步数是多少?

答案

这些是在“练习”部分第34页中找到的练习的解答:

  1. 在这个数组上进行线性搜索需要四步。我们从数组的开头开始,从左到右检查每个元素。因为数字8是第四个数字,我们将在四步内找到它。
  2. 在这种情况下,二分查找只需要一步。我们从最中间的元素开始二分查找,而8恰好是最中间的元素!
  3. 要解决这个问题,我们需要计算将100,000除以2多少次才能得到1。如果我们不断地将100,000除以2,我们会发现在大约16次后得到1.53。
    这意味着最坏情况大约需要16次。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值