接下来的三个高级排序算法,是在实践中经常使用的算法,比起基于比较和交换的三个简单的排序算法,有更快的速度。快速排序和归并排序都属于递归排序算法,对于递归排序算法来说很重要的就是对递归树的理解。
一、快速排序
快速排序使用了分治法的策略。它的基本思想是,选择一个基准数,通过一趟排序将要排序的数据分割成独立的两部分;其中一部分的所有数据都比另外一部分的所有数据都要小。然后,再按此方法对这两部分数据分别进行快速排序,整个排序过程可以递归进行,以此达到整个数据变成有序序列。可以看出,快速排序很重要的一点就是对基准数的选择。影响快速排序性能的因素除了本身数组的有序程度,还和这个基准数有关。在下面的代码中,我们使用最经典的,选择数组的第一个数作为基准数。
快速排序流程如下:
(1)从数列中挑出一个基准值。
(2)将所有比基准值小的摆放在基准前面,所有比基准值大的摆在基准的后面(相同的数可以到任一边);在这个分区退出之后,该基准就处于数列的中间位置。
(3)递归地把"基准值前面的子数列"和"基准值后面的子数列"进行排序。
1、代码:
public class Main{//快速排序
public static void main(String[] args){
int[] a=new int[]{1,3,1,2,0,34,5,2,6,2,0,-3,-1,22,44,-11,0,-1};
int n=a.length;
qsort(a, 0, n-1);
print(a);
}
public static void print(int[] a){
int n=a.length;
for(int i=0;i=end)
return;
int first=start,last=end,key=a[start];
while(first=key)
last--;
a[first]=a[last];
while(first
2、时间和空间复杂度分析
快速排序其实由于使用了递归,事实上是一个递归树。对于快速排序,复杂度是完全体现在划分的平衡性上的,划分的越均衡(二叉递归树越趋近于满二叉树),那么二叉树的深度就越浅,当然我们递归的深度就会越小,时间复杂度就会越低,排序的效果就会越好
(1)最好时间复杂度
在最好的情况下,每次划分过程中左右两边都划分地很均匀。也就是枢纽元刚好选到了它刚好的位置,而且两边都已经分好了均分的两半。假设,对于一个n个数的数组的快速排序,需要的时间是T(n),对于第一次划分,要对整个数组都扫描一遍,做了n次比较。然后,获得的基准数将数组划分为相等的两半,各自需要的快速排序时间是T(n/2),因此有如下关系式:T(n)<=2T(n/2)+n。然后,根据这个关系式,有:
T(n)<=2(2T(n/4)+n/2)+n=4T(n/4)+2n
<=4(2T(n/8)+n/4)+2n=8T(n/8)+3n
...
到了最后,我们假设迭代了m次,因此有
T(n)<=(2^m)T(n/2^m)+mn
由于T(n/2^m)=T(1),故n=2^m,有m=log2n,固有
T(n)<=n+nlog2n=O(nlogn)
因此快速排序的最好时间复杂度是O(nlogn)
(2)最坏时间复杂度
在最坏的情况下,待排序的序列为正序或者逆序,快排会退化成冒泡排序,每次划分只得到一个比上一次划分少一个记录的子序列,而另一个为空。如果递归树画出来,它就是一棵斜树。此时需要执行n‐1次递归调用,且第i次划分需要经过n‐i次关键字的比较才能找到第i个记录。因此比较次数为n-1+n-2+...+1=n(n-1)/2次,最终时间复杂度是O(n^2)。
(3)平均时间复杂度
平均情况下,O(nlogn)。
(3)空间复杂度
对于快速排序来说,主要是地柜造成的栈空间使用。最好情况,递归树的深度为log2n,其空间复杂度也就为O(logn),最坏情况,需要进行n‐1递归调用,其空间复杂度为O(n),平均情况,空间复杂度也为O(logn)。
3、稳定性
快速排序是一种不稳定的排序,因为关键字的交换和比较是跳跃进行的。
4、使用情况
快速排序是最快的通用排序算法,在大多数情况中,快速排序是最佳选择。可以从上面的分析看到,对于基准数,也就是枢纽元的选择非常重要,极大影响快排的性能。一般有首元素法、随机法、三值中值分割法等。
二、归并排序
他的基本思想是合并两个已经排序的表(如A和B)。合并的办法是用两个指针,在已经排序的A和B的开头,不断往前移,作比较,把A和B中的元素放到C中。真正实现算法时候,要用递归进行处理。其基本操作是合并,然后要不断递归,对越来越小的数组区域进行不断的合并。整个算法要分成两部分,一部分是归并操作,另一部分是总体的归并排序的操作。
1、代码
public class Main {//归并排序
public static void main(String[] args) {
int[] a=new int[] {2,1,3,2,1,-9,6,4,0,0,9,6,0,3,11};
int n=a.length;
int[] tempa=new int[n];//建一个临时数组,避免在递归中频繁开辟空间
mergesort(a, tempa, 0, n-1);
print(a);
}
public static void print(int[] a) {
int n=a.length;
for(int i=0;i
2、时间和空间复杂度分析
归并排序是典型的分治策略算法,即先“分”后“治”,把一个问题先分开成若干小问题,再从小问题解决。对于归并排序来说,总时间=分解时间+解决问题时间+合并时间。设总时间为T(n)。分解时间就是把一个待排序序列分解成两序列所需要的时间,为一常数,时间复杂度为O(1);解决问题时间是两个递归式,把一个规模为n的问题分成两个规模分别为n/2的子问题,所需时间为2T(n/2);合并时间就是把数组合并起来的时间,总是O(n)。因此有式子
T(n)=2T(n/2)+O(n)。这个式子可以画递归树来解。假设最后子问题的时间复杂度是个常数c,则对于n个待排记录来说,有这样的一个递归树:
(引用自https://blog.csdn.net/bluetjs/article/details/52485920)
可以看到最后一排被分成了有n个c的规模。对于递归树的每一行,其时间和为cn,而递归树有log2n层,因此最好、最坏和平均的时间复杂度均为O(nlogn)。就算是最坏情形,归并排序所使用的比较次数几乎是最优的,是递归算法的一个很好的实例。他的缺点是需要额外一倍的存储空间,因此空间复杂度是O(n)。
3、算法稳定性
归并排序把序列递归的分割成小序列,然后合并排好序的子序列。当有序列的左右两子序列合并的时候一般是先遍历左序列,所在左右序列如果有相等元素,则处在左边的仍然在前,这就稳定了。但是如果非得先遍历右边序列则算法变成不稳定的了。虽然这样排出来的序也是对的,但变成了不稳定的。所以是不太好的实现。
4、用法
对于归并排序,其速度仅次于快速排序,一般用于对总体无序,但是对各子项相对有序的情况。
三、堆排序(参考:http://www.cnblogs.com/chengxiao/)
1、堆
堆排序是利用堆这种数据结构设计的排序算法。在了解堆排序的步骤之前,我们先要了解堆是怎样一种数据结构。堆是具有以下性质的完全二叉树:每个结点的值都大于或等于其左右孩子结点的值,称为大顶堆;或者每个结点的值都小于或等于其左右孩子结点的值,称为小顶堆。如果要把序列从小到大排序,需要做一个大顶堆。
对于一个堆来说,由于是完全二叉树,我们一般用一个数组来进行存储。例如,对上图所示的大顶堆,反映到逻辑结构数组中如下:
可以看出:
大顶堆:arr[i]>=arr[2i+1]&&arr[i]>=arr[2i+2]
小顶堆:arr[i]<=arr[2i+1]&&arr[i]<=arr[2i+2]
2、堆排序步骤
堆排序的基本思想是:将待排序序列构造成一个大顶堆,此时,整个序列的最大值就是堆顶的根节点。将其与末尾元素进行交换,此时末尾就为最大值。然后将剩余n-1个元素重新构造成一个堆,这样会得到n个元素的次小值。如此反复执行,便能得到一个有序序列了。先直观看看步骤(用大顶堆构造升序序列)
(1)假设给定的无序序列如下:
(2)从下往上,从右往左寻找,我们从第一个非叶子节点开始调整,也就是找到6。如果符合大顶堆的定义,就不需要调整他了。如果不符合大顶堆的定义,我们就把他和他的两个叶子节点中更大的那个叶子节点进行交换。这样,这个小分支就符合大顶堆的定义了。
(3)再找到第二个非叶子节点4。也需要进行调整。把4往下降。
这个时候,发现子根[4,5,6]不符合大顶堆的定义。这个时候,要继续把4往下降。
(4)由于已经对根节点处理完了,因此已经构造完成大顶堆。综合上面所述,其实大顶堆的构造步骤就是,从下往上,从右往左,找到每一个非叶子节点,对他进行调整。调整的方法是,把他和他的叶子节点进行比较,如果符合大顶堆的定义,那就不需要做处理了;如果不符合大顶堆的定义,那就把他和他最大的叶子节点交换,也就是下沉。下沉后,要继续判断他当前位置处符不符合大顶堆的定义,如果不符合继续下沉,直到他成为叶子节点。
(5)构造完大顶堆后,将堆顶元素与末尾元素交换,就是序列的最大值。
(6)对剩下的数据继续进行上述步骤。
3、代码
import java.util.Arrays;
public class Main {//堆排序
public static void main(String[] args) {
int[] a=new int[] {2,1,3,2,1,-9,6,4,0,0,9,6,0,3,11};
int n=a.length;
for(int i=n/2-1;i>=0;i--)//n/2-1就是第一个非叶子节点
adjustheap(a, i, n);
for(int i=n-1;i>0;i--) {
swap(a, 0, i);
adjustheap(a, 0, i);
}
System.out.println(Arrays.toString(a));
}
public static void swap(int a[], int i, int j) {
int temp=a[i];
a[i]=a[j];
a[j]=temp;
}
public static void adjustheap(int[] a, int i, int n) {//这个方法对下标为i的非叶子节点进行调整,n为数组长度
int temp=a[i];
for(int k=2*i+1;ktemp) {
a[i]=a[k];
i=k;
}
else
break;
}
a[i]=temp;//这里为什么不是在for循环里面呢?因为在for循环里面,a[k]一直是和temp作对比,事实上也就是和最开始的那个a[i]作对比,因此本质上是交换。如果把这个语句加进for循环中,一开始给temp赋值也要加入for循环
}
}
4、时间和空间复杂度分析
(1)时间复杂度
对于堆排序来说,他的时间复杂度分为两个部分:初始化堆过程和每次选取最大数后重新建堆的过程。我们假设要建一个堆,这个堆是一层完全饱满的安全二叉树,他的高度是h,每一层有2^(h-1)个节点。先来分析初始化堆的过程。对于一个h高度的堆,我们从倒数第二层最右边第一个数开始调整。如果顺序对的那就不需要交换,如果顺序不对就需要调整。我们假设在最坏的条件下,每个非叶子结点都要调整,而且要层层往下一直调整到叶子结点。那这样,对于倒数第i层(i=2,3,...,k),总的调整次数是i*2^(h-(i-1))。因此,调整的总次数为2*2^(h-1)+3*2^(h-2)+...+h*2。这是一个等差数列乘等比数列的求和。求和后可以得到一个2^h-h项,而近似可以认为2^h=n,因此初始化堆过程的时间复杂度是O(n)。
在取出堆顶点放到对应位置并把原堆的最后一个节点填充到堆顶点之后,需要对堆进行重建,只需要对堆的顶点调用adjustheap()函数。
每次重建意味着有一个节点出堆,所以需要将堆的容量减一。adjustheap()函数的时间复杂度k=log(n),k为堆的层数。所以在每次重建时,随着堆的容量的减小,层数会下降,函数时间复杂度会变化。重建堆一共需要n-1次循环,每次循环的比较次数为log(i),则相加为:log2+log3+…+log(n-1)+log(n)≈log(n!)。可以证明log(n!)和nlog(n)是同阶函数:
∵(n/2)n/2≤n!≤nn,∵(n/2)n/2≤n!≤nn,
∴n/4log(n)=n/2log(n1/2)≤n/2log(n/2)≤log(n!)≤nlog(n)∴n/4log(n)=n/2log(n1/2)≤n/2log(n/2)≤log(n!)≤nlog(n)
所以时间复杂度为O(nlogn)
(2)空间复杂度
空间复杂度是O(1),因为是就地排序。
5、稳定性
非稳定。
5、适用范围
堆排序所需的辅助空间少于快速排序,并且不会出现快速排序可能出现的最坏情况。当n比较大时候可以使用堆排序。优先队列通常用堆排序来实现(毕竟优先队列直接就是数组,用堆排序那天然符合其数据结构)。