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

21 篇文章 1 订阅
6 篇文章 0 订阅

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

前言

这个是大三上的一个作业的一部分摘录用于复习。由于要匹配课程给分的查重时间节点,所以现在发布不太符合时间线, 不过我感觉之后复习在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;
    }
}
};

  • 1
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值