排序算法
0,概览
稳定:如果a原本在b前面,而a=b,排序之后a仍然在b的前面。
不稳定:如果a原本在b的前面,而a=b,排序之后 a 可能会出现在 b 的后面。
时间复杂度:对排序数据的总的操作次数。反映当n变化时,操作次数呈现什么规律。
空间复杂度:是指算法在计算机内执行时所需存储空间的度量,它也是数据规模n的函数。
简单的结论:
快速排序是最快的通用排序算法,它的内循环的指令很少,而且还能利用缓存,因为它总是顺序地访问数据。
一个例外:如果稳定性很重要而空间又不是问题,归并排序可能是最好的。
1,冒泡排序
冒泡排序是一种简单的排序算法。它重复地走访过要排序的数列,一次比较两个元素,如果它们的顺序错误就把它们交换过来。走访数列的工作是重复地进行直到没有再需要交换,也就是说该数列已经排序完成。这个算法的名字由来是因为越小的元素会经由交换慢慢“浮”到数列的顶端。
算法描述:
- 比较相邻的元素。如果第一个比第二个大,就交换它们两个;
- 对每一对相邻元素作同样的工作,从开始第一对到结尾的最后一对,这样在最后的元素应该会是最大的数;
- 针对所有的元素重复以上的步骤,除了最后一个;
- 重复步骤1~3,直到排序完成。
public void sort(Comparable[] a) {
int n = a.length;
Comparable t;
for (int i = n - 1; i > 0; i--) {
//这个循环,每次执行一轮,最大的元素必然在i位置
//从n-1开始--1,一直到第一个元素,说明排序完成
//比较0到之间的所有数,找到最大值,放到后面,通过不断交换相邻数字实现
for (int j = 0; j < i; j++) {
if (less(a[j + 1],a[j])) {
t = a[j];
a[j] = a[j + 1];
a[j + 1] = t;
}
}
}
}
2,选择排序
首先,找到数组中最小的元素,将它和数组的第一个元素交换位置;
然后,在剩下的元素中找到最小的元素,将它与数组的第二个元素将换位置。
如此往复,直到最后一次元素,数组就完成了排序。不断的选择剩余元素之中的最小元素,所以称之为选择排序。
public void sort(Comparable[] a) {
int N = a.length;
for (int i =0;i<N;i++){
int minElmIndex = i;
//找出数组中的最小值
for (int j = i+1;j<N;j++){
if(less(a[j],a[minElmIndex])){
minElmIndex = j;
}
}
//最小值和a[i]交换
exch(a,i,minElmIndex);
}
}
命题A:对于长度为N的数组,选择排序需要大约
N2/2
N
2
/
2
次比较和N次交换。
证明:首先,对于第二层For循环,执行的次数为
N−1−i
N
−
1
−
i
,而
i
i
的大小由第一层For循环决定,范围是,所以第二层For循环的执行次数为
(N−1)+(N−2)+(N−3)+...+2+1
(
N
−
1
)
+
(
N
−
2
)
+
(
N
−
3
)
+
.
.
.
+
2
+
1
; 等差数列求和为
(N−1)∗N/2
(
N
−
1
)
∗
N
/
2
,约等于
N2/2
N
2
/
2
至于交换N次,这很明显。
3,插入排序
插入排序的基本操作就是将一个数据插入到已经排好序的有序数据中,从而得到一个新的有序数据。
从第1个和第2个数据开始排序,形成第一部分已经排序好的数组,再把后面的元素按大小插入到第一个有序数组中,最后形成的数组就是完全排序好的数组,此所谓插入排序。
插入排序对于部分有序的数组会十分高效
public void sort(Comparable[] a) {
int N = a.length;
//第i个元素之前的数组为第一部分已经排序好的数组
for (int i = 1 ; i<N;i++){
//比较当前元素a[j] 与之前已排序数组a[0:j]的元素,
//如果后面小于前面的元素,交换位置,直到找到大于当前元素的索引
for (int j = i;j>0;j--){
//如果后面小于前面的元素,交换位置
//更高效的做啊做法是将元素往后移动,而不是交换
if(less(a[j],a[j-1])){
exch(a,j,j-1);
}
}
}
}
命题B:对于随机排列的长度为N且主键不重复的数组,最坏情况下需要
N2/2
N
2
/
2
次比较和
N2/2
N
2
/
2
次交换
证明和命题A类似
4,希尔排序
希尔排序(Shell’s Sort)是插入排序的一种又称“缩小增量排序”(Diminishing Increment Sort),是直接插入排序算法的一种更高效的改进版本。
- 先取一个小于n的整数d1作为第一个增量,把全部元素分组。所有距离为d1的倍数的元素放在同一个数组中。
- 先在各数组内进行直接插入排序;
- 取第二个增量d2 < d1重复上述的分组和排序,直至所取的增量 = 1;
- 即所有记录放在同一组中进行直接插入排序为止;
希尔排序的思想是使数组中任意间隔为h的元素都是有序的,这样的数组称之为h有序数组
希尔排序是基于插入排序的以下两点性质而提出改进方法的:
- 插入排序在对几乎已经排好序的数据操作时,效率高,即可以达到线性排序的效率。
- 但插入排序一般来说是低效的,因为插入排序每次只能将数据移动一位。
希尔排序更高效的原因在于权衡了子数组的规模性和有序性。比选择排序和插入排序快很多,规模越大,优势越大。
h = 1
while h < n, h = 3*h + 1
while h > 0,
h = h / 3
for k = 1:h, insertion sort a[k:h:n]
→ invariant: each h-sub-array is sorted
end
public void sort(Comparable[] a) {
int N = a.length;
int h = 1;
//这里h的选择相对随意,/2也可以
while (h < N / 3) {
//h= 1,4,13,40……
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 = j - h) {
exch(a, j, j - h);
}
}
/*
* 缩小h,直至所取的增量 =1
*/
h = h / 3;
}
}
希尔排序的性能:目前最重要的结论是它的运行时间达不到平方的级别。
平均时间复杂度:希尔排序的时间复杂度和其增量序列有关系,这涉及到数学上尚未解决的难题;不过在某些序列中复杂度可以为O(n1.3);
空间复杂度:O(1)
稳定性:不稳定
5,归并排序
将数组递归分为两个部分分别进行排序,然后将结果归并起来,基本原理是分治法。
将已有序的子序列合并,得到完全有序的序列;即先使每个子序列有序,再使子序列段间有序。若将两个有序表合并成一个有序表,称为2-路归并。
**归并排序能保证任意长度为N的数组排序所需要的时间和
NlogN
N
l
o
g
N
成正比;
它的主要缺点是它所需要的额外空间和N成正比。**
算法描述:
- 把长度为n的输入序列分成两个长度为n/2的子序列;
- 对这两个子序列分别采用归并排序;
- 将两个排序好的子序列合并成一个最终的排序序列。
@Override
public void sort(Comparable[] a) {
Comparable[] aux = new Comparable[a.length];
sort(a, aux, 0, a.length - 1);
}
/**
* 实际上sort方法只是负责拆分数组,排序和合并都在Merge方法中实现
*/
private void sort(Comparable[] a, Comparable[] aux, int lo, int hi) {
if (hi <= lo) {
//递归中止条件
return;
}
int mid = lo + (hi - lo) / 2;
System.out.println(lo);
sort(a, aux, lo, mid);
sort(a, aux, mid + 1, hi);
merge(a, aux, lo, mid, hi);
}
public void merge(Comparable[] a, Comparable[] aux, int lo, int mid, int hi) {
//将a[lo...mid] 和a[mid+1...hi]归并
int i = lo; // 第一个有序区域的索引
int j = mid + 1; //第二个有序区域的索引
//将a[lo...hi]复制到aux[lo...hi]中,这样在操作a[]是数据不会被覆盖而丢
for (int k = lo; k <= hi; k++) {
//aux[] 是在类中建立的一个辅助数组,它的大小和a[]的大小一样大
aux[k] = a[k];
}
//归并到a[lo...hi]中
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++];
}
}
}
另一种自低向上的归并排序:
首先进行归并微型数组,然后再成对归并得到的子数组,如此往复,直到将整个数组归并在一起。
两两归并(每个元素想象成一个大小为1的数组)——四四归并(将两个大小为2的数组归并为一个有4个元素的数组)——八八归并。。。。。
public void sort(Comparable[] a) {
int N = a.length;
Comparable[] aux = new Comparable[N];
for (int sz = 1; sz < N; sz = sz*2) {
for (int lo = 0; lo < N - sz; lo =lo+ sz*2) {
merge(a, aux, lo, lo + sz - 1, Math.min(lo + sz*2 - 1, N - 1));
}
}
}
归并排序和希尔排序的运行时间的差距在常数级别之内
6,快速排序
快速排序是原地排序,只需要一个很小的辅助栈,且将长度为N的数组排序所需的时间和
NlogN
N
l
o
g
N
成正比。
之前的算法无法将这两点结合起来。
主要缺点是非常脆弱,在实现时容易造成低劣的性能。
快速排序是一种分治的排序算法。
归并排序将数组分为两个部分,分别排序,然后将有序的子数组归并,整个数组排序完成;
而快速排序将数组排序的方式则是当两个子数组都有序时,整个数组也就自然有序了。
算法描述:
- 从数列中挑出一个元素,称为 “基准”(pivot);
- 重新排序数列,所有元素比基准值小的摆放在基准前面,所有元素比基准值大的摆在基准的后面(相同的数可以到任一边)。在这个分区退出之后,该基准就处于数列的中间位置。这个称为分区(partition)操作;
- 递归地(recursive)把小于基准值元素的子数列和大于基准值元素的子数列排序。
快速切分示意图:
切分轨迹:左边数组元素<切分元素,右边元素>切分元素
性能:
将长度为N的无重复数组排序,快速排序平均需要
2NlnN
2
N
l
n
N
次比较,大约等于
1.39NlgN
1.39
N
l
g
N
,也就是说平均比较次数纸币最好情况多39%
快速排序最多需要约 N2/2 N 2 / 2 次比较,但随机打乱数组能够避免这种情况
public static void sort(Comparable[] a) {
StdRandom.shuffle(a);
sort(a, 0, a.length - 1);
assert isSorted(a);
}
private static void sort(Comparable[] a, int lo, int hi) {
if (hi <= lo) return;
int j = partition(a, lo, hi);//切分,找到j
sort(a, lo, j-1);//给左半部分lo....j-1排序
sort(a, j+1, hi);//给右半部分j+1....hi排序
assert isSorted(a, lo, hi);
}
//切分数组,返回j
//左边数组元素<切分元素<右边元素
// partition the subarray a[lo..hi] so that a[lo..j-1] <= a[j] <= a[j+1..hi]
// and return the index j.
private static int partition(Comparable[] a, int lo, int hi) {
int i = lo;//左边的低位索引i
int j = hi + 1;//右边的高位索引j
Comparable v = a[lo];// 选定a[lo]为元素v,被比较的对象,也就是最后放在j索引位置切分数组的元素
//交换左右元素的循环
while (true) {
// find item on lo to swap
while (less(a[++i], v)) {
if (i == hi) break;
}
// find item on hi to swap
while (less(v, a[--j])) {
if (j == lo) break; // redundant since a[lo] acts as sentinel
}
// check if pointers cross
if (i >= j) break;
//走到这里,说明a[i]>=a[v]>=a[j],左边的元素a[i]>右边的元素a[j]
//需要将两者交换位置
exch(a, i, j);
}
// put partitioning item v at a[j]
//将元素v放到a[j]位置
exch(a, lo, j);
// now, a[lo .. j-1] <= a[j] <= a[j+1 .. hi]
return j;
}
优化方案:
1,切换到插入排序
- 对于小数组,插入排序要比快速排序快
- 因为递归,快速排序的sort方法在小数组中也会调用自己
if (hi <= lo) return;
替换为:
if (hi <= lo+5) {
new InsertionSort().sort(a,lo,hi);
return;
}
2,三取样切分
使用子数组的中位数切分数组
3,熵最优的排序
适用于需要排序存在大量重复元素的情况下。
将数组分为三部分,分别对应小于、等于和大于切分元素的数组元素。
private static void sort(Comparable[] a, int lo, int hi) {
if (hi <= lo) return;
int lt = lo, gt = hi;
Comparable v = a[lo];
int i = lo + 1;
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++;
}
// a[lo..lt-1] < v = a[lt..gt] < a[gt+1..hi].
sort(a, lo, lt - 1);
sort(a, gt + 1, hi);
assert isSorted(a, lo, hi);
}
7,堆排序
7.1 优先级队列的定义:
适用场景:不一定需要全部有序,或者不一定要一次就将它们排序,比如应用程序优先级。
需要支持的两种关键操作:删除最大元素和插入元素。
在问题的规模非常大的情况下,甚至不能存入内存的情况下,只能使用优先队列。
初级实现:
- 无序数组,删除最大元素的复杂度为N
- 有序数组,插入元素的复杂度为N
- 链表实现
当一颗二叉树的每个节点都大于等于它的两个字结点时,这颗二叉树就是堆有序的。
由此推论,根结点是堆有序的二叉树中的最大结点
二叉堆是一组能够用堆有序的完全二叉树排序的元素,并在数组中按照层级存储
命题P:一颗大小为N的完全二叉树的高度为 LgN L g N
由下至上的堆有序化(上浮):
private void swim(int k) {
while (k > 1) {
//比较k节点和他的父节点的大小
//k结点的父结点是k/2
if (less(pq[k / 2], pq[k])) {
//比父节点大,则交换位置,上浮
exch(pq, k / 2, k);
}
//更新K值,继续循环
k = k / 2;
}
}
由上至下的堆有序化(下沉)
private void sink(int k) {
while (2 * k <= N) {
//1,先找到K节点左边的子节点
int j = 2 * k;
if (j < N && less(pq[j], pq[j + 1])) {
//2,说明右边的结点更大
j++;
}
//3,和比较大的子结点比较大小,如果K节点比他的两个子节点都大
//说明排序结束,退出循环
if (!less(pq[k], pq[j])) {
break;
}
//4,否则就交换k和j的位置
exch(pq, k, j);
//5,然后再不断更新k的值,继续循环
k = j;
}
}
删除最大元素
数组顶端删除最大的元素,并将数组的最后一个元素放到顶端,减小堆的大小并让这个元素下沉到合适的位置。
命题Q:对于一个含有N个元素的基于堆的优先队列,插入元素的时间复杂度是不超过
(lgN+1)
(
l
g
N
+
1
)
次比较,删除最大元素的操作的时间复杂度不超过
2lgN
2
l
g
N
次比较。
证明:两种操作都需要在根节点和堆底之间移动元素,而路径的长度不超过
lgN
l
g
N
,插入操作只需要一次上浮;而删除操作需要两次比较,一次用来找出较大的子结点,一次用来确定该子结点是否需要上浮。
/**
* 删除最大元素操作
* 即,删除根节点
* 复杂度2lgN
*/
public Key delMax() {
//根接点
Key max = pq[1];
//交换根节点位置和最后位置
exch(pq, 1, N);
N = N - 1;
//防止泄露
pq[N + 1] = null;
//恢复有序性
sink(1);
return max;
}
插入元素
先把等待插入的元素放到最后,然后将该元素上浮即可
/**
* 插入操作
* 复杂度lgN+1
*/
public void insert(Key key) {
//先把等待插入的元素放到最后
N = N + 1;
pq[N] = key;
//显然,这会打破有序性,上浮即可
swim(N);
}
7.2 堆排序:
定义:将所有元素插入一个查找最小元素的优先队列,然后再重复调用删除最小元素的操作来将它们按顺序删除。
两个阶段:
1. 堆的构造,堆有序,每个结点都大于它的两个子结点
2. 下沉排序
public void sort() {
//第一步,堆的构造,堆有序
for (int k = N / 2; k >= 1; k--) {
sink(k);
}
//第二步,下沉排序
while (N > 1) {
//最大的元素(也就是根节点)放到最后,原最后的元素放到根节点,数组大小N-1
exch(pq, 1, N);
N = N - 1;
//此时根节点不是最大的元素,需要恢复堆的有序性
sink(1);
}
}
堆构造的性能:构造N个元素的堆,最多需要2N次比较以及N次交换。
堆排序的性能:将N个元素排序,堆排序只需要少于$(2NlgN+2N)$次比较,以及一半次数的交换。
2N来自于堆的构造,2NlgN来自于每次下沉操作最大可能需要2NlgN次比较
堆排序是目前我们唯一能够同时最优地利用空间和时间的方法,当空间很紧张的情况下它很流行。但现在操作系统很少使用它,因为他无法利用缓存。数组元素很少和相邻的其他进行比较,因此缓存未命中的次数要远远高于大多数比较都在相邻元素间进行的算法,如快速排序、归并排序,甚至是希尔排序。