Knowledge Point(KP): Timsort && Introsort

排序算法,在严蔚敏著作《数据结构(C语言版)》单独为一章,在高德纳著作《计算机程序设计艺术》,这套圣经里,单独为一卷(PS,真的厚)。

本文并不准备深入每一个角落,而是先简要的总结,之后分别对Timesort和Introsort做深入的分析。

本文仅作为个人学习总结,受个人知识上界限制,如有疏漏,恭请指正。


第一部分、各种基本排序策略的简要总结
(1)插入排序
基本思想:讲一个记录插入到已经排好序的有序表中,进而得到一个新的、记录数增1的有序表。
方法包括:直接插入排序,折半插入排序,2-路插入排序,希尔排序
(2)交换排序:
基本思想:比较两个记录的关键字,如果有序则不交换,如果反序则交换
方法包括:冒泡排序,快速排序
(3)选择排序
基本思想:每一趟在记录 n − i + 1 n-i+1 ni+1中选取关键字最小的记录,作为有序序列中第 i i i个记录。
方法包括:简单选择排序,树形选择排序,堆排序
(4)归并排序
基本思想:将两个或两个以上的有序表组合成一个新的有序表
方法包括:归并排序
(5)基数排序
基本思想:借助多关键字排序的思想对单逻辑关键字进行排序
方法包括:多关键字的排序,链式基数排序

基本性能对比:

排序方法平均时间最坏情况辅助存储
简单排序 O ( n 2 ) O(n^2) O(n2) O ( n 2 ) O(n^2) O(n2) O ( 1 ) O(1) O(1)
快速排序 O ( n log ⁡ n ) O(n\log n) O(nlogn) O ( n 2 ) O(n^2) O(n2) O ( log ⁡ n ) O(\log n) O(logn)
堆排序 O ( n log ⁡ n ) O(n \log n) O(nlogn) O ( n log ⁡ n ) O(n \log n) O(nlogn) O ( 1 ) O(1) O(1)
归并排序 O ( n log ⁡ n ) O(n \log n) O(nlogn) O ( n log ⁡ n ) O(n \log n) O(nlogn) O ( n ) O(n) O(n)
基数排序 O ( d ( n + r d ) ) O(d(n+rd)) O(d(n+rd)) O ( d ( n + r d ) ) O(d(n+rd)) O(d(n+rd)) O ( r d ) O(rd) O(rd)

总结概要:
(1)从平均时间性能而言,快速排序最佳,其所需时间最少,但快速排序在最坏情况下的时间性能比如堆排序和归并排序。而后者相比较的结果是, n n n较大时,归并排序所需时间较堆排序少,但它所需的辅助存储量最多。
(2)上表中“简单排序”包括除希尔排序之外的所有插入排序,冒泡排序和简单选择排序,其中以直接插入排序为最简单,当序列中的记录“基本有序”或 n n n值较小时,它是最佳的排序方法,因此常见它和其他的排序方法,如快速排序、归并排序等结合在一起使用。
(3)基数排序的时间复杂度也可以写成 O ( d n ˙ ) O(d \dot n) O(dn˙)。因此,它最适合用于 n n n值很大而关键字较小的序列。若关键字也很大,而序列中大多数记录的“最高位关键字”均不同,则也可以先按“最高位关键字”不同将序列分成若干“小”的子序列,而后进行直接插入排序。
(4)简单排序和基数排序是稳定的,而快速排序、堆排序和希尔排序等时间性能较好的排序方法都是不稳定的(稳定性由方法本身决定)。


第二部分、Timsort
在第一部分的总结概要的第二条中提到:插入排序常结合快速排序、归并排序来结合使用,那么对于Timsort,就是插入排序与归并排序结合使用。So,它们是怎么结合的呢?

这里是Timsort的维基百科。Timsort是一种混合稳定排序算法,来源于归并排序和插入排序;该算法在很多真实世界中的数据上性能显著。该方法来自Peter Mcllroy’s “Optimistic Sorting and Information Theoretic Complexity”, in Proceeding of the Fourth Annual ACM-SIAM Symposium on Discrete Algorithms, pp.467-474, January 1993. 在2002年把该算法引入python。

该算法找出数据中存在的有序子序列,再用排序的知识对剩下的数据进行高效排序,即用归并排序对有序子序列进行合并。

