跳转至

14 排序优化:如何实现一个通用的、高性能的排序函数?

几乎所有的编程语言都会提供排序函数,比如C语言中qsort(),C++ STL中的sort()、stable_sort(),还有Java语言中的Collections.sort()。在平时的开发中,我们也都是直接使用这些现成的函数来实现业务逻辑中的排序功能。那你知道这些排序函数是如何实现的吗?底层都利用了哪种排序算法呢?

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

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

如果要实现一个通用的、高效率的排序函数,我们应该选择哪种排序算法?我们先回顾一下前面讲过的几种排序算法。

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

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

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

不知道你有没有发现,使用归并排序的情况其实并不多。我们知道,快排在最坏情况下的时间复杂度是O(n2),而归并排序可以做到平均情况、最坏情况下的时间复杂度都是O(nlogn),从这点上看起来很诱人,那为什么它还是没能得到“宠信”呢?

还记得我们上一节讲的归并排序的空间复杂度吗?归并排序并不是原地排序算法,空间复杂度是O(n)。所以,粗略点、夸张点讲,如果要排序100MB的数据,除了数据本身占用的内存之外,排序算法还要额外再占用100MB的内存空间,空间耗费就翻倍了。

前面我们讲到,快速排序比较适合来实现排序函数,但是,我们也知道,快速排序在最坏情况下的时间复杂度是O(n2),如何来解决这个“复杂度恶化”的问题呢?

如何优化快速排序?

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

那什么样的分区点是好的分区点呢?或者说如何来选择分区点呢?

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

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

我这里介绍两个比较常用、比较简单的分区算法,你可以直观地感受一下。

1.三数取中法

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

2.随机法

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

好了,我这里也只是抛砖引玉,如果想了解更多寻找分区点的方法,你可以自己课下深入去学习一下。

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

举例分析排序函数

为了让你对如何实现一个排序函数有一个更直观的感受,我拿Glibc中的qsort()函数举例说明一下。虽说qsort()从名字上看,很像是基于快速排序算法实现的,实际上它并不仅仅用了快排这一种算法。

如果你去看源码,你就会发现,qsort()会优先使用归并排序来排序输入数据,因为归并排序的空间复杂度是O(n),所以对于小数据量的排序,比如1KB、2KB等,归并排序额外需要1KB、2KB的内存空间,这个问题不大。现在计算机的内存都挺大的,我们很多时候追求的是速度。还记得我们前面讲过的用空间换时间的技巧吗?这就是一个典型的应用。

但如果数据量太大,就跟我们前面提到的,排序100MB的数据,这个时候我们再用归并排序就不合适了。所以,要排序的数据量比较大的时候,qsort()会改为用快速排序算法来排序

那qsort()是如何选择快速排序算法的分区点的呢?如果去看源码,你就会发现,qsort()选择分区点的方法就是“三数取中法”。是不是也并不复杂?

还有我们前面提到的递归太深会导致堆栈溢出的问题,qsort()是通过自己实现一个堆上的栈,手动模拟递归来解决的。我们之前在讲递归那一节也讲过,不知道你还有没有印象?

实际上,qsort()并不仅仅用到了归并排序和快速排序,它还用到了插入排序。在快速排序的过程中,当要排序的区间中,元素的个数小于等于4时,qsort()就退化为插入排序,不再继续用递归来做快速排序,因为我们前面也讲过,在小规模数据面前,O(n2)时间复杂度的算法并不一定比O(nlogn)的算法执行时间长。我们现在就来分析下这个说法。

我们在讲复杂度分析的时候讲过,算法的性能可以通过时间复杂度来分析,但是,这种复杂度分析是比较偏理论的,如果我们深究的话,实际上时间复杂度并不等于代码实际的运行时间。

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

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

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

n^2 = 100*100 = 10000

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

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

