Algorithm第四版笔记-排序
Table of Contents
1 初级排序算法
- 排序算法的模板
- less()方法对元素比较
- exch()方法将元素交换位置
- sort()方法对数组进行排序
package code;
import edu.princeton.cs.algs4.In;
import edu.princeton.cs.algs4.StdOut;
public class Example {
public static void sort(Comparable[] a) {}
private static boolean less(Comparable v, Comparable w) {
return v.compareTo(w) < 0;
}
private static void exch(Comparable[] a, int i, int j) {
Comparable t = a[i];
a[i] = a[j];
a[j] = t;
}
private static void show(Comparable[] a) {
// 在单行中打印数组
for (int i = 0; i < a.length; i++) {
StdOut.print(a[i] + " ");
}
StdOut.println();
}
public static boolean isSorted(Comparable[] a) {
// 测试数组元素是否有序
for (int i = 0; i < a.length; i++) {
if (less(a[i], a[i - 1])) return false;
}
return true;
}
public static void main(String[] args) {
String[] a = In.readStrings();
sort(a);
assert isSorted(a);
show(a);
}
}
1.1 运行时间
- 排序成本模型: 在研究排序算法时,我们需要计算比较和交换的数量.对于不交换元素的算法,我们会计算访问数组的次数.
1.2 额外的内存使用
- 排序算法可以分为两类
- 除了函数调用所需的栈和固定数目的实例变量之外无需额外内存的原地排序算法.
- 需要额外内存空间来存储另一份数组副本的其他排序算法.
1.3 数据类型
- 排序算法模板适用于任何实现了
Comparable
接口的数据类型. - 对于
v<w
,v=w
,v=>w
三种情况,Java的习惯是在v.compareTo(w)
被调用时候分别返回一个负数,零,和一个正整数(-1,0和1). - compareTo必须实现一个完整的比较序列,即:
- 自反性: 对于所有的v,
v=v
- 反对称性: 对于所有的
v<w
都有w>v
,且v=w
时w=v
- 传递性: 对于所有v,w和x,如果
v<=w
且w<=x
,则v<=x
- 自反性: 对于所有的v,
1.4 选择排序
- 原理: 找到数组中最小的那个元素,其次,将它和数组的第一个元素交换位置(如果第一个元素就是最小元素那么它就和自己交换).再次,在剩下的元素中找到最小的元素,将它与数组的第二个元素交换位置,如此往复.
- 对于长度为N的数组,选择排序需要大约 \(N^2/2\) 次比较和N次交换.
- 最坏情况下,比较次数为
N-1
到1的等差数列求和.所以是大约 \(N^2/2\) 次比较
- 最坏情况下,比较次数为
- 选择排序的特点
- 运行时间与输入无关.
- 数据移动是最小的.
- 选择排序代码如下
package code;
import edu.princeton.cs.algs4.In;
import edu.princeton.cs.algs4.StdOut;
public class Selection {
public static void sort(Comparable[] a) {
// 将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);
}
}
private static boolean less(Comparable v, Comparable w) {
return v.compareTo(w) < 0;
}
private static void exch(Comparable[] a, int i, int j) {
Comparable t = a[i];
a[i] = a[j];
a[j] = t;
}
private static void show(Comparable[] a) {
// 在单行中打印数组
for (int i = 0; i < a.length; i++) {
StdOut.print(a[i] + " ");
}
StdOut.println();
}
public static boolean isSorted(Comparable[] a) {
// 测试数组元素是否有序
for (int i = 0; i < a.length; i++) {
if (less(a[i], a[i - 1])) return false;
}
return true;
}
public static void main(String[] args) {
String[] a = In.readStrings();
sort(a);
assert isSorted(a);
show(a);
}
}
1.5 插入排序
- 插入排序为了给插入元素腾出空间,需要将其余所有元素在插入之前都向右移动一位.但是当索引到达数组的最右端时,数组排序就完成了.
- 与选择排序不同,插入排序所需的时间取决于输入中元素的初始顺序.
- 对于随机排列的长度为N且主键不重复的数组,平均情况下插入排序需要约 \(N^2/4\) 次比较以及 \(N^2/4\) 次交换.
最坏情况下需要 \(N^2/2\) 次比较和 \(N^2/2\) 次交换,最好情况下需要 N-1
次比较和0次交换.
- 插入排序的代码如下:
package code;
import edu.princeton.cs.algs4.In;
import edu.princeton.cs.algs4.StdOut;
public class Insertion {
public static void sort(Comparable[] a) {
// 将a[]按升序排列
int N = a.length;
for (int i = 0; i < N; i++) {
// 将a[i]插入到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);
}
}
}
private static boolean less(Comparable v, Comparable w) {
return v.compareTo(w) < 0;
}
private static void exch(Comparable[] a, int i, int j) {
Comparable t = a[i];
a[i] = a[j];
a[j] = t;
}
private static void show(Comparable[] a) {
// 在单行中打印数组
for (int i = 0; i < a.length; i++) {
StdOut.print(a[i] + " ");
}
StdOut.println();
}
public static boolean isSorted(Comparable[] a) {
// 测试数组元素是否有序
for (int i = 0; i < a.length; i++) {
if (less(a[i], a[i - 1])) return false;
}
return true;
}
public static void main(String[] args) {
String[] a = In.readStrings();
sort(a);
assert isSorted(a);
show(a);
}
}
- 插入排序对于倒置数量很少的数组运行时间比较短.
- 插入排序需要的交换操作和数组中的倒置的数量相同,需要的比较次数大于等于倒置的数量,小于等于倒置的数量加上数组的大小再减一.
- 要大幅提高插入排序的速度,只需要在内循环中将较大的元素都向右移动而不总是交换两个元素(这样访问数组的次数技能减半).
1.6 比较两种排序算法
- 对于随机排序的无重复主键的数组,插入排序和选择排序的运行时间是平方级别的,两者之比应该是一个较小的常数.
- 比较排序算法的代码如下
package code;
import edu.princeton.cs.algs4.StdOut;
import edu.princeton.cs.algs4.StdRandom;
import edu.princeton.cs.algs4.Stopwatch;
public class SortCompare {
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);
return timer.elapsedTime();
}
public static double timerRandomInput(String alg, int N, int T) {
// 使用算法1将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 = timerRandomInput(alg1, N, T);
// 算法2的总时间
double t2 = timerRandomInput(alg2, N, T);
StdOut.printf("For %d random Doubles\n\t%s is", N, alg1);
StdOut.printf(" %.1f times faster than %s\n", t2/t1, alg2);
}
}
1.7 希尔排序
- 对于大规模乱序数组插入排序很慢,因为它只会交换相邻的元素,因此元素只能一点一点地从数组的一端移动到另一端.
- 希尔排序为了加快速度简单地改进了插入排序,交换不相邻的元素以对数组的局部进行排序.并最终用插入排序将局部有序的数组排序.
- 希尔排序的思想是使数组中任意间隔为h的元素都是有序的,这样的数组被称为h有序数组.用这种方式,对任意以1结尾的h序列,我们都能将数组排序.
- 希尔排序更高效是因为它权衡了数组的规模和有序性.对于每个h有序数组,用插入排序将h个子数组独立地排序.
- 希尔排序代码如下
package code;
import edu.princeton.cs.algs4.In;
import edu.princeton.cs.algs4.StdOut;
public class Shell {
public static void sort(Comparable[] a) {
// 将a[]按升序排序
int N = a.length;
int h = 1;
// 1, 4, 13, 40, 121, 364, 1093, ...
while (h < N / 3) {
h = 3*h + 1;
}
while (h >= 1) {
// 将数组变为h有序
for (int i = h; i < N; i++) {
// 将a[i]插入到a[i-h],a[i-2*h],a[i-3*h]...之中
for (int j = i; j >= h && less(a[j], a[j - h]); j-=h) {
exch(a, j, j - h);
}
}
h = h / 3;
}
}
private static boolean less(Comparable v, Comparable w) {
return v.compareTo(w) < 0;
}
private static void exch(Comparable[] a, int i, int j) {
Comparable t = a[i];
a[i] = a[j];
a[j] = t;
}
private static void show(Comparable[] a) {
// 在单行中打印数组
for (int i = 0; i < a.length; i++) {
StdOut.print(a[i] + " ");
}
StdOut.println();
}
public static boolean isSorted(Comparable[] a) {
// 测试数组元素是否有序
for (int i = 0; i < a.length; i++) {
if (less(a[i], a[i - 1])) return false;
}
return true;
}
public static void main(String[] args) {
String[] a = In.readStrings();
sort(a);
assert isSorted(a);
show(a);
}
}
- 希尔排序最坏情况下的比较次数是和 \(N^{3/2}\) 成正比.
2 归并排序
- 归并排序: 将一个数组排序,可以先(递归地)将它分成两半分别排序,然后将结果归并起来.
- 归并排序的优缺点
- 优点: 归并排序能保证将任意长度为N的数组排序所需时间和 \(NlogN\) 成正比.
- 缺点: 它所需的额外空间和N成正比.
2.1 原地归并的抽象方法
- 思路:
- 将旧数组拷贝复制到新数组中
- 归并到原数组中
- 代码如下:
public static void merge(Comparable[] a, int lo, int mid, int hi) {
// 将[lo..md] 和 a[mid+1..hi] 归并
int i = lo, j = mid + 1;
for (int k = lo; k <= hi; k++) {
// 将a[lo..hi]复制到aux[lo..hi]
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 (less(aux[j], aux[i]))
// 右半元素小于左半元素,取右半元素
a[k] = aux[j++];
else
// 右半元素大于左半元素,取左半元素
a[k] = aux[i++];
}
}
2.2 自顶而上的归并排序
- 思路:
- 自顶而上的归并排序是基于原地归并排序实现了另一种递归排序
public class Merge {
// 归并所需要的辅助数组
private static Comparable[] aux;
public static void sort(Comparable[] a) {
// 一次性分配空间
aux = new Comparable[a.length];
sort(a, 0, a.length - 1);
}
private static void sort(Comparable[] a, int lo, int hi) {
// 将数组a[lo..hi]排序
if (hi <= lo) return;
int mid = lo + (hi - lo) / 2;
// 将左半边排序
sort(a, lo, mid);
// 将右半边排序
sort(a, mid + 1, hi);
// 归并结果,使用原地归并的抽象方法.
merge(a, lo, mid, hi);
}
}
- 对于长度为N的任意数组,自顶而上的归并排序需要 \(1/2N\log{N}\) 到 \(N\log{N}\) 次比较.
- 对于长度为N的任意数组,自顶而上的归并排序最多需要访问数组 \(6N\lg{N}\) 次
- 2N次用来复制
- 2N次用来将排好序的元素移动回去
- 另外最多比较2N次
2.2.1 测试数组是否已经有序
- 我们可以添加一个判断条件,如果
a[mid]
小于等于a[mid+1]
, 我们就认为数组已经是有序并跳过merge()方法.这个改动不影响排序的递归调用,但是任意有序的子数组算法的运行时间就变为线性了.
2.2.2 不将元素复制到辅助数组
- 我们可以节省将数组元素复制到用于归并的辅助数组所用的时间(但空间不行).要做到这一点,我们要调用两种排序方法
- 一种将数据从输入数组排序到辅助数组
- 一种将数据从辅助数组排序到输入数组
2.3 自底而上的归并排序
- 实现归并排序的另一种方法是先归并那些微型数组,然后再成对归并得到的子数组.
public class MergeBU {
// 归并所需的辅助数组
private static Comparable[] aux;
public static void sort(Comparable[] a) {
// 运行lgN次两两归并
int N = a.length;
aux = new Comparable[N];
for (int sz = 1; sz < N; sz = sz + sz) {
// sz为子数组大小
for (int lo = 0; lo < N - sz; lo += sz+sz) {
// lo为子数组的索引
merge(a, lo, lo+sz-1, Math.min(lo+sz+sz-1, N-1));
}
}
}
}
- 对于长度为N的任意数组, 自底而上的归并排序需要 \(\frac{1}{2}n\lg{n}\) 到 \(N\lg{N}\) 次比较,最多访问 \(6N\lgN\) 次.
- 自底而上的归并排序比较时候用链表组织的数据.这种方法只需要重新组织链表链接就能将链表原地排序.
2.3.1 排序算法的复杂度
- 没有任何基于比较的算法能保证使用少于 \(\lg(N!)\) ~ \(N\lg{N}\) 次比较将长度为N的数组排序.
- 任意基于比较的排序算法对应着一棵高h的比较树
- \(N! \leq leaves \leq 2^h\) , 不等式取对数,得到 \(\lg{N!} \leq h \leq N\lg{N}\)
- 归并排序是一种渐进最优的基于比较排序的算法.
3 快速排序
- 快速排序特点包括
- 它是原地排序(只需要一个很小的辅助栈).
- 将长度为N的数组排序所需的时间和 \(Nlg{N}\) 成正比.
- 快速排序的内循环比大多数排序算法都要短小.
- 快速排序的缺点
- 非常脆弱,实现时候需要非常小心才能避免低劣的性能.
3.1 基本算法
- 快速排序是一种分治的排序算法,它将数组分成两个子数组,将两部分独立地排序.
- 快速排序和归并排序是互补的
- 归并排序将数组分成两个子数组分别排序,并将有序的子数组归并以将整个数组排序.递归调用发生在处理整个数组之前
- 快速排序将整个数组排序的方式则是当两个子数组有序时整个数组也就自然有序了.递归调用发生在处理整个数组之后.
- 在归并排序中,一个数组被等分为两半.
- 在快速排序中,切分(partition)的位置取决于数组的内容.
- 该算法关键在于切分,这个过程使得数组满足下面三个条件:
- 对于某个j,a[j]排定.
- a[lo]到a[j-1]中的所有元素都不大于a[j].
- a[j+1]到a[hi]中所有元素都不小于a[j]
- 快速排序算法代码
package code;
import edu.princeton.cs.algs4.In;
import edu.princeton.cs.algs4.StdOut;
import edu.princeton.cs.algs4.StdRandom;
public class Quick {
public static void sort(Comparable[] a) {
// 消除对输入的依赖
StdRandom.shuffle(a);
sort(a, 0, a.length - 1);
}
private static void sort(Comparable[] 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(Comparable[] a, int lo, int hi) {
// 将数组切分为a[lo..i-1],a[i],a[i+1..hi]
// 左扫描指针
int i = lo;
// 右扫描指针
int j = hi + 1;
Comparable v = a[lo];
while (true) {
// 扫描左右,检查是否结束交换元素
while (less(a[++i], v)) {
if (i == hi) {
break;
}
}
while (less(v,a[--j])) {
if (j == lo) {
break;
}
}
if (i >= j) {
break;
}
// 如果有交换元素,且i<j,交换
exch(a, i, j);
}
// 将v = a[j]放入正确的位置
exch(a, lo, j);
// a[lo..j-1] <= a[j] <= a[j+1..hi]达成
return j;
}
private static boolean less(Comparable v, Comparable w) {
return v.compareTo(w) < 0;
}
private static void exch(Comparable[] a, int i, int j) {
Comparable t = a[i];
a[i] = a[j];
a[j] = t;
}
private static void show(Comparable[] a) {
// 在单行中打印数组
for (int i = 0; i < a.length; i++) {
StdOut.print(a[i] + " ");
}
StdOut.println();
}
public static boolean isSorted(Comparable[] a) {
// 测试数组元素是否有序
for (int i = 0; i < a.length; i++) {
if (less(a[i], a[i - 1])) return false;
}
return true;
}
public static void main(String[] args) {
String[] a = In.readStrings();
sort(a);
assert isSorted(a);
show(a);
}
}
3.1.1 切分算法的注意事项
- 原地切分:避免使用辅助数组进行切分,复制的开销可能会得不偿失
- 别越界: 如果切分元素是数组中最大或最小的那个元素,我们就要小心别让指针抛出数组的边界.
- 保持随机性: 数组元素的顺序是被打乱过的.
- 终止循环
- 处理切分元素值有重复的情况
- 终止递归