从python 2.3版开始,timsort就作为python的标准排序算法;也用在Java SE 7, 安卓平台,GNU Octave和谷歌浏览器中。

1、timsort概述

首先来看看性能对比

在这里插入图片描述
图片来源

从上图来看,Timsort的运行时间与归并排序相似,由于Timsort是一种经过优化的归并排序算法,而归并排序自身已经达到了比较排序算法时间复杂度的下限,因此优化之后的Timsort是目前最快的比较排序算法之一。

基本思想:
现实世界中,大多数数据都是部分有序的,Timsort利用了这一个事实。在Timsort中,称这些有序的数据块为“natural runs”, 除了“natural runs”, 还有“run(s)”,后者是经过一定处理的。在排序时,Timsort迭代数据元素,将其放入不同的run中,同时针对这些run,按规则进行合并只剩下一个,也就是排好序的结果。

在这里插入图片描述
大致思想就是:先采用插入排序将非常小的run扩充为较大的run,然后再采用归并排序来合并多个run,因此Timsort实际为归并排序。那么具体下来就是,首先定义一个参数minrun,当run长度小于minrun时,就认为它是非常小的run,否则认为它是较大的run。
那么,Timsort的过程为:

  1. 找到小的run扩充为较大的run
  2. 按规则合并run

由此,就要对“扩充”和“合并”两个步骤作深刻理解。

扩充
从左到右处理待排序列,将其划分为若干个run。从第一个尚未处理的对象开始,找到一个尽可能长的连续严格递减(严格递减,不取等号)或连续非递减(升序,可以取等号)序列(连续+序列=子串),如果是连续严格递减序列,则可以通过一个简单的“翻转操作”在线性时间内将其变为严格递增序列。

如果这样得到的序列长度等于minrun,则视为一个完整的run,继续生成下一个run;否则用插入排序将后面的元素添加进来,直至其长度达到minrun为止。考虑下面的例子:

  • 对于待排序列的前4个数是3,6,7,5,minrun=4,则尽可能长的连续非递减序列为3,6,7,长度没有达到4。于是将后面的5插入进来,得到长度为4的run:3,5,6,7

合并
在理想的情况下,合并长度尽量相近的runs,这样可以节约时间。使用霍夫曼树的归并策略虽然可行,但不应该花费太多的时间在选择优先合并的run上。Timsort选择了一种折中的方法,即要求最右边的三个run的长度尽量满足两个条件。记最右边的三个run的长度从左到右分别是A,B,C,则满足:

  1. A > B + C A > B+C A>B+C
  2. B > C B>C B>C
    这样,就可以保证合并后的run长度从右往左以指数量级递增,这样只需要从右至左依次进行合并就可以使每次合并的两个run的长度大致相同,实现了平衡。如果 A ≤ B + C A \le B+C AB+C,则合并 A , B A, B A,B或者 B , C B, C B,C,这取决于哪一种合并方式生成的新run更短,如果 A > B + C A>B+C A>B+C或者$B\leC , 则 合 并 ,则合并 B, C$。

每生成一个新的run都试图进行合并。在算法结束之后,有可能会出现有剩余run没有合并的情况。这是采用强制合并,直至最终仅剩一个run,即排序结果。

minrun的选取方式
如果待排序列长度为minrun,则我们总共会产生 ⌈ n m i n r u n ⌉ \lceil \frac{n}{minrun} \rceil minrunn个初始run。

  • 如果 ⌈ n m i n r u n ⌉ \lceil \frac{n}{minrun} \rceil minrunn刚好是2的整数次幂,则归并过程将会非常“完美”,可看做一个满二叉树。
  • 如果 ⌈ n m i n r u n ⌉ \lceil \frac{n}{minrun} \rceil minrunn比2的某个整数次幂稍大一点点,则到算法最后阶段会出现一个超长run与一个超短run的合并,这就不太好了。

因此,需要选取的minrun,满足 ⌈ n m i n r u n ⌉ \lceil \frac{n}{minrun} \rceil minrunn刚好是2的整数次幂或比某个2的整数次幂稍小一点的数。

如果数组元素小于64个,则采用二分插入排序(在第一部分的总结部分里,提到插入排序对于小型数组最为有效)。

