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

本文深入探讨了快速排序和快速选择算法。快速排序是常用排序算法,依赖分区和递归,平均情况效率高,最坏情况为O(N^2)。快速选择可在不排序数组的情况下找到特定位置的值,平均时间复杂度为O(N)。此外,还介绍了排序在其他算法中的应用。

第13章使用递归算法提速

我们已经看到,了解递归可以解锁各种新算法,比如遍历文件系统或生成变位词。在本章中,你将学到递归也是让我们的代码运行得快得多的算法的关键。

在之前的章节中,我们遇到了许多排序算法,包括冒泡排序、选择排序和插入排序。然而,在实际生活中,这些方法都不会被用于对数组进行排序。大多数计算机语言都内置了用于数组排序的函数,这样就省去了我们自己实现的时间和精力。而在许多这些语言中,底层采用的排序算法是快速排序。

我们要深入探讨快速排序(尽管它已经内置在许多计算机语言中),是因为通过学习其工作原理,你可以学会如何利用递归大大加速算法,而且你也可以对其他实际世界中的实用算法做到同样的效果。

快速排序是一种非常快速的排序算法,特别适用于平均情况。在最坏情况下(即逆序数组),它的性能类似于插入排序和选择排序,但在平均情况下要快得多——而这种情况大多数时间都会发生。

快速排序依赖于一个称为“分区”的概念,所以我们首先来了解这个概念。

分区

对数组进行分区意味着从数组中随机选择一个值——这个值被称为枢轴(pivot)——并确保小于枢轴的每个数字都在枢轴的左边,而大于枢轴的每个数字都在枢轴的右边。我们通过一个简单的算法来完成分区,下面将描述这个算法的示例。

假设我们有以下数组:

在这里插入图片描述

出于一致性考虑,我们将始终选择最右边的值作为我们的枢轴(尽管我们技术上可以选择其他值)。**在这种情况下,数字3是我们的枢轴。我们用圈圈圈出它:

在这里插入图片描述

然后我们分配“指针”——一个指向数组的最左值,另一个指向数组的最右值,不包括枢轴本身:

在这里插入图片描述

我们现在准备开始实际的分区,遵循以下步骤。不用担心——当我们马上通过我们的示例演示这些步骤时,它们会变得更清晰。

  1. 左指针连续向右移动一个单元,直到它到达大于或等于枢轴的值,然后停止。
  2. 然后,右指针连续向左移动一个单元,直到它到达小于或等于枢轴的值,然后停止。如果右指针到达数组的开头,它也会停止。
  3. 一旦右指针停止,我们到了一个十字路口。如果左指针已经到达(或超过)右指针,我们转到步骤4。否则,我们交换左右指针指向的值,然后回到步骤1、2和3重复。
  4. 最后,我们将枢轴与左指针当前指向的值交换。

完成分区后,我们现在确保枢轴左侧的所有值都小于枢轴,而枢轴右侧的所有值都大于它。这意味着枢轴本身现在已经在数组中的正确位置,尽管其他值可能尚未完全排序。

让我们将这个步骤应用到我们的示例中:

步骤1:比较左指针(现在指向0)和我们的枢轴(值为3):

在这里插入图片描述

由于0小于枢轴,左指针在下一步中继续移动。

步骤2:左指针继续移动:

在这里插入图片描述

我们比较左指针(5)和我们的枢轴。5小于枢轴吗?不是的,所以左指针停止,我们在下一步中激活右指针。

步骤3:比较右指针(6)和我们的枢轴。值大于枢轴吗?是的,所以我们的指针在下一步中继续移动。

步骤4:右指针继续移动:

在这里插入图片描述

我们将右指针(1)与我们的枢轴进行比较。值大于枢轴吗?不是的,所以我们的右指针停止。

步骤5:由于两个指针都停止了,我们交换两个指针的值:

在这里插入图片描述

然后我们在下一步中再次激活我们的左指针。

步骤6:左指针继续移动:
在这里插入图片描述

我们将左指针(2)与我们的枢轴进行比较。值小于枢轴吗?是的,所以左指针继续移动。

步骤7:左指针移动到下一个单元。请注意,在此时,左右指针都指向相同的值:

在这里插入图片描述

我们将左指针与枢轴进行比较。因为我们的左指针指向的值大于枢轴,所以它停止了。此时,因为左指针已经达到了右指针,我们完成了指针移动。

步骤8:对于分区的最后一步,我们将左指针指向的值与枢轴交换:

在这里插入图片描述

