写在前面
排序算法大概在一个月前就在学习了,因为期末和课设的原因,这段时间一直没有碰博客,所以一直没有来得及写总结。这两天抽出时间把总结补上了,写完竟有一种意犹未尽的感觉。至于文章内容,全文共2万字左右,文中大部分的代码和思想都参考《算法》以及《算法导论》两本书,图片则大都来自网络。
规则
我们关注的主要对象是重新排列数组元素的排序算法,数组中的每一个元素都有一个主键。
排序算法的目的是将所有元素的主键按照某种方式排列(通常是按照大小或者字母顺序)。
排序按照过程中使用的存储器不同分为内部排序和外部排序。
- 内排序:指在排序期间数据对象全部存放在内存的排序。
- 外排序:指在排序期间全部对象个数太多,不能同时存放在内存,必须根据排序过程的要求,不断在内、外存之间移动的排序。
任何一个排序算法我们都会用两个指标去评判它,即时间复杂度(算法效率)和空间复杂度(内存开销)。
- 时间复杂度:是算法效率的量度,用基本操作的重复次数来度量算法的执行时间(二者成正比)。
- 空间复杂度:是算法所需存储空间的量度。
有的算法的时间复杂度依赖于输入数据集本身,我们在评价这个算法效率时,针对输入数据集本身的好坏分为:最好情况、平均情况和最差情况下的时间复杂度。
没有特殊说明的情况下,我们都取最差情况的时间复杂度。
- 更详细的说明请移步:时间复杂度和空间复杂度。
有一种情况是数组中的部分元素相等,那么排序结果可能不唯一。
如果排序后这些相同元素的相对顺序没有发生改变,我们称这个排序算法为稳定的排序算法,否则称之为不稳定的排序算法。对于不稳定的排序算法,只需要举出一组反例即可。
十大排序
我们这里所说的十大排序主要包括:选择排序,插入排序,冒泡排序,希尔排序,快速排序,归并排序,堆排序,计数排序,桶排序,和基数排序。
其中计数排序,桶排序,和基数排序属于非比较排序。
而选择排序,插入排序,冒泡排序,希尔排序,快速排序,归并排序,堆排序属于比较排序。
我们先来看比较排序,然后才是非比较排序。
关于所有排序算法的性能总结,这里有一张图片:
比较排序算法的模板
-
java中的对象比较
java中的数组元素大都是对象,对主键的抽象描述是通过一种内置的机制(Comparable接口)来完成的。java中封装数字的类型Integer、Double以及String类型等许多其他高级数据类型(URL、File)都实现了该接口。利用该接口中的compareTo()方法就可以完成对象之间的比较。compareTo()的返回值有-1,0,1,分别对应小于,等于,大于的情况。 -
模板
为了方便比较各种排序之间的性能,以及验证算法的准确性,我们写一个模板类Template 。模板类对于排序算法本身没有任何影响,可以理解为方便我们学习的工具类。如果你只关注算法,那么可以忽略这一部分,代码实现还有一份独立的封装好的代码可以直接用。
模板类主要包括:
- sort()方法是排序算法的实现
- less()方法用来比较元素(所有比较排序算法都会用到)
- exch()方法用来交换元素(所有比较排序算法都会用到)
- isSorted()方法用来检验是否有序
- show()方法用来单行打印数组
- 还有一个main()方法用来测试
public class Template {
public static void sort(Comparable[] a) {
// 在这里写排序算法
}
// less方法用来比较v和w的大小,如果v<w返回false,否则返回true
private static boolean less(Comparable v, Comparable w) {
return v.compareTo(w) < 0;
// 不一定非得是<,实际上大部分情况下 <= 0都是可以的
// 这里视情况而定,如果没有说明,就取 <
}
// exch方法用来交换两个元素的位置
private static void exch(Comparable[] a, int i, int j) {
Comparable t = a[i];
a[i] = a[j];
a[j] = t;
}
// show方法用来单行打印数组
private static void show(Comparable[] a) {
for (int i = 0; i < a.length; i++) {
System.out.print(a[i] + " ");
}
}
// isSorted方法用来检验数组是否有序(升序)
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) {
// 在这里调用排序算法并测试
Integer[] a = new Integer[10];
System.out.println("排序前:");
for (int i = 0; i < a.length; i++) {
Integer random = (int) (Math.random() * 100);
System.out.print(random + " ");
a[i] = random;
}
sort(a);
System.out.println(isSorted(a));
show(a);
}
}
简单选择排序
- 原理
选择排序(Selection-sort)的原理是最简单的:
首先找到数组中最小的那个元素,把这个最小元素和数组中的第一个元素交换位置(如果第一个元素是最小的,就和它自己交换)。然后在剩下的元素中继续寻找最小的元素,把它和第二个元素交换位置……如此往复知道将整个数组完成排序。
我们把数组分为有序区和无序区,每一次被找到的最小元素在完成交换后都归属有序区。有序区会越来越大,无序区越来越小,当无序区的元素个数 < = 1 <=1 <=1时,完成排序。
选择排序的有序区大小默认是0,无序区默认大小是n。
- 动图演示
- 代码实现
public static void sort(Comparable[] a) {
int n = a.length; //数组长度,有n个元素
for (int i = 0; i < n - 1; i++) { //长度为 n的数组需要找出 n-1个“最小元素”
int min = i; //假设最小数的索引是i,a[i]是无序区第一个元素
for (int j = i + 1; j < n; j++) { //a[i]以后的元素依次和a[i]比较
if (less(a[j], a[min])) {
min = j; // 更新最小值
}
}
exch(a, i, min); // 每一趟都把找到的最小值a[min]和无序区的第一个数a[i]交换位置
}
}
// less(),exch(),show(),isSorted()等方法见上面模板写法。
- 分析
假设数组中有n个元素。
第一次从 R[0] 到 R[n-1] 中选出最小值,与 R[0] 交换,则有序区元素个数:1,无序区元素 个数:n-1;
第二次从 R[1] 到 R[n-1] 中选出最小值,与 R[1] 交换,则有序区元素个数:2,无序区元素 个数:n-2;
……
第n-1次从 R[n-2] 到 R[n-1] 中选出最小值,与 R[n-2] 交换,有序区元素个数: n-1,无序区元素个数:1;
无序区剩下的一个元素不用再比较,是最大的,排序完毕:总共通过 n − 1 n-1 n−1 趟,大约 n ( n − 1 ) 2 \frac{n(n-1)}{2} 2n(n−1)次比较, n − 1 n - 1 n−1 次交换。
- 时间复杂度
- 最好情况: n ( n − 1 ) 2 \frac{n(n-1)}{2} 2n(n−1)次比较, 0 0 0 次交换; O ( n 2 ) O(n^2) O(n2)
- 最差情况: n ( n − 1 ) 2 \frac{n(n-1)}{2} 2n(n−1)次比较, n − 1 n-1 n−1次交换; O ( n 2 ) O(n^2) O(n2)
- 平均情况: O ( n 2 ) O(n^2) O(n2)
- 稳定性:直接选择排序是不稳定的。
总的来说,选择排序是一种比较理解,实现起来也比较简单的排序,两个非常鲜明的特点:
- 运行时间与输入无关
不管什么情况下,都要为了找出最小元素而进行的遍历并不能为下一次遍历提供什么信息,每个遍历之间是独立的。这就意味着完全有序的数组和完全无序的数组用选择排序所需要的时间是相同的,对应最好情况和最差情况的时间复杂度都为 O ( n 2 ) O(n^2) O(n2)。
针对这一点,我们可以对直接选择排序进行改进,就是堆排序。 - 数据移动是最少的
虽然比较次数多,但是选择排序最多只需要进行 n − 1 n-1 n−1 次交换,最少只需要0次交换,即交换次数和数组的大小是程线性关系的,这一点非常优秀,是其他排序算法无法达到的。
简单插入排序
- 原理
插入排序(Insertion-Sort)的原理同样非常简单:
把n个待排序的元素看成为一个有序区和一个无序区。开始时有序区只包含一个元素,无序区中含有n-1个元素。排序过程中每次从无序区中取出第一个元素,依次与有序区元素进行比较,并将它插入到有序区的适当位置中,使之成为新的有序区。
和选择排序一样,当前索引左边的所有元素都是有序的,但是位置是不确定的,为了给更小的元素腾出空间,它们可能被移动。插入排序的基本操作同样为比较和交换。当索引到达数组的右端时,排序就完成了。
- 动图演示
- 代码实现
public static void sort(Comparable[] a) {
int n = a.length;
for (int i = 1; i < n; i++) { //有序区默认为a[0],无序区默认从a[1]开始
for (int j = i; j > 0 && less(a[j], a[j - 1]); j--) {
exch(a, j, j - 1);
}
}
}
// a[j]=a[i]依次和左边有序区的元素做比较
// a[j] < a[j-1]:交换
// j--,继续向左比较直到j > 0 && less(a[j], a[j - 1])不成立,退出循环
// j>0保证不会越界
// less(),exch(),show(),isSorted()等方法见上面模板写法。
- 分析
虽然插入排序很简单也很容易实现,但是和选择排序不同,插入排序所需要的时间依赖输入集的有序化程度。
我们知道,内循环执行前会进行j > 0 && a[j] < a[j-1]
的判断,如果数组是有序的(即判断条件为false),不但交换操作不会执行,而且会直接退出循环,后面的比较也不会继续执行。这可比选择排序先进多了,在最理想的情况下,插入排序只需要进行
n
−
1
n-1
n−1次排序和
0
0
0次交换。在最坏的情况下,即数组是完全无序的,那么插入排序需要执行
n
(
n
−
1
)
2
\frac {n(n-1)}{2}
2n(n−1) 次比较和
n
(
n
−
1
)
2
\frac {n(n-1)}{2}
2n(n−1)次交换。若待排序的数组是随机的,在平均的情况下每个元素都有可能向后移动半个数组的长度,我们取上述最小值和最大值的平均值,约
n
2
4
\frac {n^2}{4}
4n2。而且,插入排序没有跨距离的交换,所以是稳定的。
所以:
-
最好情况下的时间复杂度为: O ( n ) O(n) O(n)
-
最差情况下的时间复杂度为: O ( n 2 ) O(n^2) O(n2)
-
平均情况下的时间复杂度为: O ( n 2 ) O(n^2) O(n2)
-
稳定性:插入排序是稳定的
总的来说,插入排序很适合问题数 n n n很小而且有序化程度高的情况。
冒泡排序
- 原理
冒泡排序属于交换排序的一种,它的思想也非常简单:
依次比较相邻元素,若发现逆序则交换,这样,大的数越来越靠后,小的数越来越靠前,直到把最大的数排在最后面。然后开始下一次循环直到把第二大的数排到后面……如此往复直到完成排序。
这样,较小的元素会逐渐从后部移向前部,就像水底下的气泡向上冒一样。
冒泡排序也可以分为有序区和无序区,不同的是,它的无序区在左边,有序区在右侧。
- 优化
在冒泡排序的过程中,各元素不断接近自己的位置,如果一趟比较下来没有进行过交换,就说明序列有序,可以跳出循环。因此可以在排序过程中设置一个flag来判断元素是否进行过交换,从而得知当前数组是否有序,有无必要跳出循环,从而优化代码。
- 动图演示
- 代码实现
- 基于模板类
public static void sort(Comparable[] a) {
int n = a.length;
for (int i = 0; i < n - 1; i++) { // n个元素需要跑n-1趟
boolean flag = true; // 每趟开始前,都设置一个flag
for (int j = 0; j < n - i - 1; j++) { // n-i-1表示无序区的原数个数
if (less(a[j + 1], a[j])) {
exch(a, j + 1, j);
flag = false; // 如果发生了交换,flag设为false,表示数组当前还是无序状态
}
}
if (flag)
break; // 如果哪一趟flag为true,表示数组已经是有序状态了
}
}
- 分析
冒泡排序在最差的情况下需要比较 n ( n − 1 ) 2 \frac {n(n-1)}{2} 2n(n−1)次,交换 n ( n − 1 ) 2 \frac {n(n-1)}{2} 2n(n−1)次,优化后的冒泡排序在最好的情况下只需要比较 n − 1 n-1 n−1次,交换 0 0 0次,与插入排序非常相近。
-
-最好情况下的时间复杂度为: O ( n ) O(n) O(n)
-
-最差情况下的时间复杂度为: O ( n 2 ) O(n^2) O(n2)
-
-平均情况下的时间复杂度为: O ( n 2 ) O(n^2) O(n2)
-
-稳定性:插入排序是稳定的
希尔排序
- 背景
希尔排序(Shell’s Sort)是对直接插入排序的一种改进,1959年由Shell发明,是第一个突破 O ( n 2 ) O(n^2) O(n2)的排序算法。希尔排序又叫缩小增量排序(Diminishing Increment Sort)。
回想一下直接插入排序,平均情况下的时间复杂度是 O ( n 2 ) O(n^2) O(n2),在输入集有序的情况下,可以提高到 Q ( n ) Q(n) Q(n)。可以设想一下,当待排记录序列按关键字呈现“基本有序”是,直接插入排序的效率可以大大提高。从另一方面看,由于直接插入排序的算法简单,则在 n n n值很小的时候效率也会提高。希尔排序正是从这两点分析除法对直接插入排序进行改进而得到的较为先进的插入排序算法。
直接插入排序在处理大型数组时效率低的原因是因为它只会交换相邻的元素,因此元素只能一点一点的移动到正确的位置。而希尔排序针对这一点,元素的移动是跨越性的,效率自然提高。
- 动图
- 原理
基本思想是:使数组中任意间隔为h的元素都是有序的,然后逐渐缩小h为1(缩小增量),完成排序。我们称原数组为h有序数组,则代表它的子数组有 h h h个。子数组之间是相互独立的,交叉(而非简单的拼接)在一起组成完整的数组。
- 举例
请看下面这张图:初始数组是无序的,第一趟排序时
h
=
5
h=5
h=5,那么就有五个子数组:{9,4}{1,8}{2,6}{5,3}{7,5},子数组中的两个元素间隔为5,分别使他们有序。这样做的好处是可以使得元素完成大幅度的移动而提高效率(简单插入排序中元素只能一点一点的移动)。第二趟排序时就要缩小增量
h
=
2
h=2
h=2,那么就有两个子数组,每个子数组中有5个元素,每个元素的间隔是2。第三趟排序时
h
=
1
h=1
h=1达到最小值,此时相当于全体元素的直接插入排序。经历了前面两次的排序,此时数组中的元素已经“基本有序”,效率大大提升。
- 分析
希尔排序更高效的原因是因为它权衡了子数组的规模与有序性。排序之初,各个数组都很短,排序后子数组是部分有序的,这两种情况完美的契合了插入排序。
子数组部分有序的程度取决于递增序列的选择。遗憾的是,选择递增序列来提升希尔排序的性能,由于某些尚未解决的数学问题至今都没有最优解。关于递增序列的选择,是一个很难回答的问题,算法的性能不仅取决于 h,还取决于 h 之间的数学性质。
下面的代码中使用 1 ( 3 k − 1 ) \frac{1}{(3^k-1)} (3k−1)1序列,从 n 3 \frac{n}{3} 3n开始递减到1。
实现希尔排序的一种方法是对于每个h,用插入排序将h个子数组独立地排序。但是因为子数组之间是相互独立的,一个更简单的方法是子数组中将每个元素交换到比它大的元素前面去,只需要把插入排序中移动元素的距离从1改为h即可。
- 代码实现
public static void sort(Comparable[] a) {
int n = a.length;
int h = 1;
// 根据数组长度确定h的初始值
while (h < n / 3) {
h = 3 * h + 1; // 1,4,13,40,121,363...
}
// 开始排序
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;
}
}
- 时间复杂度
这个算法在最坏的情况下时间复杂度为 Q ( n 3 / 2 ) Q(n^{3/2}) Q(n3/2)
- 使用场景
有经验的程序员会在必要的情况下会选择希尔排序,对于中等大小的数组它的运行时间是可以接受的。它的代码量很小,而不需要使用而外的内存空间。
归并排序
归并的意思是将两个或两个以上的有序数组排列成一个新的有序数组。
- 原地归并操作的抽象
我们首先应该思考的是如何把两个有序数组归并为一个有序数组?借助辅助数组可以很容易实现这一点,比如对于数组a,它的左半部分和右半部分分别是有序的,我们借助辅助数组aux复制数组a,然后将归并的结果返回数组a。我们将其抽象成一个merge(Comparable[] a,int lo,int mid,int hi)
方法,那么a[lo,mid]
就是左半部分,a[lo,mid]
就是右半部分:
// 将左半部分和右半部分的有序数组合并为一个有序数组
public static void merge(Comparable[] a, int lo, int mid, int hi) {
// 左半部分的起始索引i
int i = lo;
// 右半部分的起始索引j
int j = mid + 1;
// 初始化辅助数组,为其分配内存空间。注意,我们会在后续中提到方法外。
Comparable[] aux = new Comparable[a.length];
// 复制数组a需要归并的部分到辅助数组
for (int k = lo; k <= hi; k++) {
aux[k] = a[k];
}
// 开始归并,相当于重新给a数组赋值
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++];
}
}
对于aux[lo,mid]
和aux[mid+1,hi]
来说,我们的任务就是将他们的元素依次比较,得到较小值赋给a。i和j表示它们的索引,每次完成依次比较,较小的元素所在的部分都要刷新索引来进行下一次比较。merge方法中我们在归并时进行了四次判断:
- i > mid (意味着左半部分比较完了,把右半部分剩余的元素直接赋给a)
- j > hi (意味着右半部分比较完了,把左半部分剩余的元素直接赋给a)
- aux[j] <= aux[i] (较小值赋给a,刷新索引)
- aux[j] > aux[i] (较小值赋给a,刷新索引)
- 自顶向下的归并排序
以下算法基于原地归并的抽象实现了另一种递归归并,它是应用高效算法设计中分治思想最典型的一个例子。它的思想很简单:不断的把原数组分成左右两部分直到不可再分(lo==hi),然后依次左右归并,完成排序。sort()方法的作用其实在于安排多次merge()方法调用的正确顺序:
public class Merge {
// 定义辅助数组
private static Comparable[] aux;
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) {
if (hi <= lo) return;
int mid = lo + (hi - lo) / 2;
sort(a, lo, mid); // 将左半边排序
sort(a, mid + 1, hi); // 将右半边排序
merge(a, lo, mid, hi);
}
public static void merge(Comparable[] a, int lo, int mid, int hi) {
int i = lo;
int 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[j], aux[i])) a[k] = aux[j++];
else a[k] = aux[i++];
}
}
}
-
动图演示:
-
2-路归并排序
假设初始数组中有 n n n个元素,则可以看成 n n n个长度为1的有序数组,然后两两归并,得到 n 2 \frac{n}{2} 2n个长度为2或1的有序数组;再两两归并……如此重复,直到得到一个长度为 n n n的有序数组为止,称这种方法为2-路归并排序。2-路归并排序的核心操作是将数组中前后相邻的两个有序数组归并为一个有序数组。 -
自底而上的归并排序
public class MergeBU {
private static Comparable[] aux;
public static void sort(Comparable[] a) {
int N = a.length;
aux = new Comparable[N];
// 自底而上的归并排序,sz表示子数组的大小,从1开始,每次加倍
for (int sz = 1; sz < N; sz = sz * 2) {
// lo表示子数组的索引
for (int lo = 0; lo < N - sz; lo += sz * 2) {
merge(a, lo, lo + sz - 1, Math.min(lo + sz + sz - 1, N - 1));
}
// lo+sz+sz-1表示子数组的末位索引
// 最后一组的子数组的末位索引只能是N-1,二者只有在N是sz的整数倍时才相等
}
}
public static void merge(Comparable[] a, int lo, int mid, int hi) {
int i = lo;
int 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[j], aux[i])) a[k] = aux[j++];
else a[k] = aux[i++];
}
}
}
- 总结
- 归并排序算法时间复杂度
最好的情况下:一趟归并需要 n n n次,总共需要 l o g N logN logN次,因此为 O ( N l o g N ) O(NlogN) O(NlogN)
最坏的情况下,接近于平均情况下,为 O ( N l o g N ) O(NlogN) O(NlogN)
说明:对长度为 n n n的文件,需进行 l o g N logN logN 趟二路归并,每趟归并的时间为 O ( n ) O(n) O(n),故其时间复杂度无论是在最好情况下还是在最坏情况下均是 O ( n l g n ) O(nlgn) O(nlgn)。
总的来说,归并排序比一般的排序算法要快很多。 - 稳定性
归并排序最大的特色就是它是一种稳定的排序算法。归并过程中是不会改变元素的相对位置的。 - 缺点:它需要 O ( n ) O(n) O(n)的额外空间,但是很适合于多链表排序。
快速排序
快速排序(Quick Sort)是应用最广泛的一种排序算法,它是在冒泡排序的基础上改进过来的,之所以如此流行是因为它实现简单,适用于各种不同的输入数据而且在一般的应用中比其他排序算法要快很多。而它的缺点也比较明显 ——非常脆弱。
- 切分
快速排序的思想也是分治:它将一个数组分为两个子数组,将两部分独立的排序。这一点和归并排序很像,不同的是,快速排序没有归并(merge)这一个步骤。快速排序的关键在于切分(partition),切分的位置取决于数组的内容。切分的目的在于排定切分元素的位置,重复此步骤即可完成整个数组织的排序。
整个切分过程使得数组满足三个条件:
- 对于某个 j j j, a [ j ] a[j] a[j]已经排定;
- a [ l o ] a[lo] a[lo]到 a [ j − 1 ] a[j-1] a[j−1]中的所有元素都 < = a [ j ] <=a[j] <=a[j];
- a [ j + 1 ] a[j+1] a[j+1]到 a [ h i ] a[hi] a[hi]中的所有元素都 > = a [ j ] >=a[j] >=a[j];
在切分之前,我们取数组中的首个元素a[lo]
作为切分元素,然后从数组的左端开始向右扫描直到找到一个大于等于它的元素,从右端开始向左端扫描直到找到一个小于等于它的元素,交换它们的位置。重复这个步骤,当两个指针相遇时,整个数组已经被分为三个部分,即切分元素,小于等于切分元素的左子数组,大于等于切分元素的右子数组。我们把左子数组的最后一个元素a[j]
和切分元素a[lo]
交换然后记录下这个位置
j
j
j,将切分元素的正确位置
j
j
j返回即可:
private static int partition(Comparable[] a, int lo, int hi) {
//将数组切分为a[lo..i-1],a[i],a[i+1..hi]
int i = lo, j = hi + 1; //左右扫描指针
Comparable v = a[lo]; //切分元素
while (true) {
//扫描左右,检查扫描是否结束并交换元素
while (less(a[++i], v)) if (i == hi) break; //左指针i,a[i]<v我们增大i直到a[i]>=v
while (less(v, a[--j])) if (j == lo) break; //右指针j,a[j]>v我们减小j直到a[i]<=v
if (i >= j) break; //左右指针相遇,结束扫描
exch(a, i, j); //交换左右指针的元素
}
exch(a, lo, j); // 将v=a[j]放入正确的位置
return j; // a[lo..j-1] <= a[j] <= a[j+1..hi]达成
}
- 快速排序
不难发现,每一次切分都会将数组分为一个a[j]
以及左右两个子数组,a[j]的位置是已经排定的,那么我们只需要对左右两个子数组再次进行切分操作即可。重复这个步骤直到
h
i
<
=
l
o
hi<=lo
hi<=lo(即子数组只剩下一个元素),完成排序:
public class Quick {
public static void sort(Comparable[] 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); // 将左半部分a[lo..j-1]排序
sort(a, j + 1, hi); // 将右半部分a[j+1..hi]排序
}
}
可以看到,快速排序递归地将子数组a[lo..hi]
排序:先用partition()方法将切分元素]放到一个合适的位置,然后在递归调用将其他位置的元素排序。
- 注意
- 原地切分
使用辅助数组虽然可以很容易实现切分,但会增大内存开销,得不偿失。 - 别越界
如果切分元素是数组中最小或者最大的那个元素,就要注意别让扫描指针越界。 i = = h i i == hi i==hi和 j = = l o j == lo j==lo就是用来避免越界的(j == lo实际上是多余的,切分元素本身就是哨兵:v
==a[lo]
)。 - 保持随机性
保持随机性有利于对算法的性能进行评估,你可以在切分方法中随机选择一个切分元素来保持随机性。 - 终止循环
保证循环结束时要格外小心,快速排序中的切分循环也不例外。最常见的错误是没有考虑到数组中可能包含与切分元素的值相同的元素。 - 处理切分元素值有重复的情况
如上所述,左侧扫描最好在遇到大于等于切分元素值时停下,右侧扫描则是遇到小于等于切分元素值的元素时停下。尽管这样做可能会进行一些不必要的元素交换,但是如果在遇到与切分元素重复的元素不停下而是继续扫描时,在只有若干种元素值的数组s,它能够避免算法的运行时间变为平方级别。 - 终止递归
保证递归结束时要非常小心,快速排序也不例外,如果能够正确切分的话,那么if (hi <= lo) return;
就能够保证正常终止递归。
- 性能特点
快速排序的切分方法的内循环会用一个递增的索引将数组元素和一个定值比较,而不用像归并排序或者希尔排序那样在内循环中移动数据,这是快速排序平均性能强大的一个原因。另一个速度优势在于它的比较次数很少。
快速排序的效率依赖于切分数组的效果,追根到底是依赖切分元素的值。快速排序的最好情况是每次都能正好将数组对半分。
- 算法复杂度
最好的情况下:因为每次都将序列分为两个部分(一般二分的复杂度都和logN相关),故为 O ( N ∗ l o g N ) O(N*logN) O(N∗logN)
最坏的情况下:基本有序时,退化为冒泡排序,几乎要比较N*N次,故为 O ( N ∗ N ) O(N*N) O(N∗N) - 稳定性
由于每次都需要和中轴元素交换,因此原来的顺序就可能被打乱。所以说,快速排序是不稳定的
- 算法改进
和大多数递归排序算法一样,改进快速排序性能的一个简单办法基于: 对于小数组,快速排序比插入排序慢。
因此,当排序小数组时切换到插入排序对于性能的提升很有帮助,改进的方法很简单:将原sort()方法中的if (hi <= lo) return;
替换为if (hi <= lo + m ) { Insertion.sort(a, lo, hi); return; }
即可,m的最佳值与系统有关,一般取5~15即可。
堆排序
在了解堆排序前我们需要做一下准备工作:
优先队列与二叉堆
一、优先队列
优先队列是一种与队列(先进先出)和栈(先进后出)类似的数据结构,不同的是,优先队列中,元素被赋予优先级。当访问元素时,具有最高优先级的元素最先删除。优先队列具有最高级先出 (first in,largest out)的行为特征。
优先队列一般用二叉堆数据结构来实现:用数组保存元素并按照一定条件排序,以实现高效地(对数级别)删除最大元素和插入元素的操作。
通过插入一列元素然后一个个地删掉其中最小的元素,我们可以用优先队列实现排序。我们的主角堆排序就是基于堆的优先排列的实现。
优先队列的一些重要的应用场景包括模拟系统,其中事件的键即为发生的时间,而系统需要按照时间顺序处理所有事件。此外还有任务调度,其中键值对应的优先级决定了应该执行哪些任务等。
二、 堆
二叉堆的数据结构:
在二叉堆的数组中,每个元素要保证大于等于另两个特定位置的元素,如果我们把所有元素画成二叉树,将每个较大元素和两个较小元素连接起来就很容易理解这种结构,如上图所示。
- 堆有序:当一颗二叉树的每个结点都大于等于(或小于等于)它的两个子节点时,它被称为堆有序。根结点是堆有序的二叉树中的最大(或最小)结点。
- 完全二叉树:若设二叉树的深度为 h h h,除第 h h h 层外,其它各层 ( 1 ~ h − 1 ) (1~h-1) (1~h−1) 的结点数都达到最大个数,第 h h h 层所有的结点都连续集中在最左边,这就是完全二叉树。
完全二叉树只用数组而不需要指针就可以表示:将二叉树的结点按照层级顺序放入数组中,根结点在位置1,它的子结点在2和3,子结点的子结点则分别在位置4,5,6,7,以此类推。
二叉堆是一组能够用堆有序的完全二叉树排序的元素,并在数组中按照层级存储(数组中的第一个位置不使用)。我们把二叉堆简称为堆。
在一个堆中,位置 k k k的结点的父结点的位置是 [ k / 2 ] [k/2] [k/2],而它的两个子节点的位置则分别为 2 k 2k 2k和 2 k + 1 2k+1 2k+1。我们可以通过计算数组中的索引在树中上下移动。
当完全二叉树的大小达到2的幂时树的高度会加1。
我们用长度为
N
+
1
N+1
N+1的私有数组pq[]
来表示一个大小为
N
N
N的的堆,不使用pq[0]
,堆元素放在p[1]
到p[N]
中。在堆排序的算法中,我们只通过私有辅助函数less()和exch()来访问元素:
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)) { // 根节点没有父结点,K / 2是父结点的索引
exch(k / 2, k);
k = k / 2;
}
}
- 下沉(sink)——由上至下的堆有序化
如果堆的有序状态因为某个结点变得比它的两个子结点或者其中之一更小了而被打破,我们就需要通过交换它和它的子结点中的较大者来恢复堆,这一过程可能同样需要重复多次,直到将它放在合适的位置。
private void sink(int k) {
while (k <= N / 2) { // 如果它没有子结点,结束
int j = 2 * k; // j表示子结点中较大的那个的索引,初始值为左子结点
if (j < N && less(j, j + 1)) j++; // 比较左右两个子结点
if (!less(k, j)) break; // 如果k比它的子结点都大,那么结束
exch(k, j); // 否则交换位置
k = j; // 更新索引
}
}
四、基于堆的优先队列
我们已经知道,优先队列最重要的操作就是删除最大元素以及插入元素,swim()和sink()方法是高效实现基于堆的优先队列的基础。思路:
- 插入元素:将新元素追加到数组末尾,增加堆的大小并让这个新元素上浮到合适的位置。
- 删除最大元素:我们把根结点的元素和数组末尾的元素互换位置,然后将最大元素删除并将数组长度减一,然后将根节点的元素下沉到合适的位置。
// 提高灵活性:使用泛型,将所有实现了Comparable接口的数据类型作为参数Key
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--); // 将其余最后一个结点交换位置,然后N-1
pq[N + 1] = null; // 避免对象游离,方便系统回收
sink(1); // 恢复堆的有序性
return max; // 返回被删除的最大元素
}
// 辅助方法请看上文
private boolean less(int i, int j) {...}
private void exch(int i, int j) {...}
private void swim(int k) {...}
private void sink(int k) {...}
}
优先队列由一个基于堆的完全二叉树表示,存储于数组pq[1…N]中,pq[0]没有使用。这里省略了动态调整数组大小的代码。与MaxPQ相对应的我们可以写出MinPQ。
- 对于一个含有N个元素的基于堆的优先队列,插入元素操作只需要不超过( l g N + 1 lgN+1 lgN+1)次比较,删除最大元素的操作只需要不超过 2 l g N 2lgN 2lgN次比较。
两种操作都在根结点和堆底之间移动元素,而路径的长度不超过 l g N lgN lgN(堆的高度)。
对于需要大量混杂的插入和删除最大元素操作的典型应用来说意味着重大的性能突破。
堆排序
终于进入正题了。
堆排序分为两个阶段:
- 堆的构造:将原始数组(相当于一个完全二叉树)变成一个有序化的堆
- 下沉排序:无序队列的末位元素不断地和最大元素(根结点)交换位置后下沉,完成排序
对于给定的N个元素,要构造一个堆并不难,我们当然可以从左到右遍历数组,就像连续地向优先队列中插入元素一样,使用swim()方法完成堆的有序化。但是这种方法并不高效。
一个更聪明的方法是从右至左使用sink()方法不断地来构造子堆。如果一个结点的两个子结点都已经是堆了,那么在该结点上调用sink()方法就可以完成两个子堆的合并,不断地合并子堆就可以完成整个堆的构造。我们可以跳过大小为1的子堆,从它们的父结点开始调用sink()合并这些大小为1的子堆……所以只需要扫描一半元素即可。
至于下沉排序,对于已经构造好的堆,我们能够确定的是根结点一定是最大的元素。我们只需要把根结点和数组末尾的元素交换位置,然后调用sink()方法重新完成堆的有序化(不包括已经交换到末尾的最大元素)即可,重复这个过程,不断地把根结点的元素放到“末尾”就完成了堆排序。
public class Heapsort {
public static void sort(Comparable[] a) {
int N = a.length;
for (int k = N / 2; k >= 1; k--) { // 忽略大小为1的子堆,剩余的元素从右至左依次下沉
sink(a, k, N); // 循环完成堆的构造
}
while (N > 1) { // 当N=1时整个数组已经有序
exch(a, 0, N - 1); // 根结点的元素与末位元素交换位置
N--; // 无序的数组长度
sink(a, 1, N); // 交换到根结点的元素下沉;刷新根结点
}
}
private static void sink(Comparable[] a, int k, int N) {
// 注意,这里的j,k,N实际上都表示的数组元素在堆中的位置而并非它的真实索引,涉及数组的操作都要给索引-1
while (k <= N / 2) {
int j = 2 * k;
if (j < N && less(a[j - 1], a[j + 1 - 1])) j++; // j+1-1方便理解,实际代表右子结点的的真实索引
if (!less(a[k - 1], a[j - 1])) break;
exch(a, k - 1, j - 1);
k = j;
}
}
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;
}
}
我们实现了一个基于sink()方法的堆排序,第一个循环构造堆,第二个循环销毁堆。
一个问题值得注意,我们实际排序是将数组排序,堆结构只是优化排序的一个过程,真正涉及到数组的操作比如比较和交换时,即less()方法和exch()方法中要给索引-1。至于为什么我们在堆中不直接从0开始,其实原因很简单,因为这么做可以稍微简化计算,另外将
堆排序与选择排序有些类似,不过,堆结构提供了一种从无序数组中找到最大元素的有效方法,所以所需要的比较要少很多。
- 堆排序的性能
时间复杂度: O ( N l o g N ) O(NlogN) O(NlogN)
稳定性:堆排序中原来的数据结构会经常改变,所以是不稳定的 - 堆排序的应用
堆排序在排序复杂性的研究中有着重要的地位,因为它是唯一能够同时最优地利用空间和时间的排序方法。当空间十分紧张时,比如嵌入式系统或者低成本的移动设备中很流行。
非比较排序
常用的比较排序我们已经介绍完了,归并排序和堆排序达到了最坏情况下的上界,快速排序达到了平均情况下的上界。下面我们来看看几种线性时间复杂度的排序算法,它们都是通过运算而不是比较来排序的。
计数排序
计数排序是一个非基于比较的排序算法,该算法于1954年由 Harold H. Seward 提出。它的优势在于在对一定范围内的整数排序时,它的复杂度为 O ( n + k Ο(n+k O(n+k)(其中 k k k是整数的范围),快于任何比较排序算法。 当然这是一种牺牲空间换取时间的做法,计数排序的实现需要用到辅助数组。
- 基本思想
计数排序假设 n n n个输入元素中的每一个都是在 0 0 0到 k k k区间内的一个整数,其中k为某个整数。基本思想是:对于每一个输入元素 x x x,确定小于 x x x的元素个数,这样就可以直接把 x x x放到输出数组正确的位置上了。当有重复元素时,要做相应的修改,因为不可能把他们都放在同一位置上。
- 实现
假设输入数组为A[],我们需要两个辅助数组:B[]存放排序的输出,C[]提供临时存储空间。注意代码中对于重复元素的操作。
public class CountingSort {
// 输入数组的元素是0-k之间的整数,共k+1个
private static int k;
public static int[] sort(int[] a) {
int[] c = new int[k + 1];
int[] b = new int[a.length];
// 1.初始化数组c,所有元素赋值0
for (int i = 0; i < c.length; i++) {
c[i] = 0;
}
// 2.遍历输入数组a,统计每个元素出现的个数,并赋值给c[a[j]]
for (int j = 0; j < a.length; j++) {
c[a[j]] += 1; //a[j]每出现一次,+1
}
// 3.加总计算统计小于或等于每个元素的其他元素的个数
for (int i = 1; i < c.length; i++) {
c[i] = c[i] + c[i - 1];
}
// 4.给输出数组b赋值
for (int j = 0; j < a.length; j++) {
b[c[a[j]] - 1] = a[j]; // c[a[j]]是小于等于a[j]的元素个数,那么它在b中的正确索引是个数-1
c[a[j]]--; // 处理重复元素,每放一个到b,就给个数-1
}
return b;
}
// 构造方法
public CountingSort(int k) {
this.k = k;
}
}
- 性能
时间复杂度为 O ( K + N ) O(K+N) O(K+N)(优于所有比较排序的时间复杂度下界 O ( N l o g N ) O(NlogN) O(NlogN)),在实际工作中,当 k = O ( N ) k = O(N) k=O(N)时,我们一般会采用计数排序,但同时要注意内存开销。
计数排序的另外一个重要的性质就是它是稳定的。所以,它常常被用到基数排序中。
基数排序
基数排序(radix sort)是一种用在卡片排序机的算法(这种卡片机已经被淘汰了),它的思想非常简单:根据位的高低进行排序的,先按个位排序,然后根据十位排序……以此类推。
从直观上看,你可能会觉得应该按照最高位进行排序,然后对得到的每个容器递归地进行排序,最后把结果合并起来。但是这一过程需要许多的临时存储。
所以,基数排序采用了从最低位开始进行排序的方法。为了保证基数排序的正确性,一位数的排序算法必须是稳定的(通常采用计数排序)。
我们把整个基数排序分为两个部分,主循环中先按照特定位数上的值将元素放进0~9这10个容器中,然后挨个遍历容器,取出它们其中的元素,按照顺序重新给数组赋值:
public class RadixSort {
// d表示数组中元素的最高位
public static void sort(int[] a, int d) {
int n = 1; // 取位数上的值用
int k = 0; // 排序用
int m = 1; // 控制循环次数用
int[][] temp = new int[10][a.length]; // 10个容器,每个容器的初始长度都是a.length
int[] order = new int[10]; // 用来统计每个容器中的元素个数
while (m <= d) {
// 根据个位或十位...的值将元素挨个放进容器
for (int i = 0; i < a.length; i++) {
// 拿到位数上的值,n来控制该取哪一位
int lsd = (a[i] / n) % 10;
// order[lsd]表示lsd容器中的元素的索引,从0开始
// 将元素放入特定容器中的特定位置
temp[lsd][order[lsd]] = a[i];
// 下一个放入该容器中的元素的索引
order[lsd]++;
}
// 遍历10个容器,从0开始,9结束
for (int i = 0; i < 10; i++) {
// 容器不为空,开始排序
if (order[i] != 0) {
// 遍历容器中的元素
for (int j = 0; j < order[i]; j++) {
// k表示数组a的索引,从0开始
// 从0容器开始,依次取出容器里面的元素赋给数组a,相当于按照位数上的值从小到大排序
a[k] = temp[i][j];
k++;
}
order[i] = 0; //清空容器
}
}
n = n * 10; // 取出下一位的值用
k = 0; // 下一次排序用
m++; // 控制循环次数
}
}
}
- 性能
给定 n n n个 d d d位数,其中每一个数位有 4 4 4个可能的取值。如果RADIX-SORT使用的稳定排序方法耗时 O ( n + k ) O(n+k) O(n+k),那么它可以在 O ( d ( n + k ) ) O(d(n+k)) O(d(n+k))时间内将这些数排好序。
桶排序
桶排序(BucketSort)假设数据服从均匀分布,平均情况下它的时间复杂度是 O ( n ) O(n) O(n),与计数排序类似。
桶排序假设输入数据是由一个随机过程产生的 [ 0 , 1 ) [0, 1) [0,1)区间上均匀分布的实数。将区间 [ 0 , 1 ) [0,1) [0,1)划分为n个相同大小的子区间(桶),将数据分别放入这n个桶(链表)中,对每个桶中的数据进行排序,然后遍历每个桶,按照次序把桶中的数据取出来即可。
算法需要一个临时数组B[0..n-1]
来存放链表(桶),如何有效地维护链表是一个值得思考的问题。
public class BucketSort {
// 假设a中元素的取值范围是0~1
// 比如: Double[] a = {0.1, 0.3, 0.23, 0.94, 0.98, 0.45, 0.7, 0.38, 0.67, 0.99};
public static void sort(Double[] a) {
int k = 0;
int n = a.length;
// 指定桶的数目为10
Double[][] b = new Double[10][n];
int[] order = new int[10]; // order数组用来统计桶中的元素个数
// 入桶
for (int i = 0; i < n; i++) {
// num表示桶的索引,分为0~9共10个
int num = (int) (10 * a[i]);
b[num][order[num]] = a[i];
// 每插入一个元素把它放在最后,然后使用插入排序将其放到正确的位置上
for (int j = order[num]; j > 0 && b[num][j] < b[num][j - 1]; j--) {
// 交换
Double temp = b[num][j];
b[num][j] = b[num][j - 1];
b[num][j - 1] = temp;
}
order[num]++; // 桶中元素个数+1,同时也表示下次入桶元素的索引
}
// 把所有的桶按次序给a赋值,完成排序,相当于遍历二维数组b
for (int i = 0; i < b.length; i++) {
// 注意,这里不要写成j < b[i].length:会把null值赋给a[]而发生越界
for (int j = 0; j < order[i]; j++) {
a[k++] = b[i][j];
}
}
}
}
- 性能
你也许会说,如果只能排0~1的元素未免有些鸡肋,实际并非这样,只要能确定数据的区间,我们都可以采用桶排序。桶排序在实际应用中很广泛,它在处理海量数据时表现不俗,原因就在于桶排序把数据进行了再一次分类,这就和快速排序汇总的划分类似。即使输入数据不满足均匀分布,桶排序也可以在线性时间 O ( n ) O(n) O(n)内完成排序。
全文完,感谢阅读,本文系个人学习笔记,不正之处欢迎指出。希望能够屏幕前的你带来帮助。