排序的基本概念
在计算机程序开发过程中,经常需要一组数据元素(或记录)按某个关键字进行排序,排序完成的序列可用于快速查找相关记录。
排序概述
排序是程序开发中一种非常常见的操作,对一组任意的数据元素(或记录)经过排序操作后,就可以把它们变成一组关键字排序的有序序列。
假设含有n个记录的序列为{R1,R2,...,Rn},其相应的关键字序列为{K1,k2,...,kn}。将这些记录重新排序为{Ri1,Ri2,...,Rin},使得相应的关键字满足条件Ki1<=Ki2<=...<=Kin,这样的一种操作称为排序。
一旦将一组杂乱无章的记录重排成一组有序记录,就能快速地从这组记录中找到目标记录。因此通常来说,排序的目的是快速查找。
对于一个排序算法来说,一般从如下三个方面来衡量算法的优劣。
- 时间复杂度:主要是分析关键字的比较次数和记录的移动次数。
- 空间复杂度:分析排序算法中需要多少辅助内存。
- 稳定性:若两个记录A和B的关键字值相等,但排序后A,B的先后次序保持不变,则称这种排序算法是稳定的;反之,就是不稳定的。
即现有的排序算法来看,排序大致可分为内部排序和外部排序。如果整个排序过程不需要借助外部存储器(如磁盘等),所有排序操作都在内存中完成,这种排序就被称为内部排序。
如果参与排序的数据元素非常多,数据量非常大,计算机无法把整个排序过程放在内存中完成,必须借助外部存储器(如磁盘),这种排序就被称为外部排序。
外部排序包括以下两个步骤:
- 1.把排序的文件中的一组记录读入内存的排序区,对读入的记录按上面讲到的内部排序法进行排序,排序之后输出到外部存储器。不断重复这一过程,每次读取一组记录,知道原文件的所有记录被处理完毕。
- 将下一步分组排序好的记录两组两组地合并排序。在内存容量允许的条件下。每组中包含的记录越大越好,这样可减少合并的次数。
对于外部排序来说,程序必须将数据分批调入内存来排序,中间结果还要及时放入外存显然外部排序要比内部排序更复杂二实际上,也可认为外部排序是由多次内部排序组成的。
常说的排序都是指内部排序,而不是外部排序。
内部排序的分类
可以分为如下几类:
- 选择排序
- 交换排序
- 插入排序
- 归并排序
- 桶式排序
- 基数排序
上面这些内部排序方法人致有如下图所示的分类。
选择排序法
常用的选择排序方法有两种:直接选择排序和堆排序.直接选择排序简单直观,但性能略差,堆排序是一种较为高效的选择排序方法,但实现起来略微复杂.
直接选择排序
直接选择排序的思路很简单,它需要经过n-1趟比较。
第1趟比较:程序将记录定位在第1个数据上,拿第1个数据依次和它后面的每个数据进行比较,如果第1个数据人于后面某个数据,就交换它们.....以此类推。经过第1趟比较,组数据中最小的数据被选出,它被排在第1位。
第2趟比较:程序将记录定位在第2个数据上,拿第2个数据依次和它后面的每个数据进行比较,如果第2个数据大于后面某个数据,就交换它们......依此类推。经过第2趟比较,这组数据中第2小的数据被选出,它被排在第2位。
......
按此规则一共进行n-l趟比较,这组数据中第n-l小(第2大)的数据被选出,被排在第n-1位(倒数第1位);剩下的就是最大的数据,它排在最后。
直接选择排序的优点是算法简单,容易实现。
直接选择排序的缺点是每趟只能确定一个元索,n个数据需要进行。一!趟比较。
假设有如下一组数据:
21,30,49,30*,16,9
如果对它使用直接选择排序,因为上面这组数据包含6个数据,所以要经过5趟比较.如下所示。
第1趟比较后:9,30,49,30*,21,16
第2趟比较后:9,16,49,30*,30,21
第3趟比较后:9,21,49,49,30,30*
第4趟比较后:9,16,21,30,49,30*
第5趟比较后:9,16,21,30,30*,49
基于上面思路,用Java程序实现上面的直接选择排序,如下所示:
public class DataWrap {
int data;
String flag;
public DataWrap(int data,String flag){
this.data=data;
this.flag=flag;
}
public int compareTo(DataWrap dw){
return this.data>dw.data?1:(this.data==dw.data?0:-1);
}
public String toString() {
return data+flag;
}
}
public class SelectSort {
public static void selectSort(DataWrap[] data){
System.out.println("开始排序");
int arrayLength=data.length;
for(int i=0;i<arrayLength-1;i++){
int minIndex=i;
for(int j=i+1;j<arrayLength;j++){
if(data[minIndex].compareTo(data[j])>0){
minIndex=j;
}
}
if(minIndex!=i){
DataWrap tmp=data[i];
data[i]=data[minIndex];
data[minIndex]=tmp;
}
}
}
public static void main(String[] args) {
DataWrap[] data={
new DataWrap(21,""),
new DataWrap(30,""),
new DataWrap(49,""),
new DataWrap(30,""),
new DataWrap(16,""),
new DataWrap(9,"")
};
System.out.println("排序之前:"+Arrays.toString(data));
selectSort(data);
System.out.println("排序之后:"+Arrays.toString(data));
}
}
直接选择排序的第n趟比较至多交换一次,永远总是拿n-1位的数据和中间某个数据(本趟比较中最小的数据)进行交换。如果本趟比较时第n-1位(本趟比较的第i位)的数据已经是最小的,那就无须交换。
对于直接选择排序算法而言,假设有n个数据,数据交换的次数最多有n-1次,但程序比较的次数较多。总体来说,其时间效率为O(n*n)
直接选择排序算法的空间效率很高,它只需要一个附加程序.单元用于交换,其空问效率为O(1).
堆排序
在介绍堆排序之前,先来介绍一下于堆有关的概念。
假设有n个数据元素的序列K0,K1,...,Kn-1,当且满足如下关系时,可以将这组数据称为小顶堆(小根堆);
Ki<=K2i-1且Ki<=K2i+2(其中i=0,2,...,(n-1)/2)
或者,满足如下关系时,可以将这组数据称为大顶堆(大根堆)
Ki>=K2i+1且Ki>=K2i+2(其中i=0,2,...,(n-1/2))
对于满足小顶堆的数据序列K0,K1,...,Kn-1,如果将它们顺序排成一棵完全二叉树,则此树的特点是,树中所有节点的值都小于其左、右子节点的值,此树的根节点的值必然最小。反之,对于满足大顶堆的数据序列k0,k1,...,kn-1,如果将它们顺序排成一棵完全二叉树,则此树的特点是,树中所有节点的值都大于其左、右子节点的值,此树的根节点的值必然最大。
通过上面的介绍不难发现一点,小顶堆的任意子树也是小顶堆,大顶堆的任意子树还是大顶堆。
比如,判断数据序列如:9,30,499,46,58,79 是否为堆,将其转换为一颗完全二叉树,如下图:
上图中每个节点上的灰色数字代表该节点数据在底层数组中的索引。上图所示的完全二叉树完全满足小顶堆的要求,每个父节点的值总是小于等于它的左、右子节点的值。
再比如,判断数据序列:93,82,76,63,58,67,55是否为堆,将其转换为一颗完全二叉树,如下图:
上图的完全二叉树完全满足大顶堆的要求:每个父节点的值总是大于等于它的左、右子节点的值。
经过上面的介绍不难发现一点,大顶堆的根竹点一定是这组数据中值最大的竹点。也就是说,如果需要对一组数据进行排序,只需先将这组数据建成大项堆,就选择出了这组数据的最大值。
堆排序的关键在于健堆,它按如下步骤完成排序。
- 第1趟:将索引0~n-1处的全部数据建成大顶〔或小项)堆,就可以选择出这组数据中的最大(或最小)值。
将上一步所建的大顶(或小顶)堆的根节点与这组数据的最后一个节点交换,就使得这组数据中的最大(或最小)值排在最后。
- 第2趟:将索引0~n-2处的全部数据建成大顶〔或小顶)堆,就可以选择出这组数据中的最大<或最小)值。
将上一步所建的大顶(或小顶)堆的根节点与这组数据的倒数第2个节点交换,就使得这组数据中的最人(或最小)值排在倒数第2位。
........
第k趟;将索引O一。一处的全部数据建成大顶(或小顶)堆,就可以选择出这组数据中
的最大(或最小)值。
将上一步所建的大项(或小顶)堆的根节点与这组数据的倒数第k个节点交换,使得这组数据中的最大(或最小)值排在倒数第k位。
通过上面的介绍不难发现,堆排序的步骤就是重复执仃以下两步。
- 建堆
- 拿堆的根节点和最后一个节点交换
由此可见,对于包含N个数据元素的数据组而言,堆排序需要经过N-1次建堆,每次建堆的作用就是选出该堆的最大值或最小值。堆排序本质上依然是一种选择排序。
堆排序与直接选择排序的差别在于,堆排序可通过树形结构保存部分比较结果,可减少比较次数。对于直接选择排序而言,为了从a0,a1,a2,a3,...,an-1中选出最小的数据,必须进行n-1次比较:然后在a1,a2,a3,...,an-1中选出关键字最小的记录,又需要做n-2次比较。事实上,在后面的。n-2次比较中,有许多比较可能在前面的n-1次比较中己经做过,但由于前一趟排序时未保留这些比较结果,所以后一趟排序时又重复执行了这些比较操作_堆排序可通过树形结构保存前面的部分比较结果,从而提高效率。
接下来的关键就是建堆的过程。建堆其实比较简单,不断地重复如下步骤即可〔以建大顶堆为例)。
从最后一个非叶子节点开始,比较该节点和它两个子节点的值;如果某个子节点的值大于父节点的值,就把父节点和较大的子节点交换。
向前逐步调整直到根节点,即保证每个父节点的值都人于等于其左、右子节点的值,建堆完成。
例如,有如下数据组:
9,79,46,30,58,49
下面逐步介绍对其建堆的过程。
- 先将其转换为完全二义树,转换得到的完全二义树如图下所示。
- 完全二叉树的最后一个非叶子节点,也就是最后一个节点的父节点。最后一个节点的索引为数组长度-1。也就是len-1 ,那么最后一个非叶子节点的索引应该为(len-2)/2。也就是从索引为2的节点开始,如果其子节点的值大于它本身的值,则把它和较大的子节点进行交换,即将索引为2的节点和索引为5的元素交换,交换后的结果如下图所示。
向前处理前一个非叶子节点(索引为(len-2)1)-1),也就是处理索引为1的节点,此时79>30,79>58,因此无须交换。
向前处理前一个非叶子节点,也就是处理索引为0的节点,此时9<79,因此需要交换。应该拿索引为0的节点和索引为1的节点交换〔在9的两个子节点中。索引为1的节点的
值较大),交换后的完全二叉树如下图所示。
- 如果某个节点和它的某个子节点交换后,该子节点又有子节点,那么系统还需要再次对该子节点进行判断。例如,上图中索引为0的节点和索引为1的节点交换后,索引为1
的节点还有子节点,因此程序必须再次保证索引为l的节点的值大于等于其左、右子节点的值。因此还需要交换一次,交换后的大顶堆如下图所示。
public class SelectSort {
static void heapSort(DataWrap[] data){
System.out.println("开始排序");
int arrayLength=data.length;
for(int i=0;i<arrayLength-1;i++){
builMaxdHeap(data, arrayLength-1-i);
swap(data, 0, arrayLength-1-i);
}
}
public static void builMaxdHeap(DataWrap[] data,int lastIndex){
for(int i=(lastIndex-1)/2;i>=0;i--){
int k=i;
while(k*2+1<=lastIndex){
int biggerIndex=2*k+1;
if(biggerIndex<lastIndex){
if(data[biggerIndex].compareTo(data[biggerIndex+1])<0){
biggerIndex++;
}
}
if(data[k].compareTo(data[biggerIndex])<0){
swap(data, k, biggerIndex);
k=biggerIndex;
}else{
break;
}
}
}
}
private static void swap(DataWrap[] data,int i,int j){
DataWrap tmp=data[i];
data[i]=data[j];
data[j]=tmp;
}
public static void main(String[] args) {
DataWrap[] data={
new DataWrap(21,""),
new DataWrap(30,""),
new DataWrap(49,""),
new DataWrap(30,""),
new DataWrap(16,""),
new DataWrap(9,"")
};
System.out.println("排序之前:"+Arrays.toString(data));
heapSort(data);
System.out.println("排序之后:"+Arrays.toString(data));
}
}
运行结果如下:
对于堆排序算法而言,假设有n个数据,需要进行n-1次建堆,每次建堆本身耗时为log2n则其时间效率为例O(n*log2n)。
堆排序算法的空间效率很高,它只需要一个附加程序单元用于交换,其空间效率为O(1).
交换排序
交换排序的主体操作是对数据组中的数据不断地进行交换操作。交换排序主要有冒泡排序和快速排序,这两种排序都是广为人知且应用及广的排序算法。
冒泡排序
冒泡排序是最广为人知的交换排序之一,它具有算法思路简单、容易实现的特点。
对于包含,个数据的一组记录,在最坏的情况卜,冒泡排序需要进行n-1趟比较。
第1趟:依次比较0和1、1和2、2和3、...、n-2和n-1索引处的元素,如果发现第一个数据大于后一个数据,则交换它们,经过第1趟比较,最大的元素排到了最后。
第2趟:依次比较0和1、1和2、2和3、...、n-3和n-2索引处的元素,如果发现第一个数据大于后一个数据,则交换它们。经过第2趟比较,第2大的元素排到了倒数第2位。
.......
- 第n-1趟:依次比较0和1元素,如果发现第一个数据大于后一个数据,则交换它们。经过第n-1趟比较,第2小(第n-1大)的元素排到了第2位,
实际上,冒泡排序的每趟交换结束后,不仅能将当前最大值挤出最后面位置,还能部分理顺前面的其他元素;一旦某趟没有交换发生,即可提前结束排序。
假设有如下数据序列:
9,16,21,23,30, 49, 21,30
只需要经过如下几趟排序。
第1趟:9,16,21,23,30,21,30,49
第2趟:9,16,21,23,21,30,30,49
第3趟:9,16,21,21,23,30,30,49
第4趟:9,16,21,21,23,30,30,49
从上面的排序过程可以看出,虽然该组数据包含8个元素,但采用冒泡排序只需要经过4趟比较。因为经过第3趟排序后,这组数据已经处于有序状态,这样,第4趟将不会发生交换,因此可以提前结束循环。
//冒泡排序
public static void bubbleSort(DataWrap[] data){
System.out.println("开始排序");
int arrayLength=data.length;
for(int i=0;i<arrayLength-1;i++){
boolean flag=false;
for(int j=0;j<arrayLength-1-i;j++){
if(data[j].compareTo(data[j+1])>0){
DataWrap tmp=data[j-1];
data[j+1]=data[j];
data[j]=tmp;
flag=true;
}
}
System.out.print(Arrays.toString(data)+"\n");
if(!flag){
break;
}
}
}
冒泡排序算法的时间效率是不确定的,在最好的情况下,初始数据序列已经处于有序状态,执行1趟冒泡即可,做n-1次比较,无须进行任何交换;但在最坏的情况下,初始数据序列处于完全逆序状态,算法要执行n-1趟冒泡,第i趟(1<i<n)做了n-i次比较,执行n-i-1次对象交换。此时的比较总次数为n(n-1)/2,记录移动总次数为n(n-1)*3/2.
冒泡排序算法的空间效率很高,它只需要一个附加程序单元用于交换,其空间效率为O(1)。冒泡排序是稳定的。
快速排序
快速排序是一个速度非常快的交换排序方法,它的基本思路很简单:从待排序的数据序列中任取一个数据(如第一个数据)作为分界值,所有比它小的数据元素一律放在左边,所有比它大的数据元素一律放在右边口经过这样一趟下来,该序列形成左、右两个子序列,左边序列中数据元素的值都比分界值小,右边序列中数据元素的值都比分界值大。
接下来对左、右两个子序列进行递归,对两个子序列重新选择中心元素并依此规则调整,直到每个子序列的元素只剩一个,排序完成。
从上面的算法分析可以看出,实现快速排序的关键在于第一趟要做的事情,如下所示。
- 选出指定的分界值————这个容易完成
- 将所有比分界值小的数据元素放在左边。
- 将所有比分界值大的数据元素放在右边。
现在的问题是,如何实现上面的第2和3步?这时就要用到交换了,思路如下。
定义一个i变量,i变量从左边第一个索引开始,找大于分界值的元素的索引,并用来记录它。
定义一个j变量,j变量从右边第一个索引开始,找小于分界值的元素的索弓卜并用j来记录它。
如果i >j,则交换i, j两个索引处的元素。
重复执行以上1~3步,直到i>=j,可以判断j左边的数据元素都小于分界值,j右边的数据元素都大于分界值,最后将分界值和j索引处的元素交换即可。
下图显示了快速排序一趟操作的详细过程。
从下图可以看出,快速排序的速度确实很快,只要经过两次交换,即可让分界值左边的数据都小于分界值,分界值右边的数据都大于分界值。
//快速排序
public static void quickSort(DataWrap[] data){
subSort(data,0,data.length-1);
}
//对data数组中从start到end索引范围的子序列进行处理
//使之满足所有小于分界值的放在左边,所有大于分界值的放在右边
private static void subSort(DataWrap[] data,int start,int end){
//需要排序
if(start<end){
//以第一个元素作为分界值
DataWrap base=data[start];
//i从左边开始搜索,搜索大于分界值的元索的索引
int i=start;
//j从右边开始搜索,搜索小于分界值的元素的索引
int j=end+1;
while(true){
//找到大于分界值的元索的索引。或i已经到了end处
while(i<end&&data[++i].compareTo(base)<=0);
//找到小于分界值的元紊的索引,或j已经到了start处
while(j>start&&data[--j].compareTo(base)>=0);
if(i<j){
swap(data, i, j);
}else{
break;
}
}
swap(data, start, j);
//递归左边子序列
subSort(data, start, j-1);
//递归右边子序列
subSort(data, j+1, end);
}
}
private static void swap(DataWrap[] data,int i,int j){
DataWrap tmp=data[i];
data[i]=data[j];
data[j]=tmp;
}
快速排序的时间效率很好,因为它每趟能确定的元素呈指数增长。
快速排序需要使用递归,而递归使用栈,因此它的空间效率为O(log2n).
快速排序中包含跳跃式交换,因此是不稳定的排序算法。
插入排序
直接插入排序
直接插入排序的思路非常简单:依次将待排序的数据元素按其关键字值的大小插入前面的有序序列。
细化来说,对于一个有n个元素的数据序列,排序需要进行n-1趟插入操作.如下所示。
第1趟插入:将第2个元素插入前面的有序子序列中,此时前面只有一个元素,当然是有序的。
第2趟插入:将第3个元素插入前面的有序子序列中,前面两个元素是有序的。
......
第n-1趟插入:将第。个元素插入前面的有序子序列中,前面n-l个元素是有序的。掌握了上面的排序思路之后,如下程序实现了直接插入排序。
//直接插入排序
public static void insertSort(DataWrap[] data){
System.out.println("开始排序:\n");
int arrayLength=data.length;
for(int i=1;i<arrayLength;i++){
//当整体后移时,保证data [i]的值不会丢失
DataWrap tmp=data[i];
//i索引处的值己经比前面的所有值都大,表明己经有序,无须插入
//(i-1索引之前的教据己经有序,i-1素引处元紊的值就是最大值)
if(data[i].compareTo(data[i-1])<0){
int j=i-1;
for(;j>=0&&data[j].compareTo(tmp)>0;j--){
data[j+1]=data[j];
}
//最后将tmp的值插入合适位置
data[j+1]=tmp;
}
System.out.println(Arrays.toString(data));
}
}
直接插入排序的时间效率并不高,在最坏的情况下,所有元素的比较次数总和为(0+1+...+n-1)=O(nn);在其他情况下,也要考虑移动元素的次数,故时间复杂度为O(nn)。
直接插入排序的空间效率很好,它只需要一个缓存数据单元,也就是说,空间效率为O(1).
直接插入排序是稳定的。
折半插入排序
折半插入排序是对直接插入排序的简单改进。对于直接插入排序而言,当第i-1趟需要将第i个元索插入前面的0i-1个元素序列中时,它总是从i-1个元素开始,逐个比较每个元素,直到找到它的位置。这显然没有利用前面0i-1个元素己经有序这个特点,而折半插入排序则改进了这一点。
对于折半插入排序而言,当第i-1趟需要将第i个元素插入前面的0i-1个元素序列中时,它不会直接从0i-1个元索开始逐个比较每个元素。折半插入排序的做法如下。
计算0~i-1索引的中间点,也就是用i索引处的元素和(0+i-1)/2索引处的元素进行
比较,如果i索引处的元素大,就直接在(0+i-1)/2i-1半个范围内搜索;反之,就在0(0+i-1)/2半个范围内搜索,这就是所谓的折半.在半个范围内搜索时,再按第1步方法进行折半搜索.总是不断地折半,这样就可以将搜索范围缩小到1/2,1/4,1/8,从而快速确定第i个元素的插入位置.
一旦确定了第i个元素的插入位置,剩下的事情就简单了。程序将该位置以后的元素整体后移一位,然后将第i个元素放入该位置。
//折半插入排序
public static void binaryInsertSort(DataWrap[] data){
System.out.println("开始排序:\n");
int arrayLength=data.length;
for(int i=1;i<arrayLength;i++){
DataWrap tmp=data[i];
int low=0;
int high=i-1;
while(low<=high){
int mid=(low+high)/2;
if(tmp.compareTo(data[mid])>0){
low=mid+1;
}else{
high=mid-1;
}
}
for(int j=i;j>low;j--){
data[j]=data[j-1];
}
data[low]=tmp;
System.out.println(Arrays.toString(data));
}
}
上面程序中的粗体字代码就是折半插入排序的关键代码。程序会拿tmp的值和mid索引(就是中间索引)处的值进行比较,如果tmp大于mid索引处的元素,则将low(搜索范围的下限)设置为mid+1,即表明在mid+1到原high范围内搜索;反之,将high(搜索范围的上限)设置为mid-1,即表明在原low至mid-l范围内搜索。
上面程序的排序效果与直接插入排序的效果基本相同,只是更快一些,因为折半插入排序可以更快地确定第l个元素的插入位置。
Shell排序
对于直接插入排序而言,当插入排序执行到一半时,待插值左边的所有数据都已经处于有序状态,直接插入排序将待插值存储在一个临时变量里。然后,从待插值左边第一个数拟单元开始,只要该数据单元的值大于待插值,该数据单元就右移一格,直到找到第一个小于待插值的数据单元。接下来,将临时变量里的值放入小于待插值的数据单元之后(前面的所有数据都右移过一格,因此该数据单元有一个空格)。
从上面算法可以发现一个问题:如果一个很小的数据单元位于很靠近右端的位置上,为了把这个数据单元移动到左边正确的位置上,中间所有的数据单元都需要向右移动一格。这个步骤对每一个数据项都执行了近n次的复制。虽然不是所有数据项都必须移动。个位置,但平均下来,每个数据项都会移动n/2格,总共是nn/2次复制。因此,插入排序的执行效率是O(nn)
Shell排序对直接插入排序进行了简单改进:它通过加大插入排序中元素之间的间栖,井在这些有间隔的元素中进行插入排序,从而使数据项大跨度地移动。当这些数据项排过一趟序后,Shell排序算法减小数据项的间隔再进行排序,依此进行下去。这些进行排序的数据项之间的间隔被称为增量,习惯上用h来表示这个增量。
下面以如下数据序列为例,进行说明。
9,-16,21,23,-30,-49,21,30,30
如果采用直接插入排序算法,第i趟插入会将第i+1个元素插入前面的有序序列中,将看到:
-16,9,21,23,-30,-49,21,30,30——第1趟,将第2个元素插入,前两个元素有序
-16,,9,21,23,-30,-49,21,30,30——第2趟,将第3个元素插入,前三个元素有序。
......
Shell排序就不这样了。假设本次She}1排序的h为4,其插入操作如下.
-30,-16,21,23,9,-49,21,30,30
-30,-49,21,23,9,-16,21,30,30
-30,-49,21,23,9,-16,21,30,30
-30,-49,21,23,9,-16,21,30,30
-30,-49,21,23,9,-16,21,30,30
注意上面排序过程中的粗体字数据。
当h增量为4时,第1趟将保证索引为0, 4, 8的数据元素己经有序。第1趟完成后,算
法向右移一步,对索引为1,5的数据元素进行排序。这个排序过程持续进行,直到所有的数据项都已经完成了以4为增量的排序。也就是说,所有间隔为4的数据项之间都己经排列有序。
当完成以4为增量的shell排序后,所有元素离它在最终有序序列中的位置相差不到两个单元,这就是数组“基本有序”的含义,也正是Shell排序的奥秘所在。通过创建这种交错的内部有序的数据项集合,就可以减少直接插入排序中数据项“整体体搬家”的工作量。
上面已经演示了以4为增量的"hell排序,接下来应该减少增量,直到完成以1为增量的Shell排序,此时数据序列将会变为有序序列。
从下面介绍可知,最终确定Shell排序算法的关键就在于确定h序列的值。常用的h序列由Knuth操出.该序列从1开始.诵讨如下公式产生。
h=3*h+1
上面公式用于从1开始计算这个序列,可以看到h序列为1,4,13,40,......,反过来,
程序中还需要反向计算h序列,那应该使用如下公式。
h=(h-1/3)
上面公式从最大的h开始计算,假设h从40开始,可以看到h序列为40, 13, 4, 1。
Shell排序比插入排序快很多,因为当h值大的时候,数据项每一趟排序需要移动元素的个数很少,但数据项移动的距离很长,这是非常有效率的。当h减小时,每一趟排序需要移动的元素的个数增多,但是此时数据项己经接近于它们排序后最终的位置,这对于插入排序可以更有效率。正是这两种情况的结合才使Shell排序效率这么高。
//Shell排序
public static void shellSort(DataWrap[] data){
System.out.println("开始排序:");
int arragLength=data.length;
int h=1;
while(h<=arragLength/3){
h=h*3+1;
}
while(h>0){
for(int i=h;i<arragLength;i++){
DataWrap tmp=data[i];
if(data[i].compareTo(data[i-h])<0){
int j=i-h;
for(;j>=0&&data[j].compareTo(tmp)>0;j-=h){
data[j+h]=data[j];
}
data[j+h]=tmp;
}
System.out.println(Arrays.toString(data));
}
h=(h-1)/3;
}
}
shell排序是直接插入排序的改进版,因此它也是稳定的,它的空间开销也是O(1),时间开销估计在O(n的(3/2)次方)~O(n的(7/6)次方)之间。
归并排序
归并的基本思想是将两个(或以上〕有序的序列合并成一个新的有序序列。当然,此处介绍的归并排序主要是将两个有序的数据序列合并成一个新的有序序列。
细化来说,归并排序先将长度为月的无序序列看成是n个长度为1的有序子序,首先做两两合并,得到n/2个长度为2的有序子序列,再做两两合并……不断地重复这个过程,最终可以得到一个长度为n的有序序列。
假设有如下数据序列:
21,30,49,30*,97,62,72,08,37,16,54
程序对其不断合并的过程如下:
从上图可以看出,长度为16的数据序列,只需经过4次合并。也就是说,对于长度为n的数据序列,只需经过log2n次合并。
对于归并排序而言,其算法关键就在于“合并”。那么,如何将两个有序的数据序列合并成一个新的有序序列?合并算法的具体步骤如下。
- 定义变量i,i从0开始,依次等于A序列中每个元素的索引。
- 定义变量j,j从0开始,依次等于B序列中每个元素的索引
- 拿A序列中i索引处的元素和B序列中j索引处的元素进行比较,将较小的复制到一
个临时数组中。 - 如果i索引处的元素小,则i++;如果j索引处的元素小,则j++.
不断地重复上面四个步骤,即可将A、B两个序列中的数据元素复制到临时数组中,直到其中一个数组中的所有元素都被复制到临时数组中.最后,将另一个数组中多出来的元素全部复制到临时数组中,合并即完成,再将临时数组中的数据复制回去即可。
下图显示了归并排序算法合并操作的实现细节。
//归并排序
public static void mergeSort(DataWrap[] data){
sort(data,0,data.length-1);
}
private static void sort(DataWrap[] data,int left,int right){
if(left<right){
int center=(left+right)/2;
sort(data, left,center);
sort(data, center+1, right);
merge(data, left, center, right);
}
}
private static void merge(DataWrap[] data,int left,int center,int right){
DataWrap[] tmpArr=new DataWrap[data.length];
int mid=center+1;
int third=left;
int tmp=left;
while(left<=center&&mid<=right){
if(data[left].compareTo(data[mid])<=0){
tmpArr[third++]=data[left++];
}else{
tmpArr[third++]=data[mid++];
}
}
while(mid<=right){
tmpArr[third++]=data[mid++];
}
while(left<=center){
tmpArr[third++]=data[left++];
}
while(tmp<right){
data[tmp]=tmpArr[tmp++];
}
}
从上面的算法实现可以看出,归并算法需要递归地进行分解、合并,每进行一趟归并排序需要调用merge()方法一次,每次执行merge()方法需要比较n次,因此归并排序算法的时间复杂度为侧O(n*log2N)
归并排序算法的空间效率较差,它需要一个与原始序列同样大小的辅助序列。
归并排序算法是稳定的。
桶式排序
桶式排序不再是一种基于比较的排序方法,它是一种非常巧妙的排序方式,但这种排序方式需要待排序列满足如下两个特征。
- 待排序列的所有值处于一个可枚举范围内。
- 待排序列所在的这个可枚举范围不应该太大,否则排厅开销太大。
下面介绍桶式排序的详细过程,以如下待排序列为例。
5,4,2,4,1
这个待排序列处于0,1,2,3,4,5这个可枚举范围内,而且这个范围很小,正是桶式
排序大派用场之时。
具体步骤如下:
- 对这个可枚举范围构建一个buckets数组,用于记录“落入”每个桶中的元素的个数,于是可以得到如下图所示的buckets数组。
- 按如下公式对上图所示的buckets数组的元素进行重新计算。buckets[i] = buckets[i]+buckets[i-1](其中1 <=i<= buckets.length }
得到如下图buckets数组
桶式排序的巧妙之处如上图所示。重新计算后的buckets数组元素保存了“落入”当前桶和“落入”前面所有桶中元素的总数目,而且定义的桶本身就是从小到大排列的,也就是说,“落入”前面桶中的元素肯定小于“落入”当前桶中的元素。综合上面两点,得到了一个结论:每个buckets数组元素的值小于、等于“落入”当前桶中元素的个数。也就是说,“落入”当前桶中的元素在有序序列中应该排在buckets数组元素值所确定的位置。
上面理论还有点抽象。以待排序列中最后一个元索1为例,找到新buckets数组中元素对应桶的值,该值为1,这表明元素1就应该排在第1位:再以待排序列中倒数第2个元素4为例,找到新buckets数组中元素4对应桶的值,该值为4,这表明元素4就应该排在第4位....依此类推。
//桶式排序
public static void bucketSort(DataWrap[] data,int min,int max){
System.out.println("开始排序:");
//arrayLength记录待排序数组的长度
int arrayLength=data.length;
DataWrap[] tmp=new DataWrap[arrayLength];
//buckets数组相当于定义了max一min个桶
//buckets数组用于记录待排序元素的信息
int[] buckets=new int[max-min];
//计算每个元素在序列中出现的次数
for(int i=0;i<arrayLength;i++){
//buckets数组记录了DataWrap出现的次数
buckets[data[i].data-min]++;
}
System.out.println(Arrays.toString(data));
//计算“落入”各桶内的元素在有序序列中的位置
for(int i=1;i<max-min;i++){
//前一个bucket的值+当前bucket的值一>当前bucket新的值
buckets[i]=buckets[i]+buckets[i-1];
}
//循环结束后,buckets数组元素记录了“落入”前面所有捅和
//"落入"当前buckets中元素的总数
//也就是说,buckets数组元素的值代表了“落入”当前桶中的元紊在有序序列中的位置
System.out.print(Arrays.toString(buckets));
//将data数组中数据完全复制到tmp数组中级存起来
System.arraycopy(data,0, tmp, 0, arrayLength);
//根据buckets数组中的信息将待排序列的各元索放入相应的位置
for(int k=arrayLength-1;k>=0;k--){
data[--buckets[tmp[k].data-min]]=tmp[k];
}
}
基数排序
基数排序已经不再是一种常规的排序方法,它更多地像是一种排序方法的应用,基数排序必须依赖于另外的排序方法。基数排序的总体思路就是将待排数据拆分成多个关键字进行排序,也就是说,基数排序的实质是多关键字排序。
多关键字排序的思路是将待排数据里的排序关键字拆分成多个排序关键字:第1个子关堆字、第2个子关键字、第3个子关键字......然后,根据子关键字对待排数据进行排序。
在进行多关键字排序时有两种解决方案。
- 最高位优先法MSD(Mast Significant Digit first}.
- 最低位优先法LSD(Least Significant Digit first ).
例如,对如下数据序列进行排序:
192,221,12,23
可以观察到它的每个数据至多只有3位,因此可以将每个数据拆分成3个关键字:百位〔高位)、十位、个位(低位)。
如果按照习惯思维,会先比较百位,百位大的数据大:百位相同的再比较十位,十位大的数据大;最后再比较个位。人的习惯思维是最高位优先方式。
如果按照人的思维方式,计算机实现起来有一定困难,当开始比较十位时,程序还需要判断它们的百位是否相同—这就人为地增加了难度。计算机通常会选择最低位优先法,如下
所示。
- 第1轮先比较个位,对个位关键字排序后得到序列为:
221,192,13,23
- 第2轮再比较十位,对十位关键字排序后得到序列为:
13,23,221,192
- 第3轮再比较百位,对百位关键字排序后得到序列为:
13,23,192,22
从上面介绍可以看出,基数排序方法对任一个子关键字排序时必须借助于另一种排序方法,而且这种排序方法必须是稳定的。
如果这种排序算法不稳定,比如上面排序过程中,经过第2轮十位排序后,在第3轮百位排序时,如果该排序算法是稳定的,那么13依然位于23之前:如果该算法不稳定,那么可能l3跑到23之后,这将导致排序失败。
现在的问题是,对子关键字排序时,到底选择哪种排序方式更合适呢?答案是桶式排序。
回顾桶式排序的两个要求:
- 待排序列的所有值处于一个可枚举范围内。
- 待排序列所在的这个可枚举范围不应该太大。
- 对于多关键字拆分出来的子关键字,它们一定位于0~9这个可枚举范围内,这个范围也不大,因此用桶式排序效率非常高。
//基数排序
public static void radixSort(int[] data,int radix,int d){
System.out.println("开始排序:");
int arrayLength=data.length;
//需要一个临时教组
int[] tmp=new int[arrayLength];
//buckets数组是捅式排序必书的buckets数组
int[] buckets=new int[radix];
//依次从高位的子关健字对待排戴据进行排序
//下面循环中rate用于保存当前计算的位(比如十位时rate-10)
for(int i=0,rate=1;i<d;i++){
//重置count数组,开始统计第二个关键字
Arrays.fill(buckets, 0);
//将data数组的元素复制到trnp数组中进行缓存
System.arraycopy(data, 0, tmp, 0, arrayLength);
//计算每个待排数据的子关键字
for(int j=0;j<arrayLength;j++){
//计算数据指定位上的子关键字
int subKey=(tmp[j]/rate)%radix;
buckets[subKey]++;
}
for(int j=1;j<radix;j++){
buckets[j]=buckets[j]+buckets[j-1];
}
//按子关键字对指定数据进行排序
for(int m=arrayLength-1;m>=0;m--){
int subKey=(tmp[m]/rate)%radix;
data[--buckets[subKey]]=tmp[m];
}
System.out.println("对"+rate+"位上子关键字排序:"+Arrays.toString(data));
rate*=radix;
}
}