如果数组元素大于64个,则算法按照扩充和合并的方式来完成。首先根据minrun查数组中升序或严格降序的部分。当Timsort找到一个run时,如果run的长度小于minrun,就选择run之后的数字插入排序至run中,使得run的长度达到minrun。然后将这个run压入栈中,也将run在数组中的起始位置和run的长度放入栈中,之后根据先前压入栈中的run决定是否该合并run。

当run的数目等于或略小于2的幂时,合并两个数组最为有效。Timsort选择范围为 [ 32 , 64 ] [32, 64] [32,64]的minrun,使得原始数组的长度除以minrun时,等于或略小于2的幂。

在维基百科中,有这么一段“ The final algorithm takes the six most significant bits of the size of the array, adds one if any of the remaining bits are set, and uses that result as the minrun.”,第一次看这非常一脸懵。

这里说明一下,以上翻译过来就是,选择数组长度的六个最高标志位,如果其余的标志位被设置,则加1:

  • 211: 1101 0011,取前6个最高标志位为110100(52),最后两位为11,所以minrun为52+1, ⌈ n m i n r u n ⌉ = 4 \lceil \frac{n}{minrun} \rceil = 4 minrunn=4满足要求。
  • 976:11 1101 0000,取前6个最高标志位为111101(61),同时最后几位为0000,所以minrun为61, ⌈ n m i n r u n ⌉ = 16 \lceil \frac{n}{minrun} \rceil = 16 minrunn=16满足要求。

合并的时候,按照合并中的规则,满足条件时,合并结束。
Timsort并没有执行原址(in_place)的归并,因为保证原址并稳定的话,需要很大的开销。

实际上Timsort合并2个相邻的run需要临时存储空间,临时存储空间的大小是2个run中较小的run的大小。Timsort算法先将较小的run复制到临时空间,然后用原先存储这2个run的空间来存储合并后的run。

合并算法是用简单插入排序,一次从左到右或从右到左比较,然后合并2个run。为了提高效率,timsort用二分插入排序。

Galloping mode
在Galloping mode中,对于两个run,算法在一个run中搜索另一个run的第一个元素位置。通过该初始元素与另一个run的第 2 k − 1 ( 1 , 3 , 5... ) 2k-1(1,3, 5...) 2k1135...个元素进行比较来完成,来获得初始元素所在的元素范围。这缩短了二分查找的范围,从而提高了效率,如果发现Galloping的效率低于二分查找,则退出Galloping mode。
在这里插入图片描述
二分查找会找到X中第一个大于Y[0]的元素x,当找到x时,可以在合并时忽略x之前的元素;类似的,在Y中找到第一个大于X[-1]的元素,当找到y时,可以在合并时忽略y之后的元素。这种查找在随机数中效率不会很高,但在其他情况下有很高的效率。
在这里插入图片描述
当算法到达最小阈值min_gallop时,算法切换到Galloping mode,试图利用数据中的那些可以直接排序的元素。只有当一个run的初始元素不是另一个run的前七个元素之一时,Galloping才有用。即初始阈值是7。

为了避免Galloping mode的缺点,合并函数会调整阈值。如果所选元素来自先前返回元素的同一个数组,则min_gallop减1,否则,该值增加1,从而阻止返回到Galloping mode。在随机数据的情况下,min_gallop的值会变得非常大,以至于Galloping mode永远不会再次发生。

Galloping并不总是有效。在某些情况下,Galloping mode会有比简单的线性搜索更多的比较。

本质上Timsort是一个经过大量优化的归并排序,而归并排序已经到达了最坏的情况下,比较排序算法时间复杂度的下界,所以在最坏的情况下,Timsort的时间复杂度为 O ( n log ⁡ n ) O(n\log n) O(nlogn)。最佳情况下,即输入已经排好序,它则已线性 O ( n ) O(n) O(n)时间运行。

代码这里,官方原始代码这里


第三部分、Introsort

introsort概述
The Same,在第一部分的总结概要的第二条中提到:插入排序常结合快速排序、归并排序来结合使用,那么对于Introsort,就是插入排序,快速排序和堆排序结合使用。So,它们是怎么结合的呢?

