数据结构与算法之美_14_排序优化:如何实现一个通用的、高性能的排序函数?

匆匆过客 提交于 2020-04-05 18:37:30

几乎所有的编程语言都提供了排序函数,比如 C 语言中 qsort(),C++ STL 中的 sort()、stable_sort(),还有 Java 语言中的 Collections.sort()。在平时的开发中,我们也都是直接使用这些现成的函数来实现业务逻辑中的排序功能。

基于这些问题,我们看下排序的最后一块内容:如何实现一个通用的、高性能的排序函数?

如何选择合适的排序算法?

先回顾下前面讲的几种排序算法。

线性排序算法的时间复杂度比较低,适用场景比较特殊,如果要写一个通用的排序函数,不能选择线性排序算法。

如果对于小规模数据进行排序,可以选择时间复杂度是 O(n^2) 的算法;如果对于大规模的数据进行排序,时间复杂度是 O(nlogn) 的算法更高效。所以,为了兼顾任意规模数据的排序,一般都会首先时间复杂度是 O(nlogn) 的排序算法来实现排序函数。

时间复杂度是 O(nlogn) 的排序算法不止一个,比如之前讲过的归并排序、快速排序,后面还会讲到堆排序。堆排序和快速排序都有比较多的应用,比如 Java 语言采用堆排序实现排序函数,C 语言使用快速排序实现排序函数。

快速排序在最坏情况下,时间复杂度是 O(n^2) ,而归并排序可以做到平均情况、最坏情况下的时间复杂度都是 O(nlogn) ,但是它不是原地排序算法,空间复杂度是 O(n)。所以,粗略的说,如果要排序 100MB 的数据,除了数据本身占用的内存之外,排序算法还要额外占用 100MB 的内存空间,空间耗费就翻倍了。

快速排序比较合适实现排序函数,那如何优化它在最坏情况下的时间复杂度是 O(n^2) 这个“复杂度恶化”的问题呢?

如何优化快速排序

为什么最坏情况下快排的时间复杂度是 O(n^2) 呢?我们讲过,如果数据原来就是有序的或者接近有序的,每次分区点都选择最后一个数据,那快速排序算法就会变得非常糟糕,时间复杂度就会退化为 O(n^2)。实际上,这种 O(n^2) 时间复杂度出现的主要原因还是因为我们分区点选的不够合理。

最理想的分区点是:被分区点分开的两个分区中,数据的数量差不多。

如果很粗暴地直接选择第一个或者最后一个数据作为分区点,不考虑数据的特点,肯定会出现最坏情况时间复杂度是 O(n^2) 的。为了提高排序算法的性能,我们也要尽可能地让每次分区都比较平均。

1. 三数取中法

我们从区间的首、尾、中间,分别取出一个数,然后对比大小,取这 3 个数的中间值作为分区点。这样每间隔某个固定的长度,取数据出来比较,将中间值作为分区点的分区算法,肯定要比单纯取某一个数据更好。但是,如果要排序的数组比较大,那“三数取中”可能就不够了,需要“五数取中”或者“十数取中”。

2. 随机法

随机法就是每次从要排序的区间中,随机选择一个元素作为分区点。这种方法并不能保证每次分区都选的比较好,但是从概率的角度来看,也不太可能出现每次分区点都选的很差的情况,所以平均情况下,这样选的分区点是比较好的。时间复杂度退化为最糟糕的 O(n^2) 的情况,出现的可能性不大。

快速排序是用递归来实现的,递归要警惕堆栈溢出。为了避免快速排序里,递归过深而堆栈过小,导致堆栈溢出,我们有两种解决办法:第一种是限制递归的深度。一旦递归过深,超过了我们实现设定的阈值,就停止递归。第二种是通过在堆上模拟实现一个函数调用栈,手动模拟递归压栈、出栈的过程,这样就没有系统栈大小的限制了。

举例分析排序函数

我们拿 Glibc 中的 qsort() 函数举例说明。