好了,C语言的qsort()我已经分析完了,你有没有觉得其实也不是很难?基本上都是用了我们前面讲到的知识点,有了前面的知识积累,看一些底层的类库的时候是不是也更容易了呢?

内容小结

今天我带你分析了一下如何来实现一个工业级的通用的、高效的排序函数,内容比较偏实战,而且贯穿了一些前面几节的内容,你要多看几遍。我们大部分排序函数都是采用O(nlogn)排序算法来实现,但是为了尽可能地提高性能,会做很多优化。

我还着重讲了快速排序的一些优化策略,比如合理选择分区点、避免递归太深等等。最后,我还带你分析了一个C语言中qsort()的底层实现原理,希望你对此能有一个更加直观的感受。

课后思考

在今天的内容中,我分析了C语言的中的qsort()的底层排序算法,你能否分析一下你所熟悉的语言中的排序函数都是用什么排序算法实现的呢?都有哪些优化技巧?

欢迎留言和我分享,我会第一时间给你反馈。


特别说明:

专栏已经更新一月有余,我在留言区看到很多同学说,希望给出课后思考题的标准答案。鉴于留言区里本身就有很多非常好的答案,之后我会将我认为比较好的答案置顶在留言区,供需要的同学参考。

如果文章发布一周后,留言里依旧没有比较好的答案,我会把我的答案写出来置顶在留言区。

最后,希望你把思考的过程看得比标准答案更重要。

