简介
本文是Algorithms-Part1的总结,从简入难介绍几种排序方法。
selection sort
在你面前有3个苹果,每次你都挑最大的吃,就是选择排序。
例子
数组排序:我们需要进行n(组数的长度)次选择,每次选择的结果和第n位元素交换:
int[] stu = [2,3,4,1];
// 2341选1,和2交换
int[] stu = [1,3,4,2];
// 342选2,和3交换
int[] stu = [1,2,4,3];
// 34选3,和4交换
int[] stu = [1,2,3,4];
// 4选4,和4交换
int[] stu = [1,2,3,4];
代码实现
public static void sort(Comparable[] a) {
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;
}
// 每次循环找到最小的值,交换到i位置
exch(a, i, min);
}
}
容易发现,选择排序每次的比较次数就是剩余的元素个数,交换次数就是数组长度
小结
总结选择排序的特点:
- 简单易学
- 慢(需要n的平方级别的比较次数)
- 即使数组有序,花费的时间仍然是不变的。
insertion sort
假设10,J,Q,A已经整理好的牌,如果要找到K的位置,只要从A的后面移动到K的后面即可,这就是插入排序的一部分。
举例
数组排序:永远保证当前元素左侧的所有元素是有序的,当进行到最后一个元素时,整个数组就是有序的。
int[] stu = [2,4,3,1];
// 保证第一个元素是有序的:不动
int[] stu = [2,4,3,1];
// 保证前2个元素是有序的:不动
int[] stu = [2,4,3,1];
// 保证前3个元素有序:3和4交换
int[] stu = [2,3,4,1];
// 保证第4个元素有序,1先后和4,3,2交换
int[] stu = [1,2,3,4];
从第2,3步我们发现,插入排序只比较了比自己大的元素,而不是全面的所有元素。
代码实现
public static void sort(Comparable[] a) {
int n = a.length;
for (int i = 1; i < n; i++)
{
for (int j = i; j > 0 && less(a[j], a[j-1]); j--)
{
exch(a, j, j-1);
}
}
}
内层循环的less(a[j], a[j-1])判断正是上文得到的总结,也是插入排序比选择排序快的原因。
小结
总结插入排序的特点:
- 慢(虽然比选择排序快一些)
- 每次交换只能交换相邻的元素导致交换次数多。例子的最后一步可见:元素1必须和前面的所有元素交换才能到达自己的位置。
shell sort
希尔排序是优化版插入排序,主要改进了只能交换相邻元素的缺点。 排队时会有1、2报数,如果对所有报1的同学(隔着报2的同学)使用插入排序,就能交换不相邻的同学,提高效率。
例子
排序数组:首先保证(奇)偶数位的元素分别有序,最后使用插入排序保证全部有序。
int[] stu = [2,4,3,1];
// 【偶数位】前1位有序的:2不动
int[] stu = [2,4,3,1];
// 【偶数位】前2位有序的:2,3不动
int[] stu = [2,4,3,1];
// 【奇数位】前1位有序:4不动
int[] stu = [2,4,3,1];
// 【奇数位】前2位有序的:交换4,1
int[] stu = [2,1,3,4];
// 【所有位】前1位有序:2不动
int[] stu = [2,1,3,4];
// 【所有位】前2位有序:2,1交换
int[] stu = [1,2,3,4];
// 【所有位】前3位有序:1,2,3不动
int[] stu = [1,2,3,4];
// 【所有位】前4位有序:1,2,3,4不动
int[] stu = [1,2,3,4];
通过例子我们发现,虽然循环的次数变多,但是交换的次数大大减少(只有2次,插入排序用了4次)。
代码实现
public static void sort(Comparable[] a) {
int n = a.length;
// 3x+1增长序列: 1, 4, 13, 40, 121, ...
int h = 1;
while (h < n/3) h = 3*h + 1;
while (h >= 1) {
// 对间隔h的子数组排序
for (int i = h; i < n; i++) {
for (
int j = i;
j >= h && less(a[j], a[j-h]);
j -= h
) {
// 注意这里交换的是`j-h`
exch(a, j, j-h);
}
}
h /= 3;
}
}
从代码中可以看出,希尔排序保证相隔h的子数组有序的,逐步减小h,当成为为1(即插入排序)时,完成排序。
实例代码使用3x+1的方法确定h的值,但还有很多其他形式的增长序列,序列不同,希尔算法的性能也各不相同,目前还没有找到“最好”的增长序列。
小结
总结插入排序的特点:
- 比选择排序、插入排序快的多(没有达到平方级别)。
- 对于中等规模大小的数组都可能使用希尔排序,代码量小,不需要额外的空间,且不会很慢。
merging sort
假设有两个有序的数组,则可以合并成一个有序的大数组,这就是归并排序的思想。
例子
有两个stu数组,每次比较两个数组的最小值并取出,最终就能得到有序的数组。
int[] stuA = [2,3];
int[] stuB = [1,4];
int[] rlt[];
// 1和2取1
int[] stuA = [2,3];
int[] stuB = [4];
int[] rlt[1];
// 2和4取2
int[] stuA = [3];
int[] stuB = [4];
int[] rlt[1,2];
// 3和4取3
int[] stuA = [];
int[] stuB = [4];
int[] rlt[1,2,3];
// stuA没有了,取4
int[] stuA = [];
int[] stuB = [];
int[] rlt[1,2,3,4];
代码实现
看下归并的实现:
/**
* 这里假设`lo`到`mid`、`mid`到`hi`是有序的
* 使用i和j分别指向当前比较的位置
*/
public static void merge(
Comparable[] a,
int lo,
int mid,
int hi
) {
for (int k = lo; k <= hi; k++) {
aux[k] = a[k];
}
int i = lo, j = mid + 1;
for (int k = lo; k <= hi; k++) {
if (j > hi) a[k] = aux[i++];
else if (i > mid) a[k] = aux[j++];
else if (Util.less(aux[j], aux[i])) {
a[k] = aux[j++];
} else a[k] = aux[i++];
}
}
归并排序算法递归这个过程:将数组对半(递归)排好序,然后归并起来,看下代码
public static void sort(Comparable[] a) {
aux = new Comparable[a.length];
sort(a, 0, a.length - 1);
}
public static 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 merge() {
// 同上文
}
最重要的是,递归算法比较的时间复杂度只有NlgN。
同时需要注意的是,归并算法需要一个同样的大小的辅助数组。
小结
- 快(比选择排序、插入排序快一个数量级,比希尔排序略快)
- 需要辅助数组
quick sort
如果我们将数组拆分成左右两个子数组,左边的数组都小于某个值,右边的数组反之,那面重复拆分子数组,也能得到有序的数组,这就是快速排序的思想。
例子
我们需要给几个不同地区的电话号码排序:第一步只需要保证所有北京地区的号码在上海之前就可以了,然后才是北京、上海地区内部的电话排序。典型的任务分解的思路。
010-12345678
021-56781234
021-12345678
010-56781234
// 第一次将按区号排序
010-12345678
010-56781234
021-56781234
021-12345678
// 然后北京、上海分别排序
010-12345678
010-56781234
021-12345678
021-56781234
代码实现
先看下拆分的代码:
private static int partition(
Comparable[] a,
int lo,
int hi
) {
int i = lo, j = hi + 1;
Comparable v = a[lo];
while (true) {
// 从前往后找到比v大的元素
while (Util.less(a[++i], v)) if (i == hi) break;
// 从后往前找比v小的元素
while (Util.less(v, a[--j])) if (j == lo) break;
if (i >= j) break;
Util.exch(a, i, j);
}
Util.exch(a, lo, j);
return j;
}
上述代码以第一个元素为比较对象v,将数组拆分为“比v小的数组”,v,以及“比v大的数组”3部分,最后返回拆分点。 完整的快速排序实现:
public static void sort(Comparable[] a) {
// first must shuffle(a)
sort(a, 0, a.length - 1);
}
private static void sort(
Comparable[] a,
int lo,
int hi
) {
if (lo >= hi) return;
int mid = partition(a, lo, hi);
sort(a, lo, mid - 1);
sort(a, mid + 1, hi);
}
private static int partition(
Comparable[] a,
int lo,
int hi) {
// 省略
}
对比归并算法
两者最大的不同在于sort内递归和排序的先后顺序:
- 归并排序法先子数组有序,后合并排序。
- 快速排序先整体(大致)有序,后子数组排序:每次切分只保证数组的元素都大于(或小于)某个值,但不是有序的,对其排序需要下次切分来完成。 同时快速排序并不需要辅助函数。
小结
快速排序是本文唯一满足N * lgN复杂度且不需要辅助函数的算法。
- 快(和归并算法相同的时间复杂度)
总结
对于绝大部分情况,使用快速排序即可。如果有稳定性需求且也没有空间问题的话,归并排序可能是更好的选择。