为什么快速排序比归并排序更好?

408

面试时有人问了我这个问题。它们都是O(nlogn)的,但大多数人使用快速排序而不是归并排序。为什么呢?


105
这不是一个很好的面试问题。现实世界中的数据并不是随机排列的:它通常包含有很多顺序,而聪明的排序算法可以利用这些顺序,虽然两种算法都不能自动地做到这一点,但更容易将归并排序修改成具有这种功能,而快速排序则较难。GNU libc的qsort、Python的list.sort以及Firefox JavaScript中的Array.prototype.sort都是强化版的归并排序。(GNU STL的sort使用Introsort,但这可能是因为在C++中,交换操作可能比复制操作更加高效。) - Jason Orendorff
5
为什么“修改归并排序使其实现此目的比修改快速排序更容易”?您可以引用任何具体示例吗? - Lazer
17
合并排序(Merge Sort)是通过将初始数据分组成有序的子数组来开始的。如果数组最初包含一些已经排序好的区域,那么在开始之前检测到它们可以节省大量时间。而且你可以在O(n)的时间内完成这项工作。有关具体示例,请参见我提到的三个项目的源代码!最好的例子可能是Python的Timsort算法,在此处详细描述:http://svn.python.org/view/python/trunk/Objects/listsort.txt?view=markup 并在http://svn.python.org/view/python/trunk/Objects/listobject.c?view=markup 中实现。 - Jason Orendorff
5
不确定我是否同意你的观点,即归并排序更容易修改以利用已排序的部分。快速排序的分区步骤可以轻松修改为在分区后检查两个结果分区是否已排序,如果是,则停止递归。这可能会使比较次数翻倍,但不会改变该步骤的O(n)时间复杂度。 - j_random_hacker
4
@j_random_hacker: 对的,这就是我的意思。但请考虑一下:{10, 2, 3, 4, 5, 6, 7, 8, 1, 9},尽管已经几乎完全排序,但在分区之前和之后都不会找到它,在后续调用检查之前,分区会破坏它。与此同时,归并排序在移动任何元素之前就会在分割步骤中检查已排序的序列,而聪明的算法将特别在分割步骤中寻找像这样的运行序列(参见:Tim排序)。 - Mooing Duck
显示剩余5条评论
29个回答

6
快速排序是实践中最快的排序算法,但有一些病态情况会使其表现得像O(n2)一样差。
堆排序保证在O(n*ln(n))内运行,并且仅需要有限的额外存储空间。但是有很多真实世界测试的引用表明,平均而言堆排序比快速排序慢得多。

6
快速排序并不比归并排序更好。快速排序的最坏情况复杂度为O(n^2),可能比归并排序的O(nlogn)慢得多。快速排序的开销较小,因此在n较小且计算机速度较慢时,它更好。但是现在的计算机速度非常快,归并排序的额外开销可以忽略不计,而快速排序非常慢的风险远远超过了归并排序的微不足道的开销。
此外,归并排序可以保留具有相同键的项目的原始顺序,这是一个有用的属性。

2
你的第二句话说:“...mergesort可能比...mergesort慢得多”。第一个参考应该是quicksort。 - Jonathan Leffler
归并排序只有在归并算法稳定的情况下才是稳定的;但这并不是一定能保证的。 - Clearer
@Clearer 如果使用“<=”进行比较而不是“<”,则可以保证,而且没有理由不这样做。 - Jim Balter
@JimBalter 我可以轻松地想出一个不稳定的合并算法(例如快速排序),快速排序之所以在许多情况下比归并排序更快,并不是因为减少了开销,而是因为快速排序访问数据的方式比标准归并排序更加缓存友好。 - Clearer
@清晰一点,快速排序不是归并排序...我回应的是你在2014年12月21日关于归并排序是否稳定的陈述。快速排序和哪个更快根本与你的评论或我的回应无关。对我来说讨论到此结束...收工。 - Jim Balter
快速排序可以轻松地用于将两个数组合并为一个(将数组复制到一个数组中并进行排序 - 完成)。虽然这是一种非常糟糕的合并方式,但确实说明了可能制作不稳定(或低效)的合并算法。我关于为什么快速排序可能比归并排序更快的评论是针对原始答案的。 - Clearer

5

维基百科的解释是:

通常情况下,快速排序在实践中比其他Θ(nlogn)算法要快得多,因为它的内部循环可以在大多数架构上高效地实现,并且在大多数实际数据中,可以进行设计选择以最小化需要二次时间的概率。

快速排序

归并排序

我认为归并排序所需的存储量也存在问题(即Ω(n)),而快速排序实现则没有这个问题。在最坏情况下,它们的算法时间相同,但归并排序需要更多的存储空间。


