一、开篇说明
本篇博客主要巩固算法第四版第二章的排序算法——选择排序,插入排序,希尔排序,归并排序,快速排序。讲解它们的思想,原理、算法的实现以及这些算法的性能,希望能帮助大家快速理解并掌握它们。
二、排序算法详解
在详细介绍各个算法之前,先理清楚博客的约定,主要关注的对象是对数组元素进行重新排序的算法,主要目标是对数组中的元素按数字大小或者字母顺序进行排序,排序后的数组较大的下标主键大于等于下标主键较小的元素。
接下来,我把排序算法要用的公共方法抽象到一个抽象类中,如下:
/**
* 排序算法抽象类
*/
public abstract class Sort {
protected Comparable[] aux = {};
protected abstract void sort(Comparable[] a);
/**
* 比较两个数
* compareTo():返回-1,0,1:如果a<b返回-1,a=b返回0,a>b返回1
* @param a
* @param b
* @return
*/
protected static boolean compare(Comparable a, Comparable b) {
return a.compareTo(b) < 0;
}
/**
* 交换数组元素
* @param i
* @param j
* @param a
*/
protected static void exch(int i, int j, Comparable[] a) {
Comparable t = a[i];
a[i] = a[j];
a[j] = t;
}
/**
* 打印数组元素
* @param a
*/
protected static void show(Comparable[] a) {
for (int i = 0; i < a.length; i++) {
System.out.print(a[i] + ",");
}
System.out.println();
}
/**
* 判断数组是否已排序
* @param a
* @return
*/
protected static boolean isSorted(Comparable[] a) {
for (int i = 0; i < a.length; i++) {
if (compare(a[i], a[i+1]))
return false;
}
return true;
}
/**
* 原地归并算法
* 将两个不同的有序数组归并到第三个数组中
* @param a
* @param lo
* @param mid
* @param hi
*/
protected void merge(Comparable[] a, int lo, int mid, int hi) {
int i = lo, j = mid + 1;
for (int k = lo; k <= hi; k++) {
aux[k] = a[k];
}
//归并回a数组中
for (int k = lo; k <= hi; k++) {
if (i > mid) //左半边用尽,取右半边的元素
a[k] = aux[j++];
else if (hi < j) //右半边用尽,去做半边的元素
a[k] = aux[i++];
else if (compare(aux[j], aux[i])) //比较左半边的当前元素和右半边的当前元素,如果前者大于后者,
a[k] = aux[j++]; //就取右半边的元素,否则取左半边的。
else
a[k] = aux[i++];
}
}
}
Comparable接口:
该接口对实现该接口的每个类的对象强加了总体排序。 此排序称为类的自然排序,而该类的compareTo方法被称为其自然比较方法。通俗点说,像包装类(Integer,Double等类型)以及String类型都实现了该接口,在调用compareTo方法的时候会根据传入的具体类型而调用相应的实现类方法对两个对象进行比较。
sort()为抽象方法,由各个排序算法实现自己的算法逻辑。compare()用于比较两个对象。exch()用于交换两个元素的位置。show()用于遍历打印数组。isSorted()用于判断当前数组是否为有序。
merge()方法是用于归并排序算法,归并排序算法分两种:自顶向下和自底向上两种算法,故在这里将它抽象出来供这两种算法使用。后面跟大家详细介绍。
2.1 选择排序
2.1.1 基本思想
首先找到数组中最小的那个元素,其次将他和数组中第一个元素交换位置(如果第一个元素最小那就和自己交换)。再次找到数组中剩余元素的最小的那个,将其与数组中第二个元素交换位置,以此类推,直到将整个数组排序。这就是选择排序,因为它在不断选择数组中剩余元素之中的最小者。
2.1.2 算法实现
public class SelectionSort extends Sort {
@Override
protected void sort(Comparable[] a) {
int N = a.length;
for (int i = 0; i < N; i++) {
for (int j = i+1; j < N; j++) {
if (compare(a[j], a[i]))
exch(j, i, a);
}
}
}
public static void main(String[] args) {
//Comparable[] arr = {3,53,2,5,7,23,};
Comparable[] arr = {'a','d','t','v','w','f','h','i'};
//Comparable[] arr = {"ad","dd","dt"};
new SelectionSort().sort(arr);
show(arr);
}
}
我们看sort()方法实现还是很简单,外循环控制循环伦次,每轮次取当前的a[i]值,在内循环中与剩余数组进行比较,如果a[j]比a[i]小,就交换这两个元素位置,确保当前a[i]值最小。但是这个算法还是有可以改进的地方,就是减少元素交换次数,因为最终的目的是每一轮次找到一个最小的数,所以定义一个变量min作为最小元素的索引,在内循环中找到小元素的数组下标值赋给min,在外循环进行元素交换。算法如下:
@Override
public void sort(Comparable[] arr) {
for (int i = 0; i < arr.length; i++) {
int min = i; //最小元素的索引
for (int j = i + 1; j < arr.length; j++) {
if (compare(arr[j], arr[min]))
min = j;
}
exch(i, min, arr);
}
}
这个排序算法改进将极大的提高排序效率,这是N=1000,T=100时(下面会介绍这两个东西是啥)可以看出效率将近提高了很多
2.1.3 算法性能
从选择排序的基本思路中我们可以看出,如果数组大小为N,在每一轮筛选出最小的元素时,是进行了N-1,N-2,N-3...2,1次比较的的过程,为每一轮都会有一次数组元素的交换,故大约需要N(N-1)/2=N^2/2次比较和N次交换。
2.2 插入排序
2.2.1基本思想
通常人们整理桥牌的方法是一张一张的来,将每一张插入到其他已经有序的牌中的适当位置。在计算机的实现中,为了给要插入的元素腾出空间,我们需要将其余所有元素在插入之前都向右移动一位,这就是插入排序。这是算法第四版的定义。
我个人对插入排序是这么理解的,首先比较数组前两位元素,保证前两位元素有序,然后拿到第三个元素,先跟数组第二位的元素进行比较,如果第三个元素比第二个元素小,那就交换第二和第三个元素的位置,此时原来数组的第三个元素已经变成第二个元素,这时拿第二个元素和第一个元素比较,保证小元素在前面。由此可以看出,当前比较的数组位置左边是有序,也就意味着,在比较的过程中左边这些元素有可能要向后移动一位。
2.2.2 算法的实现
public class InsertionSort extends Sort {
@Override
protected void sort(Comparable[] a) {
for (int i = 1; i < a.length; i++) {
//将a[i]插入到a[i-1],a[i-2],a[i-3]...中
for (int j = i; j > 0 && compare(a[j], a[j-1]); j--) {
exch(j, j-1, a);
}
}
}
public static void main(String[] args) {
Comparable[] arr = {3,53,2,5,7,23};
new InsertionSort().sort(arr);
show(arr);
}
}
插入排序和快速排序如出一辙,可以减少元素交换次数提高排序效率。
@Override
public void sort(Comparable[] a){
int N = a.length;
for(int i=1;i<N;i++){
Comparable temp = a[i];
int j;
for(j=i;j>0 && compare(temp, a[j-1]); j--){
a[j] = a[j-1];
}
a[j] = temp;
}
}
2.2.3 算法性能
假设数组长度为N,如下图,不难看出,每一轮次需要(N-i)次的比较和(N-i)次的交换。最坏的情况需要N^2/2次比较和N^2/2次交换,最好的情况需要N-1比较和0次交换。平均需要N^2/4次比较和N^2/4次交换
2.3 比较两种排序算法
现在我们已经实现了两者简单的排序算法,但是我们很自然的想知道这两种算法哪种更快性能更佳。现在写另一个算法——比较排序算法的性能算法(SortCompare类),下面代码根据自己的需要进行更改。注意要引入一个common-lang的包
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
<version>3.3.2</version>
</dependency>
public class SortCompare {
public static double time(String alg, Comparable[] a) {
StopWatch watch = new StopWatch();
watch.start();
if ("Insertion".equals(alg))
new InsertionSort().sort(a);
if ("Selection".equals(alg))
new SelectionSort().sort(a);
if ("Shell".equals(alg))
new ShellSort().sort(a);
return watch.getTime();
}
public static double timeRandomInput(String alg, int N, int T) {
double total = 0.0d;
Double[] a = new Double[N];
for (int i = 0; i < T; i++) {
for (int j = 0; j < N; j++) {
a[j] = Math.random();
}
total += time(alg, a);
}
return total;
}
public static void main(String[] args) {
String alg1 = "Insertion";
String alg2 = "Selection";
int N = Integer.parseInt("1000");
int T = Integer.parseInt("100");
double t1 = timeRandomInput(alg1, N, T);
double t2 = timeRandomInput(alg2, N, T);
System.out.println("Insertion: " + t1);
System.out.println("Selection: " + t2);
System.out.println("times: " + t2/t1);
}
}
预期结果当然是插入排序比选择排序快一倍左右,事实却是如此,下面的测试结果是我的笔记本的测试结果,而当我换一台电脑测试时,结果还是有差异,读者可以进行自测。
N=1000,T=100:
N=10000,T=100:
2.3 希尔排序
2.3.1 基本思想
其思想是使数组中任意间隔为h的元素都为有序的。这样的数组成为h有序数组。换句话说,一个h有序数组就是h个互相独立的有序数组编织在一起组成的数组。在进行排序时,如果h很大,就能将元素移动到很远的地方,为实现更小的h有序创造方便。用这种方式,对于任意以1结尾的h序列,都能够将数组排序。这就是希尔排序。
是不是有点蒙!哈哈哈哈!!莫慌往下看。顺便提一下,希尔排序是基于插入排序的哦,希尔排序为了加快排序简单的改进了插入排序,交换不相邻的元素以对数组的局部进行排序,并最终用插入排序将局部有序的数组进行排序
2.3.2 算法分析
public class ShellSort extends Sort {
@Override
protected void sort(Comparable[] a) {
int n = a.length;
int h = 1;
while (h < n/3)
h = h*3 + 1; //1,4,13....
while (h >= 1) {
//将数组变为h有序
for (int i = h; i < n; i++) {
//将a[i]插入到a[i-1],a[i-2],a[i-3]...中
for (int j = i; j >= h && compare(a[j], a[j-h]); j-=h) {
exch(j, j-h, a);
}
}
h = h/3;
}
}
public static void main(String[] args) {
Comparable[] arr = {'S','H','E','L','L','S','O','R','T','E','X','A','M','P','L','E'};
new ShellSort().sort(arr);
show(arr);
}
}
就以此来分析希尔排序,当程序运行到第一个while循环时,由于数组长度为16,h的值从1到4再到13,跳出第一个while循环,进入第二个while循环,此时h=13,经过两个for循环的数组变化如下,其中有可能要交换位置的字母颜色为红色。也就是第一次'S'和'P'进行比较,显然P在S的前面,故交换它们的位置;第二次比较'H'和'L',第三次比较'E'和'E',这两次比较都不需要交换元素位置,所以数组索引间隔为13的元素变成有序子数组,即保证a[0]和a[13]、a[1]和a[14]、a[2]和a[15]是有序的;第四次跳出外层for循环,执行h = h/3语句;使h=4。
h=4,h>1继续while循环,h=4意味着数组索引间隔为4的元素进行排序,保证了(a[0]、a[4]、a[8]、a[12])有序,(a[1]、a[5]、a[9]、a[13])有序,(a[2]、a[6]、a[10]、a[14])有序,(a[3]、a[7]、a[11]、a[15])有序。不难看出,这个数组经过这次循环变成局部有序的h有序数组,从而保证在最后一轮全局排序时,数组中的元素不会有很大的位置移动,从而减少元素交换的次数加快排序速度
再次执行 h = h/3语句,使h=1,满足循环条件,内层循环将逐个的对数组元素进行依次排序,知道最后将整个数组排序完成。
给大家看看视觉效果
2.3.3 算法性能
这个就比较难以阐述了,算法第四版是这么说的:算法的性能不仅取决于h,还取决于h之间的数学性质。有很多论文研究了各种不同的递增序列,但都无法保证某个序列是最好的。但是有一点很清楚的就是,通过SortCompare类进行测试发现,希尔排序比选择排序和插入排序要快的多,并且数组越大,优势越大。
2.4 归并排序
2.4.1 算法思想
归并排序的算法思想不难理解,即将两个有序的数组归并成一个更大的有序数组。这就是归并排序。
2.4.2 算法分析
要实现一个打乱的数组排序,可以先将它分成两班分别排序,然后将结果归并起来。
(1)自顶向下的归并排序算法
/**
* 自顶向下的归并排序
*/
public class MergeSort1 extends Sort {
@Override
protected void sort(Comparable[] a) {
aux = new Comparable[a.length];
sort(a, 0, a.length - 1);
}
/**
* 对子数组进行排序且归并
* @param a
* @param lo
* @param hi
*/
private void sort(Comparable[] a, int lo, int hi) {
if (lo >= hi)
return;
int mid = lo + (hi - lo)/2;
sort(a, lo, mid); //对左半边排序
sort(a, mid+1, hi); //对右半边排序
merge(a, lo, mid, hi); //归并结果
}
public static void main(String[] args) {
Comparable[] a = { 7, 5, 1, 8, 3, 2, 4, 6 };
new MergeSort1().sort(a);
show(a);
}
}
第一个sort(Comparable[] a)方法是实现Sort抽象类的抽象方法,它调用了第二个sort(Comparable[] a, int lo, int hi)方法,第二个sort(Comparable[] a, int lo, int hi)方法是一个递归排序方法。下面是我整理的上面代码的sort(Comparable[] a, int lo, int hi)的执行流程。
这是一张演示整个示例的执行流程,想必比文字来的更直观,更容易理解吧 。
(2)自底向上的归并排序
/**
* 自顶向上的归并排序算法
*/
public class MergeSort2 extends Sort {
@Override
protected void sort(Comparable[] a) {
int n = a.length;
aux = new Comparable[a.length];
for (int sz = 1; sz < n; sz = sz+sz) {
for (int lo = 0; lo < n-sz; lo += sz+sz) {
merge(a, lo, lo+sz-1, Math.min(lo+sz+sz-1, n-1));
}
}
}
public static void main(String[] args) {
Comparable[] a = { 7, 1, 5, 8, 3, 2, 4, 6 };
new MergeSort2().sort(a);
show(a);
}
}
下面这张动图是上面自底向上的归并排序算法的执行流程。自底向上的归并排序会多次遍历整个数组,子数组初始值sz=1,每次加倍,根据子数组大小进行两两归并。
(3)二者区别
自顶向下是先将数组分为两部分a[0,mid]和a[mid+1,hi],分别通过递归将他们单独排序,最后将有序的子数组归并为最终的有序数组。而自底向上先是进行两两归并(将每个元素想象成一个大小为1的数组),然后在四四归并(将两个大小为二的数组归并为一个大小为4的数组),直到归并为一个有序的数组。
2.4.3 算法性能
归并排序是一种渐进最优的基于比较的排序算法,即归并排序在最坏的情况下的比较次数和任意基于比较的排序算法所需的最少比较次数都是近似N*lgN。具体如何计算的可以看比较二叉树,博主这里不阐述,需要的去看算法第四版,需要pdf的可以点赞评论哈
2.5 快速排序
2.5.1 算法思想
快速排序可能是应用最广泛的排序算法。快速排序是一种分治的排序算法,它将一个数组分成两个数组,分别单独排序。归并排序将数组分为两个子数组单独排序,然后将这两个有序的子数组进行归并排序;而快速排序的思想是先将数组打乱取第一个元素为切片元素,以切片元素为中心,分为左右两个数组分别进行递归排序,当左右两边的子数组有序时整个数组就自然有序了。
2.5.2 算法实现
实现如下:
public class QuickSort extends Sort {
@Override
protected void sort(Comparable[] a) {
//打乱数组
Collections.shuffle(Arrays.asList(a));
sort(a, 0, a.length - 1);
}
/**
* 排序-递归
* @param a
* @param lo
* @param hi
*/
private void sort(Comparable[] a, int lo, int hi) {
if (lo >= hi)
return;
int j = partition(a, lo, hi); //切分
sort(a, lo, j-1); //将左半部分排序
sort(a, j+1, hi); //将有半部分排序
}
/**
* 分区,每次以a[lo](数组的第一个元素)为切分元素,
* 小于a[lo]放在左边,大于a[lo]放在右边,
* @param a
* @param lo
* @param hi
* @return
*/
private int partition(Comparable[] a, int lo, int hi) {
int i = lo, j = hi+1; //左右扫描指针
Comparable v = a[lo]; //切分元素
while (true) {
//扫描左右,检查扫描是否结束并交换元素
while (compare(a[++i], v))
if (i == hi)
break;
while (compare(v, a[--j]))
if (j == lo)
break;
if (i >= j)
break;
exch(i, j, a); //将v=a[j]放入正确的位置
}
exch(lo, j, a); //将v=a[j]放入正确的位置
return j;
}
public static void main(String[] args) {
//Comparable[] a = { 49, 38, 65, 97, 76, 13, 27, 50 };
Comparable[] a = { 'Q','U','I','C','K','S','O','R','T','E','X','A','M','P','L','E' };
new QuickSort().sort(a);
show(a);
}
}
本来是画了一张图给大家解释上面代码的运行轨迹,但是刚画完图电脑竟然蓝屏了(真的是心中一万个...),现在只能借张图张了。在执行数组打乱方法Collections.shuffle()后,数组的起始位置元素变成K,此时K就是切片元素,程序运行到partition()方法后,定义了两个变量i,j作为指针,从i+1开始向右扫描数组,当遇到比切片元素小的元素时,compare(a[++i], v)方法返回true,继续循环直到找到比切片元素大的元素或者i=hi时compare(a[++i], v)方法返回false结束循环;接下来从j-1开始向左边开始扫描,当遇到比切片元素大的元素时compare(v, a[--j])方法返回true,继续循环直到遇到比切片元素小的元素或者j=lo时返回false结束循环;否则交换数组中i和j下标的元素值,当i>=j时跳出循环while(true),然后交换切片元素和数组下标为j的元素(即:a[j])。执行partition()方法后,开始执行接下来的两个sort()方法,递归sort()方法直到将数组切片到为1,就不在切分。而这两个sort()递归就是将数组左右两边的数组进行排序,最后结果就自然有序了。下面是一种情况的轨迹图。
2.5.3 算法性能
归并和希尔排序一般都比快速排序慢,其原因是归并和希尔这两个排序还需要在内循环中移动数据。另一个优势在于排序的比较次数很少。但是最终效率还是依赖切分元素的值,最好的情况就是每次切分正好都能将数组对半分,但是有一个潜在的缺陷就是如果切分元素的值每次都都是最小的,这就直接导致一个大数组会切分很多次,这将间接影响排序效率,这是我们就需要改进算法。
2.5.4 算法改进
(1)切换到插入排序
对于小数组快速排序比插入排序慢,因为sort()方法在小数组中也会调用自己递归。可以将sort()方法中的代码
if (lo >= hi)
return;
替换为
int M = 10;
if (hi <= lo + M){
new InsertionSort().sort(a, lo, hi);
return;
}
下面是数组局部使用插入排序实现的算法
public void sort(Comparable[] a, int lo, int hi) {
for (int i = lo; i <= hi; i++) {
Comparable temp = a[i];
int j;
for (j = i; j > lo && compare(temp, a[j-1]); j--) {
a[j] = a[j-1];
}
a[j] = temp;
}
}
分别测试不优化的实现和优化时对M取5、10和15进行测试的结果
(2)三向切分
思想就是将数组切分为三部分,分别对应大于、等于和小于切分元素的数组元素。切分示意图如下,lo和hi对应数组的最低位和最高位的索引,从a[lo]到a[lt-1]存放的是小于切分元素v的数组元素,a[lt]到a[i-1]存放等于v的数组元素,a[gt]到a[hi]存放大于切分元素的数组元素,而从a[i]到a[gt]之间的元素还未确定。
算法如下:
private void sort(Comparable[] a, int lo, int hi) {
if (lo >= hi)
return;
Comparable v = a[lo];
int lt = lo, i = lo+1, gt = hi;
while (i <= gt) {
int result = a[i].compareTo(v);
if (result < 0)
exch(i++, lt++, a);
if (result > 0)
exch(i++, hi--, a);
else
i++;
}
sort(a, lo, lt-1);
sort(a, gt+1, hi);
}
在数组有大量元素重复的情况下,这种算法效率极其的高。博主写了一个随机生成大写字母数组的算法,对含有大量相同字母的数组进行测试对比,测试结果发现几乎是原来的快速排序算法6到8倍左右(粗略的估计),算法的时间几乎达到了线性级别。
private static double timeRandomChars(String alg, int N, int T) {
Character[] c = {'A','B','C','D','E','F','G','H','I','J','K','V','L','M','N'};
double total = 0.0d;
Character[] a = new Character[N];
Random random = new Random();
for (int i = 0; i < T; i++) {
for (int k = 0; k < N; k++) {
int j = random.nextInt(15);
a[k]=c[j];
}
total += time(alg, a);
}
return total;
}
三、总结
总算是告一段落,整个博客断断续续写了大概4天左右。还没有整理出堆排序,后续给大家不补上,给大家上一张所有排序算法的对比图。在大多数情况下,快速排序是最快的通用排序算法,也就是最佳选择,从上面的测试我们证明这一点。
四、参考资料
算法第四版
不忘初心,死抠细节。仅以此博献给我伟大的java语言,如有不当之处,欢迎大神指正。谢谢!!!