虽然我们的数组还没有完全排序,但我们已成功完成了一个分区。也就是说,由于我们的枢轴是数字3,小于3的所有数字都在其左边,而大于3的所有数字都在其右边。这也意味着,根据定义,数字3现在已经在数组中的正确位置。

代码实现:分区

这段代码实现了一个SortableArray类,其中包含一个partition!方法,该方法可以按照我们描述的方式对数组进行分区:

class SortableArray
  attr_reader :array

  def initialize(array)
    @array = array
  end

  def partition!(left_pointer, right_pointer)
    # ... (代码块)

    # 返回left_pointer以便后续Quicksort方法使用
    return left_pointer
  end
end

partition!方法接受左右指针的起始点作为参数。最初调用此方法时,这些指针将分别指向数组的左右两端。然而,Quicksort将在数组的子部分上调用此方法。因此,不能总是假设左右指针始终是数组的两个端点,所以它们需要成为方法参数。

接下来,选择我们的枢轴,它始终是我们正在处理的范围中最右边的元素:

pivot_index = right_pointer
pivot = @array[pivot_index]

一旦确定了枢轴,将right_pointer移动到枢轴的左边一个位置:

right_pointer -= 1

然后,开始一个循环,该循环将一直运行直到left_pointerright_pointer相遇。在此循环中,我们使用另一个循环,将left_pointer一直向右移动,直到它达到大于或等于枢轴的元素:

while @array[left_pointer] < pivot do
  left_pointer += 1
end

类似地,将right_pointer向左移动,直到它达到小于或等于枢轴的元素:

while @array[right_pointer] > pivot do
  right_pointer -= 1
end

一旦left_pointerright_pointer停止移动,我们检查这两个指针是否相遇:

if left_pointer >= right_pointer
  break
end

如果它们相遇了,我们退出循环,并准备交换枢轴。然而,如果这两个指针尚未相遇,我们交换这两个指针处的值:

@array[left_pointer], @array[right_pointer] = @array[right_pointer], @array[left_pointer]

最后,一旦这两个指针相遇,我们交换枢轴与left_pointer处的值:

@array[left_pointer], @array[pivot_index] = @array[pivot_index], @array[left_pointer]

该方法返回left_pointer,因为这将被Quicksort算法使用。在这段代码中,SortableArray类提供了一个名为partition!的方法,用于对数组进行分区。这个方法通过接收左右指针的起始位置参数,将数组中的元素按照特定的规则进行分区。

首先,确定枢轴(pivot),它总是选择当前处理范围的最右边的元素:

pivot_index = right_pointer
pivot = @array[pivot_index]

接着,将right_pointer向左移动一位,位于枢轴的左边:

right_pointer -= 1

然后,partition!方法进入一个循环,该循环在left_pointerright_pointer相遇之前一直执行。循环中的两个内部循环分别移动left_pointerright_pointer,直到它们满足特定的条件:

while @array[left_pointer] < pivot do
  left_pointer += 1
end

while @array[right_pointer] > pivot do
  right_pointer -= 1
end

left_pointerright_pointer相遇时,检查它们的位置关系,若left_pointer大于等于right_pointer,则退出循环;否则,交换两个指针所指位置的元素值:

if left_pointer >= right_pointer
  break
else
  @array[left_pointer], @array[right_pointer] = @array[right_pointer], @array[left_pointer]
  left_pointer += 1
end

最后,在left_pointerright_pointer相遇后,将枢轴元素的值与left_pointer所指位置的元素值进行交换:

@array[left_pointer], @array[pivot_index] = @array[pivot_index], @array[left_pointer]

partition!方法返回left_pointer的值,这将在后续的Quicksort算法中被使用。

快速排序

Quicksort算法是分区和递归的结合。其工作步骤如下:

  1. 分区数组。此时,枢纽元素已经在其正确的位置上。
  2. 将枢纽元素左右两侧的子数组视为它们自己的数组,并递归地重复步骤1和2。这意味着我们会对每个子数组进行分区,并得到左右两侧子数组的更小子子数组。然后我们对这些子子数组进行分区,依此类推。
  3. 当我们有一个只有零个或一个元素的子数组时,这就是我们的基本情况,我们不做任何操作。

让我们回到我们的例子。我们从数组[0, 5, 2, 1, 6, 3]开始,并对整个数组进行了一次分区。由于Quicksort以这样的分区开始,这意味着我们已经部分完成了Quicksort过程。我们的最后结果是:

在这里插入图片描述

可以看到,值为3的原始枢纽元素。现在枢纽元素位于正确的位置上,我们需要对枢纽元素左右两侧的元素进行排序。请注意,在我们的例子中,恰好枢纽元素左侧的数字已经是排序好的,但计算机尚不知道这一点。

