一. 排序相关的概念
1. 排序的稳定性
如下图,有ki=kj,且ri在排序前的序列中领先于rj。在排序之后,如果ri仍领先于rj,则这个排序是稳定的,反之则不稳定。
2. 本文中排序用到的结构和函数
#define MAXSIZE 10
typedef struct{
int r[MAXSIZE+1];//r[0]作为哨兵或临时变量
int length;
}SqList;
void swap(SqList* L,int i,int j){
int temp = L->r[i];
L->r[i]=L->r[j];
L->r[j]=temp;
}
二. 冒泡排序
1. 冒泡排序的基本思想
冒泡排序(Bubble Sort)一种交换排序,它的基本思想是:两两比较相邻记录的关键字,如果反序则交换,直到没有反序的记录为止。
//冒泡排序思想的体现(一种简单的交换排序)
void BubbleSort0(SqList* L){
int i,j;
for(i=1;i<L->length;i++){
for(j=i+1;j<=L->length;j++){
if(L->r[i] > L->r[j]){//保持r[i]位置上是目前最小的
swap(L,i,j);
}
}
}
}
每次从第当前元素找下去同时保证当前元素是最小的,但是可见,这个算法的效率是十分低的。
2. 冒泡排序算法
//冒泡排序
void BubbleSort(SqList* L){
int i,j;
for(i=1;i<L->length;i++){
for(j=L->length-1;j>=i;j--){
if(L->r[j] > L->r[j+1]){//若前者大于后者
swap(L,j,j+1);
}
}
}
}
从底部开始向上遍历,一出现前者大于后者的情况就交换两者的位置,保证最顶部是最小的,每遍历一轮缩小遍历的范围。
3. 冒泡排序的优化
void BubbleSort(SqList* L){
int i,j;
bool flag = true;//判断是否有数据交换,true表示有
for(i=1;i<L->length&&flag==ture;i++){
flag=false;
for(j=L->length-1;j<=i;j--){
if(L->r[j] > L->r[j+1]){
swap(L,j,j+1);
flag=true;
}
}
}
}
这种优化进一步提高了效率,假如第一遍遍历之后就排好序了,第二遍检查如果没有进行数据交换,则结束排序。
4. 时间复杂度
此处分析最坏的情况,即是从最底部到顶部每一次遍历都发生了数据的交换,此种情况下,O(n)=(n-1)+...+2+1=n(n-1)/2=O(n^2)
三. 简单选择排序
1. 简单选择排序算法
简单选择排序法(Simple Selection Sort)就是用一个容器装下当前位置的元素,在用此容器中的元素与接下来的元素进行比较,遍历到最后时,容器中装的元素一定是目前最小的,在改变(++)当前位置前,先用当前位置的元素与容器中的元素进行对比,如果不同,则把容器中的元素放到当前位置。
void SelectSort(SqList* L){
int i,j,min;
for(i=1;i<L->length;i++){
min = i;//用一个容器装下目前获取到的最小的元素
for(j=i+1;j<=L->lenght;j++){
if(L->r[min] > L->r[j]){
min = j;//更新容器中的元素,保证它目前是最小的
}
}
if(i!=min){//与原来不一样了(找到更小的了)
swap(L,i,min);
}
}
}
2. 简单选择排序的时间复杂度
无论最好还是最坏的情况下,都要进行1+2+...+(n-1) 次遍历,因此O(n)=O(n^2)。
四. 直接插入排序
1. 直接插入排序
直接插入排序(Straight Insertion Sort),在使用该方法排序时,将会先行判断按顺序遍历下去是否符合规定的顺序(下方中要求的是升序,即是后者大于前者),如果不符合,则把后者设置为哨兵(即添加到r[0]的位置),在从当前比较的两个元素的前一个开始往前遍历,并与哨兵比较大小,如果比哨兵大,则此位置的元素往后移给哨兵腾出位置,直到当哨兵比当前比较位置的元素大时,就找到了合适的位置放置哨兵。
void InsertSort(SqList* L){
int i,j;
for(i=2;i<=L->length;i++){
if(L->r[i] < L->r[i-1]){//比较相邻两个
L->r[0] = L->r[i];//如果后者小,就把它设为哨兵
for(j=i-1;L->r[j] > L->r[0];j--){//从i-1开始,与哨兵比大小
L->r[j+1]=L->r[j];//大于哨兵,则后移
}
L->r[j+1]=L->r[0];//在合适的位置放下哨兵,然后i自增
}
}
}
顺序遍历下去,已经排好序的会被跳过,当找到哨兵,会拿哨兵去与它前面的对比,如果比哨兵大,就后移让出位置。
2. 直接插入排序的时间复杂度分析
即是O(n^2)。但在排序记录是随机的情况下,直接插入排序的效率还是会比冒泡排序和简单选择排序高的。
比如:待排序记录本来就是基本有序的(小的基本在前面,大的基本在后面),或者,记录数较少。
五. 希尔排序
1. 希尔排序原理
希尔排序(Shell Sort)是由D.L.Shell于1959年提出的,也是第一批突破O(n^2)的排序算法之一。
void ShellSort(SqList* L)
{
int i,j;
int increment = L->length;
do
{
increment = increment/3+1;//设置增量序列长度
for(i=increment+1;i<=L->length;i++)
{//从第一个序列的下一个开始往后遍历
if(L->r[i] < L->r[i-increment])//不符合,i就自增了
{//如果前一个序列中的对应位置元素大于当前位置i的元素
L->r[0]=L->r[i];//当前i位置当作哨兵(或者说暂存在r[0])
for(j=i-increment;j>0&&L->r[0] < L->r[j];j-=increment)//判定条件是和上方的if一样的
L->r[j+increment]=L->r[j];
L->r[j+increment]=L->r[0];//14和15行,交换上面说的两个元素的位置
}
}
}
while(increment>1);//当增量序列的长度大于一(排序没结束),继续排序
}
可以说是采用了直接插入排序的思想进行了进一步的延伸,简述不太好说,具体可以根据代码的注释来进行整个过程,下面给出《大话数据结构》中详细的过程分析。
2. 希尔排序时间复杂度分析
希尔排序的关键并不是随便分组后各自排序,而是将相隔某个增量的记录组成子序列,实现跳跃式的移动,从而提高效率。
可见,increment的选择是十分重要的。但事实上,目前没人找出最好的增量序列。不过大量研究表明,当增量序列为
,效率会较高,其时间复杂度为O(n^3/2),要好于之前的算法。
需要注意的是,增量序列的最后一个增量值要为1。
此外,希尔排序不是一种稳定的排序算法。
六. 堆排序
1. 前言
空话不谈,堆是具有下列性质的完全二叉树:每个结点的值都大于或等于其左右孩子结点的值,称为大顶堆(上图左);或者每个结点的值都小于或等于其左右孩子结点的值,称为小顶堆(上图右)。
不妨用层序遍历把这两个堆存入数组。
对于堆:
2. 堆排序算法
堆排序(Heap Sort)就是利用堆(假设用大顶堆)进行排序的方法。基本思想是,将待排序的序列构造成一个大顶堆。把堆的根结点,与数组末尾的元素交换。然后将剩余的n-1个序列重新构造成一个堆,这样就会得到n个元素的次小值。如此反复,便能得到一个有序序列。
void HeapSort(SqList* L){
int i;
for(i=L->length/2;i>0;i--){//建堆
HeapAdjust(L,i,L->length);//构建成大顶堆,这个函数将在下面讲
}
for(i=L->length;i>1;i--){//排序
swap(L,1,i);//堆顶和未经排序的最后一个记录交换
HeapAdjust(L,1,i-1);//剩余的重新构成大顶堆
}
}
来看看关键的HeapAdjust是怎么实现的吧!可以参照一下上面的图。
void HeapAdjust(SqList* L,int s,int m){//s一开始是最后一个结点的父节点
int temp,j;//m一开始是最后一个叶子节点
temp=L->r[s];//保存父节点
for(j=2*s;j<=m;j*=2){
if(j<m && L->r[j] < L->r[j+1]){//判断孩子结点哪一个更大
j++;
}
if(temp>=L->r[j]){//如果父节点大于两个孩子结点,跳出此次循环
break;
}
L->r[s]=L->r[j];//否则交换位置(父节点位置上的数值被修改)
s=j;//修改父节点指针
}
L->r[s]=temp;//数组中插入
}
3. 堆排序时间复杂度
建堆的时间复杂度为:O(n)
排序的时间复杂度为:O(log n)
总的时间复杂度:O(nlog n)
稳定性方面:堆排序是不稳定的
另外要说到的是,在初始建堆是需要比较的次数较多,所以堆排序不太适合待排序序列个数较少的情况。
七. 归并排序
1. 归并排序算法(递归)
“归并”在数据结构中的含义就是将两个或两个以上的有序表组合成一个新的有序表。
归并排序(Merging Sort)就是把初始的n个记录,看作n个有序的子序列,然后两两归并,得到(n/2向上取整)个长度为2或1的有序子序列,再重复归并...,直到得到一个长度为n的有序序列为止。这种方法称为2路归并排序。
void MergeSort(SqList* L){
MSort(L->r,L->r,1,L->Length);
}
接下来看看MSort是怎么实现的。
void MSort(int SR[],int TR1[],int s,int t){//将SR[s..t]归并排序为TR1[s..t]
int m;
int TR2[MAXSIZE+1];
if(s==t){//看看需不需要要排序,比较的是传入的下标
TR1[s]=SR[s];
}
else{
m=(s+t)/2;//把SR均分成SR[s..m]和SR[m+1..t]
MSort(SR,TR2,s,m);//递归把SR[s..m]归并
MSort(SR,TR2,m+1,t);//递归把SR[m+1..t]归并
Merge(TR2,TR1,s,m,t);//把TR2[s..m]和TR2[m+1..t]归并到TR1[s..t]
}
}
整个归并的过程如下,Merge函数接下来会说到。
void Merge(int SR[],int TR[],int i,int m,int n){
int j,k,l;
for (j=m+1,k=i;i<=m && j<=n;k++){//一开始这里的i对应的是第一个数组中第一个元素,j则是第二个数组的第一个元素
if(SR[i] < SR[j])//比较一下两个数组的对应元素
TR[k]=SR[i++];//先把小的加入TR[],再让i自增
else
TR[k]=SR[j++];//此时是j小一点
}
if(i<=m){//如果i还没有走到第一个数组最后
for(l=0;l<=m-i;l++)//剩余的加入TR[]
TR[k+1]=SR[i+1];
}
if(j<=n){//同上
for(l=0;l<=n-j;l++)
TR[k+1]=SR[j+1];
}
}
递归中最外层的Merge处理的就是最后上图中最后两行,可以看着推导一下。
2. 归并排序(递归)时间复杂度分析
时间复杂度:n个记录扫描会花掉O(n),而排序需要进行(O(log2 n)向上取整)次,因此总的时间复杂度为O(nlog n)。
空间复杂度:(完全二叉树log2 n) + (原始记录序列n) = O(n+log n)。
稳定性:细看代码可知,Merge函数中总是两两比较,不存在跳跃,所以归并排序是稳定的。
总的来说,归并排序比较占用内存,但是效率高且具有稳定性。
3. 通过迭代实现归并排序
递归会造成时间和空间上的性能损耗,转化为迭代后可以在性能上进一步提高。在以下代码的注释中,不妨将子序列两两一组构成一个集合来区分。Merge函数的分析本节第一部分有谈到。
void MergeSort2(SqList* L){
int* TR=(int*)malloc(L->length*sizeof(int));//申请了新的数组来放结果
int k=1;
while(k<L->length){
MergePass(L->r,TR,k,L->length);
k=2*k;//子序列长度加倍
MergePass(TR,L->r,k,L->length);
k=2*k;//子序列长度加倍
}
}
void MergePass(int SR[],int TR[],int s,int n){//SR[]可看作由两个子序列数组构成,它是待排序的
int i=1;
int j;//用来记录位置(处理最后一个集合的)
while(i<=n-2*s+1){//当没有到达最后两个子序列组成的集合
Merge(SR,TR,i,i+s-1,i+2*s-1);//一开始,i是第一个子序列的第一个元素,i+s-1则是第二个子序列中的第一个元素
i=i+2*s;//跳到下两个子序列组成的集合的判断中去
}
if(i<n-s+1)//最后一个集合中有两个子序列
Merge(SR,TR,i,i+s-1,n);
else//最后的集合中只有一个子序列
for(j=i;j<=n;j++)
TR[j]=SR[j];//最后一个子序列插入TR[]
}
空间复杂度上,对比递归的方法,只是申请了归并临时用的TR[],因此O(n)。时间上,避免使用递归也一定程度上减小了时间复杂度。所以说,使用归并排序时,应尽量使用非递归的方法。
八. 快速排序
1. 快速排序算法
快速排序(Quick Sort)的关键是通过Patition函数找出中心点(枢轴),并采用递归的方式,不断把最大的待排序序列一分为二,进行排序。
void QuickSort(SqList* L){
QSort(L,1,L->length);
}
同样是递归的办法,接下来是QSort的具体内容。
//对顺序表L中的子序列L->[low..high]作快速排序,low、high当然是下标
void QSort(SqList* L,int low,int high){
int pivot;//中心点(枢轴值)
if(low<high){
pivot=Partition(L,low,high);//分割,记录下中心点pivot(枢轴值)
QSort(L,low,pivot-1);//递归排序低子表
QSort(L,pivot+1,high);//递归排序高子表
}
}
Partition函数所做的,就是选取一个关键字,把它放到一个位置,是左边的之都比它小,右边的值都比它大。比如{50,10,90,30,70,40,80,60,20}转换为{20,10,40,30,50,70,80,60,90}并把50的下标返回给pivot。之后又在{20,10,40,30}和{70,80,60,90}中分别递归调用QSort来进行Partition操作。
int Patition(SqList* L,int low,int high){//low和high拿进来的是下标
int pivotkey;//中心值
pivotkey=L->r[low];//把第一个记录作为中心值(枢轴值)
while(low<high){//当两个指针没碰上
//接下来会从表的两边交替向中间扫描
while(low<high&&L->r[high] >= pivotkey)//当遍历的下标没有撞到一起,且高位的大于中心值
high--;//high指针前移,继续向前遍历,找到比中心值小的就会跳出循环
swap(L,low,high);//把找出来比中心值小的放到(low)去
while(low<high&&L->r[low]<=pivotkey)//当遍历的下标没有撞到一起,且低位的小于中心值
low++;//继续向后遍历,找到比中心值大的就跳出循环
swap(L,low,high);//把比中心值大的扔到(high)去
}
return low;//最后遍历完low将指向中心点
}
可以用{20,10,40,30}这个序列来实践一下。
2. 快速排序复杂度分析
下图是{50,10,90,30,70,40,80,60,20}的递归树,第一个关键字是50,正好是待排序的序列的中间值,因此递归树是平衡的,性能上看很不错。
最优情况下,递归树的深度为((log2 n向下取整)+1),即仅需递归log2 n次,最后时间复杂度为O(nlog n)。
最坏情况下,用递归树画出来是一棵斜树(只有一支的二叉树),此时需要n-1次递归调用,且第i次划分需要经过n-1次比较才能找到中心值(枢轴值),T(n)=(n-1)+(n-2)+...+1=O(n^2)。
平均情况下,O(nlog n)。
稳定性上,比较和交换是跳跃的,所以不是一种稳定的排序。