快速排序的最坏情况是O(n),归并排序是O(n log n) - 所以它们之间有很大的差异。 - paul23
1
最坏情况下的快速排序是O(n^2) - 我不能编辑我的先前评论并且打了一个错字。 - paul23
@paul23的评论可以被删除。此外,答案已经回应了你的观点:“在大多数真实世界的数据中,可以做出设计选择,以最小化需要二次时间的概率”。 - Jim Balter

4
这是一个相当古老的问题,但由于我最近都在处理这两个算法,所以我想发表一下我的看法:
归并排序平均需要 ~ N log N 次比较。对于已经(几乎)排序好的数组,这个数字会降到 1/2 N log N,因为在合并时我们(几乎)总是选择左边的一半 N 次,然后只复制右边的另一半 N 个元素。此外,我可以猜测,已经排序好的输入会使处理器的分支预测器发挥作用,几乎总是正确地猜测所有分支,从而防止管道停顿。
快速排序平均需要 ~ 1.38 N log N 次比较。它在比较方面并没有从已排序的数组中获得很大的好处(但在交换和可能在 CPU 内部的分支预测方面确实有好处)。
我在一台相当现代的处理器上进行了基准测试,结果如下:
当比较函数是回调函数(例如在 qsort() libc 实现中)时,对于 64 位整数,快速排序在随机输入上比归并排序慢 15%,在已排序数组上比归并排序慢 30%。
另一方面,如果比较不是回调,则我的经验是,快速排序比归并排序快高达 25%。
但是,如果您(大)数组中的非常少数唯一值,则无论如何,归并排序都会开始超过快速排序。
因此,也许底线是:如果比较很昂贵(例如回调函数、比较字符串、比较结构的许多部分,主要是到第二个-第三个-第四个“if”才能产生差异),那么您最好使用归并排序。对于更简单的任务,快速排序会更快。
话虽如此,之前所说的所有内容都是正确的: - 快速排序可能是 N^2,但 Sedgewick 声称一个良好的随机化实现被电脑执行排序的机会比遭受闪电打击还要小 - 归并排序需要额外的空间。

如果比较操作便宜,那么即使输入已排序,qsort是否仍然能够击败mergesort? - eonil

4

与归并排序不同,快速排序不使用辅助空间。而归并排序使用的辅助空间为O(n)。 但是,归并排序的最坏时间复杂度为O(nlogn),而快速排序的最坏情况复杂度为O(n^2),当数组已经排序时会发生。


不,快速排序的最坏情况并不会在数组已经排序好的情况下发生,除非你使用第一个或最后一个项目作为枢轴,但是没有人这样做。 - Jim Balter

4

为什么快速排序很好?

  • 在最坏情况下,即数据已排序时,快速排序的时间复杂度为N^2,平均情况下为NlogN。可以通过在排序开始前进行随机洗牌来缓解这种情况。
  • 快速排序不需要像归并排序一样占用额外的内存。
  • 如果数据集很大且存在相同的项,则使用三路划分可以减少快速排序的复杂度。相同的项越多,排序效果越好。如果所有项都相同,则可以在线性时间内完成排序。(这是大多数库的默认实现)

快速排序总是比归并排序更好吗?

并非如此。

  • 归并排序是稳定的,但快速排序不是。因此,如果您需要输出的结果是稳定的,您应该使用归并排序。在许多实际应用中,稳定性是必需的。
  • 现在内存很便宜。因此,如果归并排序使用的额外内存对您的应用程序不重要,则使用归并排序没有任何问题。

注意:在Java中,Arrays.sort()函数对于基本数据类型使用快速排序,对于对象数据类型使用归并排序。因为对象会占用内存开销,所以对于性能而言,为归并排序增加一点点额外的开销可能没有问题。

参考资料:观看Coursera普林斯顿算法课程第三周的快速排序视频


1
这可以通过在排序开始之前进行随机洗牌来缓解。- 呃,不,那会很昂贵。相反,使用随机枢轴。 - Jim Balter
非常同意@JimBalter的观点-为什么我们要洗牌数据?如果有什么问题,我认为可以争论几乎排序好的数据实际上排序更快,因为它需要较少的写操作。正如Jim所提到的,选择更好的枢轴是一个好的解决方法。三个随机数的中位数可能会有用。 - rinogo

3

就原始值而言,随着DualPivotQuickSort的改进,答案会稍微倾向于快速排序。在JAVA 7中,它被用于在java.util.Arrays中进行排序。

It is proved that for the Dual-Pivot Quicksort the average number of
comparisons is 2*n*ln(n), the average number of swaps is 0.8*n*ln(n),
whereas classical Quicksort algorithm has 2*n*ln(n) and 1*n*ln(n)
respectively. Full mathematical proof see in attached proof.txt
and proof_add.txt files. Theoretical results are also confirmed
by experimental counting of the operations.