精选留言(15)
  • Jerry银银 👍(100) 💬(11)

    说说我觉得文章可能存在的一个问题,再借此问题,正好回答下思考题! ---------------------- 文章中有一段话,如下: "时间复杂度是 O(nlogn) 的排序算法不止一个,我们已经讲过的有归并排序、快速排序,后面讲堆的时候我们还会讲到堆排序。堆排序和快速排序都有比较多的应用,比如 Java 语言采用堆排序实现排序函数,C 语言使用快速排序实现排序函数。" 这里说,”Java语言采用堆排序实现排序函数“,这句话是不是错误的? 在JDK中,排序相关的主要是两个工具类:Arrays.java 和 Collections.java,具体的排序方法是sort()。这里要注意的是,Collections.java中的sort()方法是将List转为数组,然后调用Arrays.sort()方法进行排序,具体代码如下(留言中代码格式可能有点混乱,讲究看看,也可以自行参看List.sort()): default void sort(Comparator<? super E> c) { Object[] a = this.toArray(); Arrays.sort(a, (Comparator) c); ListIterator<E> i = this.listIterator(); for (Object e : a) { i.next(); i.set((E) e); } } 在Arrays类中,sort()有一系列的重载方法,罗列几个典型的Arrays.sort()方法如下: public static void sort(int[] a) { DualPivotQuicksort.sort(a, 0, a.length - 1, null, 0, 0); } public static void sort(long[] a) { DualPivotQuicksort.sort(a, 0, a.length - 1, null, 0, 0); } public static void sort(Object[] a) { if (LegacyMergeSort.userRequested) legacyMergeSort(a); else ComparableTimSort.sort(a, 0, a.length, null, 0, 0); } 重载方法虽然多,但是从“被排序的数组所存储的内容”这个维度可以将其分为两类: 1. 存储的数据类型是基本数据类型 2. 存储的数据类型是Object 第一种情况使用的是快排,在数据量很小的时候,使用的插入排序; 第二种情况使用的是归并排序,在数据量很小的时候,使用的也是插入排序 以上两种场景所用到的排序都是「混合式的排序」,也都是为了追求极致的性能而设计的。另外,第二种排序有个专业的名称,叫:TimSort(可以自行Wikipedia)

    2019-03-02

  • Andrew 陈震 👍(47) 💬(3)

    老师,我有一个问题,关于递归太深导致堆栈溢出的问题。对于这个问题,您说一般有两种解决方法,一是设置最深的层数,如果超过层数了,就报错。对于这样的问题,我想排序一个数列,超过了层数,难道就不排了么?我看有留言说,stl中的sort默认是使用快排的,但当递归深度过大时 会转为使用归并排序。但是归并排序也是递归排序啊,如果两种排序都达到了最深层数怎么处理?另外,在排序之前,能否估算出排序是否超过最深层数呢?如果估算不出,那岂不是要先排一遍,发现超过层数,再换用别的。我的想法是设个阈值,不超过阈值,用一种,超过了,用另一种。 第二种应对堆栈溢出的方法是通过在堆上模拟实现一个函数调用栈,手动模拟递归压栈、出栈的过程。这个方法在您的几篇教程里都提到过,但是不详细,您能否稍微详细讲解一下。 谢谢老师

    2018-10-22

  • 蛐鸣 👍(41) 💬(2)

    看了一下,.NET里面的Array排序实现: 1. 三个以内的,直接比较,交换进行实现 2.大于3个小于16个的,用的是插入排序进行的实现 3.对于大于16,并且深度限制是0的,用的是堆排序实现的 4.对于大于15,并且深度限制不是0的,使用的是快速排序;然后快速排序分区使用的也是三数取中法

    2018-11-02

  • 👍(20) 💬(2)

    qsort中为避免递归调用过深,所以在堆上模拟了栈。不知道是否是将递归调用,改写为循环非递归方式呢?

    2018-10-22

  • 落叶飞逝的恋 👍(13) 💬(4)

    老师,你好,我终于认真消化完了前面的知识,没有半点马虎,也给自己打个卡记录。 关于思考题: 查看了Java的Arrays.sort 1.若数组元素个数总数小于47,使用插入排序 2.若数据元素个数总数在47~286之间,使用快速排序。应该是使用的优化版本的三值取中的优化版本。 3.若大于286的个数,使用归并排序。 底层实现的代码比之前示范写的代码校验多,所以目前只能看到这,下面继续加油吧!

    2018-12-04

  • 雨天 👍(13) 💬(1)

    使用快排如何解决不稳定排序的问题?

    2018-10-22

  • favorlm 👍(12) 💬(1)

    虽然说思考很重要,但是面试还是需要你实现一种算法。

    2018-11-04

  • helloworld2018 👍(10) 💬(1)

    老师好,请教个问题,快排不是稳定算法,为啥还要用快排

    2019-07-28

  • 学习爱好者 👍(10) 💬(1)

    王老师,总结8种排序算法的那个图,桶排序不一定是稳定排序吧?比如桶内排序用快排的时候

    2018-11-05

  • 西南偏北 👍(8) 💬(1)

    老师,你之前讲的快排、归并,原理我都理解的很清晰,但是一旦到转换成代码的时候,感觉一脸懵逼,你最开始这是这样吗?

    2018-10-23

  • 公号-彤哥读源码 👍(7) 💬(1)

    归并排序空间复杂度应该没那么高,因为实际存储的都是指针或者引用。

    2018-12-14

  • Aaaaaaaaaaayou 👍(2) 💬(1)

    宠信 => 宠幸

    2019-05-23

  • 零维 👍(1) 💬(1)

    请问,老师讲的「手动模拟调用栈」是什么意思,是指把递归代码写成非递归代码的形式吗?

    2019-09-19

  • 铃兰Neko 👍(1) 💬(1)

    对java来说,跟踪了一下 jdk1.7的源码: Arrays 这个工具类提供的 sort 方法注释说的很清楚, 使用的是: DualPivotQuicksort 这个类实现排序; 1. 显然是快排,提供 O(n long(n)) 的 时间复杂度 2. 测试过很多数据集,这些数据集让传统的 快排命中最差情况,即n方,而dual pivot不会 3. 其他的大多数情况, dual pivot 也比传统的 one pivot 要快很多。

    2018-12-18

  • Geek_eedadb 👍(0) 💬(1)

    哨兵是怎么用的?

    2019-10-24