分区之后的下一步是将枢纽元素左侧的所有元素视为它们自己的数组,并对其进行分区。

暂时隐藏其余部分的数组,因为我们目前不关注它们:

在这里插入图片描述

现在,我们有了这个[0, 1, 2]子数组,我们将使最右侧的元素成为枢纽元素。因此,最右侧的元素是数字2:

在这里插入图片描述

我们设定左右指针:

在这里插入图片描述

现在我们准备对这个子数组进行分区。让我们从我们之前停止的步骤8后继续。

步骤9:比较左指针(0)和枢纽元素(2)。由于0小于枢纽元素,我们继续移动左指针。

步骤10:我们将左指针向右移动一个单元格,现在它刚好指向右指针所指的相同值:

在这里插入图片描述

我们将左指针与枢纽元素进行比较。由于值1小于枢纽元素,我们继续。

步骤11:我们将左指针向右移动一个单元格,正好指向枢纽元素:

在这里插入图片描述

此时,左指针指向与枢纽元素相等的值(因为它就是枢纽元素!),因此左指针停止。

请注意,左指针设法穿过了右指针。但没关系。算法设计可以处理这种情况。

步骤12:现在,我们激活右指针。但是,因为右指针的值(1)小于枢纽元素,所以它保持不动。

由于左指针已经超过了右指针,所以我们在这个分区中完全停止移动指针。

步骤13:接下来,我们将左指针的值与枢纽元素进行交换。现在,左指针恰好指向枢纽元素本身,因此我们将枢纽元素与自身交换,这实际上没有改变任何东西。此时,分区已完成,枢纽元素(2)现在位于其正确的位置上:

在这里插入图片描述

现在,在枢纽元素(2)左侧我们有一个[0, 1]的子数组,而在其右侧没有子数组。下一步是对枢纽元素左侧的子数组进行递归分区,即[0, 1]。由于枢纽元素右侧没有子数组,我们不需要处理右侧的子数组。

因为接下来我们只关注子数组[0, 1],我们会把数组的其余部分屏蔽掉,看起来像这样:

在这里插入图片描述

把子数组[0, 1]作为分区的目标,我们将最右侧的元素(1)作为枢纽元素。那么我们应该把左右指针放在哪里呢?左指针将指向0,右指针也将指向0,因为我们始终从枢纽元素的左侧一个单元格开始。这给了我们这样的情况:

在这里插入图片描述

现在,我们准备开始分区。

步骤14:比较左指针(0)和枢纽元素(1):

在这里插入图片描述

它小于枢纽元素,所以我们继续。

步骤15:我们将左指针向右移动一个单元格。现在它指向枢纽元素:

在这里插入图片描述

由于左指针的值(1)不小于枢纽元素(因为它就是枢纽元素),左指针停止移动。

步骤16:我们将右指针与枢纽元素进行比较。由于它指向小于枢纽元素的值,我们不再移动右指针。由于左指针已经超过了右指针,所以我们在这个分区中不再移动指针。

步骤17:接下来,我们将左指针与枢纽元素进行交换。同样,在这种情况下,左指针实际上指向枢纽元素本身,因此交换实际上并未改变任何内容。枢纽元素现在位于其正确的位置上,我们已经完成了这个分区。

现在我们得到了这个:

在这里插入图片描述

接下来,我们需要对最近枢纽元素左侧的子数组进行分区。在这种情况下,该子数组是[0]——仅有一个元素的数组。零个或一个元素的数组是我们的基本情况,所以我们什么也不做。该元素会自动被认为已经处于正确的位置。所以,现在我们有了:

在这里插入图片描述

我们最开始将3作为我们的枢纽元素,并递归地对其左侧的子数组进行了分区([0, 1, 2])。正如承诺的那样,我们现在需要回来递归地对3右侧的子数组进行分区,即[6, 5]。

我们将隐藏[0, 1, 2, 3],因为我们已经对它们进行了排序,现在我们只关注[6, 5]:

在这里插入图片描述

接下来,我们需要以最右侧的元素(5)作为枢纽元素。这会得到:

在这里插入图片描述

设置下一个分区时,我们的左右指针都最终指向了6:

在这里插入图片描述

步骤18:比较左指针(6)和枢纽元素(5)。由于6大于枢纽元素,左指针不再移动。

步骤19:右指针也指向6,理论上我们应该再向左移动一格。但是,6的左侧没有更多的单元格,所以右指针停止移动。由于左指针已经达到了右指针,这个分区中的指针移动就完成了。这意味着我们准备进行最后一步。