qsort() 会优先使用归并排序来排序输入数据,因为归并排序的空间复杂度是 O(n),所以对于小数据量的排序,比如 1KB、2KB 等,归并排序额外需要 1KB、2KB 的内存空间,这个问题不大。

如果数据量太大,这时候 qsort() 会改用快速排序算法来排序。

qsort() 在快速排序中的的选择分区点的方法就是“三数取中法”。还有之前提到的递归太深会导致堆栈溢出的问题,qsort() 是通过自己实现一个堆上的栈,手动模拟递归来解决的。

qsort() 不仅用到了归并排序和快速排序,它还用到了插入排序。在快速排序的过程中,当腰排序的区间中,元素的个数小于等于 4 时,qsort() 就退化为插入排序,不再继续用递归来做快速排序。

在小规模数据面前,O(n^2) 时间复杂度的算法并不一定比 O(nlogn) 的算法执行时间长。

时间复杂度代表的是一个增长趋势,如果画增长曲线图,你会发现 O(n^2) 比 O(nlogn) 要陡峭,也就是说增长趋势要更猛一些。但是,在大 O 复杂度表示法中,我们会省略低阶、系数和常数,也就是说,O(nlogn) 在没有省略低阶、系数、常数之前可能是 O(knlogn + c),且 k 和 c 有可能还是一个比较大的数。

假设 k=1000,c=200,当我们对小规模数据(比如 n=100)排序时,n^2 的值实际上比 knlogn+c 还要小。

knlogn+c = 1000 * 100 * log100 + 200 远大于10000

n^2 = 100*100 = 10000

所以对于小规模数据的排序,O(n^2) 排序算法并不一定比 O(nlogn) 排序算法执行的时间长。对于小数据量的排序,我们选择比较简单、不需要递归的插入排序算法。

之前讲的哨兵来简化代码,提高执行效率,在 qsort() 插入排序的算法实现中也利用了这一编程技巧。虽然哨兵可能只是少做一次判断,但是毕竟排序函数是非常常用、非常基础的函数,性能的优化要做到机制。

内容小结

我们讲了如何来实现一个工业级的通用的、高效的排序函数。我们大部分的排序算法都是采用 O(nlogn) 排序算法来实现的,为了尽可能提高性能,会做很多优化。

还重点介绍了快速排序的一些优化策略,比如合理选择分区点、避免递归太深等等。

课后思考

Java 语言中的排序函数用什么排序算法来实现的呢?

答: 在 Java 8 中,排序算法主要是通过 Arrays 来实现的。根据数据类型它的排序又分为两种类型:

对于基本数据类型,主要是通过 DualPivotQuicksort 类来进行排序。它主要是使用双分区点的快速排序,但是当数据量小于 47 时,切换为插入排序。对于 byte、char、short 类型,当数据量到达一定量(byte 是29 个,char 和 short 是3200 个)时采用计数排序,否则就恢复快速排序。

对于引用数据类型,会使用归并排序以及 TimSort 排序。

TimSort 排序的大致思路为: 1 元素个数 < 32, 采用二分查找插入排序(Binary Sort) 2 元素个数 >= 32, 采用归并排序,归并的核心是分区(Run) 3 找连续升或降的序列作为分区,分区最终被调整为升序后压入栈 4 如果分区长度太小,通过二分插入排序扩充分区长度到分区最小阙值 5 每次压入栈,都要检查栈内已存在的分区是否满足合并条件,满足则进行合并 6 最终栈内的分区被全部合并,得到一个排序好的数组

Timsort的合并算法非常巧妙:

1 找出左分区最后一个元素(最大)及在右分区的位置 2 找出右分区第一个元素(最小)及在左分区的位置 3 仅对这两个位置之间的元素进行合并,之外的元素本身就是有序的

TimSort 排序详解

Timsort详解

标签
易学教程内所有资源均来自网络或用户发布的内容,如有违反法律规定的内容欢迎反馈
该文章没有解决你所遇到的问题?点击提问,说说你的问题,让更多的人一起探讨吧!