【问题标题】:Got stuck with O(n*logn)卡住了 O(n*logn)
【发布时间】:2020-01-01 22:17:14
【问题描述】:

我有一个快速排序:

def quickSort(array):
    if len(array) <= 1:
        return array
    else:
        center = array[0]
        l_arr = [n for n in array[1:] if n <= center]
        r_arr = [n for n in array[1:] if n > center]
        return quickSort(left) + [center] + quickSort(right)

返回值现在是排序数组。但不清楚的是该算法的时间复杂度为 n*logn。我们将映射下面的所有内容,例如n = 8:

 [1, 5, 7, 9, 10, 11, 1, 5] is given, we take the num at 0 which is 1, 
 from then now we compare all items in list as per the code, found 1 <= 1. 
 We put it in the left array. We put the rest in the right array. 
 [1] + [1] + [7, 9, 10, 11, 5]. We sort r_arr recursively which give us
 [5] +[5] + [7] + [9, 10, 11]. We sort r_arr again. [] + [9] + [10, 11]. And 
 again. [10] + [11].
 [1, 5, 7, 9, 10, 11, 1, 5] n elements
 [1] [5, 7, 9, 10, 11, 1, 5]n - 1
 [1][1][5, 7, 9, 10, 11, 5] n - 2
 [1][1][5][7, 9, 10, 11, 5] n - 3
 [1][1][5][5][7, 9, 10, 11] n - 4
 [1][1][5][5][7][9, 10, 11] n - 5 
 [1][1][5][5][7][9][10, 11] n - 6
 [1][1][5][5][7][9][10][1]  n - 7

在这里我看到我们将数组划分为 3 次,子数组长度是 2 的因数。那么为什么我们有 8 * 3 = 24 次操作而不是 8(2+2+2+2) + 3 = 11操作?