步骤20:我们将枢纽元素与左指针的值进行交换:

在这里插入图片描述

我们的枢纽元素(5)现在在其正确的位置上,剩下的是这样:

在这里插入图片描述

接下来,我们技术上需要递归地对最新枢纽元素右侧和左侧的子数组进行分区。在这种情况下,左侧的子数组不存在,这意味着我们只需要对枢纽元素右侧的子数组进行分区。由于5右侧的子数组是一个单独的元素[6],这就是我们的基本情况,我们什么也不做——6会自动被认为已经处于正确的位置上:

在这里插入图片描述

至此,我们完成了整个过程!

代码实现:快速排序

以下是我们可以添加到先前SortableArray类中的quicksort!方法,它可以成功完成快速排序:

def quicksort!(left_index, right_index)
  # 基本情况:子数组包含0或1个元素:
  if right_index - left_index <= 0
    return
  end
  # 对元素范围进行分区,并获取枢纽元素的索引:
  pivot_index = partition!(left_index, right_index)
  # 递归调用quicksort!方法处理枢纽元素左侧的元素:
  quicksort!(left_index, pivot_index - 1)
  # 递归调用quicksort!方法处理枢纽元素右侧的元素:
  quicksort!(pivot_index + 1, right_index)
end

这段代码非常简洁,但我们来看一下每一行。现在,我们先跳过基本情况。

我们首先对left_index和right_index之间的元素范围进行分区:

pivot_index = partition!(left_index, right_index)

当我们第一次运行quicksort!时,我们会对整个数组进行分区。但在后续的调用中,这行代码会对left_index和right_index之间的元素范围进行分区,这可能是原始数组的一部分。

请注意,我们将partition!的返回值赋给了一个名为pivot_index的变量。如果你还记得的话,这个值就是left_pointer,在partition!方法完成时指向了枢纽元素。

然后,我们在枢纽元素左侧和右侧的子数组上递归调用quicksort!:

quicksort!(left_index, pivot_index - 1)
quicksort!(pivot_index + 1, right_index)

当我们达到基本情况时,递归结束,即处理的子数组不超过一个元素:

if right_index - left_index <= 0
  return
end

我们可以使用以下代码来测试我们的快速排序实现:

array = [0, 5, 2, 1, 6, 3]
sortable_array = SortableArray.new(array)
sortable_array.quicksort!(0, array.length - 1)
p sortable_array.array

快速排序的效率

要确定快速排序的效率,首先让我们确定单个分区的效率。

当我们分解分区的步骤时,我们会注意到分区涉及两种主要类型的步骤:
• 比较:我们将手头的每个值与枢纽元素进行比较。
• 交换:在适当的情况下,我们交换左右指针指向的值。

每个分区至少有N次比较 - 也就是说,我们将数组的每个元素与枢纽元素进行比较。这是因为分区总是使左右指针遍历每个单元,直到左右指针相遇为止。
然而,交换的次数将取决于数据的排序方式。单个分区最多可以有N / 2次交换,即使我们在每个可能的地方进行交换,每次交换也会处理两个值。正如您在下图中所看到的,我们在三次交换中对六个元素进行了分区:
在这里插入图片描述

现在,在大多数情况下,我们并不是每一步都进行交换。对于随机排序的数据,我们通常交换大约一半的值。因此,平均而言,我们大约进行N / 4次交换。
因此,平均而言,我们进行了大约N次比较和N / 4次交换。因此,对于N个数据元素,大约有1.25N步骤。在大O表示法中,我们忽略常数,因此我们会说分区的运行时间是O(N)。
现在,这是单个分区的效率。但是快速排序涉及许多分区,因此我们需要进行进一步的分析以确定快速排序的效率。

鸟瞰图解快速排序

为了更容易地进行可视化,这里有一个图表,展示了鸟瞰八个元素数组上的典型快速排序过程。特别是,图表展示了每个分区操作的元素数量。我们省略了数组中的实际数字,因为确切的值并不重要。请注意,在图表中,活动子数组是未被灰掉的单元格组。

在这里插入图片描述

我们可以看到我们有八个分区,但每个分区都在不同大小的子数组上进行。我们对原始的八个元素数组进行了分区,同时也对大小为4、3和2的子数组进行了分区,并对大小为1的另外四个数组进行了分区。

由于快速排序本质上由这一系列分区组成,并且每个分区对于每个子数组的N个元素大约需要N步,如果我们将所有子数组的大小相加,就可以得到快速排序所需的总步数:

8个元素
3个元素
1个元素
1个元素
4个元素
2个元素
1个元素

  • 1个元素
    总计 = 约21步

