[本系列博文会对常见的排序算法进行分析与总结,并会在最后提供几道相关的一线互联网企业面试/笔试题来巩固所学及帮助我们查漏补缺。项目地址:https://github.com/absfree/Algo。由于个人水平有限,叙述中难免存在不清晰准确的地方,希望大家可以指正,谢谢大家:)]
一、概述
我们在日常开发中经常需要对一组数据对象进行排序,这里的数据对象不仅包括数字,还可能是字符串等抽象数据类型(Abstract Data Type)。由于排序是很多其他操作(比如二分查找)能够高效进行的基础,因此我们有必要掌握好常见的排序算法,本系列文章会分析几种最常用的排序算法,并进一步探索排序的本质,从而能够更加全面透彻的理解各种排序算法。本系列博文会用Java来描述各种排序算法的实现,由于我们的侧重点在与分析各项算法的原理及其一般实现,因此我们假定待比较的数据对象均为int类型(然而在实际应用中我们应该假定它们为Comparable类型)。若未加特殊说明,我们所介绍的排序算法都会按照升序排列。
二、初级排序算法
本篇博文我们介绍四种初级排序算法,分别是冒泡排序、选择排序、插入排序和希尔排序。之所以称之为初级,是因为相比于归并排序、快速排序等高级排序算法,它们的实现较简单,不过(对于大规模数据集的)性能也较差些。
1. 冒泡排序
假如我们现在按身高升序排队,一种排队的方法是:从第一名开始,让两人相互比身高,若前者高则交换位置,更高的那个在与剩下的人比,这样一趟下来之后最高的人就站到了队尾。接着重复以上过程,直到最矮的人站在了队列首部。我们把队头看作水底,队尾看作水面,那么第一趟比较下来,最高的人就像泡泡一样从水底”冒“到水面,第二趟比较则是第二高的人……排队的过程即为对数据对象进行排序的过程(这里我们排序的”指标“是身高),上述过程即描述了冒泡排序的思想。从以上过程我们可以看到,若对n个人进行排队,我们需要n-1趟比较,而且第k趟比较需要进行n-k次比较。通过这些信息,我们能够很容易的算出冒泡排序的复杂的。首先,排序算法通常都以数据对象的两两比较作为”关键操作“,这里我们可以得出,冒泡排序需要进行的比较次数为: (n-1) + (n-2) + ... + 1 = n*(n-1) / 2,因此冒泡排序的时间复杂度为O(n^2)。
理解了冒泡排序的原理,就不难实现它了,具体实现代码如下:
public class Bubble { public static void sort(int[] a) { int N = a.length; for (int i = 0; i < N; i++) { for (int j = 0; j < N - i - 1; j++) { if (a[j] > a[j+1]) { exchange(a, j, j+1); } } } } private static void exchange(int a[], int i, int j) { int temp = a[i]; a[i] = a[j]; a[j] = temp; } public static void main(String[] args) { int N = 20; int[] a = new int[N]; for (int i = 0; i < N; i++) { a[i] = StdRandom.uniform(0, 1000); } sort(a); for (Integer i : a) { StdOut.print(i + " "); } } }
关于冒泡排序有一点需要注意的是,在最好情况下(即输入数组已经完全有序),冒泡排序的时间复杂度能够提升到O(N)。我们只需增加一个boolean型变量isOrdered,在第一轮排序中一旦a[j] > a[j+1],就把isOrdered设为false,否则isOrdered设为true,然后我们在每趟排序前检查isOrdered,一旦发现它为false,即认为排序已完成。
2. 选择排序
回到上面我们提到的排队问题,除了上面提到的方法,还有这样一种排队的方法,让目前队头的人依次与其后的每个人进行比较,比较后较矮的那个人继续与后面的人进行比较,这样第一趟比较下来,就能够找到最矮的人, 然后把这个最矮的人和当前队头的人交换一下位置。然后第二趟比较,让第二名依次与后面比较,可以找到第二矮的人,然后让第二矮的人和当前队列第二名交换位置,依此类推,一共进行n-1趟比较后,就能完成整个排队过程。根据上述描述,我们可以知道,第k趟比较需要进行的数组元素的两两比较的次数为n-k次,所以共需要的比较次数为n*(n-1) / 2,因此选择排序算法的时间复杂度与冒泡排序一样,也为O(n^2)。选择排序的Java描述如下:
public class Selection { public static void sort(int[] a) { int N = a.length; for (int i = 0; i < N - 1; i++) { int min = i; for (int j = i + 1; j < N; j++) { if (a[j] < a[min]) { min = j; } } exchange(a, i, min); } } private static void exchange(int[] a, int i, int j) { int temp = a[i]; a[i] = a[j]; a[j] = temp; } }
3. 插入排序
回想下我们平时打扑克抓牌的过程,通常我们用右手抓牌,每抓一张牌,就放到左手上,抓下一张牌后,会把这张牌依次与左手上的牌比较,并把它插入到一个合适的位置(通常按照牌面大小)。上述的过程即为插入排序的过程,假设待排序数组为a,我们从a[1]开始,让a[1]与a[0]比较,若a[1]较小,则让a[1]和a[0]交换位置,此时a[0]和a[1]就相当于已经放入左手中的牌。然后我们再让a[2]与a[1]、a[0]比较,并为它找到一个合适的位置,以此类推,直到为数组的最后一个元素也找到了合适的位置。
理解了插入排序的思想后,我们便能够得到它的时间复杂度。对于n个元素,一共需要进行n-1轮比较,而第k轮比较需要进行k次数组元素的两两比较,因此共需要进行的比较次数为:1 + 2 + ... + (n-1),所以插入排序的时间复杂度同冒泡排序一样,也为O(n^2)。插入排序的Java描述如下:
public class Insertion { public static void sort(int[] a) { int N = a.length; int i, j; for (i = 1; i < N; i++) { for (j = i - 1; j >= 0 && a[i] < a[j]; j--) { } //这里跳出内层循环,a[i]应被插入到a[j]后 int tmp = a[i]; for (int k = i; k > j + 1; k--) { a[k] = a[k-1]; } a[j+1] = tmp; } } }
我们来简单地解释下以上代码。以抓牌过程来举例,i为刚抓的牌的索引,i-1即为我们刚排好的牌中的最后一张的索引,j为左手中当前正与我们刚抓的牌进行比较的牌的索引。在内层循环中,我们从左手已排好牌中的最后一张开始,若发现刚抓的牌比当前牌的牌面大,就再与前一张比较(j--),直到刚抓的牌大于等于当前牌的牌面,就会跳出内层循环,这时我们把a[i]插入到a[j]后,就把刚抓的牌插入到已排好牌中的合适的位置了。重复以上过程就能完成待排序数组的排序。
关于插入排序我们需要注意的是,在平均情况下以及最坏情况下,它的时间复杂度均为O(n^2),而在最好情况下(输入数组完全有序),插入排序的时间复杂度能够提升至O(N)。实际上,排序的本质就是消除逆序对,所谓逆序对,就是不符合我们所要求的排序顺序的两个数。比如说[1,3,4,2]为待排序数组,那么它的逆序数为2——(3,2)和(4,2)都是降序的,不符合我们升序的要求。插入排序对于部分有序的数组的排序尤为有效,所谓部分有序,指的是待排序数组的逆序数小于数组尺寸的某个倍数。若我们待排序数组完全有序时,每一轮排序都只需比较一次,就能找到待排序元素在已排序数组中的合适的位置,而部分有序时,比较的次数也能控制在数组尺寸的常数倍之内。因此,插入排序对于部分有序的数组十分高效,也很适合小规模的数组。
4. 希尔排序
希尔排序是对插入排序的一种改进,它的核心思想是将待排序数组中任意间隔为h的元素都变为有序的,这样的数组叫做h有序数组。比如数组[5, 3, 2, 8, 6, 4, 7, 9, 5], 我们可以看到a[0]、a[3]、a[6]是有序的,a[1]、a[4]、a[7]是有序的,a[2]、a[5]、a[8]是有序的,因此这个数组是一个h有序数组(h=3)。根据h有序数组的定:义,我们可以知道,当h=1时,相应的h有序数组就是一个已经排序完毕的数组了。希尔排序的大致过程如下:把待排序数组分割为若干子序列(一个子序列中的元素在原数组中间隔为h,即中间隔了h-1个元素),然后对每个子序列分别进行插入排序。然后再逐渐减小h,重复以上过程,直至h变为足够小时,再对整体进行一次插入排序。由于h足够小时,待排序数组的逆序数已经很小,所以再进行一次希尔排序是很快的。希尔排序通常要比插入排序更加高效。
实现希尔排序时,我们需要选取一个h的取值序列,这里我们直接采用算法(第4版) (豆瓣)一书中提供的h取值序列(1,4,13,40,121, ...)。即h = 3 * k + 1,其中k为[0, N/3)区间内的整数。希尔排序的Java描述如下:
public class Shell { public static void sort(int[] a) { int N = a.length; int h = 1; while (h < N / 3) { h = 3 * h + 1; //h的取值序列为1, 4, 13, 40, ... } while (h >= 1) { int n, i ,j, k; //分割后,产生n个子序列 for (n = 0; n < h; n++) { //分别对每个子序列进行插入排序 for (i = n + h; i < N; i += h) { for (j = i - h; j >= 0 && a[i] < a[j]; j -= h) { } int tmp = a[i]; for (k = i; k > j + h; k -= h) { a[k] = a[k-h]; } a[j+h] = tmp; } } h = h / 3; } } }
实际上,h的取值序列的选取会影响到希尔排序的性能,不过以上我们选取的h值序列在通常情况下性能与复杂的取值序列相接近,但是在最坏情况下的性能要差一些。分析希尔排序的复杂度不是一件容易的事,这里我们引用《算法》一书中关于希尔排序复杂度的结论:
使用递增序列1, 4, 13, 40, 121, 364, ...的希尔排序所需的比较次数不会超过数组尺寸的若干倍乘以递增序列的长度。
也就是说,在通常情况下,希尔排序的复杂度要比O(n^2)好得多。实际上,最坏情况下希尔排序所需要的比较次数与O(n^1.5)成正比,在实际使用中,希尔排序要比插入排序和选择排序、冒泡排序快得多。而且尽管待排序数组很大,希尔排序也不会比快速排序等高级算法慢很多。因此当需要解决排序问题而用没有现成系统排序函数可用时,可以优先考虑希尔排序,当希尔排序确实满足不了对性能的要求时,在考虑使用快速排序等算法。
到这里,我们要介绍的基本排序算法就介绍完了,再介绍快速排序、归并排序、堆排序等高级排序算法前,我们先来简单地介绍下如何比较各种排序算法的实际性能,这也能够帮助我们直观的看到希尔排序相比与插入排序等的性能优势。
三、比较不同排序算法的性能
尽管插入排序和选择排序的复杂度都为O(n^2),但是它们所包含的常数系数是不同的,因而这两种算法的实际执行时间之比应该是一个常数,下面我们来设计实验来测试下以上我们介绍的几种基本排序算法的实际执行性能。相关代码如下:
public class SortCompare { public static double time(String alg, int[] a) { long startTime = System.currentTimeMillis(); if (alg.equals("Insertion")) { Insertion.sort(a); } else if (alg.equals("Selection")) { Selection.sort(a); } else if (alg.equals("Bubble")) { Bubble.sort(a); } else if (alg.equals("Shell")) { Shell.sort(a); } long endTime = System.currentTimeMillis(); return (double) (endTime - startTime) / 1000.0; } public static double timeRandomInput(String alg, int N, int T) { //使用alg指定的排序算法将长度为N的数组排序,共排序T次,并计算总时间 double total = 0.0; int[] a = new int[N]; for (int t = 0; t < T; t++) { for (int i = 0; i < N; i++) { a[i] = StdRandom.uniform(10 * N); } total += time(alg, a); } return total; } public static void main(String[] args) { String alg1 = args[0]; String alg2 = args[1]; int N = Integer.parseInt(args[2]); int T = Integer.parseInt(args[3]); double t1 = timeRandomInput(alg1, N, T); double t2 = timeRandomInput(alg2, N, T); StdOut.printf("For %d random ints\n %s is", N, alg1); StdOut.printf(" %.1f times faster than %s", t2/t1, alg2); } }
我们来对1000个数进行排序,来比较下以上介绍的算法的性能。我这里得到的输出结果如下:
For 1000 random ints Shell is 4.9 times faster than Insertion For 1000 random ints Shell is 7.6 times faster than Selection For 1000 random ints Shell is 11.7 times faster than Bubble
我们可以直观的看到,希尔排序要比其他三种排序都快,而插入排序要比选择排序、冒泡排序快,冒泡排序在实际执行性能最差。
基本排序算法对于中小规模的数据集的排序在一般情况下足够用了,但是对于大规模数据集的排序,我们还是很有必要使用一些较高级的排序算法,在接下来的博文中我们会详细介绍它们。