一、选择排序
1、算法描述
- 首先,找到数组中的最小的元素
- 其次,将它与数组的第一个元素交互位置
- 再次,在剩余的数组元素中找到最小的元素,将它与数组的第二个元素交互位置
- 如此重复,直到将整个数组排序
2、分析
- 比较元素:比较当前元素与目前已知的最小元素(以及将当前索引加1和检测是否代码越界)
- 交互元素:每轮只交互一个元素,因此交换的总次数是N(数组长度)
索引算法的效率取决于比较的次数。
命题A:对于长度为N的数组,选择排序需要大约 N 2 2 \frac{N^2}{2} 2N2次比较和N次交互。
证明:通过查看代码知,交互次数为N
比较次数为(N-1)+(N-2)+…+3+2+1 = N(N-1)/2~ N 2 N^2 N2/2
3、特点
- 运行时间和输入无关(初始状态)
- 数据移动是最少的:每次交互都会改变2个数组元素的值,因此选择排序用了N次交换-交换次数和数组大小是线性关系。
3、代码实现
import edu.princeton.cs.algs4.StdIn;
import edu.princeton.cs.algs4.StdOut;
/**
* 选择排序
* 算法:
* 首先,找到数组中的最小的元素
* 其次,将它与数组的第一个元素交互位置
* 再次,在剩余的数组元素中找到最小的元素,将它与数组的第二个元素交互位置
* 如此重复,直到将整个数组排序
*/
public class Selection {
/**
* 排序方法
* @param a 实现了Comparable接口的待排序数组
*/
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;
}
exch(a, i, min);
}
}
/**
* 比较大小
* @param a 目标a
* @param b 目标b
* @return 返回布尔值
*/
private static boolean less(Comparable a, Comparable b) {
return a.compareTo(b) < 0;
}
/**
* 交换数组元素
* @param a 数组
* @param i 索引
* @param j 索引
*/
private static void exch(Comparable[] a, int i, int j) {
Comparable t = a[i];
a[i] = a[j];
a[j] = t;
}
/**
* 打印数组
* @param a 数组
*/
private static void show(Comparable[] a) {
// 单行打印数组
for (int i = 0; i < a.length; i++) {
StdOut.print(a[i] + " ");
}
StdOut.println();
}
/**
* 测试数组是否已经有序
* @param a 带测试数组
* @return 测试结果: true-数组有序;false-数组无序
*/
public static boolean isSorted(Comparable[] a) {
// 测试数组是否已经有序
for (int i = 1; i < a.length; i++) {
if (less(a[i], a[i-1])) return false;
}
return true;
}
public static void main(String[] args) {
// 从标准输入读取字符串,将他们排序并输出
String[] a = StdIn.readAllStrings();
sort(a);
assert isSorted(a);
show(a);
}
}
main方法也可单独在一个测试类中测试,此时需要将show()修饰符改为public
二、插入排序
1、算法描述
- 插入排序是指在待排序的元素中,假设前面n-1(其中n>=2)个数已经是排好顺序的
- 现将第n个数插到前面已经排好的序列中,然后找到合适自己的位置,使得插入第n个数的这个序列也是排好顺序的
- 按照此法对所有元素进行插入,直到整个序列排为有序的过
2、分析
- 当前索引左边的所有元素都是有序的,但他们的最终位置不确定;当索引到达数组的右端时,数组排序完成
- 插入排序所需的时间取决于输入中元素的初始顺序。
命题B。对于随机排列的长度为N且主键不重复的数组,平均情况下插入排序需要~ N 2 N^2 N2/4次比较以及~ N 2 N^2 N2/4次交换。最坏情况下需要~ N 2 N^2 N2/2次比较和~ N 2 N^2 N2/2次交换,最好情况下需要N-1次比较和0次交换。
3、代码实现
import edu.princeton.cs.algs4.StdIn;
import edu.princeton.cs.algs4.StdOut;
/**
* 插入排序
* 算法:
* 1. 插入排序是指在待排序的元素中,假设前面n-1(其中n>=2)个数已经是排好顺序的
* 2. 现将第n个数插到前面已经排好的序列中,然后找到合适自己的位置,使得插入第n个数的这个序列也是排好顺序的
* 3. 按照此法对所有元素进行插入,直到整个序列排为有序的过
*/
public class Insertion {
/**
* 排序方法
* @param a 实现了Comparable接口的待排序数组
*/
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);
}
}
}
/**
* 比较大小
* @param a 目标a
* @param b 目标b
* @return 返回布尔值
*/
private static boolean less(Comparable a, Comparable b) {
return a.compareTo(b) < 0;
}
/**
* 交换数组元素
* @param a 数组
* @param i 索引
* @param j 索引
*/
private static void exch(Comparable[] a, int i, int j) {
Comparable t = a[i];
a[i] = a[j];
a[j] = t;
}
/**
* 打印数组
* @param a 数组
*/
private static void show(Comparable[] a) {
// 单行打印数组
for (int i = 0; i < a.length; i++) {
StdOut.print(a[i] + " ");
}
StdOut.println();
}
/**
* 测试数组是否已经有序
* @param a 带测试数组
* @return 测试结果: true-数组有序;false-数组无序
*/
public static boolean isSorted(Comparable[] a) {
// 测试数组是否已经有序
for (int i = 1; i < a.length; i++) {
if (less(a[i], a[i-1])) return false;
}
return true;
}
public static void main(String[] args) {
// 从标准输入读取字符串,将他们排序并输出
String[] a = StdIn.readAllStrings();
sort(a);
assert isSorted(a);
show(a);
}
}
4、总结
我们要考虑的更一般的情况是部分有序的数组。
倒置指的是数组中的两个顺序颠倒的元素。比如EXAMPLE中有11对倒置:E-A、X-A、X-M、X-P、X-L、X-E、M-L、M-E、P-L、P-E以及L-E。如果数组中倒置的数量小鱼数组大小的某个倍数,那么我们说这个数组部分有序的。
部分有序情况:
- 数组中每个元素距离它的最终位置都不远
- 一个有序的大数组接一个小数组
- 数组中有几个元素的位置不正确
插入排序对这样的数组很有效。
命题C。插入排序需要的交换操作和数组中倒置的数量相同,需要的比较次数大于等于倒置的数量,小于等于倒置的数量加上数组的大小再减1
证明。每次交换都改变了2个顺序颠倒的元素的位置,相当于减少了一对倒置,当倒置数量为0时,排序完成。每次交换对应这一次比较,且1到N-1之间的每个i都可能需要一次额外的比较(在a[i]本轮交换完成,但是没有到达数组的最左端时)。
要提高插入排序的速度,只需要在内循环中将较大的元素都向右移动而不总是交换两个元素(这样访问数组的次数就可以减半)
三、两种排序算法的比较
1、比较步骤
- 实现并调试它们
- 分析它们的基本性质
- 对它们的相对性能做出猜想
- 用实验验证我们的猜想
现在第一步已经实现,命题A、命题B、命题C组成第二步,下面的性质D是第三步,之后“比较两种排序算法”的SortCompare类将会完成第四布。
这些简洁的步骤之下是大量的算法实现、调试分析和测试工作。每个程序员都知道只有经过长期的调试和改进才能得到这样的代码,只有研究那些最重要的算法的专家才会经历完整的研究过程,单每个使用算法的程序员都应该了解算法的性能特性背后的科学过程。
对于排序,命题A、命题B、命题C用到的自然输入模型假设数组中的元素随机排序,且主键不会重复。对于有很多重复主键的应用来说,我们需要一个更加复杂的模型。
如何估计插入排序和选择排序在随机排序数组下的性能呢?通过算法实现以及命题A、命题B和命题C可以发现,对应随机排序的数组,两者的运行时间都是平方级别。也就是说,在这样输入下插入排序的运行时间和 N 2 N^2 N2乘以一个小常数成正比,选择排序的运行时间和 N 2 N^2 N2乘以另外一个小常数成正比。这2个常数取决于所使用的计算机中比较和交换元素的成本。对应许多数据类型和一般的计算机,可以假设这些成本都是相近的。因此我们直接得出一下猜想。
性质D。对应随机排序的无重复主键的数组,插入排序和选择排序的运行时间是平方级别的,两者之比是一个较小的常数。
2、代码实现
“比较两种排序算法”类SortCompare代码实现
import edu.princeton.cs.algs4.*;
/**
* 比较2个算法用时
*/
public class SortCompare {
/**
* 计算某个算法用时
* @param alg 算法名称
* @param a 随机数组
* @return 时间
*/
public static double time(String alg, Double[] a) {
Stopwatch timer = new Stopwatch();
if (alg.equals("Insertion")) Insertion.sort(a);
if (alg.equals("Selection")) Selection.sort(a);
if (alg.equals("Shell")) Shell.sort(a);
if (alg.equals("Merge")) Merge.sort(a);
if (alg.equals("Quick")) Quick.sort(a);
if (alg.equals("Heap")) Heap.sort(a);
return timer.elapsedTime();
}
/**
* 测试T次长度为N的数组排序用时
* @param alg 算法名称
* @param N 数组长度
* @param T 测试次数
* @return 总用时
*/
public static double timeRandomInput(String alg, int N, int T) {
// 使用算法alg将T个长度为N的数组排序
double total = 0.0;
Double[] a = new Double[N];
for (int t = 0; t < T; t++) {
//进行一次测试(生成一个数组并排序
for (int i = 0; i < N; i++)
a[i] = StdRandom.uniform();
total += time(alg, a);
}
return total;
}
public static void main(String[] args) {
String alg1 = args[0];
String alg2 = args[1];
int N = Integer.parseInt(args[2]);
int T = Integer.parseInt(args[3]);
// 算法1总时间
double t1 = timeRandomInput(alg1, N, T);
// 算法2总时间
double t2 = timeRandomInput(alg2, N, T);
StdOut.printf("For %d random Doubles\n %s is ", N, alg1);
StdOut.printf("%.1f times faster then %s\n", t2/t1, alg2);
}
}
性质D没有说明小常量的值,以及对比较和交换的成本相近的假设,这样性质D才能广泛适用于各种情况,才能尽量抓住每个算法的本质。
对于实际应用,还有一个很重要的步骤,就是用实际数据在实验中验证我们的猜想。我们会在2.5节和练习中在考虑这一点。在这种情况下,当主键重复活是排序不随机,性质D就可能会不成立。有大量重复主键的情况需要更加细致的分析。