本文是学习算法的笔记,《数据结构与算法之美》,极客时间的课程
如何实现一个通用的、高性能的排序函数?
如何选择合适的排序算法?
如果要实现一个通用的、高效率的排序函数,我们应该选择哪种排序算法?我们先回顾一下前面讲过的几种排序算法。
我们前面讲过的,线性排序的时间复杂度较低,适用场景比较特殊,所以如果写一个通用的排序函数,不能选择线性排序算法。
如果对小规模进行排序,可以选择时间复杂度是O(n^2)的算法;如果对大规模的数据进行排序,时间复杂度是O(nlogn)的算法更加高效。所以,为了兼顾任意规模的数据的排序,一般都会首选时间复杂度是O(nlogn)的排序算法来实现排序函数。
时间复杂度是O(nlogn)的排序算法不止一个,我们已经讲过的有归并排序、快速排序,后面讲堆的时候,还会讲到堆排序。堆排序和快速排序都有比较多的应用,比如Java语言采用堆排序来实现排序函数,C语言使用快速排序实现排序函数。
快速排序比较适合实现排序函数,但是,我们也知道,快速排序在最坏情况下时间复杂度是O(n^2)如何来解决这个“复杂度恶化”的问题呢?
如何优化快速排序?
前面讲过,快速排序,每次分区点都选择最后一个数据。如果数据原来就是有序的或者接近有序,那时间复杂度就退化为O(n^2)。为了提高排序算法的性能,我们也要尽可能的让每次分区都比较平均。
这里介绍两个比较常用、比较简单的分区算法,你可以直观的感受下。
1、三数取中法
我们从区间的首、尾、中间,分别取出一个娄,然后比大小,取这3数的中间值作为分区点。但是,如果要排序的数组比较大,那“三娄取中”可能就不够了,可能要“五数取中”或者“十数取中”。
2、随机法
随机法就是每次从要排序的区间中,随机选择一个元素作为分区点。这种方法不能俣每次分区点都选的比较好,但从概率角度来看,也不大可能出现每次分区点都出现很差的情况,所以平均情况下,这样的分区点是比较好的。
我们知道,快速排序是用递归来实现的,这就要警惕堆栈溢出。为了避免快速排序里,递归深而堆栈过小,导致堆栈溢出,我们有两种解决办法:一是递归深度,超过了我们事先设定的阈值,就停止递归 。二是通过堆上的模拟实现一个函数调用栈,手动模拟递归压栈,出栈的过程,这样就没有了系统栈大小的限制。
举例分析排序函数
为了让你对如何实现一个排序函数有一个更直观的感受,我拿Glibc中的 qsort() 数数举例说明一下。虽说 qsort() 从名字上看,很低基于快速排序算法实现的,实际上它并不仅仅用了快排这一种算法。
如果你去看源码,你就会发现, qsort() 会优先使用归并排序来排序输入数据,因为归并排序的空间复杂度是O(n),所以小数据量的排序,比如1KB、2KB等,归并排序额外需要1KB、2KB的内存空间,这个问题不磊。现在计算机的内存都挺大的,我们很多时候追求的是速度。用空间来换时间,还是可以的。
但如果数据量太大,就跟我们前面提到的,排序100MB的数据,这个时候我们再用归并排序就不合适了。所以,要排序数据量比较大的时候, qsort() 会改用快速排序算法来排序。
那 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还要小。
所以,对于小规模数据的排序,O(n^2)的排序算法并不一定比O(nlogn) 的排序算法执行的时间长。对于小数据量的排序,我们选择比较简单、不需要递归的插入排序算法。
还记得我们之前讲到的哨兵来简化代码,提高执行效率吗?虽然哨兵可能只是少做一次判断,但是毕竟排序函数是非常常用、非常基础的函数,性能的优化要做做到极致。
好了,C语言qsort() 我已经分析完了,你有没有觉得其实也不是很难?基本上都是用了我们前面讲到的知识点,有了前面的知识积累,看一些底层类库的时候,是不是也更容易了呢?
内容小结
今天我带你分析了一下如何实现一个工业级的通用的、高效的排序函数,内容比较偏实战,而且贯穿了一些前面几节的内容。我们大部分的排序函数都是采用O(nlogn)排序算法来实现,但是为了尽可能地提高性能,会做很多优化。