我们可以看到,原始数组有八个元素时,快速排序大约需要21步。这假设了最佳或平均情况,即在每个分区之后,枢纽元素大致位于子数组中间。

对于16个元素的数组,快速排序大约需要64步,对于32个元素的数组,快速排序大约需要160步。看一下这个表格:

在这里插入图片描述

(虽然在我们之前的例子中,8个元素的数组快速排序步骤数为21,但我在这个表格中放了24。确切的数字可能因情况而异,24也是一个合理的近似值。我特意将它设置为24,以便更清楚地解释接下来的内容。)

快速排序的大O

如何用大 O 表示法对快速排序进行分类呢?

如果我们观察前面展示的模式,我们会注意到数组中 N 个元素的快速排序步数大约是 N 乘以 log N,如下表所示:

在这里插入图片描述

事实上,这确切地表达了快速排序的效率。它是一个 O(N log N) 的算法。我们发现了一个新的大 O 类别!

下图表显示了 O(N log N) 与其他大 O 类别的对比。

在这里插入图片描述

现在,快速排序的步数恰好与 N * log N 相符并非巧合。如果我们更广泛地思考快速排序,我们可以看到为什么会是这样:

每次我们对数组进行分区时,最终将其分解为两个子数组。假设枢纽元素最终位于数组的中间位置(这是平均情况下发生的情况),这两个子数组大致相等大小。

我们可以将数组分成多少半直到将其完全分解为每个子数组的大小为 1 呢?对于大小为 N 的数组,这将花费 log N 次。看一下下面的图表:

在这里插入图片描述

正如您所看到的,对于大小为 8 的数组,我们需要三次“对半分”,直到将数组减小为八个单独的元素。这就是 log N,并且符合我们对 log N 的定义,即对某物进行对半分直到达到 1 的次数。

因此,这就是为什么快速排序需要 N * log N 步的原因。我们有 log N 次对半分,对于每次对半分,我们对所有元素总和为 N 的子数组进行分区。(它们的总和为 N,因为所有子数组只是由 N 个元素的原始数组的片段组成。)

这在上一个图表中有所说明。例如,在图表的顶部,我们首先对包含八个元素的原始数组进行分区,创建了两个大小为 4 的子数组。然后我们再次对大小为 4 的两个子数组进行分区,这意味着我们再次对八个元素进行分区。

请记住,O(N * log N) 只是一个近似值。事实上,我们还首先在原始数组上执行了额外的 O(N) 分区。此外,一个数组并不能干净利落地分成两个均匀的半部分,因为枢纽元素并不是“对半分”的一部分。

以下是一个更现实的例子,我们忽略了每次分区后的枢纽元素的情况:
在这里插入图片描述

最坏情况下的快速排序

对于我们遇到的许多其他算法来说,最佳情况是数组已经排序好了。然而,对于快速排序来说,最佳情况是在分区后枢纽元素总是刚好位于子数组的中间位置。有趣的是,这通常发生在数组的值被很好地混合时。

快速排序的最坏情况是枢纽元素总是最终位于子数组的一侧,而不是中间。这可能发生在数组完全按升序或降序排列的情况下。这个过程的可视化如下所示:

在这里插入图片描述

在这个图表中,您可以看到枢纽元素总是最终位于每个子数组的左端。

在这种情况下,虽然每个分区仍然只涉及一个交换,但由于比较次数增加,我们会失去效率。在第一个例子中,当枢纽元素总是位于中间时,除了第一个分区外,每个分区都在相对较小的子数组上进行(最大的子数组大小为 4)。然而,在这个例子中,前五个分区发生在大小为 4 或更大的子数组上。而且每个这些分区的比较次数与子数组中的元素个数一样多。

所以,在这种最坏情况下,我们有 8 + 7 + 6 + 5 + 4 + 3 + 2 + 1 个元素的分区,总共有 36 次比较。

为了更加公式化地描述这一点,我们可以说对于 N 个元素,步骤数量是 N + (N - 1) + (N - 2) + (N - 3) … + 1。我们在对于《获取所有产品》的讨论中看到,这相当于 N^2 / 2 步骤,对于大 O 记号来说是 O(N^2)。

所以,在最坏情况下,快速排序的效率是 O(N^2)。

快速排序VS插入排序

现在我们已经了解了快速排序,让我们将其与一种更简单的排序算法之一——插入排序进行比较:

在这里插入图片描述

我们可以看到它们在最坏情况下是相同的,并且在最佳情况下,插入排序实际上比快速排序更快。然而,快速排序优于插入排序的原因是因为平均情况——这通常是发生的情况。对于平均情况,插入排序需要大量的 O(N^2) 时间,而快速排序则快得多,为 O(N log N)。

