每篇一句:
The furthest distance in the world is not between life and death. But when I stand in front of you, yet you don’t know that I love you.
一、算法分类:
常见的算法可以分为两大类:
非线性时间比较类排序:通过比较来决定元素间的相对次序,由于其时间复杂度不能突破O(nlogn),因此称为非线性时间比较类排序。
线性时间非比较类排序:不通过比较来决定元素间的相对次序,它可以突破基于比较排序的时间下界,以线性时间运行,因此称为线性时间非比较类排序。时间复杂度:
(上面两张图片来源:十大经典排序算法(动图演示))
二、选择排序 (SelctionSort)
算法描述:
首先,找到数组中最小的元素,然后将它与数组的第一个元素交换位置(若果第一个元素就是最小元素,那么就和自己交换)。
再次,在剩下的元素中找到最小元素,将它与数组的第二个元素交换位置。
如此往复,直到将整个数组排序。
算法演示:
算法分析:
算法特点:对于长度为N的数组,无论是否有序,交换的次数总为N。
时间复杂度:
- 平均:O(n^2)
- 最好:O(n^2) (有序数列)
- 最坏:O(n^2) (倒序数列)
稳定
算法稳定:如果a=b, 且a原本在b前面,排序之后a必然在b的前面。
算法不稳定:如果a=b, 且a原本在b前面,排序之后a可能会出现在b的后面。
算法实现:
public static void sort(Comparable[] a){ // 选择排序 int N = a.length; for (int i = 0; i < N; i++){ // 将a[i]与之后元素中最小元素交换位置 int min = i; // 记录最小元素的位置 for (int j = i+1; j < N; j++){ if (less(a[j], a[min])) min = j; } exch(a, i ,min); //交换a[i]与a[min]位置 } }
三、插入排序:
算法描述:
将第一个元素视为已排序,
取第二个元素,与第一个元素比较,插入到合适位置,
取第三个元素,与前两个以排序的元素按从后往前的顺序比较,插入到合适的位置,
- 直到取第N个元素,与前N-1个已排序元素按从后往前的顺序比较,插入到合适位置,排序完成。
算法演示:
算法分析:
算法特点:对于取得的当前元素,其之前的元素总是有序的。
时间复杂度:
- 平均:O(n^2)
- 最好:O(n)
- 最坏:O(n^2)
- 稳定
算法实现:
public static void sort(Comparable[] a){ // 插入排序 int n = a.length; for (int i = 1; i < n; i++){ Comparable temp = a[i]; int j = i; while(j >= 1 && less(temp, a[j-1])){ // 将比当前元素大的值往后移位 a[j] = a[j-1]; j--; } a[j] = temp; } }
四、希尔排序:
算法描述:
希尔排序的思想是使数组中任意间隔为h的元素都是有序的,这样的数组被称为h有序数组。
希尔排序是基于插入排序的快速的排序算法,只需在插入排序算法中加入一个外循环来将间隔h按照递增序列递减,并将插入排序算法中元素的移动距离由1改为h即可。
算法演示:
算法分析:
算法特点:希尔排序的核心在于间隔序列h的设定:h可以是提前设定的间隔序列,也可以是动态定义的间隔序列。希尔排序的时间复杂度是根据选中的增量h有关的, 一个合适的h增量序列,可以使算法的效率提高很多。
时间复杂度:
- 平均:O(n^1.3)
- 最好:O(n)
- 最坏:O(n^2)
- 稳定
算法实现:
public static void sort(Comparable[] 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++){ // 将间隔为h的序列按插入排序算法排序 Comparable temp = a[i]; int j = i; while(j >= h && less(temp, a[j-h])){ a[j] = a[j-h]; j-=h; } a[j] = temp; } h = h / 3; } }
五、归并排序:
算法描述:
归并排序基于“归并”这个简答的操作,即将两个有序数组归并成一个更大的有序数组。
要将一个数组排序,可以将它分为两半分别归并排序(递归),然后将结果归并起来即可。(自顶向下的归并排序)
算法演示:
(自顶向下的归并排序)
算法分析:
- 算法特点:归并排序算法是应用“分治思想”的最典型的的例子。
- 优点:能够保证将任意长度为N的数组排序所需时间和NlgN成正比。
- 缺点:所需的额外空间和N成正比。
对于长度为N的任意数组,自顶向下的归并排序:
- 需要1/2*NlgN~NlgN次的比较;
- 最多需要访问数组6NlgN次(每次归并最多需要访问数组6N次,其中,2N次用来复制,2N次用来将排好序的元素移动回去,另外,最多比较2N次)
(证明过程见《算法》(第四版)173页)
时间复杂度:O(NlgN)
(归并排序是一种渐进最优的基于比较排序的算法。)- 稳定
- 算法特点:归并排序算法是应用“分治思想”的最典型的的例子。
算法实现:
// 自顶向下的归并排序 public static void sort(Comparable[] a){ Comparable[] aux = new Comparable[a.length]; sort(aux, a, 0, a.length -1); } private static void sort(Comparable[] aux, Comparable[] a, int lo, int hi){ if (lo >= hi) return; int mid = lo + (hi - lo) / 2; sort(aux, a, lo, mid); sort(aux, a, mid+1, hi); merge(aux, a, lo, mid, hi); } private static void merge(Comparable[] aux, Comparable[] a, int lo, int mid, int hi){ // 归并 int i = lo; int j = mid + 1; // 复制数组元素到辅助数组。 System.arraycopy(a, lo, aux, lo, hi + 1 - lo); 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++]; } } }
算法优化:
1:对小规模数组使用插入排序:
//第一种优化:对小规模数组使用插入排序 public static void sort(Comparable[] a){ Comparable[] aux = new Comparable[a.length]; sort_update_1(aux, a, 0, a.length-1); } private static void sort_update_1(Comparable[] aux, Comparable[] a, int lo, int hi){ if ((hi -lo) <= 10){ // 当数组元素少于10时,使用插入排序 insert_sort(a, lo, hi); return; } int mid = lo + (hi - lo) / 2; sort_update_1(aux, a, lo, mid); sort_update_1(aux, a, mid+1, hi); merge(aux, a, lo, mid, hi); } private static void insert_sort(Comparable[] a, int lo, int hi){ // 插入排序 for (int i = lo; i < hi; i++){ Comparable temp = a[i]; int j = i; while(j >= 1 && less(temp, a[j-1])){ a[j] = a[j-1]; j--; } a[j] = temp; } }
2:测试数组是否有序,如果a[mid] 小于等于a[mid+1], 我们认为数组已经有序,跳过merge()方法
// 第二种优化:测试数组是否已经有序,如果a[mid] 小于等于a[mid+1], 我们认为数组已经有序,跳过merge()方法 public static void sort(Comparable[] a){ Comparable[] aux = new Comparable[a.length]; sort_update_2(aux, a, 0, a.length-1); } private static void sort_update_2(Comparable[] aux, Comparable[] a, int lo, int hi){ if (lo >= hi) return; int mid = lo + (hi - lo) / 2; sort_update_2(aux, a, lo, mid); sort_update_2(aux, a, mid+1, hi); // 比较a[mid]与a[mid+1] if(less_or_equal(a[mid], a[mid+1])) return; merge(aux, a, lo, mid, hi); }
3:在递归调用的每个层次交换要被排序的数组和辅助数组的角色,可以节省将数组元素复制到辅助数组所用的时间。
// 第三种优化:在递归调用的每个层次交换要被排序的数组和辅助数组的角色。可以节省将数组元素复制到辅助数组所用的时间 public static void sort(Comparable[] a){ // 此时辅助数组只将原数组每个元素复制一次 Comparable[] aux = a.clone(); sort(aux, a, 0, a.length -1); } private static void sort(Comparable[] aux, Comparable[] a, int lo, int hi){ if (lo >= hi) return; int mid = lo + (hi - lo) / 2; // 交换排序数组与辅助数组的角色 sort(a, aux, lo, mid); sort(a, aux, mid+1, hi); merge(aux, a, lo, mid, hi); } private static void merge(Comparable[] aux, Comparable[] a, int lo, int mid, int hi){ // 为了排序原数组;只要求每次的辅助数组有序。这也是第三种优化可行的原因。 int i = lo; int j = mid + 1; 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++]; } } }
自底向上的归并排序:
描述:归并排序的另一种方法是,先归并那些微型数组,然后再成对归并得到的子数组,如此这般,直到将整个数组归并在一起。
分析:当数组的长度为2的幂时,自顶向下和自底向上的归并排序所用的比较次数以及数组访问次数正好相同,只是顺序不同。
实现:
// 自底向上的归并排序 public static void sort(Comparable[] a){ // 进行lgN次两两归并 Comparable[] aux = new Comparable[a.length]; // 辅助数组 int N = a.length; for (int sz = 1; sz < N; sz = sz+sz){ // sz:子数组大小 for (int lo = 0; lo < N - sz; lo += sz + sz){ // lo:子数组索引 merge(aux, a, lo, lo+sz-1, Math.min(lo+sz+sz-1, N-1)); } } } private static void merge(Comparable[] aux, Comparable[] a, int lo, int mid, int hi){ // 归并 int i = lo; int j = mid + 1; System.arraycopy(a, lo, aux, lo, hi + 1 - lo); 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++]; } } }
六、快速排序:
算法描述:快速排序是一种分治的排序。它将一个数组分成两个数组,将两部分独立的再次进行快速排序(递归)。
先在序列中随意的选取一个作为切分元素,
扫描数组,先从左端开始向右扫描直到找到一个大于等于它的元素,再从数组的右端向左扫描,直到找到一个小于等于它的元素,交换这两个元素的位置。如此继续,保证左指针i的左侧元素都不大于切分元素;右指针j的右侧元素都不小于切分元素。当两个指针相遇时,将切分元素与左子数组最右侧的元素
a[j]
交换。这样切分元素就被排序了。- 分别对上次的切分元素左侧、右侧的子数组进行上述过程,直到整个数组序列排序完成。
算法演示:
算法分析:
算法特点:上述描述中,左侧扫描最好是在遇到大于等于切分元素值的元素时停下,右侧扫描则是遇到小于等于切分元素值的元素时停下。尽管这样可能会不必要地将一些等值的元素交换,但在某些典型应用(只有若干种元素值的数组)中,它能够避免算法的运行时间变为平方级别。另外,要特别注意切分过程中循环的结束条件。
时间复杂度:
- 平均:O(NlgN)
- 最好:O(NlgN)
- 最坏:O(n^2)
- 不稳定
算法实现:
// 快速排序: public static void sort(Comparable[] a){ sort(a, 0, a.length-1); } private static 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); } private static int partition(Comparable[] a, int lo, int hi){ int i = lo; int j = hi+1; Comparable temp = a[lo]; while(true){ // 从左向右扫描 while (less(a[++i], temp)) if (i == hi) break; // 从右向左扫描 while (less(temp, a[--j])) if (j == lo) break; if (i >= j) break; // 交换元素 exch(a, i, j); } // 交换切分元素与a[j] exch(a, lo, j); return j; }
算法优化:
对于小规模数组切换到插入排序
private static void sort(Comparable[] a, int lo, int hi){ if (hi <= lo+10) { // 当数组元素小于10时,切换到插入排序 for (int i = lo; i < hi; i++){ Comparable temp = a[i]; int j = i; while(j >= 1 && less(temp, a[j-1])){ a[j] = a[j-1]; j--; } a[j] = temp; } return; } int j = partition(a, lo, hi); sort(a, lo, j-1); sort(a, j+1, hi); }
三取样切分
改进快速排序性能的第二个办法时使用子数组的一小部分元素的中位数来切分数组。这样做得到的切分更好,但代价时要计算中位数。人们发现将取样大小设为3并用大小居中的元素切分的效果最好。还可以将取样元素放在数组末尾做“哨兵”来去掉
partition()
中的数组边界测试。(具体实现,读者可自行完成)三向切分的快速排序
描述:选取切分元素;扫描数组,指针i从左到右遍历数组一次,维护一个指针lt使得lt之前的元素都小于切分元素,指针gt使得gt之后的元素都大于切分元素,指针i与lt之间的元素都等于切分元素,指针i与gt之间的元素还未确定,直到i = gt ,扫描完成,与切分元素相等的元素都被排序;分别对上次lt左侧(小于切分元素)、gt右侧(大于切分元素)的子数组进行上述过程,直到整个数组序列排序完成。
特点:这样方式的快速排序,与切分元素相等的元素就不会包含在递归调用处理的子数组中,适用于序列中含有大量重复元素的情况。
实现:
public static void sort(Comparable[] a){ sort(a, 0, a.length-1); } private static void sort(Comparable[] a, int lo, int hi){ if (hi <= lo) return; int lt = lo, i = lo+1, gt = hi; Comparable temp = a[lo]; while(i <= gt){ int cmp = a[i].compareTo(temp); if (cmp < 0){ exch(a, lt++, i++); }else if (cmp > 0){ exch(a, gt--, i); }else{ i++; } } sort(a, lo, lt-1); sort(a, gt+1, hi); }
七、冒泡排序:
算法描述:
比较相邻的元素。如果第二个数小,就交换这两个元素。
从后往前两两比较,一直比较最前两个元素。最终最小数被交换到起始的位置,这样第一个最小数的位置就排好了。
继续重复上述过程(arr.length-1次),依次将第2,3…n-1个最小数排好位置。
算法演示(图片为先排最大元素):
算法分析:
算法特点:两个数比较大小,较大的数下沉,较小的数冒起来。
时间复杂度:
- 平均:O(n^2)
- 最好:O(n)
- 最坏:O(n^2)
算法实现:
// 冒泡排序 public static void sort(Comparable[] a){ int n = a.length; for (int i = 0; i < n-1; i++){ for (int j = n-1; j > i; j--){ if (less(a[j], a[j-1])){ exch(a,j, j-1); } } } }
算法优化:
问题:如果数组在某次排序后已有序,冒泡排序算法仍然会继续进行下一轮的比较,直到进行arr.length-1次。后面的比较是没有意义的。
方案:设置标志位flag = true,如果发生了交换flag设置为false;
这样当一轮比较结束后如果flag仍为true,即:这一轮没有发生交换,说明数据的顺序已经排好,没有必要继续进行下去。实现:
// 冒泡排序 public static void sort(Comparable[] a){ int n = a.length; for (int i = 0; i < n-1; i++){ boolean flag = true; // 标识:是否发生了交换。 for (int j = n-1; j > i; j--){ if (less(a[j], a[j-1])){ exch(a,j, j-1); flag = false; } } //如果没有发生交换,说明已经有序,不必进行不必要的遍历。 if (flag) break; } }
八、堆排序:
算法描述:
将初始待排序列(大小为n)构造成最大二叉堆(堆顶元素最大)。
最大二叉堆:二叉堆是一组能够用堆有序的完全二叉树排序的元素,并在数组中按照层级储存。二叉堆有两种:最大堆和最小堆。最大堆:父结点的键值总是大于或等于任何一个子节点的键值;最小堆:父结点的键值总是小于或等于任何一个子节点的键值。
将堆顶元素与最后一个元素交换位置,此时前n-1个元素为无序状态,使用下沉操作对前n-1个元素进行堆的有序化。此时最后位置元素已被排序。
- 将堆顶元素与倒数第二个元素交换位置,此时前n-2个元素为无序状态,使用下沉操作对前n-2个元素进行堆的有序化。此时,最后两个元素已被排序。
- ……持续进行,直到所有元素被排序。
算法演示:
算法分析:
堆的构造方式:从右至左用
sink()
函数构造子堆。如果一个结点的两个子结点都已经是堆了,那么在该结点上调用sink()
可以将他们变成一个堆。这个过程会递归地建立起堆的秩序。我们只需要扫描数组中的一半元素,因为我们可以跳过大小为1的子堆。直到在堆顶调用sink()
,扫描结束。特点:
- 使用下沉操作
sink()
将有N个元素的数组构造为最大堆,只需少于2N次比较以及少于N次交换。 - 将N个元素排序,堆排序只需少于(2NlgN+2N)次比较一半次数的比较。(2N项来自堆的构造,2NlgN项来自每次下沉操作最大可能需要2lgN次比较)
- 使用下沉操作
时间复杂度:O(nlgn)
不稳定
算法实现:
// 堆排序 public static void sort(Comparable[] a){ int N = a.length; // for循环为最大堆的构造过程 for (int k = (N/2-1); k >=0; k--){ sink(a, k, N-1); } // while循环为排序过程 while(N > 0){ exch(a, 0, N-1); N--; sink(a, 0, N-1); } } // 下沉,堆的有序化 private static void sink(Comparable[] a, int k, int n) { while ( 2*k+1<=n){ int j = 2*k+1; if (j < n && less(a, j, j+1)) j++; if (!less(a, k, j)) break; exch(a, k, j); k = j; } }
九、计数排序
十、桶排序
十一、基数排序
未完待续……
最后:
完整代码地址:
参考资料:
《算法》第四版——第二章 :排序
算法演示动图制作网站:
如果文中有什么不足或错误之处,欢迎指出!