面试时有人问了我这个问题。它们都是O(nlogn)的,但大多数人使用快速排序而不是归并排序。为什么呢?
面试时有人问了我这个问题。它们都是O(nlogn)的,但大多数人使用快速排序而不是归并排序。为什么呢?
快速排序的最坏运行时间为O(n2),平均情况下的运行时间为O(nlogn)。然而,在许多情况下,它比归并排序更优秀,因为许多因素会影响算法的运行时间,综合考虑,快速排序胜出。
特别是,经常引用的排序算法运行时间是指执行数据排序所需的比较次数或交换次数。这确实是一个很好的性能指标,尤其是它不受底层硬件设计的影响。但是,其他因素(如参考局部性 - 即我们读取的元素是否在缓存中?)也在当前硬件上发挥着重要作用。快速排序特别需要很少的额外空间,并且表现出良好的高速缓存局部性,这使其在许多情况下比归并排序更快。
此外,通过使用适当的枢轴选择(例如随机选择),可以很容易地几乎完全避免快速排序的最坏情况运行时间O(n2)。
实际上,许多现代快速排序的实现(特别是libstdc++的std::sort
)实际上是introsort,其理论最坏情况与归并排序相同为O(nlogn)。通过限制递归深度,并在超过logn时切换到另一种算法(heapsort),它实现了这一点。
正如许多人所指出的,快速排序的平均情况性能比归并排序更快。但是,这只有在假定可以随时访问任何内存部分的时间保持不变的情况下才成立。
在RAM中,这种假设通常还不错(由于缓存的存在,它不总是正确的,但也不算太糟糕)。但是,如果您的数据结构足够大以存储在磁盘上,则快速排序会因为平均硬盘每秒执行大约200个随机寻道的事实而受到影响。但是,同样的硬盘可以毫不费力地按顺序读取或写入每秒兆字节级别的数据。这正是归并排序所做的。
因此,如果必须在磁盘上对数据进行排序,则确实需要使用归并排序的某种变体。(通常,您会将子列表快速排序,然后在某些大小阈值以上开始将它们合并在一起。)
此外,如果您必须处理那么大的数据集,要好好考虑如何避免寻道磁盘。例如,这就是为什么在数据库中进行大规模数据加载之前,请删除索引,然后再重新构建索引的标准建议。在加载过程中维护索引意味着不断寻道到磁盘。相反,如果您删除索引,则数据库可以通过首先对要处理的信息进行排序(当然使用归并排序!)然后将其加载到BTREE数据结构以用于索引来重新构建索引。(BTREE自然保持有序,因此您可以从已排序的数据集中加载它并减少到磁盘的寻道次数。)
有许多场合,了解如何避免磁盘寻道使我能够使数据处理作业从需要数天甚至数周缩短为数小时。
实际上,快速排序的时间复杂度为O(n2)。它的平均运行时间是O(nlog(n)), 但其最坏情况是O(n2),当你对包含少量唯一项的列表进行排序时会出现最坏情况。随机化需要O(n)的时间。当然,这不会改变它的最坏情况,只是防止恶意用户使您的排序花费很长时间。
相比之下,快速排序更受欢迎的原因是:
"为什么大多数人使用Quicksort而不是Mergesort呢?"
一个未被提到的心理原因是Quicksort的名称更加巧妙,也就是说它有很好的市场营销。
是的,使用三路快排的Quicksort可能是最好的通用排序算法之一,但无法否认的事实是,“Quick”排序听起来比“Merge”排序更加强大。
正如其他人所指出的,快速排序的最坏情况时间复杂度为O(n^2),而归并排序和堆排序则保持在O(nlogn)。然而,在平均情况下,这三种算法都是O(nlogn)的;因此,它们在绝大多数情况下是可以相互比较的。
使得快速排序平均更好的原因是其内部循环涉及将多个值与单个值进行比较,而在另外两种算法中,每次比较都涉及到不同的两个术语。换句话说,快速排序只需读取其他两个算法的一半数据。在现代CPU上,性能主要受访问时间的影响,因此最终快速排序成为了一个很好的首选。
2) 极差的主元选择可能导致最坏情况的性能。在理想情况下,主元将始终是这样的:50%的数据较小,50%的数据较大,以便在每次迭代期间将输入拆分成两半。这给我们n比较和交换乘以log-2(n)递归,时间复杂度为O(n * logn)。
非理想主元选择对执行时间有多大影响?
让我们考虑一种情况,其中主元被一致地选择,以使75%的数据位于主元的一侧。它仍然是O(n * logn),但现在log的底数已更改为1 / 0.75或1.33。更改底数时性能之间的关系始终是一个常数,由log(2)/ log(newBase)表示。在这种情况下,该常数为2.4。因此,这种主元选择质量比理想情况慢2.4倍。
这会变得多坏?
除非主元选择变得(一致地)非常糟糕,否则不会很快恶化:
当我们接近一侧达到100%时,执行的对数部分趋近于n,并且整个执行渐近地趋向于O(n^2)。
在快速排序的朴素实现中,像一个有序数组(以第一个元素为枢轴)或反向排序的数组(以最后一个元素为枢轴)这样的情况将可靠地产生最坏情况下的O(n^2)执行时间。此外,具有可预测的枢轴选择的实现可以受到旨在产生最坏情况执行的数据的DoS攻击。现代实现通过各种方法来避免这种情况,例如在排序之前随机化数据,选择3个随机选择的索引的中位数等。在这种随机化混合的情况下,我们有两种情况:
我们有多大可能看到糟糕的表现?
机会是微乎其微的。让我们考虑一个包含5,000个值的排序:
我们假设的实现将使用三个随机选择的索引的中位数来选择枢轴。我们将认为处于25% -75%范围内的枢轴是“好”的,而处于0%-25%或75%-100%范围内的枢轴是“坏”的。如果您查看使用三个随机索引的中位数的概率分布,每次递归都有11/16的几率最终得到一个好的枢轴。为了简化计算,让我们进行两个保守(错误的)假设:
好的轴点总是恰好在25%/75%的分割处,并以2.4倍的理想情况运作。我们从未得到过理想的分割或任何比25/75更好的分割。
坏的轴点总是最坏情况,并且基本上对解决方案没有贡献。
我们的快速排序实现将在n = 10时停止并切换到插入排序,因此我们需要22个25%/75%的轴点分区来将5000个值的输入拆分到那么深的程度。(10 * 1.333333 ^ 22> 5000) 或者,我们需要4990个最坏情况的轴点。请记住,如果我们在任何时候积累了22个好的轴点,则排序将完成,因此最坏情况或接近最坏情况需要非常糟糕的运气。如果我们实际上需要88次递归才能达到22个好的轴点来排序到n = 10,那将是4 * 2.4倍的理想情况,大约是理想情况的10倍执行时间。在88次递归后仍无法获得所需的22个好的轴点的可能性有多大?
二项式概率分布可以回答这个问题,答案约为10^-18。(n为88,k为21,p为0.6875) 在点击[排序]所需的1秒钟内,用户被雷击的可能性是看到5000个项目排序运行比10 *理想情况更糟糕的可能性的千倍左右。随着数据集变大,这种可能性会变得更小。以下是一些数组大小及其对应的长时间运行超过10 *理想的机会:
请记住,这是基于比实际情况更差的两个保守假设。因此,实际表现要更好,剩余概率的平衡更接近理想情况。
最后,正如其他人所提到的,如果递归栈过深,甚至这些极不可能的情况也可以通过切换到堆排序来消除。因此,简而言之,对于好的快速排序实现来说,最坏情况实际上并不存在,因为它已经被设计出来,并且执行时间为O(n*logn)。
快速排序和归并排序是两种递归排序算法,但归并排序具有最坏情况Θ(nlogn)运行时间的优点。与快速排序和堆排序不同,归并排序是一种稳定排序,并且可以轻松地适应于操作链接列表和存储在慢速访问媒体(如磁盘存储或网络附加存储)上的非常大的列表。虽然快速排序可以编写为操作链接列表,但如果没有随机访问,它通常会遭受糟糕的枢轴选择。归并排序的主要缺点是,在操作数组时,它在最佳情况下需要Θ(n)的辅助空间,而使用原位分区和尾递归的快速排序变体仅使用Θ(logn)的空间。(请注意,在操作链接列表时,归并排序仅需要少量的常量辅助存储器。)
qsort
、Python的list.sort
以及Firefox JavaScript中的Array.prototype.sort
都是强化版的归并排序。(GNU STL的sort
使用Introsort,但这可能是因为在C++中,交换操作可能比复制操作更加高效。) - Jason Orendorff