经典常用排序算法的原理及 实现以及算法分析.docx
《经典常用排序算法的原理及 实现以及算法分析.docx》由会员分享,可在线阅读,更多相关《经典常用排序算法的原理及 实现以及算法分析.docx(28页珍藏版)》请在冰豆网上搜索。
经典常用排序算法的原理及实现以及算法分析
本文介绍了7种最经典、最常用的排序算法,分别是:
冒泡排序、插入排序、选择排序、归并排序、快速排序、桶排序、计数排序、基数排序。
对应的时间复杂度如下所示:
排序算法
时间复杂度
是否基于比较
冒泡、插入、选择
O(n^2)
√
快排、归并
O(nlogn)
√
桶、计数、基数
O(n)
×
整篇文章的主要知识提纲如图所示:
1.排序算法分析
学习排序算法除了学习它的算法原理、代码实现之外,最重要的是学会如何评价、分析一个排序算法。
分析一个排序算法通常从以下几点出发。
1.1.执行效率
而对执行效率的分析,一般从这几个方面来衡量:
∙最好情况、最坏情况、平均情况
除了需要给出这三种情况下的时间复杂度还要给出对应的要排序的原始数据是怎么样的。
∙时间复杂度的系数、常数、低阶
大O时间复杂度反应的是算法时间随n的一个增长趋势,比如O(n^2)表示算法时间随n的增加,呈现的是平方的增长趋势。
这种情况下往往会忽略掉系数、常数、低阶等。
但是实际开发过程中,排序的数据往往是10个、100个、1000个这样规模很小的数据,所以在比较同阶复杂度的排序算法时,这些系数、常数、低阶不能省略。
∙比较次数和交换(或移动)次数
在基于比较的算法中,会涉及到元素比较和元素交换等操作。
所以分析的时候,还需要对比较次数和交换次数进行分析。
1.2.内存消耗
内存消耗其实就是空间复杂度。
针对排序算法来说,如果该排序算法的空间复杂度为O
(1),那么这个排序算法又称为原地排序。
1.3.稳定性是什么
稳定性是指待排序的序列中存在值相等的元素。
在排序之后,相等元素的前后顺序跟排序之前的是一样的。
为什么
我们将排序的原理和实现排序时用的大部分都是整数,但是实际开发过程中要排序的往往是一组对象,而我们只是按照对象中的某个key来进行排序。
比如一个对象有两个属性,下单时间和订单金额。
在存入到数据库的时候,这些对象已经按照时间先后的顺序存入了。
但是我们现在要以订单金额为主key,在订单金额相同的时候,以下单时间为key。
那么在采用稳定的算法之后,只需要按照订单金额进行一次排序即可。
比如有这么三个数据,第一个数据是下单时间、第二数据是订单金额:
(20200515、20)、(20200516、10)、(20200517、30)、(20200518、20)。
在采用稳定的算法之后,排序的情况如下:
(20200516、10)、(20200515、20)、(20200518、20)、(20200517、30)可以发现在订单金额相同的情况下是按订单时间进行排序的。
2.经典的常用排序算法
2.1.冒泡排序
冒泡排序就是依次对两个相邻的元素进行比较,然后在不满足大小条件的情况下进行元素交换。
一趟冒泡排序下来至少会让一个元素排好序(元素排序好的区域相当于有序区,因此冒泡排序中相当于待排序数组分成了两个已排序区间和未排序区间)。
因此为了将n个元素排好序,需要n-1趟冒泡排序(第n趟的时候就不需要)。
下面用冒泡排序对这么一组数据4、5、6、3、2、1,从小到大进行排序。
第一次排序情况如下:
可以看出,经过一次冒泡操作之后,6这个元素已经存储在正确的位置上了,要想完成有所有数据的排序,我们其实只需要5次这样的冒泡排序就行了。
图中给出的是带第6次了的,但是第6次其实没必要。
2.1.1.优化
使用冒泡排序的过程中,如果有一趟冒泡过程中元素之间没有发生交换,那么就说明已经排序好了,可以直接退出不再继续执行后续的冒泡操作了。
2.1.2.实现
下面的冒泡排序实现是优化之后的:
/**
*冒泡排序:
*以升序为例,就是比较相邻两个数,如果逆序就交换,类似于冒泡;
*一次冒泡确定一个数的位置,因为要确定n-1个数,因此需要n-1
*次冒泡;
*冒泡排序时,其实相当于把整个待排序序列分为未排序区和已排序区
*/
publicvoidbubbleSort(int[]arr,intlen){
//len-1趟
for(intj=0;jintsortedFlag=0;
//一趟冒泡
for(inti=0;iif(arr[i]>arr[i+1]){
inttemp=arr[i];
arr[i]=arr[i+1];
arr[i+1]=temp;
sortedFlag=1;
}
}
//该趟排序中没有发生,表示已经有序
if(0==sortedFlag){
break;
}
}
}
2.1.3.算法分析
∙冒泡排序是原地排序。
因为冒泡过程中只涉及到相邻数据的交换,相当于只需要开辟一个内存空间用来完成相邻的数据交换即可。
∙在元素大小相等的时候,不进行交换,那么冒泡排序就是稳定的排序算法。
∙冒泡排序的时间复杂度。
▪当元素已经是排序好了的,那么最好情况的时间复杂度是O(n)。
因为只需要跑一趟,然后发现已经排好序了,那么就可以退出了。
▪当元素正好是倒序排列的,那么需要进行n-1趟排序,最坏情况复杂度为O(n^2)。
▪一般情况下,平均时间复杂度是O(n^2)。
使用有序度和逆序度的方法来求时间复杂度,冒泡排序过程中主要是两个操作:
比较和交换。
每交换一次,有序度就增加一,因此有序度增加的次数就是交换的次数。
又因为有序度需要增加的次数等于逆序度,所以交换的次数其实就等于逆序度。
因此当要对包含n个数据的数组进行冒泡排序时。
最坏情况下,有序度为0,那么需要进行n*(n-1)/2次交换;最好情况下,不需要进行交换。
我们取中间值n*(n-1)/4,来表示初始有序度不是很高也不是很低的平均情况。
由于平均情况下需要进行n*(n-1)/4次交换,比较操作肯定比交换操作要多。
但是时间复杂度的上限是O(n^2),所以平均情况下的时间复杂度就是O(n^2)。
这种方法虽然不严格,但是很实用。
主要是因为概率的定量分析太复杂,不实用。
(PS:
我就喜欢这种的)
”
2.2.插入排序
插入排序中将数组中的元素分成两个区间:
已排序区间和未排序区间(最开始的时候已排序区间的元素只有数组的第一个元素),插入排序就是将未排序区间的元素依次插入到已排序区间(需要保持已排序区间的有序)。
最终整个数组都是已排序区间,即排序好了。
**假设要对n个元素进行排序,那么未排序区间的元素个数为n-1,因此需要n-1次插入。
插入位置的查找可以从尾到头遍历已排序区间也可以从头到尾遍历已排序区间。
如图所示,假设要对4、5、6、1、3、2进行排序。
左侧橙红色表示的是已排序区间,右侧黄色的表示未排序区间。
整个插入排序过程如下所示
2.2.1.优化
∙采用希尔排序的方式。
∙**使用哨兵机制。
**比如要排序的数组是[2、1、3、4],为了使用哨兵机制,首先需要将数组的第0位空出来,然后数组元素全都往后移动一格,变成[0、2、1、3、4]。
那么数组0的位置用来存放要插入的数据,这样一来,判断条件就少了一个,不用再判断j>=0这个条件了,只需要使用arr[j]>arr[0]的条件就可以了。
因为就算遍历到下标为0的位置,由于0处这个值跟要插入的值是一样的,所以会退出循环,不会出现越界的问题。
2.2.2.实现
这边查找插入位置的方式采用从尾到头遍历已排序区间,也没有使用哨兵。
/**
*插入排序:
*插入排序也相当于把待排序序列分成已排序区和未排序区;
*每趟排序都将从未排序区选择一个元素插入到已排序合适的位置;
*假设第一个元素属于已排序区,那么还需要插入len-1趟;
*/
publicvoidinsertSort(int[]arr,intlen){
//len-1趟
for(inti=1;i//一趟排序
inttemp=arr[i];
intj;
for(j=i-1;j>=0;j--){
if(arr[j]>temp){
arr[j+1]=arr[j];
}else{
break;
}
}
arr[j+1]=temp;
}
}
2.2.3.算法分析
∙插入排序是原地算法。
因为只需要开辟一个额外的存储空间来临时存储元素。
∙当比较元素时发现元素相等,那么插入到相等元素的后面,此时就是稳定排序。
也就是说只有当有序区间中的元素大于要插入的元素时才移到到后面的位置,不大于(小于等于)了的话直接插入。
∙插入排序的时间复杂度。
▪待排序的数据是有序的情况下,不需要搬移任何数据。
那么采用从尾到头在已排序区间中查找插入位置的方式,最好时间复杂度是O(n)。
▪待排序的数据是倒序的情况,需要依次移动1、2、3、...、n-1个数据,因此最坏时间复杂度是O(n^2)。
▪平均时间复杂度是O(n^2)。
因此将一个数据插入到一个有序数组中的平均时间度是O(n),那么需要插入n-1个数据,因此平均时间复杂度是O(n^2)
最好的情况是在这个数组中的末尾插入元素的话,不需要移动数组,时间复杂度是O
(1),假如在数组开头插入数据的话,那么所有的数据都需要依次往后移动一位,所以时间复杂度是O(n)。
往数组第k个位置插入的话,那么k~n这部分的元素都需要往后移动一位。
因此此时插入的平均时间复杂度是O(n)。
2.2.4.VS冒泡排序
冒泡排序和插入排序的时间复杂度都是O(n^2),都是原地稳定排序。
而且冒泡排序不管怎么优化,元素交换的次数是一个固定值,是原始数据的逆序度。
插入排序是同样的,不管怎么优化,元素移动的次数也等于原始数据的逆序度。
但是,从代码的实现上来看,冒泡排序的数据交换要比插入排序的数据移动要复杂,冒泡排序需要3个赋值操作,而插入排序只需要一个赋值操作。
所以,虽然冒泡排序和插入排序在时间复杂度上都是O(n^2),但是如果希望把性能做到极致,首选插入排序。
其实该点分析的主要出发点就是在同阶复杂度下,需要考虑系数、常数、低阶等。
2.3.选择排序
选择排序也分为已排序区间和未排序区间(刚开始的已排序区间没有数据),选择排序每趟都会从未排序区间中找到最小的值(从小到大排序的话)放到已排序区间的末尾。
2.3.1.实现
/**
*选择排序:
*选择排序将待排序序列分成未排序区和已排序区;
*第一趟排序的时候整个待排序序列是未排序区;
*每一趟排序其实就是从未排序区选择一个最值,放到已排序区;
*跑len-1趟就好
*/
publicvoidswitchSort(int[]arr,intlen){
//len-1趟,0-i为已排序区
for(inti=0;iintminIndex=i;
for(intj=i+1;jif(arr[j]minIndex=j;
}
}
if(minIndex!
=i){
inttemp=arr[i];
arr[i]=arr[minIndex];
arr[minIndex]=temp;
}
}
}
2.3.2.算法分析
∙选择排序是原地排序,因为只需要用来存储最小值所处位置的额外空间和交换时所需的额外空间。
∙选择排序不是一个稳定的算法。
因为选择排序是从未排序区间中找一个最小值,并且和前面的元素交换位置,这会破坏稳定性。
比如1、5、5、2这样一组数据中,使用排序算法的话。
当找到2为5、5、2当前未排序区间最小的元素时,2会与第一个5交换位置,那么两个5的顺序就变了,就破坏了稳定性。
∙时间复杂度分析。
最好、最坏、平均都是O(n^2),因为无论待排序数组情况怎么样,就算是已经有序了,都是需要依次遍历完未排序区间,需要比较的次数依次是n-1、n-2,所以时间复杂度是O(n^2)。
2.4.归并排序(MergeSort)
归并排序的核心思想就是我要对一个数组进行排序:
首先将数组分成前后两部分,然后对两部分分别进行排序,排序好之后再将两部分合在一起,那整个数组就是有序的了。
对于分出的两部分可以采用相同的方式进行排序。
**这个思想就是分治的思想,就是先将大问题分解成小的子问题来解决,子问题解决之后,大问题也就解决了。
而对于子问题的求解也是一样的套路。
这个套路有点类似于递归的方式,所以分治算法一般使用递归来实现。
分治是一种解决问题的处理思想,而递归是一种实现它的编程方法。
2.4.1.实现
下面使用递归的方式来实现归并排序。
递归的递推公式是:
merge_sort(p...r)=merge(merge_sort(p...q),merge_sort(q+1...r)),终止条件是p>=r,不再递归下去了。
整个实现过程是先调用 __mergeSort() 函数将两部分分别排好序,之后再使用数组合并的方式将两个排序好的部分进行合并。
/**
*归并排序
*/
publicvoidmergeSort(int[]arr,intlen){
__mergerSort(arr,0,len-1);
}
privatevoid__mergerSort(int[]arr,intbegin,intend){
if(begin==end){
return;
}
__mergerSort(arr,begin,(begin+end)/2);
__mergerSort(arr,(begin+end)/2+1,end);
merge(arr,begin,end);
return;
}
privatevoidmerge(int[]arr,intbegin,intend){
int[]copyArr=newint[end-begin+1];
System.arraycopy(arr,begin,copyArr,0,end-begin+1);
intmid=(end-begin+1)/2;
inti=0;//begin-mid的指针
intj=mid;//mid-end的指针
intcount=begin;//合并之后数组的指针
while(i<=mid-1&&j<=end-begin){
arr[count++]=copyArr[i]copyArr[i++]:
copyArr[j++];
}
while(i<=mid-1){
arr[count++]=copyArr[i++];
}
while(j<=end-begin){
arr[count++]=copyArr[j++];
}
}
2.4.2.算法分析
∙归并排序可以是稳定的排序算法,只要确保合并时,如果遇到两个相等值的,前半部分那个相等的值是在后半部分那个相等的值的前面即可保证是稳定的排序算法。
∙归并排序的时间复杂度为O(nlogn),无论是最好、最坏还是平均情况都一样。
归并的时间复杂度分析则是递归代码的时间复杂度的分析。
假设求解问题a可以分为对b、c两个子问题的求解。
那么问题a的时间是T(a)、求解b、c的时间分别是T(b)和T(c),那么T(a)=T(b)+T(c)+K。
k等于将b、c两个子问题的结果合并问题a所消耗的时间。
套用上述的套路,假设对n个元素进行归并排序需要的时间是T(n),子问题归并排序的时间是T(n/2),合并操作的时间复杂度是O(n)。
所以,T(n)=2*T(n/2)+O(n),T
(1)=C。
最终得到:
T(n)=2*T(n/2)+n
=2*(2*T(n/4)+n/2)+n=2^2*T(n/4)+2*n
=2^2*(2*T(n/8)+n/4)+2*n=2^3*T(n/8)+3*n
=....
=2^k*T(n/2^K)+k*n
=....
=2^(log_2^n)*T
(1)+log_2^n*n
最终得到 ,使用大O时间复杂表示T(n)=O(nlogn)。
归并排序中,无论待排数列是有序还是倒序,最终递归的层次都是到只有一个数组为主,所以归并排序跟待排序列没有什么关系,最好、最坏、平均的时间复杂度都是O(nlogn)。
∙归并排序并不是原地排序,因为在归并排序的合并函数中,还需要额外的存储空间,这个存储空间是O(n)。
递归过程中,空间复杂度并不能像时间复杂度那样累加。
因为在每次递归下去的过程中,虽然合并操作都会申请额外的内存空间,但是合并之后,这些申请的内存空间就会被释放掉。
因此其实主要考虑最大问题合并时所需的空间复杂度即可,该空间复杂度为O(n)。
2.5.快速排序(QuickSort)
快速排序利用的也是分治思想,核心思想是从待排数组中选择一个元素,然后将待排数组划分成两个部分:
左边部分的元素都小于该元素的值,右边部分的元素都大于该元素的值,中间是该元素的值。
然后对左右两个部分套用相同的处理方法,也就是将左边部分的元素再划分成左右两部分,右边部分的元素也再划分成左右两部分。
以此类推,当递归到只有一个元素的时候,就说明此时数组是有序了的。
2.5.1.实现
首先要对下标从begin到end之间的数据进行分区,可以选择begin到end之间的任意一个数据作为pivot(分区点),一般是最后一个数据作为分区点。
之后遍历begin到end之间的数据,将小于pivot的放在左边,大于的pivot的放在右边,将pivot放在中间(位置p)。
经过这一操作之后,数组begin到end之间的数据就被分成了三个部分:
begin到p-1、p、p+1到end。
最后,返回pivot的下标。
那么这个过程一般有三种方式:
∙首先说明这种方法不可取。
在不考虑空间消耗的情况下,分区操作可以非常简单。
使用两个临时数组X和Y,遍历begin到end之间的数据,将小于pivot的数据都放到数组X中,将大于pivot的数据都放到数组Y中,最后将数组X拷贝到原数组中,然后再放入pivot,最后再放入数组Y。
但是采用这种方式之后,快排就不是原地排序算法了,因此可以采用以下两种方法在原数组的基础之上完成分区操作。
∙第一种方法还是使用两个指针:
i和j,i和j一开始都放置在begin初。
之后j指针开始遍历,如果j指针所指的元素小于等于pivot,那么则将j指针的元素放到i指针的处,i 指针的元素放置于j处,然后i后移,j后移。
如果j指针所指的元素大于pivot那么j后移即可。
首先个人觉得其实整个数组被分成三个区域:
0-i-1的为小于等于pivot的区域,i-j-1为大于pivot的区域,j之后的区域是未排序的区域。
∙第二种方法还是使用两个指针:
i和j,i从begin处开始,j从end处开始。
首先j从end开始往前遍历,当遇到小于pivot的时候停下来,然后此时i从begin开始往后遍历,当遇到大于pivot的时候停下来,此时交换i和j处的元素。
之后j继续移动,重复上述过程,直至i>=j。
在返回pivot的下标q之后,再根据分治的思想,将begin到q-1之间的数据和下标q+1到end之间的数据进行递归。
这边一定要q-1和q+1而不能是q和q+1是因为:
考虑数据已经有序的极端情况,一开始是对begin到end;当分区之后q的位置还是end的位置,那么相当于死循环了。
最终,当区间缩小至1时,说明所有的数据都有序了。
如果用递推公式来描述上述的过程的话,递推公式:
quick_sort(begin...end)=quick_sort(begin...q-1)+quick_sort(q+1...end),终止条件是:
begin>=end。
将这两个公式转化为代码之后,如下所示:
/**
*快速排序
*/
publicvoidquickSort(int[]arr,intlen){
__quickSort(arr,0,len-1);
}
//注意边界条件
privatevoid__quickSort(int[]arr,intbegin,intend){
if(begin>=end){
return;
}
//一定要是p-1!
intp=partition(arr,begin,end);//先进行大致排序,并获取区分点
__quickSort(arr,begin,p-1);
__quickSort(arr,p+1,end);
}
privateintpartition(int[]arr,intbegin,intend){
intpValue=arr[end];
//整两个指针,两个指针都从头开始
//begin---i-1(含i-1):
小于pValue的区
//i---j-1(含j-1):
大于pValue的区
//j---end:
未排序区
inti=begin;
intj=begin;
while(j<=end){
if(arr[j]<=pValue){
inttemp=arr[j];
arr[j]=arr[i];
arr[i]=temp;
i++;
j++;
}else{
j++;
}
}
returni-1;
}
2.5.2.优化
∙由于分区点很重要(为什么重要见算法分析),因此可以想方法寻找一个好的分区点来使得被分区点分开的两个分区中,数据的数量差不多。
下面介绍两种比较常见的算法:
▪**三数取中法。
就是从区间的首、尾、中间分别取出一个数,然后对比大小,取这3个数的中间值作为分区点。
**但是,如果排序的数组比较大,那“三数取中”可能不够了,可能就要“五数取中”或者“十数取中”,也就是间隔某个固定的长度,取数据进行比较,然后选择中间值最为分区点。
▪随机法。
随机法就是从排序的区间中,随机选择一个元素作为分区点。
随机法不能保证每次分区点都是比较好的,但是从概率的角度来看,也不太可能出现每次分区点都很差的情况。
所以平均情况下,随机法取分区点还是比较好的。
∙递归可能会栈