目录
排序算法
为什么有这么多排序算法?
原因之一是许多排序算法的性能都和输入模型有很大的关系,因此不同的算法适用于不同应用场景中不同输入。例如,对于部分有序和小规模的数组应该选择插入排序。其他限制条件,例如空间和重复的主键,也都是需要考虑的因素。
通用代码
(比较元素大小、将元素在数组中的位置进行交换、判断数组是否已经有序、打印数组)
public static boolean less(Comparable v, Comparable w) {
return v.compareTo(w) < 0;
}
public static void exch(Comparable[] a, int i, int j) {
Comparable t = a[i];
a[i] = a[j];
a[j] = t;
}
public static boolean isSorted(Comparable[] a) {
for (int i = 1; i < a.length; i++)
if (a[i].compareTo(a[i - 1]) < 0) return false;
return true;
}
public static void show(Comparable[] a) {
for (Comparable e : a) {
System.out.print(e + " ");
}
System.out.println();
}
初级排序算法
选择排序
介绍:首先找到数组中最小的那个元素,其次将它和数组中第一个元素交换位置(如果第一个元素就是最小元素那么它就和自己交换)。再次,在剩下的元素中找到最小的元素,将它与数组的第二个元素交换位置。如此往复,直到将整个数组排序。
性能分析:对于长度为N的数组,选择排序需要大约N*N/2次比较和N次交换。
特点:
1.运行时间和输入无关。
2.数据移动是最少的。
public static void selection(Comparable[] a) {//选择排序
for (int i = 0; i < a.length; i++) {
int min = i;
for (int j = i + 1; j < a.length; j++)
if (less(a[j], a[min])) min = j;
exch(a, i, min);
}
}
插入排序
介绍:逐个插入,将每一个元素插入到其他已经有序的元素数组中。为了给要插入的元素腾出空间,需要将其余所有元素在插入之前都向右移动一个位置。
性能分析:1.对于随机排列的长度为N且主键不重复的数组,平均情况下插入排序需要~N*N/4次比较以及以及~N*N/4次交换。最坏的情况(逆序)下需要~N*N/2次比较和~N*N/2次交换,最好的情况(顺序/主键相同的数组)下需要N-1次比较和0次交换。
2.插入排序需要的交换操作和数组中倒置的数量相同,需要的比较次数大于等于倒置的数量,小于等于倒置的数量加上数组的大小再减一。
特点:
1.插入排序所需时间取决于输入中元素的初始位置;
2.插入排序对于部分有序的数组十分高效,也很适合规模较小的数组。
倒置:指数组中的两个顺序颠倒的元素。
部分有序:如果数组中倒置的数量小于数组大小的某个倍数,那么我们说这个数组是部分有序。
几种典型的部分有序数组:
1.数组中每个元素距离它的最终位置都不远;
2.一个有序的大数组接一个小数组;
3.数组中只有几个元素的位置不正确;
public static void insertion(Comparable[] a) {//插入排序(逐个交换实现)
for (int i = 1; i < a.length; i++) {
for (int j = i; j > 0 && less(a[j], a[j - 1]); j--)
exch(a, j, j - 1);
}
}
public static void insertionImprove(Comparable[] a) {//插入排序(移动实现)
for (int i = 1; i < a.length; i++) {
Comparable t = a[i];
int j;
for (j = i - 1; j >= 0 && less(t, a[j]); j--)
a[j + 1] = a[j];
a[j + 1] = t;
}
}
两种初级排序算法的比较:
对于随机排序的无重复主键的数组,插入排序和选择排序的运行时间是平方级别的,两者之比应该是一个较小的常数。(一般情况下插入排序比选择排序快一倍)。
希尔(shell)排序
介绍:基于插入排序的快速排序算法。希尔排序为了加快速度简单地改进了插入排序,交换不相邻的元素以对数组的局部进行排序,并最终用插入排序将局部有序的数组排序。
希尔排序的思想是使数组中任意间隔为h的元素都是有序的。这样的数组被称为h有序数组。换句话说,一个h有序数组就是h个互相独立的有序数组编织在一起组成的一个数组。这种方式,对于任意以1结尾的h序列,都能够将数组排序。
希尔排序更高效的原因是它权衡了子数组的规模和有序性。排序之初,各个子数组都很短,排序之后子数组都是部分有序,这两种情况都很适合插入排序。子数组部分有序的程度取决于递增序列的选择。
以下算法实现使用了序列1/2(Math.pow(3,k)-1),从N/3开始递减至1。把这个序列称为递增序列。另一种方式是将递增序列存储在一个数组中。
public static void shell(Comparable[] a) {//希尔排序
int N = a.length;
int h = 1;
while (h < N / 3) h = 3 * h + 1;
while (h >= 1) {
for (int i = h; i < N; i++) {
for (int j = i; j >= h && less(a[j], a[j - h]); j -= h) {
exch(a, j, j - h);
}
}
h /= 3;
}
}
性能:使用以上算法所使用的的递增序列1,4,13,40,121,364...的希尔排序所需的比较次数不会超过N的若干倍乘以递增序列的长度。
特点:
1.希尔排序可以用于大型的数组,它对于任意排序(不一定是随机的数组)表现的也很好;
2.运行时间达不到平方级别。
希尔排序比插入排序和选择排序要快得多,并且数组越大,优势越大。
如果需要解决一个排序问题而又没有系统排序函数可用(例如直接接触硬或是运行于嵌入式系统中的代码),可以先使用希尔排序,然后再考虑是否值得将它替换为更加复杂的排序算法。
归并排序
归并排序时算法设计中分治思想的典型应用。
将一个大问题分割成小问题分别解决,然后用所有的小问题的答案来解决整个大问题。
介绍:将两个有序数组归并成一个更大的有序数组。
归并排序时一种渐进最优的基于比较排序的算法。
优点:任意长度为N的数组排序所需要的时间和NlogN成正比。
缺点:需要的额外的空间和N成正比。
自顶向下:递归代码是归纳证明算法能够正确的将数组排序的基础:如果能够将两个子数组排序,就能够通过归并两个子数组来将整个数组排序。
自底向上:先归并微型数组,然后再成对归并得到的子数组,如此这般,直到我们将整个数组归并在一起。
public class Merge {
private static Comparable[] aux;
public static void merge(Comparable[] a, int lo, int mid, int hi) {//原地归并的抽象方法
int i = lo, j = mid + 1;
for (int k = lo; k <= hi; k++)
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[i], aux[j])) a[k] = aux[i++];
else a[k] = aux[j++];
}
}
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) {
if (hi <= lo) return;
int mid = (lo + hi) / 2;
sort(a, lo, mid);
sort(a, mid + 1, hi);
merge(a, lo, mid, hi);
}
public static void sort(Comparable[] a) {//自底向上的归并排序
int N = a.length;
aux = new Comparable[N];
for (int sz = 1; sz < N; sz += sz)
for (int lo = 0; lo < N - sz; lo += 2 * sz)
merge(a, lo, lo + sz - 1, Math.min(lo + 2 * sz - 1, N - 1));
}
}
更好的解决方案是将aux[ ]变为sort()方法的局部变量,并将它作为参数传递给merge()方法。
性能分析:1.对于长度为N的任意数组,自顶向下(化整为零)的归并排序需要1/2*NlgN至NlgN次比较,最多需要访问数组6*NlgN次。
2.对于长度为N的任意数组,自底向上(循序渐进)的归并排序需要1/2*NlgN至NlgN次比较,最多需要访问数组6*NlgN次。
3.如果所有的元素都相同,那么归并排序的运行时间将是线性的(需要一个额外的测试来规避归并已经有序的数组)。
对于含有以任意概率分布的重复元素的输出,归并排序无法保证最佳性能。
自底向上的归并排序比较适合用链表组织的数据。
归并排序改进:1.对小规模子数组使用插入排序(一般可以将归并排序的运行时间缩短10%~15%);
2.测试数组是否有序。添加一个判断条件,如果a[mid]小于等于a[mid+1],我们就认为数组已经是有序的并跳过merge()方法;
3.将元素复制到辅助数组。需要调用两种排序的方法,一种将数据从输入数组排序到辅助数组,一种将数据从辅助数组排序到输入数组。需要一些技巧,要在递归调用的每个层次交换输入数组和辅助数组的角色。(可以节省将数组元素复制到用于归并的辅助数组所用的时间,但空间不行。)
可以用归并排序处理数百万甚至更大规模的数组。
没有任何基于比较的算法能够保证使用少于lg(N!) ~ NlgN 次比较来将长度为N的数组排序。(根据斯特灵公式对阶乘函数的近似可得 lg(N!) ~ NlgN。)
快速排序
一种分治/随机化的排序算法
介绍:将一个数组分成两个子数组,将两部分独立地排序。
public class Quick {
public static int partition(Comparable[] a, int lo, int hi) {
int i = lo, j = hi + 1;
Comparable v = a[lo];
while (true) {
while (i < hi && less(a[++i], v)) ;// if (i == hi) break;
while (less(v, a[--j])) ;// if (j == lo) break;
if (i >= j) break;
exch(a, i, j);
}
exch(a, lo, j);
return j;
}
public static void sort(Comparable[] a) {
//此处可以写一个打乱原始数组的代码
// sort(a, 0, a.length - 1);
quick3way(a,0,a.length-1);
}
private static void sort(Comparable[] a, int lo, int hi) {
if (hi <= lo) return;
//if (hi <= lo + M){Insertion.sort(a, lo, hi); return;} 切换到插入排序
int j = partition(a, lo, hi);
sort(a, lo, j - 1);
sort(a, j + 1, hi);
}
}
以上算法运行时间再1.39NlgN的某个常数因子的范围内。
通过递归地调用切分(partition)来排序,切分的过程总是能排定一个元素,切分的位置取决于数组的内容。
快速排序的最好情况是每次都正好能将数组对半分。在这种情况下快速排序所用的比较次数正好满足分治递归的 公式。 表示将两个子数组排序的成本, 表示用切分元素和所有数组元素进行比较的成本。这个递归公式的解 ~ NlgN。尽管事情并不总会这么顺利,但平均而言切分元素都能落在数组的中间。
性能分析:1.将长度为N 的数组排序所需的时间和NlgN成正比;
2.将长度为N的无重复数组排序,快速排序平均需要~2NlnN次比较(以及1/6的交换)。
3.最多需要约 次比较,但随机打乱数组能够预防这种情况。
特点:1.原地排序(只需要一个很小的辅助栈);
2.内循环比大多数排序算法都要短小。
3.比较次数很少。
缺点:非常脆弱,在实现时要非常小心才能避免低劣的性能。
快速排序改进(一般情况下,性能提升20%~30%):
1.切换到插入排序;
和大多数递归排序算法一样,改进快速排序性能的一个简单办法基于以下两点:
①对于小数组,快速排序比插入排序慢;
②因为递归,快速排序的sort()方法在小数组中也会调用自己。
将sort()中语句
if (hi <= lo) return;
替换为
if (hi <= lo + M){Insertion.sort(a, lo, hi); return;}
转换参数M的最佳值和系统有关,但是5~15之间的任何值在大多数情况下都能令人满意。
2.三取样切分
使用子数组的一小部分元素的中位数来切分数组。
3.熵最优的排序
用于解决含有大量重复元素的数组排序问题。
Djikstra的解法如“三向切分的快速排序”代码所示,从左到右遍历数组一次,维护一个指针lt使得a[lo..lt-1]中的元素都小于v,指针gt使得a[gt+1..hi]中的元素都大于v,一个指针i使得a[lt..i]中的元素都等于v,a[i..gt]中的元素还未确定。
private static void quick3way(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 cmp = a[i].compareTo(v);
if (cmp < 0) exch(a, lt++, i++);
else if (cmp > 0) exch(a, i, gt--);
else i++;
}
sort(a, lo, lt);
sort(a, gt + 1, hi);
}
对重复元素的适应性使得三向切分的快速排序成为排序函数库的最佳算法选择。
代码的切分将和切分元素相等的元素归位,这样它们就不会被包含在递归调用处理的子数组中。
三向切分是信息量最优的。
元素的概率分布决定了信息量的大小,没有基于比较的排序算法能够用少于信息量决定的比较次数完成排序。
三向切分的快速排序的运行时间和输入的信息量的N倍是成正比的,对于包含大量重复元素的数组,它将排序时间从线性对数级降到了线性级别。
当所有主键值均不重复时有H=lgN (所有主键的概率均为1/N)
对于只有若干不同主键的随机数组,归并排序的时间复杂度是线性对数的,而三向切分快速排序则是线性的。
这些结论来着对于主键概率分布的分析。给定包含k个不同值的N个主键,对于从1到k的每个i,定义 为第i个主键值出现的次数, 为 ,即为随机抽取一个数组元素时,第i个主键值出现的概率。
所有主键的香农信息量(对信息含量的一种标准的度量方法)可以定义为:
给定任意一个待排序的数组,通过统计每个主键出现的频率就可以计算出它包含的信息量。可以通过信息量得出三向切分的快速排序所需要比较次数的上下界。
不存在任何基于比较的排序算法能够保证在NH-N次比较之内将N个元素排序,其中H为由主键值出现频率定义的香农信息量。
对于大小为N的数组,三向切分的快速排序需要~(2ln2)NH次比较。其中H为由主键值出现频率定义的香农信息量。
优先队列
介绍:支持删除最大元素和插入元素的一种数据结构。
应用场景:模拟系统、任务调度、数值计算、粒子碰撞模拟、抽象若干重要的图搜索算法、数据压缩算法...
初级实现:使用有序(积极)或无序(惰性)的数组或链表——适用于队列较小,大量使用两种操作之一时,或是所操作的元素的顺序已知时的情况。
初级实现中,插入元素和删除最大元素这两个操作之一在最坏情况下需要线性时间来完成。
堆
定义:当一棵二叉树的每个结点都大于等于它的两个子结点时,被称为堆有序。
根节点是堆有序二叉树中的最大结点。
定义:二叉堆是一组能够用堆有序的完全二叉树排序的元素,并在数组中按照层级储存(不使用数组的第一个位置)。
一棵大小为N的完全二叉树的高度为 。
数据结构二叉堆能够很好地实现优先队列的基本操作(实现对数级别的插入元素和删除最大元素的操作)。
堆的操作会首先进行一些简单的改动,打破堆的状态,然后再遍历堆并按照要求将堆的状态恢复,这个过程叫做堆有序化。
public class MaxPQ<Key extends Comparable<Key>> {//基于堆的优先队列
private Key[] pq;
private int N = 0;
public MaxPQ(int MaxN) {
pq = (Key[]) new Comparable[MaxN + 1];
}
public boolean isEmpty() {
return N == 0;
}
public int size() {
return N;
}
public void insert(Key v) {//插入元素
pq[++N] = v;
swim(N);
}
public Key delMax() {//删除最大元素
Key max = pq[1];
exch(1, N--);
sink(1);
pq[N + 1] = null;//防止对象游离
return max;
}
private boolean less(int i, int j) {//比较元素大小
return pq[i].compareTo(pq[j]) < 0;
}
private void exch(int i, int j) {//交换元素位置
Key t = pq[i];
pq[i] = pq[j];
pq[j] = t;
}
private void swim(int k) {//由下至上的堆有序化(上浮)
while (k > 1 && less(k / 2, k)) {
exch(k / 2, k);
k /= 2;
}
}
private void sink(int k) {//由上至下的堆有序化(下沉)
while (2 * k <= N) {
int j = 2 * k;
if (j < N && less(j, j + 1)) j++;
if (!less(k, j)) break;
exch(k, j);
k = j;
}
}
}
性能分析:对于一个含有N个元素的基于堆的优先队列,插入元素操作只需要不超过(lgN+1)次比较,删除最大元素的操作需要不超过2lgN次比较。
堆实现的优先队列在现代应用程序中越来越重要,它能在插入操作和删除最大元素操作混合的动态场景中保证对数级别的运行时间。
多叉堆
任意d叉树,需要在树高 和在每个结点d个子结点找到最大者的代价之间找到折中,这取决于实现的细节以及不同操作的预期相对频繁程度。
调整数组大小
可以添加一个没有参数的构造函数,在insert()中添加数组长度加倍的代码,在delMax()中添加将数组长度减半的代码,这样算法的用例就无需关注各种队列大小的限制。
元素的不可变性
优先队列储存了用例创建的对象,但同时假设用例代码不会改变它们(改变它们就可能打破堆的有序性)。
索引优先队列
给每个元素一个索引,可以允许用例引用已经进入优先队列中的元素。
性能分析:在一个大小为N的索引优先队列中,插入元素(insert)、改变优先级(change)、删除(delete)和删除最小元素(remove the minimum)操作所需的比较次数和logN成正比。
索引优先队列用例:解决多向归并问题,使用了优先队列,无论输入有多长都可以把它们全部读入并排序。
多向归并问题:将多个有序的输入流归并为一个有序的输入流。
堆排序
介绍:一种基于堆实现的优先队列的排序方法。
两个阶段:1.堆构造阶段:将原始数组重新组织安排进一个堆中。
从右至左用sink()函数构造子堆。数组的每个位置都已经是一个子堆的根结点了,sink()对于这些子堆也适用。如果一个结点的两个子结点都已经是堆了,那么在该结点上调用sink()可以将它们变成一个堆。这个过程会递归地建立起堆的秩序。开始时只需要扫描一半元素,因为可以跳过大小为1的子堆。最后在位置1上调用sink()方法,扫描结束。在排序的第一阶段,堆的构造方法和我们想象的有所不同,因为我们的目标是构造一个有序的数组并使最大元素位于数组的开头(次大的元素在附近)而非构造函数结束的末尾。
用下沉操作由N个元素构造堆只需要少于2N次比较以及少于N次交换。
2.下沉排序阶段:从堆中按递减顺序取出所有元素并得到排序结果。
堆排序的主要工作都是在第二阶段完成的。将堆中的最大元素删除,然后放入堆缩小后数组中空出的位置。这个过程和选择排序有些类似(按照降序而非升序取出所有元素),但所需要的比较要少得多,因为堆提供了一种从未排序部分找到最大元素的有效方法。
public class Heap {
private static boolean less(Comparable[] a, int i, int j) {//比较元素大小
return a[i].compareTo(a[j]) < 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 sink(Comparable[] a, int k, int N) {//下沉
while (2 * k + 1 < N) {
int j = 2 * k + 1;
if (less(a, j, j + 1)) j++;
if (!less(a, k, j)) break;
exch(a, k, j);
k = j;
}
}
public static void sort(Comparable[] a) {
int N = a.length - 1;
for (int k = N / 2; k >= 0; k--)
sink(a, k, N);
while (N > 0) {
exch(a, 0, N--);
sink(a, 0, N);
}
}
}
性能分析:将N个元素排序,堆排序只需要少于(2NlgN+2N)次比较(以及一半次数的交换)。
改进基于堆的优先对列的实现的堆排序:
先下沉后上浮
大多数在下沉排序期间重新插入堆的元素会被直接加入到堆底。正好可以通过免去检查元素是否达到正确的位置来节省时间。在下沉中总是直接提升较大的子结点直至达到堆底,然后再使元素上浮到正确的位置。这个想法几乎可以将比较次数减少一半——接近归并排序所需要的比较次数(随机数组)。这种方法需要额外的空间,因此在实际应用中只有当比较代价较高时才有用(例如,当我们在将字符串或者其他键值比较长的类型的元素进行排序时)。
堆排序在排序复杂性的研究中有着重要的地位,它是我们所知的唯一能够同时最优地利用空间和时间的方法——最坏的情况下它也能保持是用~2NlgN次比较和恒定的额外空间。当空间十分紧张的时候(例如在嵌入式系统或低成本的移动设备中)它很流行,因为它只用几行就能实现(甚至机器码也是)较好的性能。但现代系统的许多应用很少使用它,因为它无法利用缓存。数组元素很少和相邻的其他元素进行比较,因此缓存未命中的次数要远远高于大多数比较都在相邻元素之间进行的算法,如快速排序、归并排序、甚至是希尔排序。
应用
排序如此有用的一个主要原因是,在一个有序的数组中查找一个元素要比在一个无序的数组中查找简单得多。
Java的约定使得我们能够利用Java的回调机制将任意实现了Comparable接口的数据类型排序。
相关概念:
1.指针排序
只处理元素的引用而不移动数据本身。
C/C++中需要明确的指出操作的是数据还是指向数据的指针,而Java中,指针的操作是隐式的。
除了原始数据类型之外,操作的总是数据的引用(指针),而非数据本身。指针增加了一层间接性,因为数组保存的是待排序对象的引用,而非对象本身。
2.不可变的键
如果在排序后用例还能修改键值,那么数组就很可能不再有序了。类似,优先队列在用例能够修改键值的情况下也不太可能正常工作了。在Java中,可以用不可变的数据类型作为键来避免这个问题。例如,String、Integer、Double、File...
3.廉价交换
使用引用的另一个好处是我们不必移动/访问整个元素,使得在一般情况下交换的成本和比较的成本几乎相同(代价是需要额外的空间储存这些引用)。如果键值很长,那么交换的成本甚至会低于比较的成本。
研究数字排序的算法性能的一种方法就是观察其所需的比较和交换的总数,因为这里隐式地假设了比较和交换的成本相同。
4.多种排序方法
Java的Comparator接口允许我们在一个类之中实现多种排序方法。
Comparator接口允许我们为任意数据类型定义任意多种排序方法。用Comparator接口来代替Comparable接口能够更好地将数据类型的定义和两个该类型的对象应该如何比较的定义区分开来。
比较两个对象的确可以有多种标准,Comparator接口使得我们能够在其中进行选择。例如,Java的String类型含有很多比较器。
5.多键数组
一般的应用程序中,一个元素的多种属性都可能被用作排序的键。要实现这种灵活性,Comparator 很合适,可以定义多种比较器接口。
sort(数组引用,类的比较器);
sort()方法在每次比较中都会回调类中指定的compare()方法。为了避免每次排序都创建一个新的Comparator对象,我们使用public final来定义这些比较器。
使用比较器实现优先队列
比较器的灵活性也可以用在优先队列上。
①导入 Java.util.Comparator;
②为MaxPQ添加一个实例变量comparator以及一个构造函数,该构造函数接受一个比较器作为参数并用它将comparator初始化;
③在less()中检查comparator属性是否为null(如果不是的话就用它进行比较)。
如果在MaxPQ中去掉了Key extends Comparable<Key>,甚至可以支持尚未定义过的比较方法的键。
6.稳定性
如果一个排序算法能够保留数组中重复元素的相对位置则可以被称为是稳定的。
稳定的排序算法:插入、归并
不稳定的排序算法:选择、希尔、快速、堆
有很多办法能够将任意排序算法变成稳定的,但一般只有在稳定性是必要的情况下稳定的排序算法才有优势。
人们很容易觉得算法具有稳定性是理所当然的,但事实上并没有任何实际应用中常见的方法不是用了大量的额外时间和空间才做到这一点。
应用场景:1.商业计算(数据处理)
一般这些信息都会存储在大型的数据库里,能够按照多个键排序以提高搜索效率。一个普遍使用的有效方法是先收集新的信息并添加到数据库,将其按感兴趣的键排序,然后将每个键的排序结果归并到已存在的数据库中。
2.信息搜索
有序的信息确保可以用经典的二分查找法进行高效查找。
3.运筹学
运筹学指的是研究数学模型并将其应用于问题解决和决策的领域。
运筹学经典问题——调度(最短优先、最大优先)
4.事件驱动模拟
用大量的计算来将现实世界的某个方面建模以期更好的理解它。
5.数值计算
一些数值计算算法使用优先队列和排序来控制计算的精确度。
6.组合搜索
人工智能领域一个解决“疑难杂症”的经典范式就是定义一组状态、由一组状态演化到另一组状态的可能步骤以及每个步骤的优先级,然后定义一个起始状态和目标状态(也就是问题的解决办法)。A*算法的解决办法就是将起始状态放入优先队列中,然后重复下面的方法直到达到目的地:删去优先级最高的状态然后将能够从该状态在一步之内达到的所有状态全部加入优先队列(除了刚刚删除的那个状态之外)。将问题的解决转化为定义一个适当的优先级函数问题。
7.Prim算法和Dijkstra算法
8.Kruskal算法
算法的运行时间由排序所需的时间决定。
9.霍夫曼压缩——数据压缩算法
处理的数据中每个元素都有一个小整数作为权重,而处理的过程就是将权重最小的两个元素归并成一个新的元素,并将其权重相加得到新元素的权重。使用优先队列可以立即实现这个算法。其他几种数据压缩算法也是基于排序的。
10.字符串处理
找出给定字符串中最长重复子字符串算法时会先将字符串的后缀排序。
使用哪种排序算法?
排序算法的好坏很大程度上取决于它的应用场景和具体实现。以上通用的算法能在很多情况下达到和最佳算法接近的性能。
算法 | 是否稳定 | 是否原地排序 | 时间复杂度 | 空间复杂度 | 备注 |
---|---|---|---|---|---|
选择排序 | 否 | 是 | 1 | ||
插入排序 | 是 | 是 | 介于N和之间 | 1 | 取决于输入元素的排序情况 |
希尔排序 | 否 | 是 | NlogN? ? | 1 | |
快速排序 | 否 | 是 | NlogN | lgN | 运行效率由概率提供保证 |
三向快速排序 | 否 | 是 | 介于N和NlogN之间 | lgN | 运行效率由概率保证,同时取决于输入元素的分布情况 |
归并排序 | 是 | 否 | NlogN | N | |
堆排序 | 否 | 是 | NlogN | 1 |
快速排序是最快的通用排序算法。
运行时间的增长数量级为 ~cNlgN,c 比其他线性对数级别的排序算法的相应常数都要小。
原因:1.内循环的指令少
2.能利用缓存(它总是顺序访问数据)
如果稳定性很重要而空间又不是问题,归并排序可能是最好的。
排序算法的各种性质:
插入排序:复杂度取决于输入元素的排序情况
希尔排序:复杂度是一个近似
两种快速排序:复杂度和概率有关,取决于输入元素的分布情况
除以上三种外,将这些运行时间的增长数量级乘以适当的常数就能够估计出其运行的时间。
常数的影响因素:和算法有关(例如堆排序的比较次数是归并排序的两倍,且两者访问数组的次数都比快速排序多得多),但主要取决于算法的实现、Java编译器以及计算机性能。
排序算法可以改进的地方:
1.将原始数据类型排序
一些性能优先的应用的重点可能是将数字排序,因此更合理的做法是跳过引用直接将原始数据类型的数据排序。如果只是在将一大组数排序的话,跳过引用可以节省存储所有引用所需要的空间和通过引用来访问数字的成本。
Java系统库的排序算法
Java系统库中的主要排序方法java.util.Arrays.sort()。
根据不同的参数类型,它实际上代表了一系列排序的方法:
①每种原始数据类型都有一个不同的排序方法;
②一个适用于所有实现了Comparable接口的数据类型的排序方法;
③一个适用于实现了比较器Comparator的数据类型的排序方法;
Java的系统程序员选择对原始数据类型使用(三向切分的)快速排序,对引用类型使用归并排序。这些选择实际上也暗示着用速度和空间(对原始数据类型)来换取稳定性(对于引用类型)。
问题的归约
归约:指的是为解决某个问题而发明的算法正好可以用来解决另一种问题。
实际上,实现算法的一个目标就是使用算法的适用性尽可能的广泛,使得问题的归约更简单。
很多种问题都以算法测验的形式出现,而解决它们的第一想法往往是平方级别的暴力破解。但很多情况下如果先将数据排序,那么解决剩下的问题就只需要线性级别的时间,这样归约后的运行时间的增长数量级就由平方级别降低到了线性对数级别。例如:找出重复元素(先排序再记录)、排名、优先队列、中位数与顺序统计...
排名:一组排列(或是排名)就是一组N个整数的数组,其中0到N-1每个数都自出现一次。两个排序之间的Kendall tau距离就是两组数列中顺序不同的数对的数目。某个排列和标准排列(即每个元素都在正确的位置上排列)的Kendall tau距离就是其中逆序数对的数量(可以用记录快速排序交换的次数来高效实现计算某个排列的逆序对的数量)。
优先队列:两个被归约为优先队列操作问题的例子:找到输入流中M个最大的元素;将M个输入流归并为一个有序的输入流。用长度为M的优先队列解决以上两个问题。
中位数与顺序统计:
public static Comparable select(Comparable[] a, int k) {//查找数组中第k个小的元素
//这里可以添加一条打乱数组的语句
int lo = 0, hi = a.length - 1;
while (hi > lo) {
int j = partition(a, lo, hi);
if (j == k) return a[k];
else if (j > k) hi = j - 1;
else if (j < k) lo = j + 1;
}
return a[k];
}
select()用两个变量hi和lo来限制含有要选择的k元素的子数组,并用快速排序的切分法来缩小子数组的范围。
至于为什么这个算法是线性级别的?
比较总次数为(N+N/2+N/4+N/8...),直到找到第k个元素,显然小于2N。和快速排序一样,比较的上界比快速排序略高、分析依赖于使用随机的切分元素,性能的保证也来自于概率。
性能分析:平均来说,基于切分的选择算法是线性级别的。最坏的情况下算法的运行时间仍然是平方级别的,但于快速排序一样,将数组乱序化可以邮箱防止这种情况出现。
以上大部分内容来自《算法》(第4版),部分代码有改进(堆排序第一个元素未实现排序,待修复)。