前言
简单归纳一下最近学习的排序算法,如果有什么错误的地方还请大家指教。
本文介绍了七种经典排序算法,包括冒泡排序,选择排序,插入排序,希尔排序,归并排序,快速排序以及堆排序,并且讨论了各种算法的进一步改进,在文章最后还对所有算法的时间和空间复杂度作了一个总结。
用Java语言可以很简洁优雅的实现各种排序算法,我们在写排序算法的时候可以下面这种模板:
public class Example { public static void sort(Comparable[] a) { //具体算法 } //判断v是否小于w public static boolean less(Comparable[] v, Comparable[] W) { return v.compareTo(w) < 0; } //交换 public static void exch(Comparable[] a, int i, int j) { Comparable temp = a[i]; a[i] = a[j]; a[j] = temp; } }
此种模板适合的是实现了Comparable接口的数据类型,如String, Integer, Date 或是自己创建的实现了compareTo()方法的数据类型。此种模板实际排序的是对象的引用,然而对于没有实现该接口的类型或是一些原始数据类型则不适用(可以利用Comparator比较器来实现)。
为了简洁易懂,下面以int型数据为例来书写代码,不使用less()方法,但仍使用exch()方法来表示交换值的过程。
一、冒泡排序
冒泡排序的思想理解起来很简单,就是依次比较相邻的元素,将大的元素往后移,小的往前移。因此,数列中最大的元素总是随着交换不断浮到顶端,于是有了冒泡排序这个名字。冒泡排序会经历 n-1 次循环(n 为数组长度),第一次循环将最大的数置于最末端,第二次将第二大的数置于倒数第二的位置,每一次循环待排序的数列长度逐渐减小,直至排序完成。
代码:
public class BubbleSort { public static void sort(int[] a) { for (int i = a.length - 1; i > 0; i--) for (int j = 0; j < i; j++) if (a[j] > a[j + 1]) exch(a, j, j + 1); //交换相邻元素 }
改进:如果在某一次循环中,发现没有进行任何交换,这说明已经完成了排序,即可直接退出。代码如下:
//改进版本 public class BubbleSort { public static void sort(int[] a) { for (int i = a.length - 1; i > 0; i--) { boolean didExch = false; //利用一个变量监控是否交换 for (int j = 0; j < i; j++) { if (a[j] > a[j + 1]) { exch(a, j, j + 1); didExch = true; } } if (didExch == false) //如果没有发生交换,直接返回 return; } }分析:对于改进后的版本,在最坏情况下所需的比较次数为(n - 1) + (n - 2) + .. + 1 = ( n-1) * n /2 ~ n^2 /2,时间复杂度为O(n^2),而最好情况下(即已经是有序数列),只需进行n - 1次比较和0次交换,时间复杂度为O(n)。
二、选择排序
选择排序的思想是:首先找到数组中最小的元素,将它和数组第一个元素交换位置,其次,在剩下元素中找到最小元素,和数组中第二个元素交换位置,以此类推,直至整个数组排序完毕。这种排序方法不断地选择剩余元素中的最小者,故得名选择排序。
代码:
public class Selection { public static void sort(int[] a) { for (int i = 0; i < a.length; i++) { int min = i; //找到最小值,并把下标记为 min for (int j = i + 1; j < a.length; j++) { if (a[j] < a[min]) min = j; } exch(a, i, min); //交换 a[i] 和 a[min] } }
分析:对于长度为n的数组,选择排序需要(n - 1)*n/2 ~ n^2 /2次比较和n次交换,时间复杂度为O(n^2)。
三、插入排序
插入排序和人们打牌时整理牌的思想是一致的,将每一张牌插入到其他已经有序的牌中的适当位置。在每一次循环当中,当前索引左边的元素都是有序的,直到当前索引移动到最右端,数组完成排序。
排序的实现有两种方法,第一种是通过不断交换顺序颠倒的相邻元素来找到合适位置:
public class Insertion { public static void sort(int[] a) { for (int i = 1; i < a.length; i++) for (int j = i; j > 0 && a[j] < a[j - 1]; j--) exch(a, j, j - 1); //交换顺序颠倒的元素 }
另一种是通过在内循环中将较大元素往右移动来找到插入位置,这种方法相比上面的方法访问数组的次数减少了一半
public class Insertion { public static void sort(int[] a) { for (int i = 1; i < a.length; i++) { int j; //注意 j 的声明要在内循环的外面 int temp = a[i]; //保存 a[i] 的值 for (j = i; j > 0 && temp < a[j - 1]; j--) { a[j] = a[j - 1]; //后移较大的元素 } a[j] = temp; //把原来的 a[i] 值插入空位中 } }
分析:以方法一为例,插入排序对于长度为n的数组,最坏情况下需要~n^2 /2次比较和~n^2 /2次交换,时间复杂度为O(n^2),最好情况下需要n-1次比较和0次交换,时间复杂度为O(n)。实际上,插入排序的需要的交换与数组中倒置元素的对数一致,因此插入排序对于规模不大,部分有序的数组排序十分有效。
四、希尔排序
希尔排序是一种基于插入排序的快速改进方法,利用的是插入排序处理部分有序数组有很好效果的特点。希尔排序处理不相邻的元素对数组的局部进行排序,并最终用插入排序将局部有序的数组排序。
希尔排序的思想是使数组中间隔为h的元素都是有序的,这样的数组称为h有序数组。下图则是一个4-有序数组:
下面使用一个递增序列 h = 3 * h + 1 来实现希尔排序,在 n = 16 的例子中,h 可以取 1,4 和 13:
代码:
public class Shell { public static void sort(int[] a) { int N = a.length; int h = 1; while (h < N / 3) h = 3 * h + 1; //h的递增序列 while (h >= 1) { for (int i = h; i < N; i++) { int j; //注意 j 的声明要在内循环的外面 int temp = a[i]; //保存 a[i] 的值 for (j = i; j >= h && temp < a[j - h]; j -= h) { a[j] = a[j - h]; //后移较大的元素 } a[j] = temp; //把原来的 a[i] 值插入空位中 } h = h / 3; } }
分析:希尔排序的性能取决于h的递增序列,上面代码中所用的递增序列并不是最优秀的,但最坏情况下的运行时间仍少于平方级别,算法的时间复杂度为O(n^(2/3))。
五、归并排序
归并排序的思想是利用递归先将数组分成两半分别排序,再归并起来。
我们首先需要实现的是归并,最直接的办法就是将两个不同的有序数组归并到第三个数组中,可以通过不断比较将两个输入数组中的元素一个个按照顺序放入这个数组。
归并方法:先将所有元素赋值到辅助数组aux[]中,再归并回a[]中;
public static void merge(int[] a, int lo, int mid, int hi) { int i = lo; int j = mid + 1; for (int k = lo; k <= hi; k++) aux[k] = a[k]; for (int k = lo; k <= hi; k++) { if (i > mid) a[k] = aux[j++]; else if (j > hi) a[k] = aux[i++]; else if (aux[j] < aux[i]) a[k] = aux[j++]; else a[k] = aux[i++]; } }
自顶向下的排序:
public class Merge { private static int[] aux; //静态数组 public static void sort(int[] a) { aux = new int[a.length]; //分配空间 sort(a, 0, a.length - 1); } private static void sort(int[] a, int lo, int hi) { if (hi <= lo) return; int mid = (lo + hi) / 2; sort(a, lo, mid); sort(a, mid + 1, hi); merge(a, lo, mid, hi); public static void sort(int[] a) { int[] aux = new int[a.length]; //创建辅助数组分配空间 sort(a, aux, 0, a.length - 1); } }
自底向下的排序:
private static void sort(int[] a) { int N = a.length; aux = new int[N]; 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)); } } }
改进:归并排序在以上基础上还可以有很多改进空间
改进1:将aux[]辅助数组作为参数传递给递归的sort()方法 (作为类的私有静态变量 和 放入归并方法中都不妥当);
public static void sort(int[] a) { int[] aux = new int[a.length]; //创建辅助数组分配空间 sort(a, aux, 0, a.length - 1); }改进2:添加判断条件,如果a[mid] <= a[mid + 1],此时数组已经有序,就可以跳过merge(),此时处理已经有序的数组
的运行时间变为线性;
改进3:用插入排序处理小规模的子数组:
if (hi - lo + 1 <= M) { //长度小于等于M的子数组用插入排序 InsertSort(a, lo, hi); return; }改进4:快速归并,按降序将a[]的后半部分复制到aux[],然后从两边向中间归并,这样可以省去判断半边是否用尽,
但这样的排序是不稳定的;
public static void merge(int[] a, int[] aux, int lo, int mid, int hi) { //升序复制前一半 for (int k = lo; k <= mid; k++) aux[k] = a[k]; //降序复制后一半 for (int k = mid + 1, j = hi; k <= hi; k++, j--) aux[k] = a[j]; int i = lo, j = hi; for (int k = lo; k <= hi; k++) { if (aux[j] < aux[i]) a[k] = aux[j--]; else a[k] = aux[i++]; } }分析:对于长度为n的数组,归并排序需要~ nlgn次比较,时间复杂度为O(nlgn)。(注:这里lgn表示以2为底的n的对数)
六、快速排序
快速排序是一种分治的排序算法,主要思想是利用切分,切分点左边的元素小于等于切分元素,切分点右边的元素大于等于切分元素,当左右两边的子数组有序时即完成排序,利用递归可以很好的将数组排序。
代码:
public class Quick { public static void sort(int[] a) { StdRandom.shuffle(a); //打乱数组 sort(a, 0, a.length - 1); } private static void sort(int[] a, int lo, int hi) { if (hi <= lo) return; int j = partition(a, lo, hi); sort(a, lo, j - 1); sort(a, j + 1, hi); } private static int partition(int[] a, int lo, int hi) { int i = lo, j = hi + 1; int v = a[lo]; while (true) { while (a[++i] < v) if (i == hi) break; while (v < a[--j]); if (j <= i) break; exch(a, i, j); } exch(a, lo, j); return j; } }
改进:同样,快速排序也有很大改进空间
改进1:对于小数组切换到插入排序,5 ~ 15 之间的任意值即可;
改进2:对于含有大量重复元素的数组,使用三向切分;
//三向切分
private static void sort(int[] a, int lo, int hi) {
//对于小数组用插入排序
if (hi <= lo + 5) {InsertSort(a, lo, hi); return;}
int lt = lo, i = lo + 1, gt = hi;
int v = a[lo];
while (i <= gt) {
if (a[i] < v) exch(a, lt++, i++);
else if (v < a[i]) exch(a, i, gt--);
else i++;
}
sort(a, lo, lt - 1);
sort(a, gt + 1, hi);
}
//供小规模数组使用的插入排序
public static void InsertSort(int[] a, int lo, int hi) {
for (int i = lo; i <= hi; i++) {
int j; //注意 j 的声明要在内循环的外面
int temp = a[i]; //保存 a[i] 的值
for (j = i; j > lo && temp < a[j - 1]; j--) {
a[j] = a[j - 1]; //后移较大的元素
}
a[j] = temp; //把原来的 a[i] 值插入空位中
}
}
分析:对于长度为n的数组,归并排序平均需要~ 1.39nlgn次比较,最好情况下时间复杂度为O(nlgn),而最坏情况下(有序)时间复杂度为O(n^2)。
七、堆排序
先介绍堆的定义(这里以最大堆举例),在二叉堆的数组中,每个元素都要保证大于另两个特点位置的元素。在堆有序的二叉树中,每个节点都小于等于它的父节点(存在的话)。可以用长度为N+1的私有数组来表示一个大小为N的堆,不使用pq[0],堆元素放在pq[1]至pq[N]中。
当一个节点太大时,需要上浮到堆的更高层:
private void swim(int k) { while (k > 1 && less(k / 2, k)) { exch(k / 2, k); k /= 2; } }当一个节点太小,需要下沉到更低层时:
private void sink(int k) { while (k * 2 <= N) { int j = 2 * k; if (j < N && less(j, j + 1)) j++; //j表示两个子节点里较大的那个 if (!less(k, j)) break; //注意退出循环 exch(k, j); k = j; } }堆排序的代码如下,接收一个数组,先将其转化为堆有序,然后每一次把最大的元素下沉到数组最后(原有的less()和exch()方法将a[1]~a[N]的元素排序,将这两种方法的索引减一即可得到a[0]~a[N-1]的排序)
public class Heap { public static void sort(int[] a) { int N = a.length; //堆有序 for (int k = N / 2; k >= 1; k--) sink(a, k, N); //堆排序 while (N > 1) { exch(a, 1, N--); sink(a, 1, N); } } private static void sink(int[] a, int k, int N) { while (k * 2 <= N) { int j = k * 2; if (j < N && less(a, j, j + 1)) j++; if (!less(a, k, j)) break; exch(a, k, j); k = j; } } private static void exch(int[] a, int i, int j) { int temp = a[i - 1]; a[i - 1] = a[j - 1]; a[j - 1] = temp; } private static boolean less(int[] a, int i, int j) { return a[i - 1] < a[j - 1]; } }分析:对于长度为n的数组,堆排序只需要最多 2nlgn+2n次比较,时间复杂度为O(nlgn)。