问题:平时的开发中,我们都是直接使用这些现成的函数来实现业务逻辑中的排序功能。这些排序函数是如何实现的吗?底层都利用了哪种排序算法呢?比如 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) 时间复杂度出现的主要原因还是因为我们分区点选得不够合理。什么样的分区点合理?
最理想的分区点是:被分区点分开的两个分区中,数据的数量差不多。比较常用的分区算法:
- 三数取中法(如果数组较大,可能需要五数取中、十数取中):从区间的首、尾、中间,分别取出一个数,然后对比大小,取这 3 个数的中间值作为分区点
- 随机法:每次从要排序的区间中,随机选择一个元素作为分区点。不保证每次分区点都选的很好,但是从概率角度推理,不大可能出现每次分区都很差的情况,平均情况下,分区点还是比较好的
- 快排通过递归来实现的,要警惕堆栈溢出。解决方法:一限制递归深度,一旦递归过深超过阈值,停止递归;二是通过在堆上手动模拟一个函数调用栈,手动模拟递归压栈、出栈的过程,摆脱系统栈的大小限制
Glibc 中的 qsort() 函数举例
源码分析:
- qsort() 会优先使用归并排序来排序输入数据:因为归并排序的空间复杂度是 O(n),所以对于小数据量的排序,额外的空间问题不大,通过空间换时间
- 要排序的数据量比较大的时候,qsort() 会改为用快速排序算法来排序。如何选择分区点:源码中采用的是“三数取中法”,另外qsort() 是通过自己实现一个堆上的栈,手动模拟递归来解决的
- qsort() 并不仅仅用到了归并排序和快速排序,它还用到了插入排序。当要排序的区间中,元素的个数小于等于 4 时,qsort() 就退化为插入排序:因为在小规模数据面前,O(n2) 时间复杂度的算法并不一定比 O(nlogn) 的算法执行时间长
- 对于小规模数据的排序,O(n2) 的排序算法并不一定比 O(nlogn) 排序算法执行的时间长。对于小数据量的排序,我们选择比较简单、不需要递归的插入排序算法
- 此外,在 qsort() 插入排序的算法实现中,也利用了哨兵来简化代码,提高执行效率这种编程技巧。虽然哨兵可能只是少做一次判断,但是毕竟排序函数是非常常用、非常基础的函数,性能的优化要做到极致