前言
上篇文章排序算法(三)我们介绍了7种排序算法。
可以知道到目前我们已经介绍了27种排序算法,当然,不仅仅如此,还有若干排序算法,我们今天继续来看一下。
正文
今天我们介绍4种排序算法,它们如下:
Sort Name | Time(Best) | Time(Average) | Time(Worst) | Memory | Stable |
---|---|---|---|---|---|
IntroSort | O(n * log n) | O(n * log n) | O(n * log n) | O(log n) | No |
SmoothSort | O(n) | O(n * log n) | O(n * log n) | O(1) | No |
TreeSelectionSort | O(n * log n) | O(n * log n) | O(n * log n) | O(n) | Yes |
AmericanFlagSort | O(n * k/d) | O(n * k/d) | O(n * k/d) | O(1) | No |
我们分别来看下。
内省排序(IntroSort)
简介及原理
内省排序(Introspective Sort)是一种比较排序算法,也是一个混合排序算法,是由David Musser在1997年设计的排序算法。
它内部使用了快速排序(QuickSort)、堆排序(HeapSort)和插入排序(InsertionSort)三种排序算法。
该排序算法的主要策略如下:
- 数据量大时采用快速排序(QuickSort),分段递归排序;
- 一旦分段后的数据量小于某个阈值,为避免快排的递归调用带来的额外负荷,就改用插入排序(InsertionSort);
- 如果递归层次过深,就会采用堆排序(HeapSort);
- 三点中值”获取好的数组分割。
关于“三点中值”
内省排序使用的分段递归,每一个数组段都会重复上述 1、2、3 策略部分,内省排序如何对数组进行分段的呢?这就涉及到它的“三点中值”算法了。
我们来看下它的代码:
1 | private static int median3(int[] array, int first, int median, int end) { |
逻辑比较好理解:
如果数组首部元素比中间元素小:
- 中间元素小于尾部元素,就取中间的index;
- 首部元素小于尾部元素,就取尾部的index;
- 否则取首部的index。
如果数组首部元素比中间元素大或者等于:
- 首部元素小于尾部元素,就取首部的index;
- 中间元素小于尾部元素,就取尾部的index;
- 否则取中间的index。
我们知道快排要确立一个基准元素,对于普通快排,我们取a[low],这儿内省排序中的快排,基准元素的index为 median3(array, begin, begin + (end - begin) / 2, end)
,可以看到是寻找的begin
,end
和begin + (end - begin) / 2
这三点的“三点中值”。
算法描述
- 检测待排序数组长度,如果大于阈值(默认16),就会采用分段递归排序;
- 递归最大深度为 2 * lg(array.length) ,对于每一段,都会使用快速排序,快排基准值为数组“三点中值”位置的元素值;
- 当递归深度过大时,就会采用堆排序;
- 随着不断分割,递归重复上述排序过程,当数组分割后的长度小于阈值时,就采用插入排序完成最后排序过程。
动图演示
代码实现
1 | public class IntroSort { |
其他注意事项
- 该排序算法吸取了快排、插入排序、堆排的优点,是一种效率较高的排序算法;
- 内省排序算法是C++标准模板库
std::sort
采用的算法; 该排序算法的时间复杂度:
- 时间复杂度(最好): O(n*log n)
- 时间复杂度(平均): O(n*log n)
- 时间复杂度(最差): O(n*log n)
该排序算法的空间复杂度为:O(log n)
- 该排序算法为不稳定排序算法
平滑排序(SmoothSort)
简介及原理
平滑排序(SmoothSort)是一种比较排序算法,是堆排序的一个变体,由 Edsger Dijkstra 在1981年发明并发表。
SmoothSort作为堆排序的变种,与其不同是其引入了斐波那契数列来确定堆排序中子节点的位置。
斐波那契数列(Fibonacci sequence):又称黄金分割数列、兔子数列,函数表达式为 F[n]=F[n-1]+F[n-2] (n>=3,F[1]=1,F[2]=1)。
可以知道斐波那契数列指的是这样一个数列 1, 1, 2, 3, 5, 8, 13, 21, 34, 55, 89, 144, 233,377,610,987,1597,2584,4181,6765,10946,17711,28657,46368……..
我们平滑排序中,使用的数列和其有细微不同,其函数表达式为 F[n]=F[n-1]+F[n-2]+1 (n>=3,F[1]=1,F[2]=1)。
可以得到SmoothSort使用的数列是这样的 1, 1, 3, 5, 9, 15, 25, 41, 67, 109, 177, 287, 465, 753, 1219, 1973,3193, 5167, 8361, 13529, 21891, 35421, 57313……..
我们来回顾下堆排序原理,堆排序是利用了完全二叉堆(完全二叉树),来在数组里隐式构建一个完全二叉堆。
之所以完全二叉树可以用数组来表示,是因为完全二叉树有一个性质:除了最底层,每一层都是满的,每个结点对应数组中的一个元素。
如下图:
分别是最大完全二叉树堆(特点是父节点的值大于两个小节点的值) 和 最小完全二叉树堆(特点是父节点的值小于两个小节点的值)以及它们分别对应的隐式数组结构。
开始时我们需要构建一个隐式完全二叉堆(从大到小排就构建最小堆、从小到大排就构建最大堆),因为对于一个待排序数组,它目前的顺序不一定符合完全二叉堆的性质。
比如对于数组从小到大排列,堆排序先将数组改造为最大堆,然后将堆顶和堆底元素交换,之后将底部上升,重新构建最大堆,保持最大堆性质。由于堆顶元素必然是堆中最大的元素,所以一次操作之后,堆中存在的最大元素被分离出堆,重复n-1次之后,数组排列完毕。
如下图:
关于构建最大堆(或者最小堆)和堆调整的相关代码,可以参考上面内省排序代码中的buildHeap
和heapShiftDown
相关方法。
好,回归到SmoothSort,与堆排序类似,Smooth也是首先将数组转为隐式数组堆结构,然后通过重复提取最大剩余元素(从小到大的排序),并在剩余元素上重新构建最大堆,来生成排序数组的。
我们可以看到,我们的堆排序,隐式堆在数组中表示后,父节点一定是在子节点之前,且初始元素是该隐式堆的根节点。
而SmoothSort有所不同的是,它的父节点总是在子节点之后,就相当于将树倒了过来。
比如一个数组[4,7,8,2,3,1,5,6],使用堆排序时,初始构建的最大堆是这样的[8,7,5,6,3,1,4,2],这样构建好后,将8与2换位置,数组变为[2,7,5,6,3,1,4][8],剩余元素[2,7,5,6,3,1,4]在构建最大堆;
而使用SmoothSort后,初始构建的最大堆变为[3,1,6,2,4,7,5,8],然后分离8,数组变为[3,1,6,2,4,7,5][8],剩余部分[3,1,6,2,4,7,5]继续构建最大堆。
可以看到这样的话最大数已经在数组正确的位置,减少了不必要的交换(8已经在最后,堆排序的话还要和底部元素2进行交换)。
我们可以看到对于SmoothSort,其隐式数组结构是确定的,与数组本身无关。
PS:相比堆排序,我们需要构造完全二叉树,即每个父节点下面有两个子节点,但是SmoothSort不用这样,它可以先确定隐式二叉树结构,比如使用完全二叉树,就如上面[3,1,6,2,4,7,5,8]这个例子,也可以不使用这种树结构,比如某个父节点下有三个子节点。
Edsger Dijkstra(该算法作者)使用了一种分割方法,就是我们上面提到的斐波那契数列(Fibonacci sequence),这样的话,形成的树的兄弟节点的子节点永远不可能有相同的节点数(除为1时)。我们可以看看堆排序,使用的完全二叉树,除最后底部外,其他的节点其子节点都只有两个子节点,是固定的。
关于为什么没有使用完全二叉树,算法作者认为使用完全二叉树将会和堆排序有相同的算法效率,而且对于大量数据,使用完全二叉树会被分割成更多子树及子树的子树。
隐式树结构的实现可以很容易地通过一次性地计算斐波那契数字列表和父和子关系表(它们完全不依赖于要排序的值)来实现。但是为了使它成为一种就地(不额外占用空间)排序算法,Edsger Dijkstra以一种巧妙的方式维护了固定数量的整数变量,这样在计算的每个点都可以访问相关信息,而不需要任何表。虽然这样做不会影响算法复杂度,但是代码量大大提高且变得晦涩难懂。
算法描述
- 对于待排序数组,根据 斐波那契数列 构建堆结构;
- 每次找到堆结构根节点(最大值)分离出堆结构,剩余部分重新变为合适的堆结构;
- 依次进行,当取出堆结构最后一个元素时,排序完成。
动图演示
代码实现
1 | public class SmoothSort { |
其他注意事项
- SmoothSort是排序算法中理论值比较好的,但由于SmoothSort所用的树构建是基于斐波那契数列,复杂度因子较大,所以该算法的实际效率并不是特别好;
- 可以看到SmoothSort在数据基本有序的情况下可以达到O(n)的时间复杂度,比堆排序要好;
该排序算法时间复杂度:
- 时间复杂度(最好):O(n)
- 时间复杂度(平均):O(n * log n)
- 时间复杂度(最差):O(n * log n)
该排序算法的空间复杂度:O(1)。
- 该排序算法为不稳定排序算法。
树形选择排序(TreeSelectionSort)
简介及原理
树形选择排序(TreeSelectionSort)又称锦标赛排序(TournamentSort),是一种选择排序算法。由于其排序比较过程类似于锦标赛,因此得名锦标赛排序。
对于一个待排序数组,如下[8,6,3,2,9,5],我们可以两两比较找到一组最小值,拿到这组最小值后在两两比较下去……最终找到最小的值,如下:
[8,6,3,2,9,5]
[6,2,5]
[2,5]
[2]
拿到最小值后,我们把最小值的位置填充为无穷大,继续上述比较逻辑。
[8,6,3,Max,9,5]
[6,3,5]
[3,5]
[3]
依次进行下去,当数组所有值都为无穷大时,我们可以得到有序数组。因为这个过程很像完全二叉树,因此也被叫做树形选择排序。
如下图:
算法描述
- 对于待排序数组,构建一个满二叉树,节点总数 = 叶子节点数*2-1,其中叶子节点数即为数组长度。可以用数组表示这个满二叉树
int [] tree = new int[totalSize + 1]
; - 填充二叉树叶子节点,需要比较每个节点的“冠军”放入父节点;
- 每次找到根节点(最小的元素)移走,并将最小元素的位置设置为正无穷;
- 重复上述比较过程;
动图演示
代码实现
1 | public class TreeSelectionSort { |
其他注意事项
- 可以看到这种排序算法有辅助存储空间较多、和“最大值”进行多余比较等缺点。为了弥补该缺陷,J. willioms 在1964年提出了堆排序;
- 该排序算法的时间复杂度为:O(n * log n);
- 该排序算法的空间复杂度为:O(n);
- 该排序算法为稳定排序算法。
美国旗帜排序(AmericanFlagSort)
简介与原理
美国旗帜排序(AmericanFlagSort)是基数排序(RadixSort)的一个变体。
和基数排序不同的是,AmericanFlagSort一般排序从数据高位切入,会用一个数组保存位数据信息,再用一个数组记录数据位置。
之所以称为美国旗帜排序,是因为排序时的算法很像美国星条旗(将数组划分成很多“条纹”)。
我们来看一下:
比如对于一个数组[8,100,7622,520,6542,7,8888,33,1234],该排序算法会先找到它的最大值8888,并获取其位数,得到最大数为4位数,构建一个长度为10的数组,初始值为0,即[0,0,0,0,0,0,0,0,0,0];
该数组0位表示0-999的数的个数,1位表示1000-1999 的数的个数……依次类推,因此我们可以得到如下数组[5,1,0,0,0,0,1,1,1,0],同时需要一个位置数组记录位置,默认长度10,初始值为0,即[0,0,0,0,0,0,0,0,0,0];
如何表示数据位置呢?我们使用offset[0] = 0;offset[i] = count[i - 1] + offset[i - 1];
来记录数据位置,得到如下数组[0,5,6,6,6,6,6,7,8,9];
然后结合原数组,将数据按照高位基数排序,得到如下数组[8,100,33,520,7,1234,6542,7622,8888],继续根据第二高位进行排序,相当于重复上述过程,可以得到数组[8,33,7,100,520,1234,6542,7622,8888];
继续第三高位排序得到[8,7,33,100,520,1234,6542,7622,8888],继续最后一位排序可以得到[7,8,33,100,520,1234,6542,7622,8888],为最终有序数组。
算法描述
- 对于未排序数组,找到它的基数N,比如对于十进制数,基数是10(数的位数为0-9,不可能有其他值);对于二进制,基数是2 (只有0,1);对于字符串,通常使用256或者128作为基数;
- 构建两个数组,一个用于存储数据位信息,另一个用来记录数据位置;
- 待排序数组根据上面两个数组进行高位排序,排序完成后高位上数字较小者都在前面,较大者在后面;
- 继续对第二高位、第三高位……重复3过程,直到排序完成。
动图演示
代码实现
1 | public class AmericanFlagSort { |
其他注意事项
- 可以看到该排序和基数排序一样,只能用于正整数或者可以表示成正整数的数据排序;
- 该排序也是一种就地排序算法,除了需要新建两个基数N的数组外,排序过程都是在待排序数组内部进行的;
- 该排序效率最高的时候是处理二进制数据的时候,这时候可以使用移位操作来避免一些求幂运算;
- 因为该排序是比较随机的,每个桶里的数的分布完全取决于数据集,因此该排序对大数据集并不友好;
- 如果对于纯字符串排序,数据量较多的情况下,该排序方法理论上是优于快排的;
该排序方法的时间复杂度:
- 时间复杂度(最好):O(n*k/d)
- 时间复杂度(平均):O(n*k/d)
- 时间复杂度(最差):O(n*k/d)
该排序算法的空间复杂度:O(1)
- 该排序算法为不稳定排序算法
总结
今天的排序算法就介绍到这里,后面我会介绍两种比较有意思的排序,一个是双调排序,另一个是TimSort,TimSort是目前Java使用的排序,我们来详细了解一下它。对于双调排序,它是一种可以进行多路归并的并行排序算法,也是比较有特点的。
源码
本文所有源码详见我的 GitHub