八种常见排序算法
八种常见排序算法
初学数据结构,此博客为整理笔记,博客中所有图片均原创
若图片被吞,请看CSDN原文版本
以下根据算法是否基于比较分为两类,其中包括有
基于比较的排序:
冒泡排序
插入排序
选择排序
快速排序
合并排序
非基于比较的排序:
计数排序
桶排序
基数排序
以下论述的时候为描述方便默认待排序元素是整型且要求从小到大排序,待排序数组a。
1.1冒泡排序
冒泡排序的基本思想是,将待排序的元素看成一串气泡,较小的要往上浮,较大的要往下沉。每次处理时都自底而上地扫描一遍,若发现两相邻气泡顺序不对,则将轻的往上浮,即交换它们的位置。这样处理一趟后最轻的气泡就浮到了最高位置,在做第二遍处理的时候就可以略去这个位置的扫描。这样的扫描要做n-1趟。
原序列:
第一趟扫描完成:
第二趟扫描完成:
代码:
void bubbleSort(int a[],int n)
{
for(int i = 0; i < n-1; i++) //n-1趟
for(int j = n-1; j > i; j--) //自底向上遍历
if(a[j] < a[j-1]) swap(a[j],a[j-1]);
}
这样的算法复杂度是O(n^2)。
可以发现,当序列本身就是个几乎已经有序的序列时,其中做了大量的无用操作,那就是不停的比较。所以一个改进算法就是,当发现剩余序列已经是有序的之后,停止继续比较的操作。改进代码如下:
for(int i = 0; i < n-1; i++) //n-1趟
{
bool flag = true;
for(int j = n-1; j > i; j--) //自底向上遍历
if(a[j] < a[j-1])
{
flag = false;
swap(a[j],a[j-1]);
}
if(flag) return;
}
1.2插入排序
插入排序的基本思想是,假定待排序序列的前半段已经有序,在处理无序的后半段时,只要将它和前半段元素总后往前一一比对一下,确定应插入的位置,然后将它插入进去。
在排序a[n]时,将a[0]看做开始时有序的前半段,然后将1~n-1的元素插入进去。
原序列:
考虑1和3的位置关系,1小于3,插入到3之前。第一趟排序完成:
考虑6和3的位置关系,6大于3,插入到3之后。第二趟排序完成:
代码:
void insertSort(int a[],int n)
{
for(int i = 1; i < n; i++) //将1~n-1元素插入
{
int temp = a[i]; //temp为待插入元素
int j = i-1;
while(j >= 0 && a[j] > temp) //当j位置元素大于temp时,指针j往前移,同时将比temp大的元素往后移
{
a[j+1] = a[j];
j--;
}
a[j+1] = temp; //j+1即为temp的正确位置
}
}
考虑原序列是个从大到小的序列,那么比较和前移的个数每次都加1,最坏情况下需要做1+2+3+...+n-1次比较和移动,复杂度O(n^2)。但当最好情况也就是原序列就是个有序的序列时,需要做1+1+1+...+1次比较和移动,复杂度为O(n)。这比冒泡排序的无论序列如何都要做完全部的比较会更优,而且代码量比快排那些少,因此插入排序适合用于待排序序列大部分有序的情况。
1.3选择排序
选择排序的基本思想是,每次都从待排序序列中选出最小元素,和序列首元素交换。然后略去这个已在正确位置的最小元素,对剩余序列继续排序。
原序列:
扫描发现元素1最小,和序列首元素3交换。第一趟扫描完成:
略去原序列首元素,对剩余序列扫描发现元素3最小,和剩余序列首元素6交换。第二趟扫描完成:
代码:
void selectionSort(int a[],int n)
{
for(int i = 0; i < n-1; i++) //扫描n-1趟
{
int Min = INF;
int id;
for(int j = i+1; j < n; j++) //找到剩余序列最小元素位置
{
if(Min > a[j])
{
Min = a[j];
id = j;
}
}
swap(a[i],a[id]); //将剩余序列最小元素换到第i个位置
}
}
选择排序的时间复杂度也为O(n^2)。
它的改进为堆排序。堆排序用到的小根堆是一个完全二叉树,由于堆顶一定是最小元素,使得选择排序中选择最小元素这一步骤变得简单。它的时间复杂度是O(nlogn)。
1.4快速排序
快速排序是基于分治进行的。快排的基本思想是分为两步:①分解,对于一个选定的参照元素a[p],把序列分为小于a[p]和大于a[p]的左右两部分。当把所有小于a[p]的元素都放在它左边,所有大于a[p]元素都放它右边的时候,a[p]此时已经处在它的正确位置。②递归,分为左右两部分后,继续看做是一个规模变小了的原问题求解。每次都把一个元素放到正确的位置,做完所有子问题之后就得到了一个有序的序列。
代码:
void quickSort(int a[],int l,int r)
{
int p;
if(l >= r) return;
p = partition(a,l,r); //划分序列为两块
quickSort(a,l,p-1); //递归排序左侧子序列
quickSort(a,p+1,r); //递归排序右侧子序列
}
那么接下来只要解决怎么完成划分的问题。
考虑若选定的a[p]是序列首元素,指针i从p+1开始往右扫描,指针j从r开始往左扫描,当i遇到一个元素比a[p]大、j遇到元素比a[p]小时,交换这两个元素。直到i>j时停止,此时j的位置即是a[p]应在的位置。
扫描开始时,i = p+1,j = r:
i往右扫描,发现6大于5,i停下。j往左扫描,发现1小于5,停下。交换i、j所在位置元素。第一遍扫描结束:
i继续往右,停在元素7,j往左,停在元素4。发现i已经大于j,扫描结束。
此时j为a[p]应在位置,交换a[p]和a[j]。
int partition(int a[],int l,int r)
{
int i = l,j = r+1;
int temp = a[l];
while(1)
{
while(a[++i] < temp); //i向右扫描
while(a[--j] > temp); //j向左扫描
if(i >= j) break; //当i超过j时扫描结束
swap(a[i],a[j]); //交换i、j位置元素
}
swap(a[j],a[l]);
return j;
}
考虑快排算法的性能,显然与指定的参考元素有关。比如若每次都恰好指定的是最小值,那么每次划分都是原序列-1个元素的子序列,这时时间复杂度为O(n^2)。但在最好情况下,每次划分都取到中值,即每次划分都产生两个大小为n/2的子序列,那么复杂度为O(nlogn)。可以看出,基准值要越接近序列的中值越好。
选择基准值有多种方法,如:三数取中法,mid{a[l],a[(l+r)/2],a[r]},随机选择法等等各种。下面详细列出一个最坏情况下的线性时间选择算法。
step1:将n个输入元素划分成n/5(向上取整)个组,将每个小组排好序。
step2:将每个组的中值放到第一个空白组里,再取这个全为各小组中值的组里的中值,把它作为基准值。
在这种情况下,基准值x至少会比(n-5)/10个元素来得大,当n>=75时,这个基准值处在所有元素的1/4的位置。复杂度O(n)。
再看,基于比较的排序过程可画成一棵解答树。考虑n=3的比较排序:
从树根到达树叶有n!= 6条路径,若通过分治算法log(n!)即为nlogn级别的复杂度。
可以证明,基于比较的排序至少需要的复杂度即是O(nlogn)。
由于快排在平均情况下的时间复杂度是O(nlogn),而这个级别的复杂度在基于比较的排序中是最快的,所以得名快速排序。
1.5合并排序(归并排序)
合并排序也是一种基于分治的算法,基本思想是,考虑一个序列,它的左右两侧是两个已经有序的子序列,那么只要O(n)的合并一下就成为了一个有序序列。
先考虑合并过程。由于两块子序列都已是有序状态,每次比较a的两子序列的首元素,较小的元素即是全序列的最小值,存到b中。
继续往后扫描,直到其中一侧的全部元素被存到另一数组中,这时候只要把剩余的数全部存放到b的末尾去即是它们的正确位置。完成所有操作后的b即是顺序序列,把它拷贝回a。
开始合并之前:
第一次比对,1小于2,将1存到b末尾,i向后移:
第二次比对,2小于3,将2放到b末尾,j向后移:
代码:
void mergeSort(int a[],int l,int r)
{
int m = (l+r)/2;
if(l >= r) return;
mergeSort(a,l,m); //左段排序
mergeSort(a,m+1,r); //右段排序
int i = l,j = m+1;
int k = l;
while(i <= m && j <= r) //比对两子序列首元素
{
if(a[i] <a [j]) b[k++] = a[i++];
else b[k++] = a[j++];
}
while(i <= m) b[k++] = a[i++]; //处理剩余元素
while(j <= r) b[k++] = a[j++]; //处理剩余元素
for(int p = l; p <= r; p++) //把排序好的序列b中元素拷贝回a
a[p] = b[p];
}
这样的时间复杂度是O(nlogn),是一个渐进最优算法。
虽然归并排序的算法复杂度始终为nlogn级别,比起快排的最坏情况还是回到O(n^2)看起来好像更优,但是在空间复杂度上,它是一个非就地的排序,辅助空间有O(n)的b数组以及由于递归算法需要的栈空间O(logn),比快排更多。
因此一种优化方法是,在递归调用的时候,交替地把b和a作为辅助数组以省去复制操作。另外可以避免递归,采用自底而上的方式,先将a中相邻元素两两配对用合并算法排序,构成n/2组长度为2的数组段,然后再继续排序成长度为4的数组段直到整个数组排序完成。
由于基于比较的排序算法最优时间是nlogn,若要在线性时间内排序则要考虑非基于比较的排序算法。
2.1计数排序
计数排序的基本思想是,对于每个元素x,若已知比它小的元素有m个,那么它应在的位置就是m+1,但当多个元素的值相同时,不能够放在同个位置。因此,对于一个已知元素范围的序列,可以开一个元素范围大的count数组,当输入x时,count[x]++,然后遍历一遍,sum[i]等于count从头加到i的总和。于是sum[i]就是i这个位置元素应放的正确位置。当元素有相同值时,每将该元素放到正确位置时,sum的值-1,即将下一个相同元素放到它的前一个位置去。可以看到,这种排序算法是不稳定的。
放入元素的时候,1的sum值是1,于是将元素1放在1位置:
3的sum值是3,放在3位置,此时sum[3]-1:
往后排,一直到第二个3的时候,此时它的sum值是2,放到2位置:
这样可以用O(n)时间完成,但当元素范围很大或者元素很零散的时候,要花费大量的空间。
2.2桶排序
通排序和计数排序类似,基本思想是,设置若干个桶,将值为i的元素装入第i个桶里,然后按桶的顺序把它们连接起来。
收集的时候自桶低向上收集(箭头方向)。形成排序3,3,3,5,6,7,7。
2.3基数排序
基数排序是基于桶排序的多关键字排序。
如A1 < A2 < A3 < B1 < B2 < B3 < C1 < C2 < C3 这样字母有限于数字,并且多个关键字的排序可以用到基数排序。基数排序的基本思想是,多次分配多次收集。从末尾关键字开始往前,对每个关键字进行一次桶排序,最终得到顺序序列。
栋哥:你不是很喜欢吗,花了多少时间啊?
我:..其实并没有花多少时间orz
栋哥:没花时间说什么热爱!
我:..嘤嘤嘤orz