排序分为比较排序和非比较排序,这里先介绍比较排序
插入排序:直接插入排序,希尔排序
交换排序:冒泡排序,快速排序
选择排序:直接选择排序, 堆排序
最后是归并排序。
基于比较的排序都是遵循“决策树模型”,而在决策树模型中,我们能证明给予比较的排序算法最坏情况下的运行时间为Ω(nlgn),证明的思路是因为将n个序列构成的决策树的叶子节点个数至少有n!,因此高度至少为nlgn。(算法导论)
非比较排序:基数排序,计数排序,桶排序
线性时间排序虽然能够理想情况下能在线性时间排序,但是每个排序都需要对输入数组做一些假设,比如计数排序需要输入数组数字范围为[0,k]等。
图片来源于网络
文章将从以下几个角度来阐述这些算法:(面试常考点)
1. 每个算法的基本思想
2. 每个算法的稳定性
3. 每个算法的时间复杂度,最好、最坏以及平均
4. 参考代码
5. 是否需要辅助空间
写在文章开始的话:
是否需要额外空间的分类:当排序数据非常多的时候,额外空间会非常大,老式电脑会吃不消
In-place sort(不占用额外内存或占用常数的内存):插入排序、希尔排序、选择排序、冒泡排序、堆排序、快速排序。
Out-place sort:归并排序、计数排序、基数排序、桶排序(后三种属于分比较排序)
是否稳定 的分类:是否稳定的一种简单的判别方式:是都有跨越距离的交换(该距离内的数据没有进行比较),如果有,一般都为不稳定算法;若否,则一般为稳定算法
stable sort:插入排序、冒泡排序、归并排序、计数排序、基数排序、桶排序。
unstable sort:选择排序(5 8 5 2 9)、快速排序、堆排序。
是否稳定有一点要注意:需要注意的是,排序算法是否为稳定的是由具体算法决定的,不稳定的算法在某种条件下可以变为稳定的算法,而稳定的算法在某种条件下也可以变为不稳定的算法。
例如大于小于等于号的问题,该是大于不要写大于等于,否则很可能改变稳定性。 冒泡排序中的比较,>写成>=,两个数相等也会交换位置,这样就改变了稳定性!!
为什吗要考虑稳定性
排序算法如果是稳定的,那么从一个键上排序,然后再从另一个键上排序,第一个键排序的结果可以为第二个键排序所用。基数排序就是这样,先按低位排序,逐次按高位排序,低位排序后元素的顺序在高位也相同时是不会改变的
1. 直接插入排序
思想:插入排序就是每一步都将一个待排数据按其大小插入到已经排序的数据中的适当位置,直到全部插入完毕。
插入排序的工作方式像玩扑克牌时顺序放牌一样。开始时,左手为空并且桌子上的牌面向下。然后,每次从桌上拿一张牌并将它插入左手中正确的位置。为了找到正确的位置,我们从右到左将它与已在手中的每张牌比较,原来的牌是排好序的,如果比刚拿出的牌大,则将其再向右移一个位置,直到找到一个比刚拿出来的牌更小的,此时将这张牌放到该位置。
稳定性:在插入排序中,K1是已排序部分中的元素,当K2和K1比较时,直接插到K1的后面(没有必要插到K1的前面,这样做还需要移动!!),因此,插入排序是稳定的。
时间复杂度
最好的情况下:正序有序(从小到大),这样只需要比较n次,不需要移动。因此时间复杂度为O(n)
最坏的情况下:逆序有序,这样每一个元素就需要比较n次,共有n个元素,因此实际复杂度为O(n2)
平均情况下:O(n2)
辅助空间:无
代码
void insertSort(int A[],int n){
for(int i=1;i<n;++i){
int j=i-1;
int temp=A[i];
while(j>0 && A[j]>A[i]){
A[j+1]=A[j];
--j;
}
A[j+1]=temp;
}
}
2. 希尔排序
思想:希尔排序也是一种插入排序方法,实际上是一种分组插入方法。先取定一个小于n的整数d1作为第一个增量,把表的全部记录分成d1个组,所有距离为d1的倍数的记录放在同一个组中,在各组内进行直接插入排序;然后,取第二个增量d2(<d1),重复上述的分组和排序,直至所取的增量dt=1(dt < dt-1 < … < d2 < d1),即所有记录放在同一组中进行直接插入排序为止
稳定性:不稳定
由于多次插入排序,我们知道一次插入排序是稳定的,不会改变相同元素的相对顺序,但在不同的插入排序过程中,相同的元素可能在各自的插入排序中移动,最后其稳定性就会被打乱,所以shell排序是不稳定的。(有个猜测,方便记忆:一般来说,若存在不相邻元素间交换,则很可能是不稳定的排序。)
时间复杂度
最好情况:由于希尔排序的好坏和步长d的选择有很多关系,因此,目前还没有得出最好的步长如何选择(现在有些比较好的选择了,但不确定是否是最好的)。所以,不知道最好的情况下的算法时间复杂度。
最坏情况下:O(N*logN),最坏的情况下和平均情况下差不多。
平均情况下:O(N*logN)
额外空间:无
代码
void shellSort(int A[],int n){
int d=n/2
int j=0,temp=0;
while(d>0){
//内部插入排序
for(int i=d;i<n;i++){
j=i-d;
temp=A[i];
while(j>0 && A[j]>temp){
A[j+d]=A[j];
j-=d;
}
A[j+d]=temp;
}
d=d/2;
}
}
3.直接选择排序
思想:选择一个值array[0]作为标杆,然后循环找到除这个值外最小的值(查找小于标杆的最小值),交换这两个值,这时最小值就被放到了array[0]上,然后再将array[1]作为标杆,从剩下未排序的值中找到最小值,并交换这两个值
稳定性:由于每次都是选取未排序序列中的最小元素x与该序列中的第一个元素交换,因此跨距离了,很可能破坏了元素间的相对位置,因此选择排序是不稳定的!例如:5 4 6 5 3 7 第一次选择3 和第一个5 交换,就改变了稳定性
时间复杂度:
平均情况:循环n次,每一次都要寻找最小值,所以是O(n*n)
最好情况:最坏情况:循环n次,若有序,虽然能第一次选择到最小值,但是还是会和其他数字比较,所以还是O(n*n)。但是减少了交换次数
是否需要额外空间:不需要
代码 :
void selectSort(int A[],int n){
int min=0;
for(int i=0;i<n-1;++i){
min=A[i];
for(int j=i+1;j<n;++j){
if(A[j]<min)
min=A[j];
}
if(min!=A[i])
swap(&A[i],&min);
}
}
4.堆排序
思想:利用完全二叉树中双亲节点和孩子节点之间的内在关系,在当前无序区中选择关键字最大(或者最小)的记录。也就是说,以最小堆为例,根节点为最小元素,较大的节点偏向于分布在堆底附近。
若想了解堆排序,要了解最大堆性质,如何建堆,再进行堆排序这三个步骤(后续将从这三个方面进行阐述)
稳定性:堆排序要进行跨越式的交换,不稳定
时间复杂度:待续
代码:待续
5.冒泡排序(交换排序)
思想:将相邻的两个数比较,将较小的数调到前头;有n个数就要进行n-1趟比较,第一次比较中要进行n-1次两两比较,在第j趟比较中,要进行n-j次两两比较。
依次比较相邻的两个数,将小数放在前面,大数放在后面。即首先比较第1个和第2个数,将小数放前,大数放后。然后比较第2个数和第3个数,将小数放前,大数放后,如此继续,直至比较最后两个数,将小数放前,大数放后。重复以上过程,直至最终完成排序。
稳定性:两两交换,一定是稳定的
复杂度:
对于原始未改善的冒泡排序,时间复杂度三种情况都是O(n*n),因为无论是否有序,都要两两进行比较;
但是针对于有序序列,可以加入一个变量,在一趟比较中如果一次交换都没有发生,则通过变量退出循环排序,因为这时候已经是排序好的序列了。这时候复杂度最好是O(n)。
代码
//1
void bubbleSort(int A[],int n){
for(int i=0;i<n-1;++i){//一共进行n趟,每趟中两两比较
for(int j=n-1;j>i;--j){
if(A[j]<A[j-1])
swap(&A[j],&A[j-1]);
}
}
}
//2 改进
void bubbleSort(int A[],int n){
for(int i=0;i<n-1;++i){//一共进行n趟,每趟中两两比较
bool flag=false;
for(int j=n-1;j>i;--j){
if(A[j]<A[j-1])
swap(&A[j],&A[j-1]);
flag=true;
}
if(!flag)
break;
}
}
6.快速排序(交换排序)
思想它是由冒泡排序改进而来的。在待排序的n个记录中任取一个记录(通常取第一个记录),把该记录放入适当位置后,数据序列被此记录划分成两部分。所有关键字比该记录关键字小的记录放置在前一部分,所有比它大的记录放置在后一部分,并把该记录排在这两部分的中间(称为该记录归位),这个过程称作一趟快速排序。
稳定性:由于每次都需要和中轴元素交换,因此原来的顺序就可能被打乱。如序列为 5 3 3 4 3 8 9 10 11会将3的顺序打乱。所以说,快速排序是不稳定的!
复杂度:
平均时间:O(nlgn)
最坏情况:有序 (正序和逆序)的情况下,基本退化为冒泡排序。O(n*n)
正序:每次一分为二时,中枢都是第一个元素,只有一个部分(右)进入下一个循环。
逆序:一分为二后,左右两个部分交替轮流进行下一次循环,(不是同时进行,)其本质是第一个和最后一个交换
代码
void quickSort(int A[],int s,int e){
if(s>e) return;
int i=s;
int j=e;
int m=(s+e)/2;
int key=A[s];
while(i<j){
while(i<j && A[j]>=key)
j--;
A[i]=A[j];
while(i<j && A[i]<=key)
++i;
A[j]=A[i];
}
A[i]=key;
quickSort(A,s,i-1);
quickSort(A,i+1,e);
}
7.归并排序
思想:其采用分治的思想,先将问题拆解成相同小问题。本质是将多个有序表合并成一个有序表
稳定性:因为其不会有跨越式交换位置,所以为稳定排序
额外空间:需要O(n)的额外空间。
时间复杂度:对长度为n的文件,需进行logN 趟二路归并,每趟归并的时间为O(n),故其时间复杂度无论是在最好情况下还是在最坏情况下均是O(nlgn)。
分治法通常有3步:Divide(分解子问题的步骤) 、 Conquer(递归解决子问题的步骤)、 Combine(子问题解求出来后合并成原问题解的步骤)。
假设Divide需要f(n)时间,Conquer分解为b个子问题,且子问题大小为a,Combine需要g(n)时间,则递归式为:
T(n)=bT(n/a)+f(n)+g(n)
算法导论思考题4-3(参数传递)能够很好的考察对于分治法的理解。
就如归并排序,Divide的步骤为m=(p+q)/2,因此为O(1),Combine步骤为merge()函数,Conquer步骤为分解为2个子问题,子问题大小为n/2,因此:
归并排序的递归式:T(n)=2T(n/2)+O(n)
代码:
void mergeSort(int A[],int s,int e){
if(s>e) return;
int m=(s+e)/2;
mergeSort(A,s,m);
mergeSort(A,m+1,e);
//合并过程
int a=m-s+1;
int b=e-m;//e-(m+1)+1
int arr1[a]={0};
int arr2[b]={0};
for(int i =0;i<a;++i){
arr1[i]=A[s+i];
}
for(int j=0;j<b;++j){
arr2[j]=A[m+1+j];
}
i=j=0;
for(int k=s;k<e;++k){
if(i<a && j<b){
if(arri[i]<arr2[j]){
A[k]=arr1[i];
++i;
}
else{
A[k]=arr2[j];
++j;
}
}
}
while(i<a)
A[k++]=arr1[i++];
while(j<b)
A[k++]=arr2[j++];
}