您可以在此找到JAVA7的实现-http://grepcode.com/file/repository.grepcode.com/java/root/jdk/openjdk/7-b147/java/util/Arrays.java 另外关于DualPivotQuickSort,您可以阅读以下文章 - http://permalink.gmane.org/gmane.comp.java.openjdk.core-libs.devel/2628

3
在归并排序中,通用算法如下:
  1. 对左子数组进行排序
  2. 对右子数组进行排序
  3. 合并两个已排序的子数组
在最高层级上,将两个已排序的子数组合并涉及到处理N个元素。
在此之下的每一级别的第3步迭代都涉及处理N/2个元素,但是您必须重复这个过程两次。因此,您仍然要处理2 * N/2 == N个元素。
在此之下的一级,您正在合并4 * N/4 == N个元素,以此类推。递归堆栈中的每个深度都涉及合并相同数量的元素,跨所有该深度的调用。
相反,考虑快速排序算法:
  1. 选择一个枢轴点
  2. 将枢轴点放置在数组中的正确位置,使所有较小的元素位于左侧,较大的元素位于右侧
  3. 对左子数组进行排序
  4. 对右子数组进行排序
在最高层级上,您正在处理大小为N的数组。然后选择一个枢轴点,将其放置在正确的位置,并且可以完全忽略它在算法的其余部分中。
在此之下的一级,您正在处理两个子数组,它们的组合大小为N-1(即,减去先前的枢轴点)。您为每个子数组选择一个枢轴点,这将增加2个额外的枢轴点。
在此之下的一级,您正在处理4个子数组,其组合大小为N-3,原因与上述相同。
然后是N-7... N-15... N-32...
您的递归堆栈深度保持大致相同(logN)。对于归并排序,您始终在处理N元素合并,跨越递归堆栈的每个级别。但是,对于快速排序,随着向下移动堆栈,您处理的元素数量会减少。例如,如果查看递归堆栈中间的深度,则您正在处理的元素数为N - 2 ^ ((logN)/2)) == N - sqrt(N)。
免责声明:在归并排序中,因为每次将数组分成两个完全相等的块,递归深度正好为logN。但是,在快速排序中,由于您的枢轴点不太可能恰好位于数组的中心,因此您的递归堆栈的深度可能略大于logN。我还没有进行计算,以确定该因素和上述因素实际上在算法的复杂性中扮演了多大的角色。

快速排序更高效的原因并不是因为枢轴不是下一级排序的一部分。请参阅其他答案以获取更多见解。 - Jim Balter
@JimBalter 你指的是哪些“其他答案”?顶部答案只是说 QS “需要很少的额外空间并展现出良好的缓存局部性”,但没有解释为什么,也没有提供任何引用。第二个答案只是说对于较大的数据集,归并排序更好。 - RvPr
你在移动球门,从为什么快速排序性能更高到解释其基本工作原理。其他问题的答案已经回答了这一点:https://dev59.com/y2ox5IYBdhLWcg3wFAfW...我希望这已经足够了,我不会再做进一步回应。 - Jim Balter

2
快速排序的最坏情况是O(n^2),但平均情况下表现比归并排序更好。每个算法都是O(nlogn),但需要记住在谈论大O时,我们省略较低复杂度因素。当涉及到常数因子时,快速排序在改进方面比归并排序有显著优势。
归并排序还需要O(2n)的内存,而快速排序可以就地完成(仅需要O(n))。这也是快速排序通常优于归并排序的另一个原因。
额外信息: 快速排序的最坏情况发生在选择枢轴不当的情况下。考虑以下示例:[5, 4, 3, 2, 1]。如果将枢轴选择为组中最小或最大的数字,则快速排序将以O(n^2)运行。选择在列表最大或最小25%中的元素的概率为0.5。对于每个枢轴的选择,该算法都有50%的机会成为良好的枢轴。对于大型集合,总是选择不良枢轴的概率为0.5*n。基于此概率,快速排序对于平均(和典型)情况是高效的。

O(2n) == O(n)。正确的说法是归并排序需要O(n)的额外内存(更具体地说,它需要n/2的辅助内存)。而对于链表来说,这并不是真的。 - Jim Balter
@JimBalter 先生,您是否愿意与我们分享有关他们的表现的卓越而有价值的想法作为问题的答案?提前致谢。 - Soner from The Ottoman Empire

2

当我尝试使用这两种排序算法并计算递归调用次数时,快速排序比归并排序始终具有更少的递归调用次数。这是因为快速排序有枢轴点,而枢轴点不包含在下一个递归调用中。这样,快速排序可以更快地达到递归基本情况,而不像归并排序那样需要更多的递归调用。


枢轴点与 QS 递归调用次数较少无关...这是因为 QS 的一半递归是尾递归,可以被消除。 - Jim Balter

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接