本篇博客属于对排序算法的复习,主要是基于《算法》一书。博客正文聚焦的主要是算法的实现过程,对于辅助方法如 less()
、exch()
和 isSorted()
等请移步排序算法总结系列导读查看相关实现。
注: 本文中所有的图片均为《算法》一书的辅助图片,代码实现也源自该书。
本文只是对其要点的提炼,想要详细的学习这些算法请自行观看相关书籍。
本篇博客将复习的三种排序算法是:
选择排序是最为简单的排序算法,基于其特点和复杂度,几乎不会使用到选择排序来解决实际的排序问题,但是它的思想对于我们入门排序算法是有一定指导意义的。
首先,找到数组中最小的那个元素,其次,将它和数组的第一个元素交换(如果第一个元素就是最小元素那么它就和自己交换)。再次,在剩下的元素中找到最小的元素,将它与数组的第二个元素交换…如此往复,直到将整个数组排序。
选择排序的 Java代码实现如下所示:
public class Selection {
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;
}
}
// 将a[i]和a[min]进行交换
exch(a, min, i);
}
assert isSorted(a);
}
// less()、exch()和isSorted()方法见本文开头
}
对于长度为 N 的数组,选择排序需要大约 N 2 / 2 \ N{^2}/2 N2/2 次比较和 N \ N N 次交换。
证明。我们以对数组 [ S O R T E X A M P L E ] 进行排序证明这个复杂度。我们用一张 N ✖ N \ N✖N N✖N 的表格表示排序的轨迹,其中每个非灰色字符都表示一次比较。表格中大约有一半字符不是灰色的。而对角线上的每个元素都对应着一次交换。表格如下所示。
左边的 i 和 min 下面对应的数字分别表示要交换元素的数组下标和黑色元素中最小值所对应的数组下标。其中灰色的元素表示已经排定好的元素;黑色的元素表示在本次循环中进行比较的元素;红色的元素表示即将和 a[i] 交换的最小元素。
从上图可以清晰的看出,在长度为 N 的数组中,比较所需的次数大约为 N 2 / 2 \ N{^2}/2 N2/2 次,交换所需的次数大约为 N \ N N 次。
选择排序有以下两个很鲜明的特点:
插入排序的思想有些类似于人们整理桥牌时候的方法,即将每一张牌插入到其他已经有序的牌中的适当位置。
有一个数组 a[ ],假设 a[i] 左边的所有元素都是有序的,但它们的最终位置还不确定,为了给更小的元素腾出空间,它们可能会被移动。但是当索引到达数组的右端时,数组排序就完成了。
插入排序的 Java代码实现如下所示:
public class InsertionX {
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[j], a[j-1]); j--){
exch(a, j, j-1);
}
}
assert isSorted(a);
}
// less()、exch()和isSorted()方法见本文开头
}
上述是经典的插入排序实现代码,而插入排序代码改进可从下面两个方面进行:
改进后的插入排序实现如下所示:
public class InsertionX {
public static void sort(Comparable[] a){
int n = a.length;
// 将最小的元素置于最左边作为哨兵
int exchanges = 0;
for (int i = n-1; i > 0; i--){
if (less(a[i], a[i-1])){
exch(a, i, i-1);
exchanges++;
}
}
// 如果没有发生过交换,说明数组本来就是有序的
if (exchanges == 0){
return;
}
// 将i之前的元素中,比a[i]大的元素往前推,直到找到
// 一个不大于a[i]的元素,将a[i]插入到该位置中
for (int i = 2; i < n; i++){
Comparable temp = a[i];
int j = i;
while (less(temp, a[j-1])){
a[j] = a[j-1];
j--;
}
a[j] = temp;
}
assert isSorted(a);
}
// less()、exch()和isSorted()方法见本文开头
}
根据笔者的测试结果,改进过后的插入排序速度大约为改进之前的1.2~1.5倍。
对于随机排列的长度为 N \ N N 且元素不重复的数组,平均情况下插入排序需要 N 2 / 4 \ N{^2}/4 N2/4 次比较以及 N 2 / 4 \ N{^2}/4 N2/4 次交换。最坏情况下需要 N 2 / 2 \ N{^2}/2 N2/2 次比较以及 N 2 / 2 \ N{^2}/2 N2/2 次交换,最好情况下需要 N − 1 \ N-1 N−1 次比较和 0 次交换。
证明。我们同样可以通过一个 N ✖ N \ N✖N N✖N的轨迹表得到比较和交换的次数。最坏的情况下对角线下的所有元素都需要移动位置,最好情况下都不需要。对于随机排列的数组,在平均情况下每个元素都有可能向后移动半个数组的长度,因此交总数是对角线之下的元素总数的 1 / 2 \ 1/2 1/2。平均情况下的轨迹图如下所示:
从上面的分析我们可以得知,插入排序的比较和交换次数和数组中元素的初始顺序是有关的:
[ I H G F E D C B A ]
,那么在循环中的每次扫描都会产生交换和元素的移动,此时它的比较次数和交换次数均为 N 2 / 2 \ N{^2}/2 N2/2。[ A B C D E F G H I ]
,那么在它就不需要进行任何交换操作,而它每次循环时的比较只会进行一次,因此它的比较次数为 N − 1 \ N-1 N−1,交换次数为 0。[ S O R T E X A M P L E ]
,它的比较和交换次数约为最坏情况下的一半,即比较次数和交换次数约为 N 2 / 4 \ N{^2}/4 N2/4。接下来考虑一种部分有序的数组。
部分有序:如果数组中倒置的数量小于数组大小的某个倍数,那么这个数组就是部分有序的,下面是几种典型的部分有序数组:
- 数组中的每个元素距离它的最终位置都不远;
- 一个有序的数组接一个小数组;
- 数组中只有几个元素的位置不正确。
倒置:数组中的两个顺序颠倒的元素。例如 E X A M P L E 有 11 对倒值:E-A、X-A、X-M、X-P、X-L、X-E、M-L、M-E、P-L、P-E 以及 L-E 。
对于部分有序的数组来说,插入排序的效果非常好,性能会非常接近于线性。事实上,当倒置的数量很少时,插入排序很可能是最优解的排序算法。
除此之外插入排序还很适合小规模数组,所以插入排序也会经常出现在其他高级排序算法的代码中,当高级排序算法的排序子数组小于某个阈值时,就会切换成插入排序。
希尔排序是一种基于插入排序的快速的排序算法。对于大规模乱序数组插入排序很慢,因为它只会交换相邻的元素,因此元素只能一点一点地从数组的一端移动到另一端。而希尔排序为了加快速度对插入排序做了简单的改进,下面是它的思想。
希尔排序的思想是使数组中任意间隔为h的元素都是有序的。在h很大时,就能将元素移动到很远的地方,为实现更小的h有序创造方便。用这种方式,对于任意以1结尾的h序列,我们都能将数组进行排序,这就是希尔排序。
如上图,h=4时,数组中任何间隔为4的元素都是有序的。
public class Shell {
public static void sort(Comparable[] a){
// 将a[]按升序排列
int n = a.length;
int h = 1;
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;
}
assert isSorted(a);
}
// less()、exch()和isSorted()方法见本文开头
}
希尔排序的复杂度无法精确地给出,目前已知的最重要的结论是:它的运行时间达不到平方级别。已知在最坏的情况下,上述代码的比较次数和 N 3 / 2 \ N{^{3/2}} N3/2 成正比。