stl std::sort 内省排序 sort 函数源码分析 排序算法比较 排序算法性能 排序算法时空复杂度分析 为什么快排最快

本文主要是介绍stl std::sort 内省排序 sort 函数源码分析 排序算法比较 排序算法性能 排序算法时空复杂度分析 为什么快排最快,希望对大家解决编程问题提供一定的参考价值,需要的开发者们随着小编来一起学习吧!

算法课程第二次上机排序算法性能比较上机报告

前言

这个是大三上的一个作业的一部分摘录用于复习。由于要匹配课程给分的查重时间节点,所以现在发布不太符合时间线, 不过我感觉之后复习在csdn的笔记也不要顺着看就是了。

对于排序算法的性能分析,理论分析有些算法好像很慢或者很快。而实践起来好像又不一样。为什么快排是实际用的时候理论上最快的呢?根据时空复杂度守恒定律,为什么快排可以快呢?这些都是本文分析的内容。

而实际上,由各种大佬实现的学院派语言 C++ 的 STL 中的 sort 算法又是怎么排的呢?

回想算法分析课(额,实际主要不还是在数据结构里面学的的==)的整个学习过程,其实最快的排序算法实际是一个极限利用上所有信息的过程,网上也有些对排序算法的分析是基于香农的信息论的(正确性存疑)。


概览

整个 nlogn 排序算法的出分析思路其实是有一个分析思路的。(?)

首先从最暴力的选择排序,到利用上有限的排序好的性质,搞出了插入排序,从而在已经排好的情况下能达到 O(n),冒泡也是同样的思路(消除逆序+提前退出)。

但是其实发现插入排序也没有压榨到极限,所以试着插入过程引入二分法,结果某些极限排列时(插入的数字是有序序列的中位数)好像确实不错。

对更一般的序列好像没有提升,然而又发现了如果用快排这种思路(我唯一能记住的代码只有 lr + pivot 左右轮流挖坑法)再加上随机化选择就能让每次选择的时候不再完全依赖于原来排列了,随机化算法,赢!

但是这样就必须引入递归空间的花费,等于用空间存储更多信息换取了更多的选择。

但是这个空间并不是必须的,如果我们能容忍非1系数的复杂度,我们当然还可以上多次遍历和隐式数据结构来做掉快排那部分非尾递归的必要信息,这就是堆排,属于基于比较排序算法的中庸之道了他也是学的这么多个里面唯一一个 O1 时候能做到所有情况的复杂度都稳定发挥的算法。

不过故事最终终结在非比较的排序算法上,基数排序,最强空间换时间。

实际运行时,算法的性能涉及到大量数据的分布问题,我们都很熟悉的 Cache 再次成为性能骤降的障碍。与此同时,一直没基于有序信息上分布的数据还把分支预测给难倒。结论仍然是没有银弹,分类讨论才是上策。我们下面就会看到分支预测、Cache(数据的 locality)怎么影响了理论很好看的算法的性能。(从而 stl 不优先使用他们)

不过还是要提一的就是堆排是好用的内存上部分排序的算法了,因为他每一轮求出的是类似选择或者冒泡这样的局部最值的算法,但是比他们快,所以 TopK 次要选择可以用堆排。当然,不需要有序的最佳选择当然是基于快排 partition 的随机选择,因为快排这个 partition 也不需要前面 intra 的有序,说不定选 pivot 的时候就中奖了。至于为什么不用算法导论里面那个理论上直接就是 O(n) 的最神奇的 mutual 递归中位数估计中位数 BFPRT 呢?这是另一个故事,无非就是实际表现不如随机选择。


题目:

实现插入排序(Insertion Sort,IS),自顶向下归并排序(Top-down Mergesort,TDM),自底向上归并排序(Bottom-up Mergesort,BUM),随机快速排序(Random Quicksort,RQ),Dijkstra 3-路划分快速排序(Quicksort with Dijkstra 3-way Partition,QD3P)。在你的计算机上针对不同输入规模数据进行实验,对比上述排序算法的时间性能。要求对于每次输入运行10次,记录每次时间,取平均值。(实验结果其实根据下面的分析基本都能想出来,这里不贴实验报告了,没用)

排序算法区别与理论分析