由于在平均情况下快速排序的优越性,许多编程语言在其内置的排序函数中使用快速排序。因此,您不太可能自己实现快速排序。
然而,在实际情况下,有一个非常相似的算法可能会派上用场——它被称为快速选择(Quickselect)。

快速选择

让我们假设您有一个乱序的数组,您不需要对其进行排序,但是您想知道数组中的第十小值,或者第五大值。这可能在我们有许多测试成绩并且想要知道第25百分位是多少时非常有用,或者如果我们想要找到中位数成绩。

解决这个问题的一种方式是对整个数组进行排序,然后跳转到相应的索引。

然而,即使我们使用快速排序这样的快速排序算法,对于平均情况,该算法也至少需要 O(N log N) 的时间。虽然这不算太差,但我们可以使用一个名为快速选择(Quickselect)的精巧算法来取得更好的效果。就像快速排序一样,快速选择依赖于分区,并且可以被认为是快速排序和二分查找的混合体。

正如您在本章早些时候看到的那样,在进行分区后,枢轴值最终会在数组中适当的位置上。快速选择以以下方式利用这一点:

假设我们有一个包含八个值的数组,并且我们想在数组中找到第二小的值。首先,我们对整个数组进行分区:
在这里插入图片描述

分区后,枢轴有望最终出现在数组的中间位置:
在这里插入图片描述

此时的枢轴现在在其正确的位置上,因为它在第五个位置上,我们现在知道了数组中第五小的值是什么。

现在,我们要找的是第二小的值,而不是第五小。但我们知道第二小的值一定在枢轴的左边。现在,我们可以忽略枢轴右侧的所有内容,专注于左侧的子数组。在这方面,快速选择类似于二分查找:我们不断将数组分成两半,并专注于我们知道要找的值的那一半。

接下来,我们对枢轴左侧的子数组进行分区:

在这里插入图片描述

假设此子数组的新枢轴最终出现在第三个位置:

在这里插入图片描述

现在,我们知道第三个位置的值处于其正确的位置上,这意味着它是数组中第三小的值。根据定义,第二小的值将会在它的左边某处。我们现在可以对第三个位置左侧的子数组进行分区:

在这里插入图片描述

在这个下一个分区之后,最小值和第二小的值将出现在数组中它们正确的位置上:

在这里插入图片描述

然后我们可以获取第二个位置的值,并且确信它是整个数组中第二小的值。快速选择的一个美妙之处在于,我们可以在不必对整个数组进行排序的情况下找到正确的值。

对于快速排序,每次我们将数组减半时,我们需要重新对每个单独的元素(在它们的子数组形式下)进行分区,这给我们带来了 O(N log N) 的时间复杂度。而另一方面,对于快速选择,每次我们将数组减半时,我们只需对我们关心的那一半进行分区——即我们知道我们的值会在其中找到的那一半。

快速选择的效率

在分析快速选择的效率时,我们会发现它在平均情况下的时间复杂度为 O(N)。为什么会是这样呢?

在我们之前的一个包含八个元素的数组的示例中,我们执行了三次分区:一次在一个包含八个元素的数组上,一次在一个包含四个元素的子数组上,以及一次在一个包含两个元素的子数组上。

回想一下,对于运行分区的子数组,每次分区大约需要 N 步。那么,这三次分区的总步数为 8 + 4 + 2 = 14 步。所以,一个包含八个元素的数组大约需要 14 步。

对于一个包含 64 个元素的数组,我们大约需要运行 64 + 32 + 16 + 8 + 4 + 2 = 126 步。对于 128 个元素,我们需要大约 254 步。对于 256 个元素,我们将需要 510 步。

我们可以看到,对于数组中的 N 个元素,我们大约需要 2N 步。(另一种表达方法是说,对于 N 个元素,我们需要 N + (N/2) + (N/4) + (N/8) + … 2 步。这总是大约等于 2N 步。)

由于大 O 表示法忽略常数,我们去掉 2N 中的 2,并且说快速选择的效率是 O(N)。

代码实现:快速选择

以下是一个 quickselect! 方法的实现,可以嵌入之前描述的 SortableArray 类中。您会注意到,它与 quicksort! 方法非常相似:

