参考书: 算法(第四版)
- 优秀的算法因为能解决实际问题而变得尤为重要
- 高效算法的代码也可以很简单
- 理解某个实现的性能特点是一项有趣而令人满足的挑战
- 在解决同一个问题的多种算法之间进行选择时,科学方法时一种重要的工具
- 迭代式改进能让算法的效率越来越高
1. 排序
三大实际意义
- 对排序算法的分析将有助于全面理解书中比较算法性能的方法
- 类似的技术也能有效解决其他类型的问题
- 排序算法常常是我们解决其他问题的第一步
1.1. 前言
1.1.1 模板
/**
排序算法模板
**/
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=1; i<a.length; i++) {
if(less(a[i], a[i-1])) //默认升序排列
return false;
}
return true;
}
public static void main(String[] args) {
String[] a = null;
sort(a);
assert isSorted(a); //断言
show(a);
}
}
在研究排序算法时,需要比较的是比较和交换的数量。对于不交换元素的算法,比较访问数组的次数
1.1.2 简述
本节接下来即将研究经典的几种排序算法:
- 选择排序
- 插入排序
- 希尔排序
- 归并排序
- 快速排序
- 堆排序
1.2 选择排序
1.2.1 简述
选择排序的基本思想是:
在当前的轮数 中,找到从索引到末尾的元素的最小值,并于 比较,若小于当前元素则交换
1.2.2 代码
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);
}
}
选择排序的轨迹
1.2.3 复杂度分析
显然可以看出,最坏的情况总共需要 次交换, 以及 次的比较
时间复杂度为
1.3 插入排序
1.3.1 简述
相当于我们在玩扑克牌时理牌过程的模拟,将第j张牌移动到前面合适的位置
1.3.2 代码
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[i], a[j - 1]); j--) { // 将a[j]置入前面序列中合适的位置
exch(a, j, j - 1);
}
}
}
插入排序的轨迹
1.3.3 复杂度分析
首先,我们先思考一下最好情况的情形:所有对象都已升序,这时便不需要交换,仅仅比较次
接下来我们再看最坏情况下的分析:当所有对象都是逆序排列时,显然,需要 次比较和 次交换
时间复杂度
1.3.4 进一步思考
我们现在考虑更加一般的情况:_部分有序_的数组
下面是几种典型的部分有序的数组
- 数组中每个元素离它最终的位置不远
- 一个有序数组接一个小数组
- 数组中只有几个元素位置不对
插入排序对这样的数组很有效,选择排序则相反
1.3.5 插入排序和选择排序的比较
根据可视化的轨迹图,我们可以发现:
- 插入排序不会访问索引右侧的元素
- 而选择排序不会访问索引左侧的元素
两个算法的时间复杂度均为 ,虽然两者都是平方级别的,但还是有所差异的
在本书1980年第一版完成之时,插入排序就比选择排序_快一倍_, 现在仍是这样
1.4 希尔排序
1.4.1 简述
希尔排序是_基于插入排序_的快速的排序算法
可以看成是对插入排序的优化
由于对于大规模乱序的数组而言,插入排序的速度很慢,因为总是需要将相邻的两个元素交换,如果最小的元素在数组的
最右端,那么就需要N-1次移动才能慢慢将它移到正确的位置
1.4.2 代码
public static void sort(Comparable[] a) {
int N = a.length;
int h = 1; // h大小的子数组进行排序,优化了插入排序中最小值在最右端要一次一次移动带来的高开销
while (h < N / 3)
h = h * 3 + 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.3 分析
贯穿本书的一个重要理念:
通过提升速度来解决其他方式无法解决的问题是研究算法的设计和性能的主要原因之一
尽管希尔排序在数学上对于平均次数的分析比较复杂,但我们已经知道它已经突破了前两个算法的平方级别
有经验的程序员会使用希尔排序,它对于中等规模数组的运行时间是可以接受的,它的代码量很小,且不需要额外的内存空间
如果你要解决一个排序问题而有没有系统排序函数可以用,不妨先用希尔排序,然后再考虑使用其他更加复杂的排序算法
1.5 归并排序
归并排序是一种简单的递归排序算法:
要将一个数组排序,可以先(递归地)将它分成两半分别排序,然后将结果合并起来
你将会看到,归并排序吸引人的性质在于时间复杂度为,但主要的缺点是需要的额外空间和数组规模成正比
1.5.1 原地归并的抽象方法
我们知道,当用归并对大数组进行排序的时候,需要很多次归并,因此每次归并时创建一个新的数组会带来一些问题
我们更希望用一种_原地归并_的方法,能够在数组中移动而无需额外的空间,乍一看很容易,但实际上却是非常复杂的
//原地归并的抽象方法
public static void merge(Comparable[] a, int lo, int mid, int hi){
//将a[lo....mid]和a[mid+1.....hi]归并
int i=lo;
int 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[i], aux[j])) a[k] = aux[i++]; //放入较小元素
else a[k] = aux[j++];
}
}
1.5.2 自顶向下的归并排序
基于原地归并的抽象方法,实现了另一种递归归并,这也是分治思想的一个典型例子
分治算法
将问题划分成较小规模,利用小问题解决初始问题
假设一个递归算法把一个规模为的问题分成个子问题,其中每个子问题的规模为(为简单起见,假设b是n的倍数)。此外,假设把子问题的解组合成原来问题的算法处理需要总量为的额外运算
那么,如果表示求解规模为n的问题需要的运算数,则满足递推关系式
$f(n) = af(\frac {n}{b}) + g(n)$这就叫做分治递推关系
//自顶向下
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) {
int mid = (lo + hi) / 2;
if (lo < hi) {
sort(a, lo, mid); // 左半边排序
sort(a, mid + 1, hi); // 右半边排序
merge(a, lo, mid, hi); // 归并(见2.5.1代码)
}
}
1.5.3 调用轨迹
要理解归并排序就要仔细研究该方法的调用情况,例如输入为
"m" "e" "r" "g" "e" "s" "o" "r" "t"
加入调试语句后,我们通过控制台打印出来的调用轨迹
左半部份排序:
调用了sort(a, 0,15) 调用了sort(a, 0,7) 调用了sort(a, 0,3) 调用了sort(a, 0,1) 调用了merge(a, 0,0,1) 调用了sort(a, 2,3) 调用了merge(a, 2,2,3) 调用了merge(a, 0,1,3) 调用了sort(a, 4,7) 调用了sort(a, 4,5) 调用了merge(a, 4,4,5) 调用了sort(a, 6,7) 调用了merge(a, 6,6,7) 调用了merge(a, 4,5,7) 调用了merge(a, 0,3,7)
右半部分排序:
调用了sort(a, 8,15) 调用了sort(a, 8,11) 调用了sort(a, 8,9) 调用了merge(a, 8,8,9) 调用了sort(a, 10,11) 调用了merge(a, 10,10,11) 调用了merge(a, 8,9,11) 调用了sort(a, 12,15) 调用了sort(a, 12,13) 调用了merge(a, 12,12,13) 调用了sort(a, 14,15) 调用了merge(a, 14,14,15) 调用了merge(a, 12,13,15) 调用了merge(a, 8,11,15) 调用了merge(a, 0,7,15)
另外,我们可以通过树结构图来理解
1.5.4 自底向上的归并排序
现在,我们换一种思路——先归并小数组,然后再成对归并已得到的子数组,如此这般,我们将整个数组调整到一起
首先是归并两个大小为1的数组,然后再归并两个大小为2的,然后是大小为4的数组,……
有可能数组的总大小并不是2的幂次,但对于函数来说,这并不是问题
//自底向上
public static void sort(Comparable[] a) {
int N = a.length;
aux = new Comparable[N];
for(int size = 1; size<N; size+=size) { //size 子数组大小
for(int lo = 0; lo<N-size; lo += size+size) { //lo 子数组索引
merge(a, lo, lo+size-1 , Math.min(lo+size+size-1, N-1));//若数组大小非2的幂次,右半部分数组最多只能到末尾
}
}
}
1.5.5 调用轨迹
同样的输入,我们来观察一下自底向上归并排序的调用轨迹
size = 1
调用了merge(a, 0,0,1) 调用了merge(a, 2,2,3) 调用了merge(a, 4,4,5) 调用了merge(a, 6,6,7) 调用了merge(a, 8,8,9) 调用了merge(a, 10,10,11) 调用了merge(a, 12,12,13) 调用了merge(a, 14,14,15)
size = 2
调用了merge(a, 0,1,3) 调用了merge(a, 4,5,7) 调用了merge(a, 8,9,11) 调用了merge(a, 12,13,15)
size = 4
调用了merge(a, 0,3,7) 调用了merge(a, 8,11,15)
size = 8
调用了merge(a, 0,7,15)
1.5.6 两种方式小结
当数组长度为2的幂时,两者所用的比较次数和数组访问次数刚好相等,只是顺序不同
_自底向上_适用于_链表_组织的数据,按照这种方法只用重新组织链表的排序方式,不需要额外的链表辅助节点
用自顶向下还是自底向上实现都很自然,归并排序告诉我们,当我们使用一种方式解决时,都应该去尝试另一种
有时,我们看问题的角度会决定了我们解决问题的方式
1.6 快速排序
1.6.1 前言
快速排序是一种分治的排序算法,它将一个数组分成两个子数组,将两部分独立地排序
快速排序和归并排序是互补的:
- 归并排序将数组分成两个子数组分别排序,并将有序子数组归并(递归调用发生在处理数组之前)
- 快速排序则是当两个子数组都有序时整个数组也就自然有序了(递归调用发生在处理数组之后)
在快速排序中,切分()的位置取决于数组内容
//快速排序算法
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); //lo...j-1排序
sort(a, j+1, hi); //j+1...hi排序
}
1.6.2 切分
快速排序算法的关键————切分
现在我们需要实现切分方法:
一般的策略是先随意地选取a[lo]作为_切分元素_。然后从数组左端向右,数组右端向左,直到找到一个比a[lo]大的a[i]和比a[lo]小的a[j],交换a[i]和a[j]
如此反复,我们就可以保证左指针i的左侧都不大与切分元素,右指针j的右侧都不小于切分元素
当两指针相遇时,我们只需将a[lo]和a[j]交换,并返回j即可
//切分
private static int partition(Comparable[] a, int lo, int hi) {
int i = lo;
int j = hi+1;
while(true) {
//扫描左右,检查是否结束并交换
while(less(a[++i], a[lo]))
if(i == hi) break;
while(less(a[lo], a[--j]))
if(j == lo) break;
if(i >= j) break;
exch(a, i, j); //交换i,j
}
exch(a, lo, j); //将a[lo]放入适当的位置,此时a[lo...j-1]都比a[j]小,a[j+1....hi]都比a[j]大
return j;
}
/*
思考:为什么最后是lo和j交换?
i指针一直在移动,但i指针停止移动说明了当前的i元素大于lo元素
那么,j<=i时,意味着当前的j元素小于lo元素
那么交换lo和j也就顺理成章了
*/
我们观察一下切分的轨迹
1.6.3 算法的改进
(预留)
1.7 堆排序
1.7.1 优先队列
一般而言,支持——【删除最大元素】、【插入元素】的数据结构称为优先队列
优先队列的使用和队列及栈类似,但高效地实现则更有挑战性
我们接下来会学习【二叉堆】数据结构,这是一种优先队列的经典实现,用数组保存元素并按一定条件排序,以实现高效地(对数级别)删除最大元素和插入元素
优先队列的应用场景:
- 模拟系统,系统按照时间顺序处理所有事件
- 任务调度,优先级决定了应该首先执行哪些任务
- 数值计算,键值代表计算错误,需要按照键值指定顺序修正
1.7.2 初级实现
- 数组无序实现: 插入方法和栈的push()一样,但是删除最大元素需要遍历所有数组找到最大值
- 数组有序实现: 删除最大元素方法和栈的pop()一样,但插入元素需要将较大元素向右放,以保证最次都能删除最右边的元素(最大元素)
使用无序序列解决为_惰性方法_,我们仅在必要的时候采取行动(找最大元素)
而使用有序序列解决会_积极方法_,因为我们会尽可能未雨绸缪(插入时就保证序列有序)
1.7.3 二叉堆
当一棵二叉树的每个节点都大于等于它的两个子节点时,称为【堆有序】
相应地,堆有序的二叉树中,每个节点都小于等于它的父节点
完全二叉树只需用数组就可以清楚的表示,具体方法时将节点按照_层级顺序_放入数组:
根节点在1——子节点在2,3——子节点的子节点在4,5,6,7
二叉堆是一组能够用堆有序的完全二叉树排序的元素,并在数组中按照层级存储(不使用数组第一个位置)
在一个堆中,位置k的节点,其父节点为【k/2】,子节点为【2k】和【2k+1】
1.7.4 堆算法
1.7.4.1 由下至上堆有序——上浮(swim)
如果堆的某个节点比起父节点大,就打破了堆有序的规则
这时候我们就需要进行修复:反复将该节点与父节点交换,直到恢复堆有序
//若某个节点比其父节点 大,则需要修正堆,进行上浮【swim】操作
private void swim(int k) {
while(k > 1 && less(k/2, k)) {
exch(k, k/2);
k = k/2; //重复该过程,直到满足堆有序
}
}
1.7.4.2 由上至下堆有序——下沉(sink)
如果某节点比起两个子节点还要小,这时候我们就需要下沉【sink】操作:将子节点中较大的节点和其父节点交换,反复执行,直到恢复堆有序
//若某节点比起子节点小,则需要进行下沉【sink】操作
private void sink(int k) {
while(k*2 <= N) {
int j = k*2;
if(j < N && less(j, j+1))
j = j+1;
if(!less(k, j))
break;
exch(k, j);
k = j;
}
}
1.7.5 基于堆的优先队列实现
public class MaxPQ<Key extends Comparable<Key>> {
private Key[] pq; // 基于堆的完全二叉树
private int N = 0; // 数据存在a[1.....N]中,a【0】不用
// 创建一个最大容量max的优先队列
public MaxPQ(int max) {
pq = (Key[]) new Comparable[max + 1];
}
// 是否为空
public boolean isEmpty() {
return N == 0;
}
// 返回元素个数
public int size() {
return N;
}
// 比较方法
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;
}
// 若某个节点比其父节点 大,则需要修正堆,进行上浮【swim】操作
private void swim(int k) {
while (k > 1 && less(k / 2, k)) {
exch(k, k / 2);
k = k / 2; // 重复该过程,直到满足堆有序
}
}
// 若某节点比起子节点小,则需要进行下沉【sink】操作
private void sink(int k) {
while (k * 2 <= N) {
int j = k * 2;
if (j < N && less(j, j + 1))
j = j + 1;
if (!less(k, j))
break;
exch(k, j);
k = j;
}
}
// 插入元素
public void insert(Key v) {
pq[++N] = v;
swim(N); // 插入到最后,然后执行上浮操作
}
// 删除并返回最大元素
public Key delMax() {
Key ret = pq[1];
exch(1, N--); // 和最后一个元素交换
pq[N + 1] = null; // 防止越界
sink(1); // 执行下沉操作
return ret;
}
// 返回最大元素
public Key max() {
return pq[1];
}
}
1.7.6 堆排序
1.7.6.1 堆的构造
给定N个元素如何构造堆?
我们首先想到的是从左遍历数组,用swim()扫描指针左侧所有元素,保证为一颗堆有序的完全树,时间复杂度
一个更聪明且有效的方法是从右到做用sink()函数构造子堆
public static void sort(Comparable[] a){
int N = a.length;
for(int k = N/2; k>=1; k--){
sink(a, k, N); //第一阶段:构造了堆
}
/*
思考:为什么k初值使N/2
N/2意味着,当前初始值是从堆的倒数第二层(或倒数第一层【满二叉树时】)进行下沉
*/
while(N > 1){
exch(a, 1, N--);
sink(a, 1, N); //第二阶段:下沉排序
}
}
1.7.6.2 下沉排序
堆排序的主要工作由下沉排序sink()函数来完成
//k-n之间进行下沉操作
private static void sink(Comparable[] a, int k, int n) {
int j = k*2;
while(k <= n) {
if(j < n && less(j, j+1))
j = j+1;
if(!less(k, j))
break;
exch(a, k, j);
k = j;
}
}
1.7.7 小结
堆排序在排序复杂性的研究中有着重要地位,因为它是我们所知的唯一能同时最优地利用时间和空间的方法
在最坏的情况下保持与 成正比
当空间十分紧张时他很流行,因为它只用几行代码就行实现较好的性能,但现代系统的许多应用很少使用,因为它无法利用缓存
另一方面,堆实现的优先队列越来越重要,是因为能在【插入操作】和【删除最大元素操作】保证对数级别运行时间