搞不懂的算法-排序篇1
最近在学习算法,跟着<Algorithms>这本书,可能是自己水平不够吧,看完排序算法后各种,希尔,归并,快排,堆的实现在脑子里乱成一锅粥,所以就打算大概总结一下,不求精确,全面,只想用平白的语言来理一理,如有错误之处,请直言。
为什么所有的算法书籍都重墨介绍排序,一、对一组数据进行排序在生活中是如此的常见,我们常常需要使用它;二、排序是实现很多一些高级算法的基础,一些复杂问题,如果处理的是已经拍过序的数据,那么就容易处理很多;三、排序算法中包含了很多重要的思想和方法,对于其他算法的研究也具有借鉴意义。研究排序算法之前,有一些关于算法的基础是需要掌握的。什么是好的算法?当然是算的快的算法,就是所谓的时间复杂性分析。计算机的内存是有限的,如果算法使用的空间也比较小,那就更好了,所谓的空间复杂度分析。如果代码写起来简单又好理解,这简直就是完美。此外针对不同的输入,同一个算法的性能也有很大的区别,所以也可以分别讨论。一些搞算法的人将这些情况总结起来,形成了科学的方法,甚至抽象成了思想。包括:算法的时间复杂度,该算法的运行时间?这个问题,我们能达到的最快运行时间;空间复杂度,算法解决问题使用多少空间。输入模型,针对不容的输入情况,比如大致排好序的,重复元素多的等等。算的又快,空间利用少,针对不同输入保持稳定高效,这样的算法是完美的算法。
排序-比如,给你一个包含N个随机double元素的数组,想要得到一个按大小排好序的数组。
1,选择排序/selection sort
选择排序大概是最简单易于理解的排序方法了,想象有一群高矮不同的人群,现在要将他们按高矮排一列。你可以从这群人中找出最矮的那个,放到最前面,然后找出第二矮的,依次下去直到排完。
1 public class Selection{ 2 private static boolean less(Comparable[] a,int i,int j){ 3 return a[i].compareTo(a[j])<0; 4 } 5 private static void exch(Comparable[] a,int i,int j){ 6 Comparable temp=a[i]; 7 a[i]=a[j]; 8 a[j]=temp; 9 } 10 public static void sort(Comparable[] a){ 11 int N=a.length; 12 for(int i=0;i<N-1;i++){ 13 int temp=i; 14 for(int j=i+1;j<N;j++) 15 if(less(a,j,temp)) temp=j; 16 exch(a,i,temp); 17 } 18 } 19 }
这里面有两个函数:less(),exch(),前者以索引比较数组中的两个元素的大小,后者以索引交换两个元素的位置。之所以将两个操作封装成两个函数,是为了算法分析的方便。以后所有的算法都只使用着两个操作,分析也主要集中于不同算法执行的着两个操作的次数,因为其他的操作都是常数次的,只有这两个操作是与N有关的。
分析:对于选择排序,我们可以看到,比较的次数为(N-1)+(N-2)+...1=N2/2,平方级别;交换的次数为N-1,线性级别。非常稳定,无论输入如何,效率不变。
2.插入排序/Insertion sort
从数组左边到右边遍历元素,如果元素i小于前一个元素,将i与i-1交换,重复这个操作直到i落到合适的位置。
1 public class Insertion{ 2 private static boolean less(Comparable[] a,int i,int j){ 3 return a[i].compareTo(a[j])<0; 4 } 5 private static void exch(Comparable[] a,int i,int j){ 6 Comparable temp=a[i]; 7 a[i]=a[j]; 8 a[j]=temp; 9 } 10 public static void sort(Comparable[] a){ 11 int N=a.length; 12 for(int i=1;i<N;i++) 13 while(i>0 && less(a,i,i-1)){ 14 exch(a,i,i-1); 15 i--; 16 } 17 } 18 }
分析:这个算法是依赖于输入的,最好的情况是数组已经排好序,那么只需要经过N-1次比较,0次交换,就能完成。最坏的情况,数组是逆序的,那么需要1+2+...(N-1)=N2/2次比较,同样多次的交换达成目标。平均情况下同样分析可以知道,需要N2/4次比较和同样多次交换达成目标。值得注意的一点是,插入排序对于基本有序的数组排序很有效果,因为只需要线性次数的比较和较少次操作,就能达成目标。
3.冒泡排序/bubble sort
对于很多没学过算法的人,听到这个名字感觉很高大上,但其实这是个很简单的算法,效率也较低,基本上不怎么用。方法就是进行一个N次的循环,每次循环遍历数组元素,将相邻两个元素中较大的放后面,因为元素像泡泡一样浮出得名。有两个优化策略,1、每次重复,遍历数组长度-1,2、当一次遍历没有进行交换,表明已经排好序,跳出循环而不用执行固定的循环次数。
1 public class Bubble{ 2 private static boolean less(Comparable[] a,int i,int j){...代码上同} 3 private static void exch(Comparable[] a,int i,int j){...代码上同} 4 public static void sort(Comparable[] a){ 5 int N=a.length; 6 int temp=1; 7 for(int i=0;i<N-1 && temp==1;i++){ 8 temp=0; 9 for(int j=0;j<N-i-1;j++){ 10 if(less(a,j+1,j)){ 11 exch(a,j,j+1); 12 temp=1; 13 } 14 } 15 } 16 } 17 }
分析:冒泡排序感觉跟插入排序有很多相同,也是依赖于输入的,最好的情况:顺序排列的数组,需要N-1次比较和0次交换,达成目标。最差的情况:逆序排列:需要1+2+...(N-1)=N2/2次比较,同样多次的交换达成目标。
以上是三种最基本的排序方式,可以看到三者一般情况下都是平方级别的,所以相互之间的速度差别为常数级别的,试验发现,插入排序相当是较快的一种,但快的有限,大概比选择排序快0.7倍。
实际上通过分析可以发现,三种排序方法效率都有某种程度的浪费,所以可以对其进行优化,比如一种对插入排序的优化,希尔排序。
4.希尔排序/shell short
希尔排序有点难理解,通过观察插入排序,我们发现它的一个性能问题是每次只能将一个元素和相邻位置的交换,比如将一个最小的元素从尾端移动到头端,那么就需要N-1次移动,有没有办法一次移动多步呢,我们可以通过增加步长来实现。
希尔排序的特点是先构造一个递增序列,比如下面代码中所用的(1,4,13,40,121...)序列。先构造一个h有序的数组,比如h=13,即i-h,i,i+h,i+2h,等在数组内是有序的,然后缩小h的范围令h=4,在构造一个h有序的数组。直到h=1,那么最终得到一个h=1有序的数组,即为目标数组。
递增序列的选择多种多样,这里选择h=3*h+1,只是因为比较好构造,性能尚可,也可以不用构造直接把递增序列放到一个数组中。递增序列的选择对于算法效率有很大的影响,如何根据输入找到最佳的递增序列,以实现最佳效率是个非常复杂的问题。
1 public class Shell{ 2 private static boolean less(Comparable[] a,int i,int j){代码同上} 3 private static void exch(Comparable[] a,int i,int j){代码同上} 4 public static void sort(Comparable[] a){ 5 int N=a.length; 6 int h=1; 7 while(3*h<N) h=3*h+1; 8 while(h>0){ 9 for(int i=h;i<N;i++){ 10 while(i>=h && less(a,i,i-h)){ 11 exch(a,i,i-h); 12 i-=h; 13 } 14 } 15 h=h/3; 16 } 17 } 18 }
分析:希尔排序的性能很难分析,因为至今还没有确定的最佳递增序列,但是它的性能大于插入排序和选择排序是确定的,数据量越到优势越明显,经过试验,希尔排序的时间是小于平方级别的,大概在N3/4级别。对于一个10万随机数据的数组,希尔排序大概比插入排序快600倍。
从上面的分析可以看出,希尔排序已经比三种基本排序快了不少,对于一般长度的数组,希尔排序已经够用了,但还有没有更快的算法呢?排序算法的速度极限在哪里?下面介绍两种算法:鼎鼎大名的-----归并排序和被称为20世纪最牛逼算法之一的----快速排序,以及他们所体现的分治思想。
5.归并排序/merge sort
想象一下,比如我们有两个有序的数组,[2,2,5,9,22,88]和[6,9,11,13,19,38],如果我们想把二者合并为一个数组该怎么做,我们可以创建一个长度为两者之和的新数组,分别用索引i,j代表二者中最小元素的索引,每次比较两个元素的大小,将较小的元素放入新数组,其索引加1。直到到达数组尾部。代码如下:
1 public class Merge{ 2 public static int[] merge(int[] a,int [] b){ 3 int M=a.length,N=b.length; 4 int[] array=new int[M+N]; 5 int i=0,j=0,k=0; 6 for(int h=0;h<M+N;h++){ 7 if(i>=M) array[k++]=b[j++]; 8 else if(j>=N) array[k++]=a[i++]; 9 else if(a[i]<=b[j]) array[k++]=a[i++]; 10 else array[k++]=b[j++]; 11 } 12 return array; 13 } 14 }
这段代码接收两个数组作为参数,返回一个新的合并后的数组,这就叫做归并操作。归并算法就是将一个数组先分成两个数组,对两个数组归并,再对分成的两个数组,切分,归并。总的来看,归并是一种递归切分数组,然后依次归并的操作。代码如下:
1 public class Merge{ 2 private static Comparable[] aux;//声明一个辅助数组aux 3 private static boolean less(Comparable[] a,int i,int j){代码同上} 4 private static void exch(Comparable[] a,int i,int j){代码同上} 5 //归并操作部分 6 private static void merge(Comparable[] a,int lo,int mid,int hi){ 7 int i=lo,j=mid+1; 8 for(int k=lo;k<=hi;k++) 9 aux[k]=a[k];//将待归并数组复制到辅助数组中 10 for(int k=lo;k<=hi;k++){ 11 if(i>mid) a[k]=aux[j++]; 12 else if(j>hi) a[k]=aux[i++]; 13 else if(less(aux,j,i)) a[k]=aux[j++]; 14 else a[k]=aux[i++]; 15 } 16 } 17 public static void sort(Comparable[] a){ 18 int N=a.length; 19 aux=new Comparable[N]; 20 sort(a,0,N-1); 21 } 22 //二分切分数组,递归调用 23 private static void sort(Comparable[] a,int lo,int hi){ 24 if(lo>=hi) return; 25 int mid=lo+(hi-lo)/2; 26 sort(a,lo,mid); 27 sort(a,mid+1,hi); 28 merge(a,lo,mid,hi); 29 } 30 }
这段递归算法代码写的头都大了,结果bug无数,改了N久。思想还是很好理解的,就是递归的切分数组,每次切分后对切分出的数组做归并操作。经过牛人分析得知,将数组每次平分时效率最高的。
时间分析,归并算法的时间分析是比较复杂的,这里我们引入一个决策树的模型,归并算法适用于该模型的一种情况,对应于一个公式。不了解的话可以参看网易公开课里MIT开设的<算法导论>课程第三讲,我只能大概听懂,根本讲不出来。T(N)=2T(N/2)+Θ(n),其中Θ(n)反映的是归并操作与N的关系,这里是线性的,套用公式得到T(N)=Θ(Nlgn),所以归并算法的时间是线性对数级的,当N足够大时,该算法是远远优于上面四种算法的。
另外需要注意的一点是该算法过程中的空间占用,需要构造一个与a等大的数组;这个算法也是可以优化的,比如在数组较小时,递归调用函数占用了大量的成本,可以在切分出的数组较小时,使用插入排序。
对于排序算法来说,算法最快能有多快,线性对数级别的最快的吗?可以使用上面提到的决策数模型来进行分析,对于N个数的数组,将其排序可以有N!种情况,对于数组的排序,我们可以把它分解成一个决策树模型,树的高度是h,叶子结点的数量最多为2h,所以2h >=N!,根据斯特灵公式,h=NlgN,所以得出一个结论,那就是对于一般的N个元素的随机数组,基于比较操作与交换操作的排序的算法最快也只能达到线性对数级别。上面的归并排序就是这样一种算法,所以,归并是渐近最优的。
归并排序就是终点吗?不,还有吊炸天的快速排序呢,归并排序虽然时间性能上接近最优,但需要辅助数组,当要排序数组非常大时,这一点会成为它的缺陷。下面介绍一种更优同样基于分治思想的算法--快速排序。
6.快速排序/quick sort
我们想象这样一种情况,有一个数组[9,7,18,3,17,25],我想把所有小于9的元素都放在9的前面,大于9的元素都放在9后面,返回9在数组中的位置。该如何实现?代码如下:
1 public static int merge(int[] a){ 2 int num=a[0]; 3 int i=1,j=a.length-1; 4 //从前往后找比num大的元素,从后往前找比num小的元素,二者交换,指针相遇时跳出循环 5 while(true){ 6 while(a[i]<num) i++; 7 while(a[j]>num) j--; 8 if(i>=j) break; 9 int temp=a[i]; 10 a[i]=a[j]; 11 a[j]=temp; 12 } 13 //将该元素放到合适的位置上 14 int temp=a[j]; 15 a[j]=a[0]; 16 a[0]=temp; 17 return j; 18 }
其实对于数组中任何一个元素,我们都可以以这个元素来实现对该数组的切分,只需要将num=a[i],i为你想要切分元素的索引。基于这种数组元素的切分方法,我们来实现快速排序。代码如下:
1 public class Quick{ 2 private static boolean less(Comparable[] a,int i,int j){代码同上} 3 private static void exch(Comparable[] a,int i,int j){代码同上} 4 //以数组第一个元素切分数组 5 private static int partition(Comparable[] a,int lo,int hi){ 6 int i=lo,j=hi+1; 7 while(true){ 8 while(less(a,++i,lo)) if(i==hi) break; 9 while(less(a,lo,--j)) if(j==lo) break; 10 if(i>=j) break; 11 exch(a,i,j); 12 } 13 exch(a,lo,j); 14 return j; 15 } 16 //递归调用私有sort方法 17 private static void sort(Comparable[] a,int lo,int hi){ 18 if(lo>=hi) return; 19 int j=partition(a,lo,hi); 20 sort(a,lo,j-1); 21 sort(a,j+1,hi); 22 } 23 //quick sort的对外接口 24 public static void sort(Comparable[] a){ 25 StdRandom.shuffle(a);//将数组变为乱序 26 sort(a,0,a.length-1); 27 } 28 }
需要注意的一点是在sort方法里,首先调用了StdRandom.shuffle()方法,来将数组变为乱序。shuffle()的实现也较为简单,遍历数组,将当前元素与后面一堆元素中的一个随机元素交换位置。注意,是当前元素后面的随机元素。这样做是为了使快排能适应更多不同的输入,比如输入数组第一个值正好是数组的最大值和最小值,以后每次第一个元素都是该切分数组的最大或最小值,那么快排的效率会变得非常糟糕。将数组排为乱序,可以确保这种极端情况不会出现。当然也可以采用另外一种方法,就是在每次切分数组时,选择数组中的随机元素作为切分值,这也可以避免极端情况的出现。
分析:快速排序和归并一样,经过数学证明,其时间级别是线性对数级别的。约为1.39倍NlgN,而且该算法不需要额外的空间需求,几乎是一般情况下最优秀的算法。所以得到了广泛的应用。
快速排序也是可以改进的,以实现更高的效率,因为快排是基于函数的递归调用,所以当对较小的子数组排序时,可以切换到插入排序。对于重复值较多的数组,可以使用三向切分等。
7.堆排序/heap sort
堆排序是基于二叉堆实现的排序方法。二叉堆就是一个从索引1到N的有一定顺序的数组。数组中的索引为k的元素一定大于等于索引为2*k,2*k+1的元素,用二叉堆得树结构的话,就是说父节点一定大于等于(小于等于)任一个子节点。由此可知,其根节点,即索引为1的元素为数组元素中的最大值。
二叉堆数据结构的实现基于数组和三个特殊的操作数组中元素的方法。
1.往二叉堆中添加元素,构造新的二叉堆:将该元素添加到队列尾部,上浮该元素到合适的位置。
2.删除堆中的最大元素,备份索引为1的元素,将索引为1的元素和队尾元素互换,下沉索引为1的元素到合适位置,队列尾元素清空,更新队列长,返回备份元素。
3.根据二叉堆的性质实现元素的下沉和上浮操作。
根据以上内容可以判断出二叉堆得实现主要是在数组中利用上浮和下沉来构造堆有序的数组,数组索引从1到N存储元素。
1 //假设我们现在有一个长为N+1的数组 2 //将数组中索引为k的元素上浮到合适位置 3 public static void swim(int k){ 4 while(k>1 && a[k]>a[k/2]){ 5 exch(a,k,k/2); 6 k/=2; 7 } 8 } 9 10 //将数组中索引为k的元素下沉到合适的位置 11 public static void sink(int k){ 12 while(2*k <=N){ 13 int j=2*k; 14 if(j<N && a[j]<a[j+1]) j++; //确定j为k的较大的那个子节点 15 if(a[k]>=a[j]) break; //如果元素大于两个子节点,说明落到了合适位置 16 exch(a,k,j); //否则下沉该元素到合适位置 17 k=j; //更新k的值,继续循环 18 } 19 }
根据上面的两个基础操作,就能实现优先队列中最重要的删除最大元素和插入新元素的操作。
下面我们根据这个思想来看看堆排序。
一,对于一个无序数组,先从左到右遍历数组,上浮每个元素,使得数组变为堆有序;如果改为从右到左,下沉每个元素,会更高效(可以从N/2到1下沉,单个叶子节点无法下沉,不考虑)。
二,指针从数组尾开始递减,持续交换数组的根节点元素和尾元素,下沉交换后的尾元素,这里改写了下沉函数,将已交换的元素不考虑。最后得到一个有序数组。
这里需要注意两点:1,堆的实现是从1到N的,而我们传入的数组是从0到N-1的,所以在访问数组元素时(比较和交换)将索引减一即可。2,也可以将数组按从大到小排序,只需要改变比较规则>和<,即可。
代码如下:
1 import java.util.Arrays; 2 public class Test{ 3 public static void heapSort(int[] a){ 4 //将数组建立堆序 5 int N=a.length; 6 for(int k=N/2;k>=1;k--){ 7 sink(a,k,N); 8 } 9 //依次取出最大值,将数组排序 10 while(N>1){ 11 exch(a,N--,1); 12 sink(a,1,N); 13 } 14 } 15 16 //下沉函数 17 private static void sink(int[] a,int k,int N){ 18 while(2*k <=N){ 19 int j=2*k; 20 if(j<N && less(a,j,j+1)) j++; //j为较大的子节点 21 if(! less(a,k,j)) break; 22 exch(a,k,j); 23 k=j; 24 } 25 } 26 27 //交换数组中元素 28 private static void exch(int[] a,int i,int j){ 29 i--;j--; //基于堆索引的特殊性,将参数减一 30 int temp=a[i]; 31 a[i]=a[j]; 32 a[j]=temp; 33 } 34 35 //比较数组中元素 36 private static boolean less(int[] a,int i,int j){ 37 i--;j--; //基于堆索引的特殊性,将参数减一 38 return a[i]<a[j] ? true :false; 39 } 40 41 //测试排序方法 42 public static void main(String[] args){ 43 int[] a={5,4,2,1,7,0,3,6}; 44 heapSort(a); 45 System.out.println(Arrays.toString(a)); 46 } 47 }
View Code
堆排序算法也是线性对数级别的算法,对于任何为N的数组,排序都可以在2NlgN时间内完成,但排序算法很少使用它的原因是它无法利用缓存,数组元素很少和相邻元素比较。而相比较下,快排,归并,希尔等排序算法对缓存的利用要高的多。
二叉堆这种数据结构更多的应用在基于优先队列的一些需求上。
转载于:https://www.cnblogs.com/charsandrew/p/5918514.html
搞不懂的算法-排序篇1相关推荐
- 边界框的回归策略搞不懂?算法太多分不清?看这篇就够了
作者 | fivetrees 来源 | https://zhuanlan.zhihu.com/p/76477248 本文已由作者授权,未经允许,不得二次转载 [导读]目标检测包括目标分类和目标定位 2 ...
- PID算法搞不懂?看这篇文章就够了。
点击上方"大鱼机器人",选择"置顶/星标公众号" 福利干货,第一时间送达! 转自知乎: jason 原文链接:https://zhuanlan.zhihu.co ...
- PID算法搞不懂?看这篇文章。
大家好,我是张巧龙,网上关于PID算法的文章很多,但是感觉有必要自己再进行一次总结,抽丝剥茧地重新认识了一下PID: 1 前言 2 开环控制 3 闭环控制 4 PID 4.1 系统架构 4.2 理论基 ...
- C++两个函数可以相互递归吗_[算法系列] 搞懂递归, 看这篇就够了 !! 递归设计思路 + 经典例题层层递进
[算法系列] 搞懂递归, 看这篇就够了 !! 递归设计思路 + 经典例题层层递进 从学习写代码伊始, 总有个坎不好迈过去, 那就是遇上一些有关递归的东西时, 看着简短的代码, 怎么稀里糊涂就出来了. ...
- [算法系列] 搞懂递归, 看这篇就够了 !! 递归设计思路 + 经典例题层层递进
[算法系列] 搞懂递归, 看这篇就够了 !! 递归设计思路 + 经典例题层层递进 从学习写代码伊始, 总有个坎不好迈过去, 那就是遇上一些有关递归的东西时, 看着简短的代码, 怎么稀里糊涂就出来了. ...
- 我就不信看完这篇你还搞不懂信息熵
我就不信看完这篇你还搞不懂信息熵 https://mp.weixin.qq.com/s/7NrB0UtmELXD3UNO3C6jGA 让我们说人话!好的数学概念都应该是通俗易懂的. 信息熵,信息熵,怎 ...
- Java入门算法(排序篇)丨蓄力计划
本专栏已参加蓄力计划,感谢读者支持 往期文章 一. Java入门算法(贪心篇)丨蓄力计划 二. Java入门算法(暴力篇)丨蓄力计划 三. Java入门算法(排序篇)丨蓄力计划 四. Java入门算法 ...
- C#算法设计排序篇之04-选择排序(附带动画演示程序)
选择排序(Selection Sort) 该文章的最新版本已迁移至个人博客[比特飞],单击链接 https://www.byteflying.com/archives/681 访问. 选择排序是一种简 ...
- 如果你看不懂KMP算法,那就看一看这篇文章( 绝对原创,绝对通俗易懂)
如果你看不懂KMP算法,那就看一看这篇文章(绝对原创,绝对通俗易懂) KMP算法,俗称"看毛片"算法,是字符串匹配中的很强大的一个算法,不过,对于初学者来说,要弄懂它确实不易.整个 ...
最新文章
- rails安装与卸载
- 过河问题 还是不会 去学请教一下 数学老师 -----
- Linux大文件切割命令split
- DataFrame关于某一列做归一化处理
- RS485 串口调试如何操作
- java 自带观察者模式_java 内置的观察者模式
- 高等数学上-赵立军-北京大学出版社-题解-练习5.1
- python多项式回归_在python中实现多项式回归
- Visio显示不完整
- mysql写入 cpu飙升_分析MySQL中索引引引发的CPU负载飙升的问题
- python 装机配置_Python实现自动装机功能案例分析
- STM32F103_RGB彩灯
- 冒死曝光这个软件,希望不要被封杀!
- Java 员工信息管理系统
- VMWare Workstation 15 serial number
- 数字信号处理(1)- 频谱分析
- AAAI 2022 论文列表
- 2021年中国皮卡产销量及市场竞争格局分析[图]
- BM46 最小的 K 个数
- 谷歌黑客搜索看这些就够了!