【问题讨论】:

  • 谁说有24个操作?
  • 公式这么说。 8 * 3。但是为什么每次总共 n 次 3 次。
  • 哪个公式?谁说公式是正确的?
  • 我只是用一个简单的案例把结束值放入公式中来理解。
  • O(n log n) 并不意味着对于 n=8 会有 完全 8 * log(8) 操作(请注意,对数的底是'甚至没有指定,因为没关系)。这意味着如果你缩放 n,那么算法的步数将按比例缩放 n 倍 log n(粗略地说)。

标签: python math big-o quicksort


【解决方案1】:

您似乎误解了算法的大 O 值实际上是什么以及应该如何解释它。这里有3个问题需要解决:

  1. 算法的大 O 顺序并没有为您提供所采取的确切步数,它是随着输入数量的增加将算法分组到行为类别中。
  2. 在快速排序的情况下,重要的不是递归调用,而是比较。
  3. 您的输入不是平均情况,它实际上比平均步数要更多

首先,当人们说快速排序是一种 O(N logN) 算法时,他们的意思是当您增加输入数量时,该算法将花费相对于输入数量的一定时间 。这称为算法的渐近行为。如果您要生成越来越长的随机输入序列,并且您测量了对每个序列进行排序所花费的时间,您会注意到这些时间会随着 N log(N) 趋势而增长,一条仅略微偏离直线的线(但如果您在图表的时间轴上使用对数刻度,则可以使其笔直)。

的意思是对于任何给定的输入,该算法将完全采取N次log(N)步。 Big O 表示法可让您对算法进行分类,它们为您提供算法的顺序,可以将其视为算法平均情况的上限,然后您可以将该算法与其他算法可以让您实现相同的目标(排序输出,此处)并知道在您给它大量输入时哪个算法会表现更好。当谈到算法时,你关心的是知道你必须做的工作是否会在合理的时间内完成,而大 O 顺序告诉你什么算法可以实现这一点。

这就是为什么当涉及到渐近数学时,我们会删除任何常数或低阶分量,因为如果您的算法在给定几乎无限数量的输入或只有近无限时间的一半。两者都会产生直图,在相同的输入 幅度 下达到 不再值得等待 状态。当您谈论每 24 小时处理数十亿个输入时,是 50 亿还是 60 亿个输入不再重要,重要的是如果您用 O(N^2) 替换算法,那么同样的工作需要几十万年,但如果你能找到一个 O(N) 算法,你可以将时间缩短到 45 分钟。

算法通常也有最好和最坏的情况; 一些排序算法在输入已经排序时达到最佳情况,因此只需要线性时间在 N 步中产生相同的已排序输出。相反,如果输入以 reverse 排序顺序进行,则算法可能必须进行多达 N 次 N 比较,即 N 平方,这将是最坏的情况。然而,这并不意味着它会表现得像 all 输入的最佳或最坏情况。

顺便说一句,对于快速排序,最好的情况仍然需要 O(N logN),因为在最好的情况下,每个分区步骤的枢轴值都恰好位于中间。 Quicksort 最坏的情况是它总是选择数组中的最低或最高值作为基准,因为那时您实际上还没有对任何东西进行排序,并且您的递归实现必须为每个后续完整分区进行 N 递归调用包含每一步少一个元素。

正如我所说,我们使用大 O 表示法来分类算法并比较它们。使用 O(N logN) 算法通常比使用 O(N^2) 算法更好,因为随着输入的扩展,O(N logN) 算法在完成任务时会(快得多) .我说经常更好,因为如果你的输入总是很小,那么不同的因素会起作用,“慢” O(N^2) 方法实际上可能会击败 O(N logN) 方法,因为它有恒定成本,执行单个步骤所需的工作量要低得多。

所以您的具体输入可能是一个非典型案例。它没有必须遵循理想的 N 倍 log(N) 步数,如果它倾向于最坏的情况,它可能需要更多。

对于快速排序,比较重要的是 比较,因为随着 N 的增长,你会做更多的事情。 仅围绕枢轴划分输入需要 N-1 步,因为您需要首先选择枢轴,然后将所有 N-1 元素与所选的枢轴值进行比较。组合结果(连接第一个分区的递归调用加上枢轴加上第二个分区)也有成本,因为您正在创建一个包含来自输​​入的 N 个元素的新列表。这也需要 N 步,但是因为这与分区的步数基本相同,所以我们可以在这里将其视为每 N 个输入的恒定成本。同样,您的快速排序实现对所有数据透视比较进行两次,每个分区一次。

请注意,我们这里没有计算递归调用。您使用了递归实现,但您也可以使用堆栈和迭代来实现 Quicksort(有效地将 Python 为您管理的调用堆栈替换为您自己的堆栈),并且仍然进行相同数量的比较。因此,不是计算递归调用,而是对quickSort() 的每次调用实际上应该被视为执行len(array) 步骤来创建分区。

对于您的输入,让我们计算创建分区所需的比较:

  • [1, 5, 7, 9, 10, 11, 1, 5],以 1 为轴心。将其他 7 个值与之比较以创建 [1][5, 7, 9, 10, 11, 5] 分区:8 个比较
    • [1] 已经排序,返回[1]
    • [5, 7, 9, 10, 11, 5],以 5 为轴心。将其他 5 个值与 5 进行比较以创建 [5][7, 9, 10, 11] 分区:6 个比较
      • [5] 已经排序,返回[5]
      • [7, 9, 10, 11],以 7 为轴心。将其他 3 个值与 7 进行比较以创建 [][9, 10, 11] 分区:4 个比较
        • [] 已经排序,返回[]
        • [9, 10, 11],以 9 为轴心。将其他 2 个值与 9 进行比较以创建 [][10, 11] 分区:3 个比较
          • [] 已经排序,返回[]
          • [10, 11],以 10 为轴心。将 1 个其他值与 10 进行比较以创建 [][11] 分区:2 个比较
            • [] 已经排序,返回[]
            • [11] 已经排序,返回[11]

这使得 8 + 6 + 4 + 3 + 2 == 23 步。这已经非常接近“预测”的 24 个步骤,但考虑到我没有计算测试 [][1][5][7][11] 分区,所有这些都会导致简单return array。实际上,您的示例案例接近最坏情况,这将需要 8 + 7 + 6 + 5 + 4 + 3 + 2 = 35 步。虽然这高于 Big O 假设的“理想”24 步,但您也会注意到这低于严格的 N^2 == 8 * 8 == 64 数字,但同样,我们在谈论 渐近行为,而不是精确计数。

同样,这是因为 Big O 告诉我们 Quicksort 将在 O(N logN) 和 O(N^2) 步之间进行,而不是确切那些步数。实际上,Quicksort 的最坏情况需要 (N * (N + 1)) // 2 步(这是因为在这种情况下,您遇到了算术级数,N + N - 1 + N - 2 + ... + 1triangle number)。但是删除常量并通过O((N * N) // 2) == 1/2 * N^2 留下 O(N^2)。

另一方面,理想情况下,将每个数组输入精确地划分为中间,并在两个递归调用中的每一个中处理 (N - 1) // 2 个元素。因此,最终最多 log(N) 个级别的深度(在 log^2(N) 个步骤将数组完美地分成两半之后,你最终会得到 N 个包含 0 或 1 个元素的数组),并且每个递归级别在所有调用中最多进行 N 次比较,因此您总共进行最多 O(N logN) 次比较。请注意其中的 最多,因为您不会将枢轴带到下一个级别,因此实际数字略低但可以忽略,因为它们只是更多的常数。重新洗牌您的样本数组以构造一个 ideal 输入给我[7, 5, 1, 1, 5, 10, 9, 11],这会导致 8 + 4 + 3 + 2 == 17 次实际比较。

但是当你扩大 N 时,你会注意到与输入大小相关的步数增长速度快于线性增长;随着输入大小从 A - 1 增长到 A,您可以看到算法需要超过 1 个额外的时间单位才能完成任务。相反,它需要 A // 2 多时间,大约。理想的 9 元素输入需要 9 + 4 + 4 + 2 + 2 + 2 + 2 = 25 步,理想的 10 元素输入需要 10 + 5 + 4 + 3 + 2 + 2 + 2 = 28 步,11 个元素需要 11 + 5 + 5 + 3 + 3 + 2 + 2 + 2 + 2 = 35 步,12 个元素 == 12 + 6 + 5 + 3 + 3 + 3 + 2 + 2 + 2 + 2 == 40 步.这些数字与 N * log^2(N) 遵循同一行,其中(向下舍入)将是 28、33、38 和 43;快速排序以完全相同的增量增长,仅减少了 3 个“单位”!同样,对于最坏的情况,当您从 A - 1 到 A 时,快速排序会增加一个额外的 A 步骤,就像一个“理想的”O(N^2) 算法需要 (2A - 1) 个单位的额外时间相同的增量。

您可能想学习Kahn Academy's analysis of Quicksort's running time,它详细解释了快速排序的最佳、平均和最坏情况,作为算法优秀介绍的一部分。您可能想从他们的section on asymptotic notation 开始,再试一遍我刚刚在上面列出的内容。

【讨论】:

    猜你喜欢
    • 2012-11-22
    • 1970-01-01
    • 2023-03-25
    • 2011-10-22
    • 2022-10-13
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    相关资源
    最近更新 更多