def quickselect!(kth_lowest_value, left_index, right_index)
  # 如果达到基本情况 - 即子数组只有一个单元格,我们知道找到了正在寻找的值:
  if right_index - left_index <= 0
    return @array[left_index]
  end
  # 对数组进行分区,并获取枢轴的索引:
  pivot_index = partition!(left_index, right_index)
  # 如果我们要查找的值位于枢轴的左侧:
  if kth_lowest_value < pivot_index
    # 递归地在枢轴左侧的子数组上执行 quickselect:
    quickselect!(kth_lowest_value, left_index, pivot_index - 1)
  # 如果我们要查找的值位于枢轴的右侧:
  elsif kth_lowest_value > pivot_index
    # 递归地在枢轴右侧的子数组上执行 quickselect:
    quickselect!(kth_lowest_value, pivot_index + 1, right_index)
  else # 如果 kth_lowest_value == pivot_index
    # 如果在分区后,枢轴位置与第 k 低的值位置相同,那么我们找到了正在寻找的值
    return @array[pivot_index]
  end
end

如果您想要在未排序的数组中找到第二低的值,可以运行以下代码:

array = [0, 50, 20, 10, 60, 30]
sortable_array = SortableArray.new(array)
p sortable_array.quickselect!(1, 0, array.length - 1)

quickselect! 方法的第一个参数接受您正在寻找的位置,从索引 0 开始。我们放入了 1 来代表第二低的值。第二个和第三个值分别是数组的左索引和右索引。

排序作为其他算法的关键

截至目前,我们所知最快的排序算法的速度为 O(N log N)。虽然快速排序是其中最受欢迎的之一,但还有许多其他算法。归并排序是另一种著名的 O(N log N) 排序算法,我建议你查阅一下,因为它是一个美丽的递归算法。

最快的排序算法是 O(N log N) 这一事实非常重要,因为这也影响其他算法。这是因为有些算法将排序作为更大流程的一部分。

例如,如果你还记得《使用大O加速代码》,我们处理了在数组中检查是否存在重复值的问题。我们首先看了一个涉及嵌套循环的解决方案,其效率为 O(N^2)。尽管我们找到了一个 O(N) 的解决方案,但我在括号里暗示了这种方法的一个缺点,与额外的内存消耗有关。(我最终会在《处理空间限制》中详细讨论这个问题。)因此,让我们假设 O(N) 的方法不可行。我们是否有其他方法可以改进这个二次 O(N^2) 的解决方案呢?提示:解决方案与排序有关!

如果我们预先对数组进行排序,我们实际上可以构建一个漂亮的算法。

假设原始数组是 [5, 9, 3, 2, 4, 5, 6]。这里有两个5,所以我们有重复的情况。
现在,如果我们首先对这个数组进行排序,它将变成 [2, 3, 4, 5, 5, 6, 9]。

接下来,我们可以使用一个循环来迭代每个数字。当我们检查每个数字时,我们会检查它是否与下一个数字相同。如果相同,我们就找到了一个重复项。如果我们在循环结束时没有找到重复项,那么我们就知道没有重复项。

这里的诀窍是通过预先排序数字,我们将重复的数字放在一起。

在我们的例子中,我们将首先查看第一个数字,即2。我们将检查它是否与下一个数字相同。下一个数字是3,所以它们不重复。

然后我们将3与后面的数字进行比较,后面的数字是4,这使我们可以继续。我们将4与5进行比较,然后再继续。

此时,我们检查第一个5并将其与后面的数字进行比较,即第二个5。啊哈!我们找到了一对重复的数字,我们可以返回true。

这是一个 JavaScript 实现:

function hasDuplicateValue(array) {
  // 预先对数组进行排序:
  // (在 JavaScript 中,为了确保数字按照数值顺序排序,以下使用 sort 函数是必需的,而不是“字母”顺序。)
  array.sort((a, b) => (a < b) ? -1 : 1);
  // 遍历数组中的值,直到倒数第二个:
  for(let i = 0; i < array.length - 1; i++) {
    // 如果值与数组中的下一个值相同,我们找到了一个重复项:
    if(array[i] == array[i + 1]) {
      return true;
    }
  }
  // 如果我们在没有返回true的情况下到达数组的末尾,这意味着没有重复项:
  return false;
}

现在,这是一个利用排序作为其组成部分的算法。这个算法的时间复杂度是多少呢?

我们首先对数组进行排序。我们可以假设 JavaScript 的 sort() 函数的效率是 O(N log N)。接下来,我们最多花费 N 步来遍历数组。因此,我们的算法需要 (N log N) + N 步。

你学到了当我们将多个阶数相加时,大O符号仅保留最高阶数的N,因为在高阶数旁边,低阶数是微不足道的。在这里,N 在 N log N 旁边微不足道,所以我们将表达式简化为 O(N log N)。

