搞不懂的算法-排序篇<1>
最近在学习算法,跟着<Algorithms>这本书,可能是自己水平不够吧,看完排序算法后各种,希尔,归并,快排,堆的实现在脑子里乱成一锅粥,所以就打算大概总结一下,不求精确,全面,只想用平白的语言来理一理,如有错误之处,请直言。
为什么所有的算法书籍都重墨介绍排序,一、对一组数据进行排序在生活中是如此的常见,我们常常需要使用它;二、排序是实现很多一些高级算法的基础,一些复杂问题,如果处理的是已经拍过序的数据,那么就容易处理很多;三、排序算法中包含了很多重要的思想和方法,对于其他算法的研究也具有借鉴意义。研究排序算法之前,有一些关于算法的基础是需要掌握的。什么是好的算法?当然是算的快的算法,就是所谓的时间复杂性分析。计算机的内存是有限的,如果算法使用的空间也比较小,那就更好了,所谓的空间复杂度分析。如果代码写起来简单又好理解,这简直就是完美。此外针对不同的输入,同一个算法的性能也有很大的区别,所以也可以分别讨论。一些搞算法的人将这些情况总结起来,形成了科学的方法,甚至抽象成了思想。包括:算法的时间复杂度,该算法的运行时间?这个问题,我们能达到的最快运行时间;空间复杂度,算法解决问题使用多少空间。输入模型,针对不容的输入情况,比如大致排好序的,重复元素多的等等。算的又快,空间利用少,针对不同输入保持稳定高效,这样的算法是完美的算法。
排序-比如,给你一个包含N个随机double元素的数组,想要得到一个按大小排好序的数组。
1,选择排序/selection sort
选择排序大概是最简单易于理解的排序方法了,想象有一群高矮不同的人群,现在要将他们按高矮排一列。你可以从这群人中找出最矮的那个,放到最前面,然后找出第二矮的,依次下去直到排完。
public class Selection{
private static boolean less(Comparable[] a,int i,int j){
return a[i].compareTo(a[j])<0;
}
private static void exch(Comparable[] a,int i,int j){
Comparable temp=a[i];
a[i]=a[j];
a[j]=temp;
}
public static void sort(Comparable[] a){
int N=a.length;
for(int i=0;i<N-1;i++){
int temp=i;
for(int j=i+1;j<N;j++)
if(less(a,j,temp)) temp=j;
exch(a,i,temp);
}
}
}
这里面有两个函数:less(),exch(),前者以索引比较数组中的两个元素的大小,后者以索引交换两个元素的位置。之所以将两个操作封装成两个函数,是为了算法分析的方便。以后所有的算法都只使用着两个操作,分析也主要集中于不同算法执行的着两个操作的次数,因为其他的操作都是常数次的,只有这两个操作是与N有关的。
分析:对于选择排序,我们可以看到,比较的次数为(N-1)+(N-2)+...1=N2/2,平方级别;交换的次数为N-1,线性级别。非常稳定,无论输入如何,效率不变。
2.插入排序/Insertion sort
从数组左边到右边遍历元素,如果元素i小于前一个元素,将i与i-1交换,重复这个操作直到i落到合适的位置。
public class Insertion{
private static boolean less(Comparable[] a,int i,int j){
return a[i].compareTo(a[j])<0;
}
private static void exch(Comparable[] a,int i,int j){
Comparable temp=a[i];
a[i]=a[j];
a[j]=temp;
}
public static void sort(Comparable[] a){
int N=a.length;
for(int i=1;i<N;i++)
while(i>0 && less(a,i,i-1)){
exch(a,i,i-1);
i--;
}
}
}
分析:这个算法是依赖于输入的,最好的情况是数组已经排好序,那么只需要经过N-1次比较,0次交换,就能完成。最坏的情况,数组是逆序的,那么需要1+2+...(N-1)=N2/2次比较,同样多次的交换达成目标。平均情况下同样分析可以知道,需要N2/4次比较和同样多次交换达成目标。值得注意的一点是,插入排序对于基本有序的数组排序很有效果,因为只需要线性次数的比较和较少次操作,就能达成目标。
3.冒泡排序/bubble sort
对于很多没学过算法的人,听到这个名字感觉很高大上,但其实这是个很简单的算法,效率也较低,基本上不怎么用。方法就是进行一个N次的循环,每次循环遍历数组元素,将相邻两个元素中较大的放后面,因为元素像泡泡一样浮出得名。有两个优化策略,1、每次重复,遍历数组长度-1,2、当一次遍历没有进行交换,表明已经排好序,跳出循环而不用执行固定的循环次数。
public class Bubble{
private static boolean less(Comparable[] a,int i,int j){...代码上同}
private static void exch(Comparable[] a,int i,int j){...代码上同}
public static void sort(Comparable[] a){
int N=a.length;
int temp=1;
for(int i=0;i<N-1 && temp==1;i++){
temp=0;
for(int j=0;j<N-i-1;j++){
if(less(a,j+1,j)){
exch(a,j,j+1);
temp=1;
}
}
}
}
}
分析:冒泡排序感觉跟插入排序有很多相同,也是依赖于输入的,最好的情况:顺序排列的数组,需要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,只是因为比较好构造,性能尚可,也可以不用构造直接把递增序列放到一个数组中。递增序列的选择对于算法效率有很大的影响,如何根据输入找到最佳的递增序列,以实现最佳效率是个非常复杂的问题。
public class Shell{
private static boolean less(Comparable[] a,int i,int j){代码同上}
private static void exch(Comparable[] a,int i,int j){代码同上}
public static void sort(Comparable[] a){
int N=a.length;
int h=1;
while(3*h<N) h=3*h+1;
while(h>0){
for(int i=h;i<N;i++){
while(i>=h && less(a,i,i-h)){
exch(a,i,i-h);
i-=h;
}
}
h=h/3;
}
}
}
分析:希尔排序的性能很难分析,因为至今还没有确定的最佳递增序列,但是它的性能大于插入排序和选择排序是确定的,数据量越到优势越明显,经过试验,希尔排序的时间是小于平方级别的,大概在N3/4级别。对于一个10万随机数据的数组,希尔排序大概比插入排序快600倍。
从上面的分析可以看出,希尔排序已经比三种基本排序快了不少,对于一般长度的数组,希尔排序已经够用了,但还有没有更快的算法呢?排序算法的速度极限在哪里?下面介绍两种算法:鼎鼎大名的-----归并排序和被称为20世纪最牛逼算法之一的----快速排序,以及他们所体现的分治思想。
5.归并排序/merge sort
想象一下,比如我们有两个有序的数组,[2,2,5,9,22,88]和[6,9,11,13,19,38],如果我们想把二者合并为一个数组该怎么做,我们可以创建一个长度为两者之和的新数组,分别用索引i,j代表二者中最小元素的索引,每次比较两个元素的大小,将较小的元素放入新数组,其索引加1。直到到达数组尾部。代码如下:
public class Merge{
public static int[] merge(int[] a,int [] b){
int M=a.length,N=b.length;
int[] array=new int[M+N];
int i=0,j=0,k=0;
for(int h=0;h<M+N;h++){
if(i>=M) array[k++]=b[j++];
else if(j>=N) array[k++]=a[i++];
else if(a[i]<=b[j]) array[k++]=a[i++];
else array[k++]=b[j++];
}
return array;
}
}
这段代码接收两个数组作为参数,返回一个新的合并后的数组,这就叫做归并操作。归并算法就是将一个数组先分成两个数组,对两个数组归并,再对分成的两个数组,切分,归并。总的来看,归并是一种递归切分数组,然后依次归并的操作。代码如下:
public class Merge{
private static Comparable[] aux;//声明一个辅助数组aux
private static boolean less(Comparable[] a,int i,int j){代码同上}
private static void exch(Comparable[] a,int i,int j){代码同上}
//归并操作部分
private static void merge(Comparable[] a,int lo,int mid,int hi){
int i=lo,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,i)) a[k]=aux[j++];
else a[k]=aux[i++];
}
}
public static void sort(Comparable[] a){
int N=a.length;
aux=new Comparable[N];
sort(a,0,N-1);
}
//二分切分数组,递归调用
private static void sort(Comparable[] a,int lo,int hi){
if(lo>=hi) return;
int mid=lo+(hi-lo)/2;
sort(a,lo,mid);
sort(a,mid+1,hi);
merge(a,lo,mid,hi);
}
}
这段递归算法代码写的头都大了,结果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在数组中的位置。该如何实现?代码如下:
public static int merge(int[] a){
int num=a[0];
int i=1,j=a.length-1;
//从前往后找比num大的元素,从后往前找比num小的元素,二者交换,指针相遇时跳出循环
while(true){
while(a[i]<num) i++;
while(a[j]>num) j--;
if(i>=j) break;
int temp=a[i];
a[i]=a[j];
a[j]=temp;
}
//将该元素放到合适的位置上
int temp=a[j];
a[j]=a[0];
a[0]=temp;
return j;
}
其实对于数组中任何一个元素,我们都可以以这个元素来实现对该数组的切分,只需要将num=a[i],i为你想要切分元素的索引。基于这种数组元素的切分方法,我们来实现快速排序。代码如下:
public class Quick{
private static boolean less(Comparable[] a,int i,int j){代码同上}
private static void exch(Comparable[] a,int i,int j){代码同上}
//以数组第一个元素切分数组
private static int partition(Comparable[] a,int lo,int hi){
int i=lo,j=hi+;
while(true){
while(less(a,++i,lo)) if(i==hi) break;
while(less(a,lo,--j)) if(j==lo) break;
if(i>=j) break;
exch(a,i,j);
}
exch(a,lo,j);
return j;
}
//递归调用私有sort方法
private static void sort(Comparable[] a,int lo,int hi){
if(lo>=hi) return;
int j=partition(a,lo,hi);
sort(a,lo,j-);
sort(a,j+,hi);
}
//quick sort的对外接口
public static void sort(Comparable[] a){
StdRandom.shuffle(a);//将数组变为乱序
sort(a,,a.length-);
}
}
需要注意的一点是在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存储元素。
//假设我们现在有一个长为N+1的数组
//将数组中索引为k的元素上浮到合适位置
public static void swim(int k){
while(k>1 && a[k]>a[k/2]){
exch(a,k,k/2);
k/=2;
}
} //将数组中索引为k的元素下沉到合适的位置
public static void sink(int k){
while(2*k <=N){
int j=2*k;
if(j<N && a[j]<a[j+1]) j++; //确定j为k的较大的那个子节点
if(a[k]>=a[j]) break; //如果元素大于两个子节点,说明落到了合适位置
exch(a,k,j); //否则下沉该元素到合适位置
k=j; //更新k的值,继续循环
}
}
根据上面的两个基础操作,就能实现优先队列中最重要的删除最大元素和插入新元素的操作。
下面我们根据这个思想来看看堆排序。
一,对于一个无序数组,先从左到右遍历数组,上浮每个元素,使得数组变为堆有序;如果改为从右到左,下沉每个元素,会更高效(可以从N/2到1下沉,单个叶子节点无法下沉,不考虑)。
二,指针从数组尾开始递减,持续交换数组的根节点元素和尾元素,下沉交换后的尾元素,这里改写了下沉函数,将已交换的元素不考虑。最后得到一个有序数组。
这里需要注意两点:1,堆的实现是从1到N的,而我们传入的数组是从0到N-1的,所以在访问数组元素时(比较和交换)将索引减一即可。2,也可以将数组按从大到小排序,只需要改变比较规则>和<,即可。
代码如下:
import java.util.Arrays;
public class Test{
public static void heapSort(int[] a){
//将数组建立堆序
int N=a.length;
for(int k=N/2;k>=1;k--){
sink(a,k,N);
}
//依次取出最大值,将数组排序
while(N>1){
exch(a,N--,1);
sink(a,1,N);
}
} //下沉函数
private static void sink(int[] a,int k,int N){
while(2*k <=N){
int j=2*k;
if(j<N && less(a,j,j+1)) j++; //j为较大的子节点
if(! less(a,k,j)) break;
exch(a,k,j);
k=j;
}
} //交换数组中元素
private static void exch(int[] a,int i,int j){
i--;j--; //基于堆索引的特殊性,将参数减一
int temp=a[i];
a[i]=a[j];
a[j]=temp;
} //比较数组中元素
private static boolean less(int[] a,int i,int j){
i--;j--; //基于堆索引的特殊性,将参数减一
return a[i]<a[j] ? true :false;
} //测试排序方法
public static void main(String[] args){
int[] a={5,4,2,1,7,0,3,6};
heapSort(a);
System.out.println(Arrays.toString(a));
}
}
堆排序算法也是线性对数级别的算法,对于任何为N的数组,排序都可以在2NlgN时间内完成,但排序算法很少使用它的原因是它无法利用缓存,数组元素很少和相邻元素比较。而相比较下,快排,归并,希尔等排序算法对缓存的利用要高的多。
二叉堆这种数据结构更多的应用在基于优先队列的一些需求上。
搞不懂的算法-排序篇<1>的更多相关文章
- 搞不懂的算法-排序篇<2>
上一篇排序算法<1>中,排序算法的时间复杂度从N2到NlgN变化,但他们都有一个共同的特点,基于比较和交换数组中的元素来实现排序,我们称这些排序算法为比较排序算法.对于比较排序算法,所有的 ...
- C#算法设计排序篇之04-选择排序(附带动画演示程序)
选择排序(Selection Sort) 该文章的最新版本已迁移至个人博客[比特飞],单击链接 https://www.byteflying.com/archives/681 访问. 选择排序是一种简 ...
- C#算法设计排序篇之07-希尔排序(附带动画演示程序)
希尔排序(Shell's Sort) 该文章的最新版本已迁移至个人博客[比特飞],单击链接 https://www.byteflying.com/archives/687 访问. 希尔排序是插入排序的 ...
- 4. GC 算法(实现篇) - GC参考手册
您应该已经阅读了前面的章节: 垃圾收集简介 - GC参考手册 Java中的垃圾收集 - GC参考手册 GC 算法(基础篇) - GC参考手册 学习了GC算法的相关概念之后, 我们将介绍在JVM中这些算 ...
- (转载)微软数据挖掘算法:Microsoft 神经网络分析算法原理篇(9)
前言 本篇文章继续我们的微软挖掘系列算法总结,前几篇文章已经将相关的主要算法做了详细的介绍,我为了展示方便,特地的整理了一个目录提纲篇:大数据时代:深入浅出微软数据挖掘算法总结连载,有兴趣的童鞋可以点 ...
- JS的十大经典算法排序
引子 有句话怎么说来着: 雷锋推倒雷峰塔,Java implements JavaScript. 当年,想凭借抱Java大腿火一把而不惜把自己名字给改了的JavaScript(原名LiveScript ...
- js 数组排序和算法排序
1.算法排序 a.插入排序 var arr = [23,34,3,4,23,44,333,444]; var arrShow = (function insertionSort(array){ if( ...
- 【编程练习】收集的一些c++代码片,算法排序,读文件,写日志,快速求积分等等
写日志: class LogFile { public: static LogFile &instance(); operator FILE *() const { return m_file ...
- 微信技术分享:微信的海量IM聊天消息序列号生成实践(算法原理篇)
1.点评 对于IM系统来说,如何做到IM聊天消息离线差异拉取(差异拉取是为了节省流量).消息多端同步.消息顺序保证等,是典型的IM技术难点. 就像即时通讯网整理的以下IM开发干货系列一样: <I ...
随机推荐
- 减小数据库Log文件大小 1MB 为自定义大小
--减小数据库Log文件大小 1MB 为自定义大小USE [master]GOALTER DATABASE DataBaeName SET RECOVERY SIMPLE WITH NO_WAITGO ...
- 将 Vue 组件库发布到 npm
制作了一套自己的组件库,并发布到npm上,项目代码见 GitHub . 前期准备 有一个npm账号 安装了vue-cli 搭建项目 vue init webpack hg-vcomponents cd ...
- django数据库设置为MySQL
django默认使用sqlite,然后想使用MySQL数据库 在项目的setting文件中找到 DATABASES = { 'default': { 'ENGINE': 'django.db.back ...
- CentOS7下安装docker(Docker系列1)
CentOS7下安装docker 系统要求 为了安装docker,需要准备 64-bit的CentOS 7 删除非官方的Docker包 yum的仓库中有一个很旧的Docker包, 现在Docker官方 ...
- 【[Offer收割]编程练习赛10 C】区间价值
[题目链接]:http://hihocoder.com/problemset/problem/1483 [题意] 中文题 [题解] 二分最后的答案; 二分的时候; 对于每一个枚举的值x; 计算小于等于 ...
- 第五节、矩阵分解之LU分解
一.A的LU分解:A=LU 我们之前探讨过矩阵消元,当时我们通过EA=U将A消元得到了U,这一节,我们从另一个角度分析A与U的关系 假设A是非奇异矩阵且消元过程中没有行交换,我们便可以将矩阵消元的EA ...
- mdl 锁 SYSTEMTAP跟踪
systemtap : 各种资源的使用限制由所生成的C代码中的宏来设置.这些值可在编译时由-D选项来重写.下面描述了部分挑选出来的宏: MAXNESTING 递归函数的最大调用层数,默认值是10. M ...
- IOS - 查找未使用的图片
实现细节都在代码里面, 帮助 -h. # -*- coding: utf-8 -*- """ 检查IOS应用图片是否使用 1. 读取有效文件: 图片(.png, .jpg ...
- 開始搭建第一个zookeeper
首先须要下载zookeeper的tar包.地址为http://zookeeper.apache.org,然后再linux中解压并编译tar包. # tar-xvzf zookeeper-3.4.5.t ...
- Vultr好server不敢独享
Vultr是一家美国2014年成立的新公司.瞬间红遍世界,他是干什么的?他是serverVPS(Virtual Private Server)提供商,这个价格真实惊人的廉价5美金/月.折合人民币30元 ...