典型的排序算法有:选择排序(selection sort)、插入排序(insertion sort)、希尔排序(shellsort)、合并排序(mergesort)、快速排序(quicksort)、堆排序(heapsort)。
选择哪种排序算法,要考虑三个条件: 是否依赖输入值(input values)、是否需要额外空间(extra memory)、对于相等键如何处理。
基础概念:
An inversion is a pair of entries that are out of order in the array.
1. 逆序(inversion): 在数组中,一个逆序(inversion)是不正常顺序的项目对。
例如:{3, 1, 5 , 2} 的逆序对有:(3,1)、(3,2)、(5,2)
2. 部分排序(partially sorted): 在一个数组中,逆序的个数小于数组大小的固定倍数(逆序个数<= cN, c为固定倍数,N为数组大小),那么对该数组的排序称为 部分排序。
说明: 进行排序的数组,每个元素应该实现了 Comparable ,各种排序通用的方法有:
/**
* 比较大小
* @param v
* @param w
* @return
*/
public static boolean less(Comparable v, Comparable w){
return v.compareTo(w) < 0;
}
/**
* 交换元素
* @param a
* @param i
* @param j
*/
public static void exch(Comparable[] a, int i, int j){
Comparable t = a[i];
a[i] = a[j];
a[j] = t;
}
一、选择排序(Selection sort)
排序后会打乱原有的正确排序,是在原地进行排序,排序运行时间的增长率为,是需要使用一个额外的空间。
/**
* 选择排序,将 最小值移到最前面
* @param a
*/
@Override
public void sort(Comparable[] a) {
// Sort a[] into increasing order.
int N = a.length;
for (int i=0; i<N; i++){
int min = i;
for (int j=i+1; j<N; j++){
if(less(a[j], a[min])){
min = j;
}
exch(a, i, min);
}
}
}
二、插入排序(insertion sort)
适用: 插入排序对“部分排序”和小数组排序是非常好的方法。
对于特定类型的非随机数组,使用插入排序,工作效率比较高。
插入排序大概比选择排序的效率快两倍。
排序之后不会打乱原来正常的排序(stable),是原地排序,排序运行时间的增长率为N到之间, 取决于要排序的项。
/**
* 插入排序
* @param a
*/
@Override
public void sort(Comparable[] a) {
// Sort a[] into inCreasing order
int N = a.length;
for (int i = 0; i < N; i++) {
// Insert a[i] among a[i-1], a[i-2], a[i-3].....
for (int j = i; j >0 && less(a[j], a[j-1]); j--) {
exch(a, j, j-1);
}
}
}
缺点: 插入排序对大的无序数组是效率低的。因为插入排序一次只能改变相邻两个元素的位置,如果一个最小的元素位于数组的末尾,就需要N-1次排序。
三、希尔排序(Shellsort)
希尔排序是对插入排序(Insertion sort)的拓展。增长序列可以计算出来,也可以将增长序列存在数据中。下面的算法增长序列是计算出来的 :((3^k) -1 )/2 ==> while (h < N /3) h= 3 * h + 1;
性能: 希尔排序不一定是二次的。
例如,对于下面的具体实现,在最坏的情况下,排序中的比较次数是和 N^(3/2) 成比例。
希尔排序不是稳定的(改变相同值的位置),是原地排序算法,运行时间的增长率为NlogN , 需要1个额外空间。
@Override
public void sort(Comparable[] a) {
// Sort a[] into increasing order.
int N = a.length;
int h = 1;
// (3^k -1)/2
while (h < N / 3) h = 3 * h + 1;
while (h >= 1) {
// h-sort the array
for (int i = h; i < N; i++) {
// Insert a[i] among a[i-h], a[i-2*h]...
for (int j = i; j >= h && less(a[j], a[j - h]); j -= h) {
exch(a, j, j - h);
}
}
h = h / 3;
}
}
经验丰富的程序员通常选择希尔排序(shellsort),因为即便对于中等大小的数组,这个排序算法的运行时间也是可以接受的。
shellsort只需要很少数量的代码,并且使用shellsort不需要额外的空间。
使用场景: 如果在一个系统排序不可用的情况下(例如,代码用于硬件或者嵌入式系统),解决一个排序问题,可以安全的选择希尔排序(shellsort)。一段时间之后,再确定是否值得换成更复杂的、高级的方法。
四、归并排序(mergesort)
归并排序:为了排序一个数组,将这个数组分成两半,排序这两半(递归操作),然后合并最终的结果。
递归排序最诱人的是,该算法保证对于任何有N个项的数组,所花时间与NlogN成比例。而递归排序重要的缺点是,使用了额外的空间,额外空间的大小与数组大小N成比例。
归并排序有两种实现方法,Top-down和down-up。 归并排序是稳定的(如果两个元素相同,不改变原来的位置),不是原地排序,排序运行时间的增长率为NlogN, 额外的空间为lgN。
4.1 在原地合并的抽象: Abstract in-place merge
前提假设: 一个数据Comparable[] a 被分成两部分第一部分坐标为 lo 到 mid ,第二部分的坐标为 mid+1 到hi;这两部分都已经排完序了。
/**
* 原地合并的抽象 Abstract in-place merge
* 通过复制数组中的每一项到一个副本中,然后再合并到原来的数组中
* @param a
* @param lo
* @param mid
* @param hi
*/
public static void merge(Comparable[] a, int lo, int mid, int hi){
// Merge a[lo..mid] with a[mid+1..hi].
int i=lo, j= mid+1;
// Copy a[lo..hi] to aux[lo..hi].
Comparable[] aux = new Comparable[a.length];
for (int k=lo; k<=hi;k++){
aux[k] = a[k];
}
// Merge back to a[lo..hi].
for (int k=lo; k<=hi; k++){
if (i>mid) a[k] = aux[j++];
else if (j>hi) a[k] = aux[i++];
else if (less(aux[j], aux[i])) a[k] = aux[j++];
else a[k] = aux[i++];
}
}
4.2 自上而下的归并排序 (Top-down mergesort)
Top-down mergesort 是一个基于Abstract in-place merge 的递归归并算法。
/**
* Top-down mergesort
* recursive
* @param a
* @param lo
* @param hi
*/
private static void sort(Comparable[] a, int lo, int hi){
if (hi<=lo) return;
int mid = lo + (hi-lo)/2;
// Sort left half
sort(a, lo, mid);
// Sort right half
sort(a, mid + 1, hi);
// Merge results
merge(a, lo, mid, hi);
}
Top-down mergesort 和 abstract in-place mergesort 不在同一个层次,因为我们能排序一个大的数组,而不需要检查每一个条目。我们能够对百万量级(或更多)的元素项使用Top-down mergesort方法来排序,但却不能使用插入排序(insertion sort)或选择排序(selection sort)。
mergesort 的主要缺点是需要 为辅助合并的数组 分配与数组大小N成比例的额外空间。如果空间稀缺,我们需要考虑其他方法。
另外,通过认真考虑后对实现进行修改,还可以大幅缩短mergesort的运行时间。
(1) 对于子数组使用插入排序:对小的数组使用插入排序,将优化典型归并排序运行时间10%或15%;
(2)测试数据是否已经排序:通过增加一个测试,如果a[mid]小于或等于a[mid+1],就跳过调用merge()方法。这样做,仍然需要递归调用,但是对于任何排序的子数组是线性的。
(3)消除向辅助数组中的拷贝:这样是消除向辅助数组拷贝的时间,但不能消除因为辅助数组而使用的空间。为了这样做,需要进行两次排序方法的调用:第一次从给定数组拿到输入经排序后放到辅助数组中,第二次从辅助数组获取输入经过排序后放到给定数组。
4.3 Bottom-up mergesort
@Override
public void sort(Comparable[] a) {
int N = 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));
}
}
}
当数组的长度为2的乘方的时候,top-down 和 bottom-up mersort 有相同的比较和数组读取次数,仅仅顺序不同。当数组的长度不是2的乘方的时候,这两种算法对于比较和数组的读取次数是不同的。
Bottom-up mergesort 算法为了排序一个长度为N的数组,需要使用 1/2NlgN 到 NlgN 次比较 和 最多 6NlgN 次数组的读取。
归并排序是基于比较的算法中渐进最优的算法。
五、快速排序(Quick sort)
快速排序比任何其他的排序算法使用都广泛。quicksort 有两个特点:原地排序(仅使用很小的辅助‘堆stack’容器),平均排序一个数组所需时间和NlogN 成正比。
算法的基础
快速排序是一个分治(divide-and-conquer)的排序方法。通过将数组分成两部分,然后分别排序子数组。
快速排序是对归并排序(mergesort)的补充:对于归并排序mergesort,我们将数组分成两个子数组,将这两个子数组排序,然后结合这两个已经排序的子数组,让真个数组排序;对于快速排序quicksort,当两个子数组是已经排序了,那么整个数组都是已经排序了。第一种情况是,在作用于整个数组之前进行递归;第二种情况是,在作用于整个数组之后进行递归。
对于归并排序(mergesort),数组是按照一半进行分割的;对于快速排序(quicksort),被分割的位置取决于数组的内容。
快速排序算法的核心是分离的过程。被分割的数组由三部分组成: 左边的子数组、分割项、右边的子数组。应该保证:
(1)分割项 a[j] 所处的位置就是在排序后数组的最终位置;
(2)对于分割项左边的数组,没有一个比分割项的大;
(3)对于分割项右边的数组,没有一个比分割项的小;
/**
* @Date 2019-07-12
* @Author lifei
*/
public class Quick extends SortCommont {
@Override
public void sort(Comparable[] a) {
/**
* 随机打乱 数据a元素的顺序
* 让这个算法变成随机算法,为了更好预测算法的性能
* 排除对输入的依赖(Eliminate dependence on input))
*/
StdRandom.shuffle(a);
sort(a, 0, a.length-1);
}
/**
* quicksort 的递归排序
* @param a 数组
* @param lo 较小的索引值
* @param hi 较大的索引值
*/
public static void sort(Comparable[] a, int lo, int hi){
if (hi <= lo) return;
// 分割
int j = partition(a, lo, hi);
// 对左边子数组进行排序: a[lo...j-1]
sort(a, lo, j-1);
// 对右边子数组进行排序: a[j+1.. hi]
sort(a, j+1, hi);
}
/**
* quicksort 的关键方法
* @param a
* @param lo
* @param hi
* @return
*/
public static int partition(Comparable[] a, int lo, int hi){
int i = lo, j=hi+1;
Comparable v = a[lo];
while (true){
// Scan right, Scan left, check for scan complete, and exchange
while (less(v, a[++i])) if (i==hi) break;
while (less(a[--j], v)) if (j==lo) break;
if (i>=j) break;
exch(a, i, j);
}
// Put v = a[j] into position
exch(a, lo, j);
return j;
}
}
快速排序算法的潜在麻烦: 如果分区不平衡,该算法将极其低效。例如,第一次分区的项是最小的项,第二次分区的时用第二小的项,依次类推,最终导致出现大量分区的大的子数组。为了避免这种情况,在使用快速排序之前进行随机排序的原因。
快速排序的优化
(1)切断,使用插入排序。基于两点观察:对极其小的子数组进行排序的时候,快速排序比插入排序要慢;快速排序对于小的子数组也会递归调用;为此,使用下面的语句代替原来 if (hi <= lo) return;
if(hi <= lo + M) { Insertion.sort(a, lo, hi); return;}
M的取值要依赖于系统,但在大多情况下,M取 5 到15 之间的值 ,工作的效果都很好。
(2) Quicksort with 3-way partitioning对有大量重复元素的数组进行排序
思路:将数组分为三段 before、during、after。
before段的的所有元素值小于during段的值,during段的所有元素值都都相等,after段的所有元素值都大于after。
/**
* 使用三段式对数据进行排序: 第一段存放的是小于分割值,第二段存放的是等于分割值, 第三段存放的是大于分割值
* @param a
* @param lo
* @param hi
*/
private static void sort(Comparable[]a, int lo, int hi){
if (hi<=lo) return;
int lt = lo, i = lo+1, gt = hi;
Comparable v = a[lo];
while (i<=gt){
int com = a[i].compareTo(v);
if (com<0) exch(a, lt++, i++);
else if (com>0) exch(a, i, gt--);
else i++;
}
sort(a, lo, lt-1);
sort(a, gt+1, hi);
}
六、堆排序
写到另一篇博客了:(二叉树)堆排序:HeapSort