下面从算法复杂度分析,实际算法在现在体系结构中的使用时的各种情况进行理论分析,得出为什么快排实际会快,从理论上分析各个排序算法的优劣和适用情况。

插入排序小数据最佳

Insertion Sort 的优点在于他代码紧凑不复杂,这样对于短小数据实际的速度会很快,对于流水线和分支预测的打乱较少,对于短小数据,实际速度快地话就不用考虑复杂度了。

快速排序的退化

快速排序如果每次都能选到 median,就能保证是最好情况的 nlogn 复杂度,所以一般需要采用随机化方案。但是问题在于对于重复的数据, 他递归进入的时候等价于最坏情况的每次 pivot 都没选中 median,反而选中了 min或者 max的话,退化到 n平方。所以这种情况 Dijkstra 的 3-way partition 就解决了这部分的问题。

归并耗空间

Merge sort 是稳定的,而且复杂度也是稳定的,不会引发时间消耗的骤降或升,但是问题在于他的空间复杂度高

实际快排也有递归的空间开销,而且无法避免(改迭代也要用栈),优化到极限也最多是利用尾递归(我之前另一篇分析了这个不断展开左边或右边,最终空间复杂度只能做到 1/2logn,数量级不变。

时空复杂度权衡

总的来说,排序的性能就是 trade off 问题,选择排序是最简单的算法,但是他没有用上部分有序的性质,每次做了同样的工作,而插入排序将已经排序好的部分数组的有序性利用上,最好情况即已经有序能做到 n。冒泡排序也是一样的通过利用如果已经有序提前退出也利用上了部分有序性。插入排序另一个做法是用上 binary search,最好情况能做到 nlogn,但是这要求插入的元素能真好落在中点位置(即中位数咯),快排和二分插入排序是同一种策略的两种不同用法,对于二分插入来说每次选择的将要插入的元素是固定的,对于快排来说,则可以通过随机选 pivot 的方法使得这个能真好落在中点位置的元素数量增多。这个利用非常美好。但是就要求用上递归的空间消耗了,因为每次都要保留更多的信息量,就比如二分插入排序通过在后面部分随机选取插入的元素一样,必须维护你选了谁剩下谁。堆排序没有增加空间开销就实现了 nlogn,但是他其实实际是3n/2 *logn,和 mergesort 的纯粹 nlogn 也不一样,通过利用一个 implicit 的堆结构,实现了更多的信息量。

三路划分对于不是重复的数据很多的话实际判断增加了,划分多了,自然时间复杂度会高一点。我们后面也能看到实测普通的数据来说三路划分的确不够快排好。而对于有重复数组多的情况,三路划分的确更好。

底层角度性能分析

前面说了 insertion sort 的实现简单(一个 nested for loop,里面一个比较语句如果偏序成立就插入),对于小数据是很好的,因为 CPU 在 Cache 没有 miss 的情况下,简单的比较和内存移动也很快就能执行完毕(因为物理寄存器很多,有重命名算法,此时小量冲突的话,他们的数据相关性和分支预测的失败都不重要了),所以小数据 n方复杂度其实也就等于常数

对于堆排序和快速排序的比较,如果快排能做到每次选 pivot 选的好,堆排序的复杂度肯定是比快排高的(1.5nlogn 和 nlogn),而实际从比较的角度来说,堆排序还有一个缺点就是他比较的元素会差很远,完全二叉树父子间距会是两倍,如果数据量稍大一点,就会触发 Cache miss 然后开销直接增加很多了。但是堆排的空间是稳定 O(1),如果能规避这个缓存失效问题,实际还是能用的(毕竟快排也无法保证能选中 median 做 pivot)。

Merge sort 实际使用只有要求 stable_sort 的时候才用,这是因为一个是他的空间开销太大了,在 stl 里面 stable_sort ,如果内存足够,会采用 merge sort,否则就会用 n方的稳定算法了。但是实际为什么 mergesort 的时间性能也不够快排好呢?这个点实际很容易理解,首先 merge sort 不管是 top down 还是 bottom top,都必须发生一个情况:已经访问过的元素将来还要访问!比如你本来 merge 了左边的,但是这个等一下又要去和右边的 merged 的结果再 merge 一次,理论上来讲,如果内存跨度大,这就意味着刚才 evict 出去的的 page 之后必须再 reload 回来。而快排比较过程只和 pivot 比较,如果左边排序结束了之后,之后再也不会访问左边。谁的 locality 访问性质更好一目了然。这样也就决定了 merge sort 并没有用在非 stable sort 中,因为的确不讨好。但是他的时间复杂度又的确是稳定中理论上比较好的一个。但是对于 external storage 的数据排序,merge sort 就大显身手了。广泛用于数据库,大数据等方面。

内省排序

综上所述,很容易设计出适应各种不同数据类型的排序方案了,STL 的 std::sort 这个排序实际就是一个考虑了各种情况的内省算法,他通过利用多种排序保证了快速排序永远不会退化到 n方复杂度的情况。而且保证空间复杂度不会引发太多消耗(如果递归过深可能超过编译器指定的最大栈宽度引发爆栈,而用堆上内存来实现快排本身会引发一次内存读写获取区间范围,远不如作为递归调用参数(通常在寄存器)的访问快)。所以内省排序主要就是解决两个问题,一个是退化到 n平方,一个是爆栈。

首先是首先采用随机化快排,个基本是板上钉钉的了。而快排本身 partition 了之后,就是一个完全的子数组了,只需要保证 partition 互相独立内是有序即可,再没有合并操作。这个性质就能够开展各种不同的子数组排序方案了。对于一个比较小的 partition 来说,之前分析的那些 cache locality 的问题也不复存在了。

对于数据量十分小的 partition,直接使用插入排序,又快又好。如果递归深度过高,但是数据量又不够小,就只能采用堆排序了,毕竟插入排序此时的 locality还不如堆排,理论复杂度也不如堆排。

实际 STL 的 std::sort 还有很多优化,首先前面说到的类似尾递归的优化必须做。然后是尽可能去减少分支,采用了课本 algs4 练习题提到的去掉 bound checking 的方案,所以这也要求自定义 comparator 的时候必须提供一个严格弱序的比较器,不能提供一个含等号的。(至于为什么这个在algs4的方案里讲到了,要避免边界判断,就要保证做指针到达边界右边最后一个元素必须触发退出条件,即分区内都小于 pivot,具体的分析这里不赘述了)。

一种内省排序算法的实现

下面这个代码能写出来,主要是学习了 algs4、以及 MSVC 的 std::sort 的实现(虽然 C++ 很牛,但是他的命名空间管理实在拉跨,基础库的代码基本没法看,全是宏和下划线,和 Java 的比起来,高下立判,这里也不贴 stl 的代码了,有兴趣的可以自己开个 IDE F12或者Ctrl+B 看一下)。正确性不敢保证,只是用 leetcode OJ 测试了一下`(*>﹏<*)′。不过 push_back 这个避免边界判断确实是太拉了,实际 stl 的写法更加巧妙,这里写不下了。堆排我曾经可以默写,但是实际 leetcode 的测试并不会出现爆栈的可能,所以偷懒就不写了,对什么时候转用堆排感兴趣可以直接看 MSVC 的实现。

//内省排序算法的实现:
//这里给出一个根据上面的理论分析的最终的排序 cpp 代码
class Solution {
public:vector<int> sortArray(vector<int>& nums) {nums.push_back(INT_MAX);qs(nums, 0, nums.size()-2);nums.pop_back();return nums;}void qs(vector<int>& array, int left, int right){int sep;if(right-left < 4){insertion(array, left, right);}while(left < right){sep = partation(array, left,right);swap(array[left], array[sep]);qs(array, sep+1, right);right = sep-1;}
}
inline int partation(vector<int>&a, int lo, int hi) {int k = rand() % (hi - lo + 1) + lo;swap(a[lo], a[k]);int v = a[lo]; k = lo; ++hi; while(true){while (a[++lo] < v);while(v < a[--hi]);if(lo>=hi)return hi;swap(a[lo], a[hi]);}
}
void insertion(vector<int>&a, int lo, int hi){for(int i = lo;i<hi;i++){int to_ins = a[i+1];int cur = i;while(cur >= 0 && a[cur]>to_ins){a[cur+1] = a[cur];--cur;}a[cur+1] = to_ins;}
}
};

这篇关于stl std::sort 内省排序 sort 函数源码分析 排序算法比较 排序算法性能 排序算法时空复杂度分析 为什么快排最快的文章就介绍到这儿,希望我们推荐的文章对编程师们有所帮助!



http://www.chinasem.cn/article/338750

相关文章

Java调用C++动态库超详细步骤讲解(附源码)

《Java调用C++动态库超详细步骤讲解(附源码)》C语言因其高效和接近硬件的特性,时常会被用在性能要求较高或者需要直接操作硬件的场合,:本文主要介绍Java调用C++动态库的相关资料,文中通过代... 目录一、直接调用C++库第一步:动态库生成(vs2017+qt5.12.10)第二步:Java调用C++

springboot+dubbo实现时间轮算法

《springboot+dubbo实现时间轮算法》时间轮是一种高效利用线程资源进行批量化调度的算法,本文主要介绍了springboot+dubbo实现时间轮算法,文中通过示例代码介绍的非常详细,对大家... 目录前言一、参数说明二、具体实现1、HashedwheelTimer2、createWheel3、n

Mybatis 传参与排序模糊查询功能实现

《Mybatis传参与排序模糊查询功能实现》:本文主要介绍Mybatis传参与排序模糊查询功能实现,本文通过实例代码给大家介绍的非常详细,感兴趣的朋友跟随小编一起看看吧... 目录一、#{ }和${ }传参的区别二、排序三、like查询四、数据库连接池五、mysql 开发企业规范一、#{ }和${ }传参的

C/C++错误信息处理的常见方法及函数

《C/C++错误信息处理的常见方法及函数》C/C++是两种广泛使用的编程语言,特别是在系统编程、嵌入式开发以及高性能计算领域,:本文主要介绍C/C++错误信息处理的常见方法及函数,文中通过代码介绍... 目录前言1. errno 和 perror()示例:2. strerror()示例:3. perror(

kotlin中const 和val的区别及使用场景分析

《kotlin中const和val的区别及使用场景分析》在Kotlin中,const和val都是用来声明常量的,但它们的使用场景和功能有所不同,下面给大家介绍kotlin中const和val的区别,... 目录kotlin中const 和val的区别1. val:2. const:二 代码示例1 Java

C++中std::distance使用方法示例

《C++中std::distance使用方法示例》std::distance是C++标准库中的一个函数,用于计算两个迭代器之间的距离,本文主要介绍了C++中std::distance使用方法示例,具... 目录语法使用方式解释示例输出:其他说明:总结std::distance&n编程bsp;是 C++ 标准

Go标准库常见错误分析和解决办法

《Go标准库常见错误分析和解决办法》Go语言的标准库为开发者提供了丰富且高效的工具,涵盖了从网络编程到文件操作等各个方面,然而,标准库虽好,使用不当却可能适得其反,正所谓工欲善其事,必先利其器,本文将... 目录1. 使用了错误的time.Duration2. time.After导致的内存泄漏3. jsO

Kotlin 作用域函数apply、let、run、with、also使用指南

《Kotlin作用域函数apply、let、run、with、also使用指南》在Kotlin开发中,作用域函数(ScopeFunctions)是一组能让代码更简洁、更函数式的高阶函数,本文将... 目录一、引言:为什么需要作用域函数?二、作用域函China编程数详解1. apply:对象配置的 “流式构建器”最

Python如何使用__slots__实现节省内存和性能优化

《Python如何使用__slots__实现节省内存和性能优化》你有想过,一个小小的__slots__能让你的Python类内存消耗直接减半吗,没错,今天咱们要聊的就是这个让人眼前一亮的技巧,感兴趣的... 目录背景:内存吃得满满的类__slots__:你的内存管理小助手举个大概的例子:看看效果如何?1.

Python实现无痛修改第三方库源码的方法详解

《Python实现无痛修改第三方库源码的方法详解》很多时候,我们下载的第三方库是不会有需求不满足的情况,但也有极少的情况,第三方库没有兼顾到需求,本文将介绍几个修改源码的操作,大家可以根据需求进行选择... 目录需求不符合模拟示例 1. 修改源文件2. 继承修改3. 猴子补丁4. 追踪局部变量需求不符合很