所以,就是这样!我们利用排序开发了一个时间复杂度为 O(N log N) 的算法,这相对于原始的 O(N^2) 算法来说是一个重大改进。

许多算法将排序作为更大流程的一部分。我们现在知道,每当我们这样做时,我们至少有一个 O(N log N) 的算法。当然,如果算法还有其他操作,它可能比这个更慢,但我们知道 O(N log N) 将始终是基准。"

总结

快速排序和快速选择算法是递归算法,为棘手问题提供了美观高效的解决方案。它们是非显而易见但经过深思熟虑的算法的很好例子,可以提升性能。

现在我们已经了解了一些更高级的算法,我们现在要探索一个新方向,探索更多的附加数据结构。其中一些数据结构涉及递归操作,所以我们现在将会充分准备好去解决这些。除了非常有趣之外,我们将看到每个数据结构都有一种特殊的能力,可以为各种应用带来重大优势。

练习

  1. 给定一个正数数组,编写一个函数,返回任意三个数字的乘积中最大的值。使用三个嵌套循环的方法会达到 O(N^3) 的时间复杂度,速度非常慢。使用排序的方法来实现该函数,可以将时间复杂度降低至 O(N log N)。(虽然还有更快的实现方法,但我们着重使用排序作为一种加快代码速度的技巧。)

  2. 下面的函数用于从一个整数数组中找到“缺失的数字”。也就是说,该数组应包含从 0 到数组长度的所有整数,但其中一个数字缺失。例如,数组 [5, 2, 4, 1, 0] 缺失数字 3,数组 [9, 3, 2, 5, 6, 7, 1, 0, 4] 缺失数字 8。
    下面是一个时间复杂度为 O(N^2) 的实现(仅使用 includes 方法本身就是 O(N),因为计算机需要搜索整个数组来找到数字 n):

function findMissingNumber(array) {
  for(let i = 0; i < array.length; i++) {
    if(!array.includes(i)) {
      return i;
    }
  }
  // 如果所有数字都存在:
  return null;
}

使用排序来编写这个函数的新实现,时间复杂度只需 O(N log N)。(虽然还有更快的实现方法,但我们着重使用排序作为一种加快代码速度的技巧。)

  1. 编写三种不同的函数实现,用于在数组中找到最大的数。编写一个时间复杂度为 O(N^2) 的函数,一个时间复杂度为 O(N log N) 的函数,和一个时间复杂度为 O(N) 的函数。

答案

  1. 如果我们对数字进行排序,我们知道最大的三个数字将在数组的末尾,我们只需将它们相乘即可。排序将花费 O(N log N) 的时间:
function greatestProductOf3(array) {
    array.sort((a, b) => (a < b) ? -1 : 1);
    return array[array.length - 1] * array[array.length - 2] * array[array.length - 3];
}
// (此代码假定数组中至少有三个值。您可以添加处理不满足这种情况的数组的代码。)
  1. 如果我们预先对数组进行排序,我们可以期望每个数字在其自己的索引位置。也就是说,0 应该在索引 0,1 应该在索引 1,以此类推。然后我们可以遍历数组寻找一个与索引不相等的数字。一旦找到它,我们就知道刚刚跳过了缺失的数字:
function findMissingNumber(array) {
    array.sort((a, b) => (a < b) ? -1 : 1);
    for(let i = 0; i < array.length; i++) {
        if(array[i] !== i) {
            return i;
        }
    }
    return null;
}
// 排序需要 N log N 步,随后的循环需要 N 步。然而,我们将 (N log N) + N 的表达式简化为 O(N log N),因为添加的 N 是相对于 N log N 较低阶次的。
  1. 这个实现使用了嵌套循环,时间复杂度为 O(N^2):
function max(array) {
    for(let i = 0; i < array.length; i++) {
        let iIsGreatestNumber = true;
        for(let j = 0; j < array.length; j++) {
            if(array[j] > array[i]) {
                iIsGreatestNumber = false;
            }
        }
        if(iIsGreatestNumber) {
            return array[i];
        }
    }
}

此实现简单地对数组进行排序并返回最后一个数字。排序的时间复杂度为 O(N log N):

function max(array) {
    array.sort((a, b) => (a < b) ? -1 : 1);
    return array[array.length - 1];
}

这个实现的时间复杂度为 O(N),因为我们只需一次遍历整个数组:

function max(array) {
    let greatestNumberSoFar = array[0];
    for(let i = 0; i < array.length; i++) {
        if(array[i] > greatestNumberSoFar) {
            greatestNumberSoFar = array[i];
        }
    }
    return greatestNumberSoFar;
}
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值