[本篇博文会对常见的排序算法进行分析与总结,并会在最后提供几道相关的一线互联网企业面试/笔试题来巩固所学及帮助咱们查漏补缺。项目地址:https://github.com/absfree/Algo。因为我的水平有限,叙述中不免存在不清晰准确的地方,但愿你们能够指正,谢谢你们:)] node
咱们在平常开发中常常须要对一组数据对象进行排序,这里的数据对象不只包括数字,还多是字符串等抽象数据类型(Abstract Data Type)。因为排序是不少其余操做(好比二分查找)可以高效进行的基础,所以咱们有必要掌握好常见的排序算法,本篇文章会分析几种最经常使用的排序算法,并进一步探索排序的本质,从而可以更加全面透彻的理解各类排序算法。本篇博文会用Java来描述各类排序算法的实现,因为本篇文章的侧重点在与分析各项算法的原理及其通常实现,所以咱们假定待比较的数据对象均为int类型(然而在实际应用中咱们应该假定它们为Comparable类型)。若未加特殊说明,咱们如下的排序算法都会按照升序排列。在算法的具体实现中,咱们用到了StdRandom、StdOut和StdIn这三个静态代码库,它们能够在这里https://github.com/absfree/Algo/tree/master/src/util找到,每一个方法的做用均可以经过它们的名称看出来,源码中也有相应的注释。git
假如咱们如今按身高升序排队,一种排队的方法是:从第一名开始,让两人相互比身高,若前者高则交换位置,更高的那个在与剩下的人比,这样一趟下来以后最高的人就站到了队尾。接着重复以上过程,直到最矮的人站在了队列首部。咱们把队头看做水底,队尾看做水面,那么第一趟比较下来,最高的人就像泡泡同样从水底”冒“到水面,第二趟比较则是第二高的人……排队的过程即为对数据对象进行排序的过程(这里咱们排序的”指标“是身高),上述过程即描述了冒泡排序的思想。从以上过程咱们能够看到,若对n我的进行排队,咱们须要n-1趟比较,并且第k趟比较须要进行n-k次比较。经过这些信息,咱们可以很容易的算出冒泡排序的复杂的。首先,排序算法一般都以数据对象的两两比较做为”关键操做“,这里咱们能够得出,冒泡排序须要进行的比较次数为: (n-1) + (n-2) + ... + 1 = n*(n-1) / 2,所以冒泡排序的时间复杂度为O(n^2)。github
理解了冒泡排序的原理,就不难实现它了,具体实现代码以下:面试
public class Bubble { public static void sort(int[] a) { int N = a.length; for (int i = 0; i < N - 1; i++) { for (int j = 0; j < N - i - 1; j++) { if (a[j] > a[j+1]) { exchange(a, j, j+1); } } } } public 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,即认为排序已完成。算法
回到上面咱们提到的排队问题,除了上面提到的方法,还有这样一种排队的方法,让目前队头的人依次与其后的每一个人进行比较,比较后较矮的那我的继续与后面的人进行比较,这样第一趟比较下来,就可以找到最矮的人, 而后把这个最矮的人和当前队头的人交换一下位置。而后第二趟比较,让第二名依次与后面比较,能够找到第二矮的人,而后让第二矮的人和当前队列第二名交换位置,依此类推,一共进行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); } } public static void exchange(int[] a, int i, int j) { int temp = a[i]; a[i] = a[j]; a[j] = temp; } }
回想下咱们平时打扑克抓牌的过程,一般咱们用右手抓牌,每抓一张牌,就放到左手上,抓下一张牌后,会把这张牌依次与左手上的牌比较,并把它插入到一个合适的位置(一般按照牌面大小)。上述的过程即为插入排序的过程,假设待排序数组为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]后,就把刚抓的牌插入到已排好牌中的合适的位置了。重复以上过程就能完成待排序数组的排序。dom
关于插入排序咱们须要注意的是,在平均状况下以及最坏状况下,它的时间复杂度均为O(n^2),而在最好状况下(输入数组彻底有序),插入排序的时间复杂度可以提高至O(N)。实际上,排序的本质就是消除逆序对,所谓逆序对,就是不符合咱们所要求的排序顺序的两个数。好比说[1,3,4,2]为待排序数组,那么它的逆序数为2——(3,2)和(4,2)都是降序的,不符合咱们升序的要求。插入排序对于部分有序的数组的排序尤其有效,所谓部分有序,指的是待排序数组的逆序数小于数组尺寸的某个倍数。若咱们待排序数组彻底有序时,每一轮排序都只需比较一次,就能找到待排序元素在已排序数组中的合适的位置,而部分有序时,比较的次数也能控制在数组尺寸的常数倍以内。所以,插入排序对于部分有序的数组十分高效,也很适合小规模的数组。函数
希尔排序是对插入排序的一种改进,它的核心思想是将待排序数组中任意间隔为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
咱们能够直观的看到,希尔排序要比其余三种排序都快,而插入排序要比选择排序、冒泡排序快,冒泡排序在实际执行性能最差。
基本排序算法对于中小规模的数据集的排序在通常状况下足够用了,可是对于大规模数据集的排序,咱们仍是颇有必要使用一些较高级的排序算法,下面咱们来逐一介绍它们。
归并排序使用了一种叫作”分治“的思想来解决排序问题。分治也就是"分而治之“,也就是把一个大问题分解为众多子问题,然后分别获得每一个子问题的解,最终以某种方式合并这些子问题的解就能够获得原问题的解。归并排序的主要思想是:将待排序数组递归的分解成两半,分别对它们进行排序,而后将结果“归并”(递归的合并)起来。咱们知道,递归算法都有一个base case,递归分解数组的base case就是分解完的两个数组长度为为1,这时候它们自己就有序,此时就能够进行归并了。
归并排序的时间复杂度为O(nlogn), 它的主要缺点是所需的额外空间与待排序数组的尺寸成正比。
首先,咱们先来实现归并方法,这个方法接收一个int[]数组a以及low、mid、high参数,用于将a[low..mid](表明a[low]到a[mid]间的元素,包括a[low]和a[mid])和a[mid+1..high]归并为一个数组,这个方法假设a[low..mid]与a[mid+1..high]都是有序的。
下面咱们用一个具体例子来描述归并算法的执行过程,假如咱们的输入数组为[2, 4, 6, 8, 1, 3, 5, 7],low为0,mid为3,high为7。咱们称a[low..mid]为左数组,a[mid+1..high]为右数组,归并方法的执行过程以下:
理解了归并方法的原理,咱们就不难用Java来描述它了,相关代码以下:
private static void merge(int[] a, int low, int mid, int high) { int i = low; //左数组下一个要进行比较的元素的索引 int j = mid + 1; //右数组下一个要进行比较的元素的索引 int N = high + 1; //本次归并的元素数目 int[] tmpArray = new int[N]; //用于暂时存放比较后的元素 for (int k = low; k <= high; k++) { if (i > mid) { //左数组元素已全比较完 tmpArray[k] = a[j++]; } else if (j > high) { //右数组元素已全比较完 tmpArray[k] = a[i++]; } else if (a[j] < a[i]) { //右数组元素小于左数组 tmpArray[k] = a[j++]; } else { //右数组元素大于等于左数组 tmpArray[k] = a[i++]; } } for (int k = low; k < N; k++) { a[k] = tmpArray[k]; } }
在以上代码中,咱们使用了一个辅助数组tmpArray来暂时存放比较后的数组元素,待归并完成后,再复制回原数组。
上面咱们介绍了归并过程的实现,归并方法要求输入数组的左半部分和右半部分分别有序。那么下面咱们来介绍如何利用上面咱们实现的merge方法来实现对一个数据集的归并排序。
在最开始咱们介绍过归并排序的主要思想是将待排序数组递归的分解成两半,分别对它们进行排序,而后将结果归并起来。具体过程以下:将数组递归的分为两部分,直至两部分长度都为1,则认为到达了base case,这时开始执行归并过程。
这里咱们仍是以上面的输入数组举例,递归分解数组的示意图以下:
咱们能够看到,当数组分解为只有单个元素后,那么它就是有序的了,因此这时就知足了上面咱们实现的归并方法的输入参数的条件,咱们经过调用归并方法就可以以单元素数组为起点,逐步构造出已排序的完整数组。相关的实现代码以下:
public class Merge { private static void merge(int[] a, int low, int mid, int high) { ... } public static void sort(int[] a) { int N = a.length; sort(a, 0, N - 1); } private static void sort(int[] a, int low, int high) { //base case if (high <= low) { return; } int mid = (low + high) / 2; sort(a, low, mid); sort(a, mid+1, high); merge(a, low, mid, high); } 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(1000); } sort(a); for (Integer i : a) { StdOut.print(i + " "); } } }
若是感受以上代码比较抽象,你们能够画出“递归调用图”来帮助咱们理解递归调用的过程,仍是以上面的输入数组为例,咱们画一下对它进行归并排序的递归调用图:
经过这个图,咱们能够直观地看到sort方法的递归调用过程。对于以上sort方法,如下几个方法可以提高它的性能:
快速排序是目前应用最普遍的排序算法之一,它是通常场景中大规模数据排序的首选,它的实际性能要好于归并排序。一般状况下,快速排序的时间复杂度为O(nlogn),但在最坏状况下它的时间复杂度会退化至O(n^2),不过咱们能够经过对输入数组进行“随机化”(打乱元素的排列顺序)来避免最坏状况的发生。除了实际执行性能好,快速排序的另外一个优点是它可以实现“原地排序”,也就是说它几乎不须要额外的空间来辅助排序。下面咱们来具体介绍下这个优秀排序算法的原理及实现。
快速排序的主要思想以下:假设待排序数组为a[0..N-1],递归的对该数组执行如下过程:选取一个切分元素,然后经过数组元素的交换将这个切分元素移动到位置j,使得全部a[0..j-1]的元素都小于等于a[j],全部a[j+1..N-1]的元素都大于等于a[j]。
在快速排序中,切分元素的选取很关键,一般咱们能够选取输入数组的第一个元素做为切分元素,而后把它交换到数组中的合适位置使得它左边的元素都小于等于它,右边的元素都大于等于它,然后对其左右两边的子数组递归执行切分过程,便可完成对整个数组的排序。下面咱们来看一下切分方法的Java描述,并以此来说解切分过程的具体实现:
1 private static int partition(int[] a, int low, int high) { 2 int i = low + 1; 3 int j = high + 1; 4 5 //p为切分元素 6 int p = a[low]; 7 while (true) { 8 //从数组中的第二个元素开始寻找第一个大于等于切分元素的数组元素,若找到则i为其索引 9 while (a[++i] < p) { 10 if (i == high) { 11 break; 12 } 13 } 14 //此时i为从数组首部开始第一个大于等于切分元素的数组元素的索引,若没有找到则为high 15 16 //从数组末元素开始寻找第一个小于等于切分元素的数组元素,若找到则j为其索引 17 while (a[--j] > p) { 18 if (j == low) { 19 break; 20 } 21 } 22 //此时j为从数组末开始第一个小于等于切分元素的数组元素的索引,若没有找到则为low 23 24 if (i >= j) { 25 break; 26 } 27 exchange(a, i, j); 28 } 29 exchange(a, low, j); 30 return j; 31 }
结合以上代码,咱们来说解一下肯定切分过程的具体实现。首先在第6行中,咱们选取了数组的首元素做为切分元素并将它保存在变量p中,而后在第7行进入一个无限循环中。
第9行到第13行是一个内层循环,在这个循环中,咱们从数组的第二个元素开始,让切分元素p与每一个数组元素进行比较,当相应位置的元素大于等于p或是已经到达数组末尾时,这个循环就会终止。此时i的值为第一个大于等于p的元素的索引或是high的值,若为high的值则表示数组中不存在大于等于p的元素。
而后咱们来到了第17行到第21行的内层循环中,这个循环会从数组末元素开始,让p与数组元素逐一进行比较,当相应位置的元素小于等于p或是已比较到数组首时则终止循环。此时j的值为第一个小于等于p的元素的索引或是low的值,若为low的值则表示数组中不存在小于等于p的元素。
接下来,执行第24行的if语句判断i和j的大小,若i >= j, 会跳出无限循环。i >= j对应着如下四种状况:
下面咱们再来看一下当i < j时咱们应该怎么作。首先咱们须要明确的是i < j意味着第一个大于等于p的元素(a[i])在第一个小于等于p的元素(a[j])的左边。以下图所示:
那么如今问题来了,a[i]和a[j]之间的元素和p的关系是怎样的呢?答案是没法肯定,因此当i<j时咱们还不能贸然退出无限循环,得先把a[i]与a[j]之间的元素与p的大小关系肯定了才行。不过如今的问题是出现了两只“拦路虎”——忽然出现了a[i]这个大于等于p的元素拦着咱们让咱们没法继续向数组尾部寻找小于p的元素,而a[j]这个小于等于p的元素的出现使得咱们没法向数组头部探索是否还有大于p的元素。那么解决方法来了,咱们只要想办法移除这两个挡道的不就行啦。慢着...交换下a[i]和a[j]不就行了,这样咱们就能够继续探索了呀,再遇到拦路虎的时候再交换它俩就能够了呀...以上代码第27行就完成了这个交换的工做。
关于切分方法还有一点须要咱们注意的是:在从左向右“扫描”时,必须在遇到大于等于切分元素p的元素时停下来,在从右向左扫描时,必须在遇到小于等于切分元素p的元素时停下来。若不是这样作的话,当数组有大量重复元素时,快速排序的时间复杂度就会退化至O(n^2)。
如今,咱们已经结合源代码,比较详细地阐述了切分过程的实现。下面,让咱们借助这个切分过程,来实现用快速排序算法对一个数组进行排序。
实际上,搞懂了上面的切分过程,来具体实现快速排序是很容易的,参考代码以下:
1 public static void sort(int[] a) { 2 StdRandom.shuffle(a); //打乱输入数组的元素间的相对顺序,避免出现最坏状况 3 sort(a, 0, a.length - 1); 4 } 5 6 private static void sort(int[] a, int low, int high) { 7 if (high <= low) { 8 return; 9 } 10 int j = partition(a, low, high); 11 sort(a, low, j-1); 12 sort(a, j+1, high); 13 }
跟咱们前面所描述的快速排序的基本思想同样,递归地对待排数组进行切分就可以完成排序。这里咱们简单介绍下快速排序的性能特色。快速排序算法的实际执行性能依赖与切分是否均衡,当正好把数组从中间”切开”时,快速排序的实际性能最好。切分越不均衡快速排序的实际性能就越差,最坏状况下(第一次选取的切分元素是数组里最小的,第二次的切分元素是第二小的...),算法的时间复杂度会退化到O(n^2)。因此为了不最坏状况的发生,咱们在使用快速排序对数组排序时,会先打乱一下数组元素的顺序。一个好消息是在平均状况下,咱们将数组打乱后再取第一个元素做为切分元素,切分一般是比较均衡的。
尽管快速排序已经具备很是优秀的实际性能,可是仍然有许多行之有效的方法可以明显提高快速排序的速度,下面咱们将简单地介绍如下这些方法。
对于尺寸比较小的数组,插入排序要比快速排序快,所以当递归调用切分方法到切分所得数组已经很小时,咱们不妨用插入排序来排序小数组。只须要把以上快速排序实现代码的7—9行改成以下:
if (high <= low + SIZE) { //SIZE为使用插入排序的临界数组尺寸,能够选取[5,15]上的整数 Insertion.sort(a, low, high); return; }
这项改进方案的手段是改进切分过程,具体方法以下:在每次切分时,从待切分数组中随即抽取3个元素,然后计算出它们3个元素的中位数来做为切分元素。也就是说,相比于上面咱们实现的快速排序,三取样切分就是在切分元素的选取上有所不一样。如下是三取样切分的实现:
public class Quick3d { public static void sort(int[] a) { sort(a, 0, a.length); } private static void sort(int[] a, int low, int high) { if (high <= low) { return; } int lt = low; int i = low + 1; int gt = high; int p = a[low]; while (i <= gt) { if (a[i] < p) { exchange(a, i++, lt++); } else if (a[i] > p) { exchange(a, i++, gt--); } else { i++; } } } public static void exchange(int a[], int i, int j) { int temp = a[i]; a[i] = a[j]; a[j] = temp; } }
介绍堆排序以前,咱们须要先介绍一种经常使用的数据结构——优先队列,由于堆排序就是基于优先队列实现的。优先队列能够分为最大优先队列和最小优先队列,最大优先队列主要支持两种操做:插入元素和删除最大元素,最小优先队列则支持插入元素和删除最小元素。在下面的介绍中,若未加特殊说明,咱们所说的优先队列指的是最大优先队列。优先队列适用于以下这种场景:不须要对数据集彻底有序,咱们只须要获取数据集最大的一个或几个元素。优先队列能够基于数组实现,也能够基于二叉堆来实现,一般二叉堆都基于二叉堆来实现,因此这里咱们主要介绍这种实现。
在介绍基于二叉堆实现的优先队列前,咱们先来介绍几个概念,这几个概念的定义均来自于Sedgewick的《算法》一书。第一个咱们要介绍的概念堆有序,它的定义以下:
当一棵二叉树的每一个结点都大于等于它的两个子结点时,它被称为堆有序。
第二个概念是二叉堆,它的定义以下:
二叉堆是一组可以用堆有序的彻底二叉树排序的元素,并在数组中按照层级存储(不用数组的第一个位置)。
咱们来画张图说明一下堆有序的彻底二叉树是怎样按照层级存储在数组中的:
从上图中咱们能够看到二叉树中的元素是怎样和数组中对应的。使用这种顺序将二叉树元素存储在数组中所带来的一个最直接的好处就是很容易定位到一个数组元素a[k]在树中的父结点和两个子结点在数组的的位置:a[k]的父结点为a[k/2],左子结点为a[2*k], 右子结点为a[2*k+1]。这种容易定位父子结点的性质加上下面的一个二叉堆的特性使得咱们可以基于二叉堆高效的实现优先队列:一棵大小为N的彻底二叉树的高度为floor(lgN)。(其中floor表示向下取整,lgN表示以2为底的N的对数)
咱们经过前面对二叉堆的定义能够知道,二叉堆可看作一棵堆有序的彻底二叉树,因此二叉堆的各个元素间是有着必定的相对顺序的。具体说来,就是每一个结点都大于等于它的两个子结点。所谓堆的有序化是指:当咱们向二叉堆中添加一个元素或从二叉堆中删除一个元素后,致使二叉堆的有序性贝尔打破,这时咱们要经过某种过程来恢复二叉堆的有序性,这个过程就是堆的有序化。(若未作特殊说明,如下提到的“堆”均指“二叉堆”)。
堆的有序化可分为两种,一种是由下向上的有序化,一般叫作上浮(swim);还有一种是由上向下的有序化,一般叫作下沉(sink)。它们的名字便很清楚了代表了它们各自的做用,上浮就是让一个结点向上移动到知足堆有序的位置,下沉就是让一个结点向下移动到知足堆有序的位置,下面咱们来分别介绍它们。
什么状况下咱们须要上浮呢?一般是某个结点的值变大或是咱们向堆中添加一个新结点时(它会被添加到数组尾部,也就是成为堆的叶子结点),咱们须要把这个结点上浮到一个合适的位置以保证堆有序。根据堆有序的定义,当咱们要进行上浮的结点大于它的父结点时,咱们就须要把它不断的上浮,直到它小于等于它的父结点。参考代码以下:
private void swim(int k) { while (k > 1 && a[k/2] < a[k]) { exchange(k, k/2 ); k = k / 2; } }
当某个结点的值比它的某个子结点更小时,咱们须要把该结点下沉来保证堆的有序性。下沉操做的过程当中,咱们应当先比较被下沉结点的两个子结点的大小,然后让被下沉结点与较大的那个比较,如果小于它,则二者交换,然后重复这个过程直到父结点大于等于两个子结点或是到达末尾。参考代码以下:
private void sink(int k) { while (2 * k <= N) { int j = 2 * k; if (j < N && a[2*k] < a[2*k+1]) { j++; } if (k >= j) { break; } exchange(k, j); k = j; } }
了解了堆的有序化的过程,优先队列的insert方法以及delMax方法的实现就很容易了,下面咱们来基于以上的swim和sink方法来介绍insert与delMax方法的具体实现。为简单起见,咱们假设结点均为int型。
有了上面的铺垫,实现insert方法就很简单了,咱们只须要把新结点添加到数组a的尾部,而后把它上浮到合适的位置便可,具体实现代码以下:
public void insert(int node) { a[++N] = node; swim(N); }
因为咱们始终保持二叉堆处于有序状态,因此根结点就是最大的结点,咱们能够删除根结点,而后把数组尾部结点放入根结点的位置,再把它进行下沉便可,参考代码以下:
public int delMax(int k) { exchange(1, N); int max = a[N--]; a[N+1] = -1; sink(1); return max; }
如今咱们已经成功实现了一种insert方法与delMax方法的复杂度均为O(logn)的优先队列。实际上咱们上面实现的每次能够删除一个最大结点的优先队列叫作最大有限队列,与它相对的每次能够删除一个最小结点的优先队列就是最小优先队列。接下来让咱们基于(最大)优先队列来实现堆排序。
咱们知道,每次调用优先队列的delMax方法都会删除一个最大的结点,其时间复杂度为O(logN)。那么对于一个大小为N的数据集,咱们只须要将它包含的元素都添加到优先队列中,而后调用N次delMax不就能够实现排序了吗?实际上这种区别与以前咱们所介绍的排序方法的排序实现就是堆排序,堆排序的时间复杂度为O(nlogn)。
堆排序一般分为两个阶段,第一个阶段是堆的构造阶段,用于把咱们输入的无序数组构形成二叉堆;第二个阶段是下沉排序阶段,这个阶段咱们删除一个最大结点并下沉以保证堆有序。下面咱们来具体介绍这两个阶段的实现。
这个阶段咱们的任务是把一个无序数组构形成一个二叉堆,要实现这一任务,咱们能够从数组的尾部开始对每一个元素调用sink方法,若一个结点的两个子结点都已是堆,那么咱们对该结点调用sink就能够将它们整合成一个堆。对于没有子结点的堆,咱们无需对其调用sink方法。
下沉排序的逻辑很简单,就是让最大结点(即根结点)与数组末尾对应的结点交换,这样就把最大结点移动到了数组末尾,而后把刚交换到根结点的结点进行sink,此时根结点即为第二大的结点,而后再将根结点与数组末尾对应的结点交换….这样重复N-1次就能实现数组的原地排序。
结合以上两个阶段,就能够获得堆排序的完整实现:
public class Heap { private static void sink(int[] a, int k, int N) { while (2 * k <= N) { int j = 2 * k; if (j < N && a[j] < a[j+1]) { j++; } if (a[k] >= a[j]) { break; } exchange(a, k, j); k = j; } } public static void sort(int[] a) { int N = a.length - 1; for (int k = N/2; k >= 1; k--) { sink(a, k, N); } while (N > 1) { exchange(a, 1, N--); sink(a, 1, N); } } public static void exchange(int a[], int i, int j) { int temp = a[i]; a[i] = a[j]; a[j] = temp; } }
《算法》一书中对堆排序评价以下:
堆排序是咱们所知的惟一可以同时最优地利用空间和时间的方法——在最坏状况下他也能保证~2NlgN次比较和恒定的额外空间。
其中~2NlgN表示比较次数的增加量级为2NlgN。也就是说若设比较次数为f(N),当N足够大时,f(N) / 2NlgN趋向于1。因为这个特性,堆排序算法适合于空间资源十分紧张的嵌入式系统。
堆排序的一个主要缺点在于缓存不友好,由于它常常要对内存中不相邻的元素进行比较,因此缓存命中率要低于快速排序、归并排序等算法。
在比较各个排序算法前,咱们先来介绍如下稳定性这个重要的概念。它的定义以下:
若是一个排序算法可以保留数组中重复元素的相对位置则能够被称为是稳定的。
这个性质在有些场景中是必要的,特别是咱们要对数据集进行多轮排序时。好比咱们要排序的是交易事务数据集,每一个交易事务都有交易时间和交易金额等信息。咱们第一轮先按照交易金额排序,而后咱们想再对于这些交易事务按照交易时间排一次序。此时若排序算法是稳定的,上一步具备相同交易时间的事务在第二轮排序后的相对顺序是不变的,而若算法不稳定第二轮对交易时间的排序会破坏第一轮排序的成果。显然咱们在这种状况下更但愿排序算法是稳定的。
咱们前面介绍的几种算法中,稳定的排序算法有冒泡排序、插入排序和归并排序,而选择排序、希尔排序、快速排序和堆排序都是不稳定的。
所谓的原地排序指的是对待排数组进行排序时只需在原数组处来回移动数组元素来实现排序。咱们以前介绍的排序算法中,原地排序的算法有:选择排序、插入排序、希尔排序、快速排序与堆排序;非原地排序算法只有归并排序(咱们使用了tmpArray来辅助排序)。
这一部分咱们来一块儿解决几道一线互联网企业的关于排序的面试/笔试题,以检验咱们的学习成果以及可以让咱们在之后的面试中增添一份信心。
【2015阿里巴巴研发工程师笔试题】个数约为50K的数列须要进行从小到大排序,数列特征是基本逆序(多数数字从大大小,个别乱序),如下哪一种排序算法在事先不了解数列特征的状况下性能最优。( ) A. 冒泡排序 B. 改进冒泡排序 C. 选择排序 D. 快速排序 E. 堆排序 F.插入排序
根据题目中的描述,首先咱们能够排除A、B、C,由于它们的时间复杂度都是O(n)。接下来咱们看下D选项,咱们前面提到过,快速排序在最坏状况下的时间复杂度会退化至O(n^2),F选项的插入排序在逆序数很大时性能也不好(O(n^2))。而堆排序在最坏状况下的复杂度也为O(logn),因此这里咱们应该选择堆排序。
【2016阿里巴巴校招笔试题】现有1GB数据进行排序,计算资源只有1GB内存可用,下列排序方法中最可能出现性能问题的是( )
A. 堆排序 B. 插入排序 C. 归并排序 D. 快速排序 E. 选择排序 F. 冒泡排序
根据题目的描述,咱们可以很明确的知道这道题考察咱们的是原地排序的概念,这里咱们只须要选择非原地排序的占用额外空间最大的算法,显然答案是”C. 归并排序"。
【京东】假设你只有100Mb的内存,须要对1Gb的数据进行排序,最合适的算法是( )
A. 归并排序 B. 插入排序 C. 快速排序 D. 冒泡排序
根据题目,咱们能够知道,咱们现有的内存限制使得咱们没法把数据一次性加载到内存中,因此咱们只能先加载一部分数据,对其排序后存入磁盘中。而后再加载一些数据,把它们“合并”到已排序的数据集中去,重复这个过程直到排序完成,显然最能胜任这个工做的是归并排序。
【选自《剑指offer》】输入n个整数,找出其中最小的K个数。例如输入4,5,1,6,2,7,3,8这8个数字,则最小的4个数字是1,2,3,4,。
初看这道题,根据前面的介绍,咱们马上就可以想好几种方案:
第一个方案是使用(最小)优先队列。具体方法就是把输入数组input中的元素都添加到优先队列中,而后调用k次delMin方法咱们就能欧获得最小的k个数字。相信这种解法的代码在理解了优先队列的实现后咱们你们都能写出来。
第二个方案是使用冒泡排序k轮。
第三个方案是使用快速排序中的partition方法。咱们知道partition方法会返回一个索引j,会把原数组切分为a[low..j-1](所包含元素均小于等于a[j])和a[j..high](所包含的元素都大于等于a[j],N为输入数组的尺寸)。这里咱们初始化low为0,high为input..length-1,而后调用partition方法。若返回的j等于k-1(意味着a[low..j]中的元素数等于k),则返回a[low..j]便可;若j大于k-1(意味着a[low..j]包含的元素数大于k),此时咱们把partition的high参数更新为j-1;若j小于k-1(意味着a[low..j]的元素数小于k,此时咱们把low更新为j+1)。以上状况中,只要j不等于k-1,咱们就根据j的与k-1的关系更新low或是high而后继续调用partition方法,直到返回的j等于k-1。具体实现代码以下:
public ArrayList<Integer> GetLeastNumbers_Solution(int[] input, int k) { if (input == null) { return null; } ArrayList<Integer> list = new ArrayList<Integer>(k); int low = 0; int high = input.length - 1; int j = partition(input, low, high); while (j != k-1){ if (j > k-1){ high = j - 1; } else { low = j + 1; } j = partition(input, low, high); } for (int i = 0; i < k; i++) { list.add(input[i]); } return list; } private static int partition(int[] a, int low, int high) { int i = low; int j = high + 1; int p = a[low]; while (true) { while (a[++i] < p) { if (i == high) { break; } } while (a[--j] > p) { if (j == low) { break; } } if (i >= j) { break; } exchange(a, i, j); } exchange(a, low, j); return j; }
《算法(第四版)》(Sedgewick等)
http://blog.csdn.net/shakespeare001/article/details/51280814