快速排序,平均复杂度为 O ( n log ⁡ n ) O(n \log n) O(nlogn),最坏情况下将达到 O ( N 2 ) O(N^2) O(N2)。不过Introsort,极类似于median-of-three快速排序,可将最坏情况推进到 O ( n log ⁡ n ) O(n \log n) O(nlogn)。早期的STL sort算法都采用快速排序,SGI STL采用的是Introsort。

Quick Sort
Quick Sort算法叙述如下:
1,如果S的元素个数为0或1,结束。
2,取S中的任何一个元素,当做枢轴(pivot)v。
3,将S分割为L, R两段,使L内的每个元素都小于或等于v,R内的每个元素都大于或等于v。
4,对L,R递归执行Quick Sort。
Quick Sort的精神在于将大区间分割为小区间的,分段排序。

median-of-three QuickSort
但是任何一个元素都可以被选作枢轴,其合适与否影响着Quick Sort的效率。为了避免“元素当初输入时不够随机”所带来的恶化效应,最理想最稳当的方式就是取整个序列的头,中央,尾三个位置的元素,以三者之间的中值作为枢轴。这种做法称为 median-of-three QuickSort。

小规模序列的case
当面对规模非常小的小型序列,用Quick Sort显然不合适。在小数据量的情况下,简单的插入排序的效率都比Quick Sort高,因为Quick Sort会为了极小的子序列产生许多的函数递归调用。

考虑到以上的情况,湿度评估序列的大小,然后决定采用快速排序或者插入排序是很有必要的。那么问题是,多小的序列才应该改变排序方式呢!

插入排序的优势
此外,也注意到,对于“几乎排序但尚未完成”的序列,采用插入排序,效率一般认为会比“将所有子序列彻底排序”更好。这是因为插入排序在面对“几乎排序但尚未完成”的序列时,有很好的表现。

introsort
鉴于不当的枢轴,导致不当的分割,进而影响整体效率。David R.Musser在1996年提出一种混合式排序算法:Introspective Sorting,简称Introsort。其行为在大部分情况下与media-of-three Quick Sort完全相同。但是当分割行为有恶化为二次行为的倾向时,能够自我侦测,转而改为堆排序,使得效率维持在 O ( n log ⁡ n ) O(n \log n) O(nlogn),这又比一开始就用堆排序来的好。

在SGI STL sort中,来看下Introsort算法:

template <class RandomAccessIterator>
inline void sort(RandomAccessIterator first, RandomAccessIterator last){	
	if(first != last){
		_introsort_loop(first, last, value_type(first), _lg(last-first)*2);  
		_final_insertion_sort(first, last);   // 最后的插入排序,完成整个序列排序
	}
}

其中,_lg()用来控制分割恶化的情况:

template <class Size>
inline Size _lg(Size n){	
	Size k;
	for(k=0; n>1; n>>1) ++k;
	return k;
}

因此,对于n=7,得k=2;n=20,得k=4。

template <class RandomAccessIterator, class T, class size>
void __introsort_loop(RandomAccessIterator first, RandomAccessIterator last,
			T*, Size depth_limit){
	//_stl_threshold是个全局常数,稍早定义为const int 16
	while(last - first > _stl_threshold){		// > 16
		if(depth_limit == 0){			// 分割恶化
			partial_sort(first, last, last);   	// 改用堆排序
			return;
		}
	}
	--depth_limit;
	//以下是 media-of-three partition,选择一个够好的枢轴并决定分割点
	// 分割点落在迭代器cut上
	RandomAccessIterator cut = _unguarded_partition(first, last, 
		T(_median(*first, *(fist+(last-first)/2), *(last-1))));
	// 对右半段递归进行sort
	_introsort_loop(cut, last, value_type(first), depth_limit);
	last = cut;
	//返回到while循环,准备对左半段递归进行sort
}
// 这种写法可读性就讲究吧

通过元素个数检验之后,再检查分割层次。

Introsort,由以上三段代码可以看出端倪了。


以《三傻大闹宝莱坞》里最后一句台词来作结:追求卓越,成功就会追着你跑。


参考链接
[1] https://sikasjc.github.io/2018/07/25/timsort/
[2] 《STL源码剖析》
[3] https://en.wikipedia.org/wiki/Timsort
[4] https://hackernoon.com/timsort-the-fastest-sorting-algorithm-youve-never-heard-of-36b28417f399

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值