在我们刚上学时我们用C语言实现过冒泡排序,大家不知道还清楚吗,我是很清楚的,写起来非常难受,我只能通过上网查资料来获取。哎一言难尽。遥想当年,冒泡初学了,雄姿英发。现在接触的不仅是冒泡这一种算法了。还有很多,冒泡是交换排序的一种,接下来我们进入排序的世界。
一.交互排序
1.冒泡排序
冒泡排序实现思想很简单,用两个循环就能搞定。
void BubbleSort(int*a,int n)
{
int swapped;
for(int i=0;i<n-1;i++)
{
for(int j=0;j<n-i-1;j++)
{
if(a[j]>a[j+1])
{
swap(&a[j],&a[j+1]);
}
}
if(swap==0)
{
break;
}
}
}
2.快速排序(重中之重)
标准快速排序(挖坑法实现)
排序的思路是把一个数放到中间左边比他小,右边比他大。一趟下来从到。这样就实现了左边的比他小,右边的比他大。但是这样并不一定就有序了,大概率会出现左右两边并不有序。这样我们就可能会联想到二叉树时候的遍历递归CSDN。思路是什么呢?第一次选中的数不动,左边的数和第一次排法相同进行排排到有序后就停止。那么怎么才能停止呢?代码是怎么实现的呢?接下来我就用代码来实现一下。
//快速排序
void QuickSort(int* a, int left,int right)
{
if(left>=right)
{
return; //此if语句就是判断left与right是否相同,如果相同就表明左边仅有一个排完序了
}
int begin = left;
int end = right;
int key = a[begin];
int pivot = begin;
while (begin < end)
{
while (begin<end && a[end]>key)
{
--end;
}
a[pivot] = a[end];
pivot = end;
while (begin<end&&a[begin]<key)
{
begin++;
}
a[pivot] = a[begin];
pivot = begin;
}
pivot = begin;//pivot=end也ok
a[pivot] = key;
QuickSort(a, left, pivot - 1);//左边的排序
QuickSort(a, pivot + 1, right);
}
三数取中
快速排序的确很好在一般情况下排序的性能很好,但在有序的情况下会很糟糕。在把第一个数那么如何才能避免这种情况呢?三数取中非常巧妙的化解了这种情况。原理是:取出三数中的中间值即为不大不小的放在原先的left上面。把有序变成无序状态就会好很多。至于为什么有序的性能不好我会后面用时间复杂度来解释。那么三数取中的代码实现就很简单了。关键在于取出三数中的中间值。
int GetMidIndex(int*a,int*left,int*right)
{
int mid=left+right>>1; //相当于右移left+right/2
if(a[left]>a[mid])
{
if(a[mid]>a[right])
{
return mid;
}
else if(a[right]>a[left])
{
return left;
}
else
{
return right;
}
}
if(a[left]<a[mid])
{
if(a[mid]<a[right])
{
return mid;
}
else if(a[right]<a[left])
{
return left;
}
else
{
return right;
}
}
}
//然后在快速排序的基础上加上下面的代码
//int index=GetMidIndex(a,left,right);
//swap(&a[left],&a[index]);
小区间优化
以上的代码其实已经不错了,但是如果数据特别大有1000w的数,肯定也会慢,主要是因为随着递归次数上升,所调用的函数也会增多,所以花费时间主要消耗在最后的几次排序。如果把后几次排序改成其他排序不就优化了吗?是的这样优化了不少。
if(pivot-1-left>10)
{
QuickSort(a,left,pivot-1);
}
else
{
//选择插入排序会更好
InsertSort(int*a,int n);
}
if(right-(pivot+1)>10)
{
QuickSort(a,pivot+1,right);
}
else
{
InsertSort(int*a,int n);
}
左右指针法
这种方法和标准的快速排序差不多在细节方面会有一些差别
int sortpart1(int*a,int left,int right)
{
int begin=left;
int end=right;
int keyi=begin;
while(begin<end)
{
while(begin<end&&a[end]>=a[keyi])
{
--end;
}
while(begin<end&&a[begin]<=a[keyi])
{
++begin;
}
swap(&a[end],&a[begin]);
}
swap(&a[begin],&a[keyi]);
return begin;
}
前后指针法
大家一定对前后指针法不陌生吧,在力扣上面做链表题的时候遇到过吧?!C语言实现的数据结构写完之后呢我会写一篇题目讲解的博客,供大家学习。
int partsort(int*a,int left,int right)
{
int keyii=left;
int prev=left;
int cur=left+1;
while(cur<right)
{
if(a[cur]<a[keyii]&&++prev!=cur)
{
swap(&a[prev],&a[cur]);
}
++cur;
}
swap(&a[keyii],&a[prev]);
return prev;
}
二.插入排序
1.直接插入排序
直接插入排序的思路就三个字:扑克牌。想想你怎么在完斗地主时π扑克牌的。大家基本排扑克都一样吧?差别可能有但基本思路都差不多。
void InsertSort(int*a,int n)
{
//两两比较进行插
for(int i=0;i<n-1;++i)
{
int end=i;
int tmp=a[i+1];
while(end>0)
{
if(a[end]>tmp)
{
a[end+1]=a[end];
--end;
}
else
{
break;
}
}
a[end+1]=tmp;
}
}
2.希尔排序
希尔排序相比较直接插入排序算法更优了。因为希尔排序把数据分成小的进行插入排序,这样时间复杂度低。但这样也不能排好序只能是基本有序,最后再用一次直接插入排序就ok了。
void shellSort(int*a,int sz)
{
int gap=3;
for (int i = 0; i < sz - gap; i++)
{
int end=i;
int tmp = a[end + gap];
while (end >= 0)
{
if (a[end] > tmp)
{
a[end + gap] = a[end];
end -= gap;
}
else
{
break;
}
}
a[end + gap] = tmp;
}
}
三.选择排序
1.直接选择排序
在下面的代码中有一个特殊情况,就是当begin是maxi的时候,就会出现当maxi与end交换时交换的是原来a[mini]的值,就会导致出错,我们把maxi下标换成mini下标,因为mini的下标是最大值
void selectsort(int*a,int n)
{
//定义一个起位置begin
int begin=0;
//定义一个终位置end
int end=n-1;
while(begin<end)
{
//刚开始的时候最大最小都是begin 0
int mini=begin;
int maxi=begin;
//开始找最大值和最小值
for(int i=begin;i<=end;i++)
{
if(a[i]>maxi)
{
maxi=i;
}
if(a[i]<mini)
{
mini=i;
}
}
//按照《大话数据结构》这本书的思想是要加一个判断的mini 与begin是否不同,不同才执行后面会讲
swap(&a[mini],&a[begin]);
if(begin==maxi) //特殊情况
{
maxi=mini; //此时a[mini]==max
}
swap(&a[maxi],&a[end]);
++begin;
--end;
}
}
2.堆排序
在进行堆排序的时候,我们的思想是通过二叉树实现排序。二叉树是怎么回事呢?我们就引用了大小堆,何为大小堆?举个栗子这就是小堆,父亲一定比孩子大。这就是大堆。通过大小堆可以实现排序。如果是杂乱无章的不是大小堆怎么办?那就建成大小堆。我们就用到了向下调整算法(就是下面的代码),但是向下调整算法只适用于左右树全是小堆或大堆。所以又该如何呢??聪明的罗伯特·弗洛伊德 (Robert W.Floyd)和威廉姆斯 (J.Williams)就想到了可以从后往前来调,从最后一个叶子节点开始?No!!!,我们要做的是变成大堆小堆,仅需要左右子树是小堆大堆即可,所有用叶子结点作为开始没有意义。我们就从(n-1-1)/2开始吧!建好堆之后我们再干啥?不要忘了我们最终是为了排序,我们用大堆排还是小堆呢?
这个问题先放放后面将复杂度的时候我会讲。答案是大堆!!
主函数代码:
int main()
{
//以下代码简写方便理解
int a[10]={1,2,8,9,6,7,18,4,5,14};
int n=sizeof(a)/sizeof(a[0]);
for(int i=(n-1-1)/2;i>=0;--i)
{
//进行大堆构建,从叶子结点的父亲开始
adjustdown(a,n,i);
}
//以上呢把堆变成大或小堆了开始排序了
//以下代码非常巧妙有点难理解,耐心琢磨一字涌上心头“妙”
int end=n-1;
while(end>0)
{
swap(&a[end],&a[0]); //因为是大堆,a[0]一定是最大的,交换后a[end]是最大的
adjustdown(a,end,0); //交换之后不一定是大堆排序了,赶紧进行一遍向下调整算法
--end;
}
}
向下调整算法
void adjustdown(int*a,int n,int root)//root是根吧1当做根想象成二叉树 向下调整算法规则是必须要有左右树是大堆
{
int parent = root;
int child = root * 2 + 1;//定义了左孩子
while (child < n)
{
if (child+1<n&&a[child] < a[child + 1])
{
child++;
}
if (a[child] > a[parent])
{
Swap(&a[child], &a[parent]);
parent = child;
child = child * 2 + 1;
}
else
{
break;
}
}
}
四.归并排序
俗话说淘尽黄沙始见金,归并排序最后讲也是因为归并排序在应用上很广很重要。归并排序的思路就是分为两部分,在左右区间都有序的情况下进行归并,就是把小的依次放到临时数组里最后再拷贝到原数组上,实际上归并排序是有空间复杂度的,为o(n)。像其他排序是没有空间复杂度的。既然只有左右区间都有序才可以那么又要对左右区间再分再归并,这就与快速排序中的再分有点相似了。我用递归的思路来实现一下。
void -MergeSort(int*a,int left,int right,int*tmp)
{
if(left>=right)
{
return;
}
int mid=(left+right)>>1;
-MergeSort(a,left,mid,tmp);
-MergeSort(a,mid+1,right,tmp);
//开始归并
int begin1=left, end1=mid;
int begin2=mid+1,end2=right;
int index=left;
while(begin1<=end1&&begin2<=end2)
{
if(a[begin1]<a[begin2])
{
tmp[index++]=a[begin1++];
}
else
{
tmp[index++]=a[begin2++];
}
}
while(begin1<end1)
{
tmp[index++]=a[begin1++];
}
while(begin2<end2)
{
tmp[index++]=a[begin2++];
}
//拷贝回去
for(int i=left,left<=right;left++)
{
a[i]=tmp[i];
}
}
void MergeSort(int*a,int n)
{
//新建开创临时数组
int*tmp=(int*)malloc(sizeof(int)*n);
-MergeSort(a,0,n-1,tmp);
free(tmp);
tmp=NULL;
}
五.非递归实现快速排序和归并排序
在上面像快速排序还有归并排序我都用了递归,众所周知递归效率低且在极端情况下会导致栈溢出。为了避免这种情况我们需要敢于否定递归代码的不严谨,重新开始写一段非递归代码。非递归代码的思路是基于递归代码实现逻辑实现的只不过换了种方法罢了。在递归中我们无非是分段实现排序,直到不能再分再返回排序。所以基于这一原理,我们不防把分的区间加到栈里(后进先出),然后取出来进行排序,而不是在递归里面。哇发明这东西的人真是个天才。
1.快速排序
void QuickSortNonR(int*a,int n)
{
//我们用到栈所以我们要把栈引入到本项目中
StackInit(&st);
StackPush(&st,n-1);
StackPush(&st,0);
while(!StackEmpty(&st))
{
int left=StackTop(&st);
StackPop(&st);
int right=StackPop(&st);
StackPop(&st);
int keyIndex=partsort1(a,left,right);
//判断结束的条件
if(keyIndex+1<right)
{
StackPush(&st,right);
StackPush(&st,keyIndex+1);
}
if(keyIndex-1<left)
{
StackPush(&st,keyIndex-1);
StackPush(&st,left);
}
}
StackDestory(&st);
}
2.归并排序
正向快速排序一样,归并排序也可以用非递归来实现,但是归并排序可以不用创建栈或者队列这种消耗空间的做法,而是用循环来实现。思路:归并排序的前提不就是两边都要有序吗,我们不如从一个数和一个数归并,然后两两排序就ok了。上代码!!看看循环情况下代码是如何实现的。
void MergeSortNonR(int*a,int n)
{
int*tmp=(int*)malloc(sizeof(int)*n);
int gap=1;
while(gap<n)
{
for(int i=0;i<n;i+=2*gap)
{
int begin1=i,end1=i+gap-1;
int begin2=i+gap,end2=i+2*gap-1;
if(begin2>=n)
{
break;
}
if(end2>=n)
{
end2=n-1;
}
int index=i;
while(begin1<end1&&begin2<end2)
{
if(a[begin1]<a[begin2])
{
tmp[index++]=a[begin1++];
}
else
{
tmp[index++]=a[begin2++];
}
}
while(begin1<end1)
{
tmp[index++]=a[begin1++];
}
while(begin2<end2)
{
tmp[index++]=a[begin2++];
}
}
for(int j=i;j<end2;j++)
{
a[j]=tmp[j];
}
gap*=2;
}
free(tmp);
}
六.基数排序(桶排序)
基数排序的思想就是先从个位排序,然后从十位排序,依次百位千位……。
七.计数排序
这一次我们用图来解释一下。
如果遇到100 ,101 102 ……这种情况呢?我们还要开创这么多空间吗?肯定不需要这么做。说起来太难说明白了。写代码分析吧!!
void CountSort(int*a,int n)
{
//先比较大小
int max=a[0];
int min=a[0];
for(int i=0;i<n;++i)
{
if(a[i]>max)
{
max=a[i];
}
if(a[i]<min)
{
min=a[i];
}
}
//求出范围以便开创这些空间
int range=max-min+1;
int*count=(int*)malloc(sizeof(int)*range);
//初始化使这些空间元素初始化为0,以便后面++
memset(count,0,sizeof(int)*range);
for(int i=0;i<n;++i)
{
count[a[i]-min]++; //比如count[2]加了两次就是“二”
}
int j=0;
for(int i=0;i<n;++i)
{
while(count[i]--) //两次的就多加一遍
{
a[j++]=i+min;
}
}
free(count);
}
八.排序算法时间复杂度讲解
说到时间复杂度,这是我们学习数据结构第一课的内容,时间复杂度分为最好情况和最坏情况。
①冒泡排序
最好情况下冒泡排序是在顺序情况下,时间复杂度为o(n)。为什么呢,看我上面的代码不难发现,我是有一个判断条件的,当第一趟没有任何交换时就break了。所有有多少个数就排多少次。
最坏情况下是在逆序情况下,时间复杂度为o(n^2)。这个也不难理解。(n-1)*(n-2)*(n-3)……*2*1。不就是n^2吗。
②直接插入排序与希尔排序
直接插入排序和冒泡排序时间复杂度相似。最好情况和最坏情况也想同。看上面代码即可得到。最好的理解不是看我的文章,而是自己深入代码去理解。这里我就不做详解。
希尔排序是插入排序的改良版改良在哪呢?时间复杂度最坏情况肯定小于o(n^2)了。至于是多少现在谁也不知道。为什么呢?在直接插入排序中可能会有很多数据进行交换,但在希尔排序这种跳跃式移动进行排序少了不少数据进行交换,优化了不少。
④直接选择排序
在我眼中,直接选择排序实现上不如冒泡好理解,在时间复杂上则是差不多,所以我的评价是不如冒泡。所以最差排序直接选择排序实至名归。不管他是在最好情况还是在最坏情况直接选择排序都是o(n^2)。但在数据结构这本书中作者用了一个巧妙的方法就是判断是否要交换如果下标不同就交换,我的代码则不管什么情况都交换。只不过在有序的情况下,他就不用交换了。但是综合交换和比较这两个维度综合下来还是o(n^2)。
⑤堆排序
用大堆排序时间复杂度为o(nlogn),总算来了个个好排序。比前面的冒泡插入希尔直接选择都好。完全二叉树的某个结点到根结点的距离为⌊log2i⌋+1。所以一次排是logn,但是要排n-1个所以为o(nlogn)。
⑥快速排序归并排序
归并排序快速排序时间复杂度平均下来为o(nlogn)。但是归并排序需要建立新数组,所以有空间复杂度为o(n)。用语言不好解释,看了全网的解释,也没有很好的解释。自己看我上面的代码琢磨吧!
写在最后
网上也有很多图分析,但是我想用Python做一个项目,把所有排序算法的最好最坏时间复杂度和稳定性进行可视化。用图形象的展示给你们。