【问题标题】:Intuitive explanation for why QuickSort is n log n?为什么 QuickSort 是 n log n 的直观解释?
【发布时间】:2012-05-12 14:54:48
【问题描述】:

有没有人能够给出一个“简单的英语”直观但正式的解释是什么让 QuickSort n log n?据我了解,它必须通过 n 项,并且它会记录 n 次...我不知道如何用语言表达它为什么要记录 n 次。

【问题讨论】:

    标签: algorithm complexity-theory quicksort


    【解决方案1】:

    复杂性

    快速排序首先将输入划分为两个块:它选择一个“枢轴”值,并将输入划分为小于枢轴值和大于枢轴值的部分(当然,任何等于当然,枢轴值已经进入其中一个或另一个,但对于基本描述,它们最终出现在哪个并不重要)。

    由于输入(根据定义)没有排序,因此要对其进行分区,它必须查看输入中的每个项目,因此这是一个 O(N) 操作。在第一次对输入进行分区后,它递归地对每个“块”进行排序。这些递归调用中的每一个都会查看它的每一个输入,因此在这两个调用之间它最终会访问每个输入值(再次)。因此,在分区的第一个“级别”,我们有一个调用来查看每个输入项。在第二级,我们有两个分区步骤,但在这两个步骤之间,他们(再次)查看每个输入项。每个连续的级别都有更多单独的分区步骤,但每个级别的调用总共会查看所有输入项。

    它继续将输入划分为越来越小的部分,直到达到分区大小的某个下限。最小的可能是每个分区中的单个项目。

    理想情况

    在理想情况下,我们希望每个分区步骤将输入分成两半。 “一半”可能不会完全相等,但如果我们选择好枢轴,它们应该非常接近。为了简单起见,让我们假设完美的分区,所以我们每次都得到精确的一半。

    在这种情况下,我们可以将其分成两半的次数将是输入数量的以 2 为底的对数。例如,给定 128 个输入,我们得到 64、32、16、8、4、2 和 1 的分区大小。这是 7 个分区级别(是的,log2(128) = 7) .

    所以,我们有 log(N) 个分区“级别”,每个级别都必须访问所有 N 个输入。因此,log(N) 个级别乘以每个级别的 N 个操作给我们 O(N log N) 的整体复杂度。

    最坏情况

    现在让我们重新审视一下假设,即每个分区级别都会将输入精确地“分解”成两半。根据我们对分区元素的选择有多好,我们可能不会得到精确相等的一半。那么可能发生的最坏情况是什么?最坏的情况是枢轴实际上是输入中的最小或最大元素。在这种情况下,我们执行 O(N) 分区级别,但不是得到大小相等的两半,而是得到一个元素的一个分区和 N-1 个元素的一个分区。如果每个分区级别都发生这种情况,我们显然最终会在分区下降到一个元素之前执行 O(N) 个分区级别。

    这为快速排序提供了技术上正确的大 O 复杂度(大 O 正式指复杂度的上限)。由于我们有 O(N) 级别的分区,并且每个级别都需要 O(N) 步,因此我们最终会得到 O(N * N)(即 O(N2)))的复杂度。

    实际实现

    实际上,实际实现通常会在实际到达单个元素的分区之前停止分区。在典型情况下,当一个分区包含 10 个或更少的元素时,您将停止分区并使用插入排序之类的东西(因为它通常对少量元素更快)。

    修改后的算法

    最近已经发明了对快速排序的其他修改(例如,Introsort、PDQ 排序),以防止 O(N2) 最坏的情况。 Introsort 通过跟踪当前分区“级别”来做到这一点,当/如果它太深,它会切换到堆排序,这对于典型输入来说比快速排序慢,但保证 O(N log N) 复杂度任何输入。

    PDQ 排序为此增加了另一个转折:由于堆排序较慢,因此它会尽可能避免切换到堆排序为此,如果看起来它的枢轴值很差,它会随机打乱一些输入在选择支点之前。然后,如果(且仅当)不能产生足够好的主元值,它将切换到使用堆排序。

    【讨论】:

    • 谢谢你。这里接受的答案只是重申了 OP(和我)已经知道的内容(n 次操作完成 log n 次),但掩盖了唯一重要的部分:为什么它完成 log n 次?这个答案很好地解释了日志项的实际来源。
    • 这是最好的解释
    • 这真的正确吗?每个分区操作创建 2 个新组,这意味着在您的第一个分区之后,您最终会调用 2 次 Partition(),每个分区的大小为 64 个元素,这将生成 4 次调用,每组 32 个,然后是 8 次调用16人一组,以此类推。总的来说,对 Partition() 的调用次数应该在 254 左右,而不是 7 次。
    • @RaikolAmaro:调用的 depth 为 7。如果将给定深度的所有调用加起来,它们一起作用于所有 N 个元素。 (第一级为 2 * 128,第二级为 4*64,依此类推)。因此,您会收到 log(N) 深度的调用,并且在每个级别上,操作的总数与 N 成正比。
    • @RaikolAmaro:是的,可能并不完全清楚。我已将其重写为更加明确。
    【解决方案2】:

    每个分区操作都需要 O(n) 次操作(一次遍历数组)。 平均而言,每个分区将数组分成两部分(总计为 log n 个操作)。我们总共有 O(n * log n) 次操作。

    即平均 log n 次分区操作,每个分区需要 O(n) 次操作。

    【讨论】:

    • 对于正在阅读此答案的人,向下滚动以获得更好的答案。
    • 我发现这种直觉存在问题。考虑以下(坏的)快速排序变体:在每个时间点,我们选择数组的最大或最小元素并将其用作枢轴,并且我们以相等的概率这样做。平均而言,较小元素的子数组的大小为 (n - 1)/2,与较大元素的子数组一样。然而,这个算法总是需要时间 Omega(n^2) 才能完成。因此,平均每个拆分平均为 50/50 的事实并不一定意味着该算法会很快。故事远不止这些。
    【解决方案3】:

    对数背后有一个关键的直觉:

    在达到 1 之前,您可以将数字 n 除以常数的次数是 O(log n)。

    换句话说,如果您看到一个运行时具有 O(log n) 项,那么您很有可能会发现某些东西会以一个常数因子反复缩小。

    在快速排序中,按常数缩小的是每个级别的最大递归调用的大小。快速排序的工作原理是选择一个枢轴,将数组拆分为两个子数组,其中元素小于枢轴,元素大于枢轴,然后递归地对每个子数组进行排序。

    如果您随机选择枢轴,则选择的枢轴有 50% 的机会位于中间 50% 的元素中,这意味着有 80% 的机会两个子数组中较大的一个最多位于原件尺寸的 75%。 (你明白为什么吗?)

    因此,为什么快速排序在 O(n log n) 时间内运行的一个很好的直觉如下:递归树中的每一层都做 O(n) 的工作,并且因为每个递归调用都有很好的机会减小大小至少 25% 的数组,我们希望在你用完要丢弃的元素之前有 O(log n) 层。

    当然,这假设您随机选择枢轴。快速排序的许多实现都使用启发式方法来尝试在不做太多工作的情况下获得良好的枢轴,不幸的是,在最坏的情况下,这些实现可能会导致整体运行时差。 @Jerry Coffin 对这个问题的出色回答谈到了快速排序的一些变化,它们通过切换使用哪种排序算法来保证 O(n log n) 最坏情况的行为,这是查找有关此问题的更多信息的好地方。

    【讨论】:

      【解决方案4】:

      嗯,它并不总是 n(log n)。这是选择的枢轴大约在中间时的表演时间。在最坏的情况下,如果您选择最小或最大的元素作为枢轴,那么时间将是 O(n^2)。

      为了可视化“n log n”,您可以假设枢轴是最接近要排序的数组中所有元素的平均值的元素。 这会将数组分成两个长度大致相同的部分。 在这两个上都应用快速排序过程。

      在将数组长度减半的每一步中,您将执行 log n(base 2) 次,直到达到长度 = 1,即 1 个元素的排序数组。

      【讨论】:

      • 你是对的,但不要将平均值和中值混为一谈。中位数可以让您分成具有相同长度 (+-1) 的两部分。
      • 平均不会给出中间元素。中位数将给出中间元素。答案需要修复这部分。否则很好。
      • 由于列表可能未排序开始,并且您希望扫描一次以查找枢轴,我能想到的最好的办法是平均数字并选择最接近的枢轴。现在我们出错的地方是像 1 1 1 1 1 1 1 2 864 这样的输入。枢轴是 2,导致不平衡。
      【解决方案5】:

      将排序算法分为两部分。首先是分区和第二个递归调用。分区的复杂度是 O(N),递归调用理想情况的复杂度是 O(logN)。例如,如果您有 4 个输入,则将有 2(log4) 个递归调用。将两者相乘得到 O(NlogN)。这是一个非常基本的解释。

      【讨论】:

        【解决方案6】:

        事实上你需要找到所有N个元素的位置(pivot),但是每个元素的最大比较次数是logN(第一个是N,第二个pivot N/2,第三个N/4..假设枢轴是中间元素)

        【讨论】:

        • 这不是真的。对于初学者,请注意 N + N / 2 + N / 4 + N / 8 + ... = N(1 + 1/2 + 1/4 + 1/8 + ...)
        猜你喜欢
        • 2012-09-16
        • 1970-01-01
        • 2015-08-28
        • 2011-01-24
        • 1970-01-01
        • 1970-01-01
        • 2018-06-16
        • 2011-12-11
        相关资源
        最近更新 更多