小明特别喜欢打斗地主,但是他不喜欢理牌的过程,虽然顺子在手中成型的感觉很爽,但是他还是决定整几个排序方法帮助自己节省掉这段排序的时间。
于是他使用了常见的排序算法:插入排序,希尔排序,选择排序,堆排序,冒泡排序,快速排序
归并排序,计数排序
但是各个排序的逻辑实现并不同,小明发现他们的排序速度也因此不同,那么它们几个到底有什么特点?谁排得最快呢?
我们先抛出结论,它们各自的速度如下:
光抛结论并不能说明什么,它们的速度究竟为什么有所区别?那么我们都挨个实现一下,就知道的差不多了
目录
常见排序的逻辑与其性能分析
1:插入排序
插入排序的逻辑很像我们打扑克牌时候的理牌。
每拿到一张牌时,将其按升序或者降序的顺序排序好,下一张牌放在手边,从这张牌开始往前寻找到它符合升序的位置,将其插入。
代码如下:
void InsertSort(int* a, int n)
{
for (int i = 0; i < n - 1; ++i)
{
int end = i;
int tmp = a[end + 1];
while (end >= 0)
{
if (a[end] > tmp)
{
a[end + 1] = a[end];
end--;
}
else
break;
}
a[end+1] = tmp;
}
}
代码注释:
其中,循环的范围需要被特别注意,已在图中标注:
示例:
在这里,tmp越界访问,程序依然在走,将得到的随机值根据升序的逻辑排放到了最前端。
分析:
我们可以发现,当整个数组整体接近于有序时,end需要向后走的次数变少,而这个次数的变少也意味着挪动次数的降低,也代表了排序整体速度的提升。
性能:
1. 元素集合越接近有序,直接插入排序算法的时间效率越高
2. 时间复杂度:O(N^2)
3. 空间复杂度:O(1),它是一种稳定的排序算法
4. 稳定性:稳定
2.希尔排序
希尔排序,又称缩小增量排序,是插入排序的优化版本,作为其优化版本,我们需要回去看一眼插入排序。
我们知道插入排序的效率取决于整体数组是否趋于有序,那么希尔排序则就是冲着这点去的,相当于为插入排序打造一个高效率的排序环境。而为了打造一个趋于有序的环境,希尔排序不会挨个遍历,而是跳着遍历整个数组
其实总体的思想比较像切萝卜丁,我们一般会把整个萝卜切成几块大的,再一块剁成丁。
以下是Gap介入后的插入排序的代码,该代码是单趟希尔排序的实现。
代码如下:
int gap = 3;
for (int i = j; i < n - gap; i += gap)
{
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;
}
Gap大小对排序情况的影响图解,我们可以看得出来,当gap == 1 的时候其实就是插入排序了
需要实现整个希尔排序,我们只需要再嵌套一层循环,如下
代码如下:
void SeerSort(int* a, int n)
{
int gap = 3;
for (int j = 0; j < gap; ++j)
{
for (int i = j; i < n - gap; i += gap)
{
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;
}
}
}
代码注释:
分析:
既然希尔排序的目的是优化插入排序,而gap的大小又影响着希尔排序的效率,那么gap取值为多少时最佳呢?
gap为动态且趋近于1的时候,效果为最佳,所以我们希望gap是一个动态的值,并且不断地在变小直到为1时,整个数组成为有序,但这时的gap便是不可预测值了。
由于gap的值并不确定且时间复杂度难以计算,但根据研究可以知道取(gap/3)+1或者2/gap都是可以的,这其中的数学问题依然没有被证明二者间到底哪个最优,所以我们二者取1就可以。
那么我们在最外面再套上一层循环即可。(非最简版本,但易于理解)
void SeerSort(int* a, int n)
{
int gap = n;
while (gap > 1)
{
gap = gap / 2;
for (int j = 0; j < gap; ++j)
{
for (int i = j; i < n - gap; i += gap)
{
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.当gap > 1时都是预排序,目的是让数组更接近于有序。当gap == 1时,数组已经接近有序的了,这样就会很快。这样整体而言,可以达到优化的效果。
2. 时间复杂度:O(N^2)
3. 空间复杂度:O(1)4. 稳定性:不稳定
3.选择排序:
最耿直的排序,逻辑及其简单:疯狂遍历整个数组,每一次遍历找最小值和最大值,最大值放末尾,最小值放前头
我使用了两个下标来进行选择排序,稍微优化了一下,但其实也不会改变它效率极低的情况。
void SelectSort(int* a, int n)
{
int begin = 0, end = n - 1;
while (begin < end)
{
int mini = begin, maxi = end;
for (int i = begin+1; i <= end; ++i)
{
if (a[i] < a[mini])
{
mini = i;
}
if (a[i] > a[maxi])
{
maxi = i;
}
}
Swap(&a[mini], &a[begin]);
Swap(&a[maxi], &a[end]);
++begin;
--end;
}
}
选择排序真的太简单粗暴了,不过还是需要注意一些小细节,当我们以最大值做首元素的时候选择排序就会出错
原因如下:
这样修改即可,修正一下
分析:
选择排序效率很低,而且哪怕整个数组趋于有序也无法省去步骤
性能:
1. 直接选择排序思考非常好理解,但是效率不是很好。实际中很少使用
2. 时间复杂度:O(N^2)
3. 空间复杂度:O(1)
4. 稳定性:不稳定
4.堆排序
堆排序的大致原理以及实现方法不再这里过多赘述,我已在堆的文章详细记述。
链接:数据结构6:二叉树与堆_lanload的博客-优快云博客_数据结构堆和二叉树
对于堆排序:升序建大堆,降序建小堆。
性能:
1. 堆排序使用堆来选数,效率就高了很多。
2. 时间复杂度:O(N*logN)
3. 空间复杂度:O(1)
4. 稳定性:不稳定
5.冒泡排序
冒泡排序也是非常简单的排序类型,也不过多记述了
逻辑如下图所示,遍历n次,只要后面的数比当前的大,交换,直至遍历结束
代码如下:
void BubbleSort(int* a, int n)
{
for (int i = 0; i < n; ++i)
{
for (int j = 0; j < n - i; ++j)
{
if (a[j] < a[j - 1])
Swap(&a[j], &a[j - 1]);
}
}
}
分析:
由于冒泡排序本身的逻辑非常简单,所以它也十分的稳定,当然效率上也没法很快
性能:
1. 冒泡排序是一种非常容易理解的排序
2. 时间复杂度:O(N^2)
3. 空间复杂度:O(1)
4. 稳定性:稳定
6.快速排序
快速排序,学习C的时候就i接触过这个排序,但只是知道其使用方法而非实现原理
快速排序是Hoare于1962年提出的一种二叉树结构的交换排序方法,其基本思想为:任取待排序元素序列中的某元素作为基准值,按照该排序码将待排序集合分割成两子序列,左子序列中所有元素均小于基准值,右子序列中所有元素均大于基准值,然后最左右子序列重复该过程,直到所有元素都排列在相应位置上为止。
1.霍尔法动图如下:
代码如下:
int QSortPart(int* a, int left,int right )
{
int keyi = left;
while (left < right)
{
while (right >left && a[right] >=a[keyi])
right--;
while (right > left && a[left] <= a[keyi])
left++;
if(left<right)
Swap(&a[left], &a[right]);
}
int newkey = left;
Swap(&a[left], &a[keyi]);
return newkey;
}
当然这仅仅只是单趟的排序,我们需要使用递归来完成整个排序,逻辑如下图:
我们希望每一次的排序都以key为切割点,切割为左右两个部分,每个部分重新以霍尔法排序,直到不可再分割为止。
那么使用递归来实现就比较简单,我们每一次执行完霍尔法排序时,就返回其key值,然后由当前key值分割出两个区间,传入区间,如此往复,实现排序。
那么Qsort的代码如下:
void QuickSort(int* a, int left, int right)
{
if (left >= right)
return;
int keyi = QSortPart(a, left, right);
QuickSort(a, left, keyi - 1);
QuickSort(a, keyi + 1, right);
}
2.挖坑法,动图如下:
挖坑法作为霍尔法的优化,实现起来更加简单直接,代码如下:
int QSortPart2(int* a, int left, int right)
{
//三数取中
int mid = GetMidIndex(a, left, right);
Swap(&a[mid], &a[left]);
int hole = left;
int key = a[left];
while (left < right)
{
//右边向左走,找到左边的坑
while (right > left && a[right] >= key)
right--;
//找到了,填洞换洞
Swap(&a[right], &a[hole]);
hole = right;
//左边向右走,找到右边的坑
while (right > left && a[left] <= key)
left++;
//找到了,填洞换洞
Swap(&a[left], &a[hole]);
hole = left;
}
a[left] = key;
return hole;
}
3.前后指针法,动图如下:
这个动图稍微有些难以理解,其实prev指针和cur指针所遵循的规律如下:
代码如下:
//前后指针
int QSortPart3(int* a, int left, int right)
{
int mid = GetMidIndex(a, left, right);
Swap(&a[mid], &a[left]);
int keyi = left;
int key = a[left];
int cur = left +1 , prev = left;
while (cur <= right)
{
// 找小
if (a[cur] < a[keyi] && ++prev != cur)
Swap(&a[cur], &a[prev]);
++cur;
}
Swap(&a[keyi], &a[prev]);
return prev;
}
关于qsort的优化思路:
1.三数取中
key值的选取其实还是具有一定缺陷的,比如当key是较小值或者是较大值时没法如我们所愿能平分整个数组来进行排序,如下图所示,所以我们希望key值能尽量选取到中间值,那么我们可以使用三数取中的方式来优化key值。
我们选取到当前被分割区间的首部尾部和中部,比较它们的大小,选出其中的中间值,交换给key
三数取中代码如下:
int GetMidIndex(int* a, int left, int right)
{
int mid = left+ (right - left) / 2;
if (a[mid] > a[left])
{
if (a[mid] < a[right])
return mid;
else if (a[right] < a[left])
return left;
else
return right;
}
else
{
if (a[mid] > a[right])
return mid;
else if (a[left] < a[right])
return left;
else
return right;
}
}
2.减少递归次数
快排的最后几层递归其实显得非常蛋疼,为了成功划分成一个数值,需要创建非常多的栈帧,对空间消耗大也减低了效率。那么该怎么去优化这一部分呢?
其实最后几层的递归本质是对小区间排序,那么我们可以选择比较简单,对栈帧消耗小的简单排序来操作,比如插入排序。
那么我们的逻辑在这里就是当区间被划分到小区间的时候,不再划分,而是转用插入排序,而小区间的标志可以以8为代表,因为基本上最后三层栈帧消耗最大。
由于插入排序传入的是首元素地址,针对递归会处理右半部分区间,还需要+left
代码如下:
void QuickSort(int* a, int left, int right)
{
if (left >= right)
return;
int keyi = QSortPart(a, left, right);
if (right - left <= 8)
{
InsertSort(a + left, right - left + 1);
}
else
{
QuickSort(a, left, keyi - 1);
QuickSort(a, keyi + 1, right);
}
}
快速排序非递归版本:
递归再怎么优化也难免在极端情况下发生栈溢出的情况,所以非递归版本也是有必要的。
在这里,为了实现类似递归控制区间的效果,我们使用栈来实现。
代码如下:
//非递归快速排序
void Qsortother(int* a, int begin, int end)
{
Stack st;
StackInit(&st);
//先往栈里扔入整个区间范围,让循环启动
StackPush(&st, begin);
StackPush(&st, end);
while (!StackEmpty(&st))
{
int right = StackTop(&st);
StackPop(&st);
int left = StackTop(&st);
StackPop(&st);
//求取当前区间Key值
int key = QSortPart3(a, left, right);
//得到Key值后,借由key划分两个区间,分别入栈
//区间分配切割完毕的标志是left >= key -1 时,此时不必再次入栈,再等一轮pop使得栈为空结束循环
if (left < key - 1)
{
StackPush(&st, left);
StackPush(&st, key - 1);
}
if (key + 1 < right)
{
StackPush(&st, key + 1);
StackPush(&st, right);
}
}
//排序结束,销毁栈
StackDestroy(&st);
}
图解:
我们以一个简单且直观的有序数组来分析
这是最开始的情况,最初的区间值被存放入栈
这是接下来循环内,我们取出栈内数据,存入left和right这两个临时变量中
然后借由前后指针法求出当前区间的key值,得到key值的下标应该为5
这是第一趟由key值切割得到的区间,分别为0 4 , 6 9
接着左右区间入栈:
再回到循环开始,取新左值右值
求取当前左右值的key,此时的key应该为7
接着再往下处理,直到右半部分区间被处理完毕。
性能:
1. 快速排序整体的综合性能和使用场景都是比较好的,所以才敢叫快速排序
2. 时间复杂度:O(N*logN)
3. 空间复杂度:O(logN)
4. 稳定性:不稳定
7.归并排序:
归并排序(MERGE-SORT)是建立在归并操作上的一种有效的排序算法,该算法是采用分治法(Divide andConquer)的一个非常典型的应用。将已有序的子序列合并,得到完全有序的序列;即先使每个子序列有序,再使子序列段间有序。若将两个有序表合并成一个有序表,称为二路归并。 归并排序核心步骤:
其核心思想其实同二叉树的后序遍历差不太多,都是先遍历分割完毕整个数组,然后再做处理。
由于不能在原数组上动手脚,我们开辟一个新的数组做转移容器:
void MergeSort(int* a, int n)
{
int* tmp =(int*) malloc(sizeof(int) * n);
if (tmp == NULL)
{
perror("malloc failed!");
return ;
}
_MergeSort(a, 0, n - 1, tmp);
free(tmp);
tmp = NULL;
}
我们先实现分割的递归分支代码
代码如下:
void _MergeSort(int* a, int begin, int end, int* tmp)
{
//什么时候结束递归?同快排类似
if (begin >= end)
return;
//求一下分割点,也就是中间值
int mid = (end +begin) / 2;
//先分割,分割完事儿了再归并
_MergeSort(a, begin, mid, tmp);
_MergeSort(a, mid + 1, end, tmp);
//归并 取小的尾插
}
在这段代码执行完毕后,整个数组已经被划分好了。那么接下来就是归并的过程
其核心逻辑就是寻找当前区间内部小的数值尾插到一个过度用的数组,代码如下:
void _MergeSort(int* a, int begin, int end, int* tmp)
{
//什么时候结束递归?同快排类似
if (begin >= end)
return;
//求一下分割点,也就是中间值
int mid = (end +begin) / 2;
//先分割,分割完事儿了再归并
_MergeSort(a, begin, mid, tmp);
_MergeSort(a, mid + 1, end, tmp);
//归并 取小的尾插
//按照升序将数据存放入tmp中
int begin1 = begin, end1 = mid;
int begin2 = mid+1, end2 = end;
int count = begin;
while (begin1 <= end1 && begin2 <= end2 )
{
if (a[begin1] <= a[begin2])
tmp[count++] = a[begin1++];
else
tmp[count++] = a[begin2++];
}
//寻找小的环节已经结束,这个时候剩下的那个区间内所有剩下的数据一股脑传进去就好
while(begin1<=end1)
tmp[count++] = a[begin1++];
while(begin2<=end2)
tmp[count++] = a[begin2++];
// 拷贝回原数组 -- 归并哪部分就拷贝哪部分回去
//此处需要进行修正,而非直接传递参数
memcpy(a + begin, tmp + begin, (end - begin + 1) * sizeof(int));
}
归并排序的非递归版本:
非递归的思想还就是那个单个数字当有序处理。
但是其实这里面还有一些边界问题,不过晚点再处理
根据逻辑,相当于我们先两两归并,然后 4 4归并 得到完整排序数组,所以我们其实只需要在循环内部控制好我们的边界就可以。
剩下的逻辑和递归版本差不多,那么我们就要准备解决一下有关越界的问题
我们在写非递归版本时其实默认整个数组的数据个数是偶数,当数据个数不是偶数的时候就会发生如下非常尴尬的事情也就是越界访问。
所以我们在执行代码的时候还需要修正边界问题。
当数组不是整数倍的时候,把所有边界区间取值打印出来分析,越界的情况就一目了然了。
那么我们就需要特殊处理如上3种越界情况
1.end1越界
2.begin2越界
3.end2越界
但是光是特殊处理这些棘手的边界问题并不能解决我们无法正确以2倍分割整个数组,如下图所示:
那么我们退而求其次,我们不将越界的这一部分纳入我们当前归并的范围内,也就是越界的这一部分数据直到最后左半区间和右半区间整体归并的时候才加入归并的环节。
处理代码如下
//end1越界,直接break,不加入归并,把前面已经归并过的放入数组
if (end1 >= n)
break;
//begin2同上,直接break,越界区间不能进入归并
if (begin2 >= n)
break;
//end1与begin2都已不再越界,此时将end2修正至n-1,整体归并完成排序
if (end2 >= n)
{
end2 = n - 1;
}
当然,为了不去处理越界区间的数据,数据拷贝的范围也是需要处理的
memcpy(a + i , tmp + i, (end2 - i + 1) * sizeof(int));
整体代码如下:
void MergeSortNonR(int* a, int n)
{
int* tmp =(int*) malloc(sizeof(int) * n);
if (tmp == NULL)
{
perror("malloc failed!");
return ;
}
int gap = 1;
while (gap < n)
{
for (int i = 0; i < n; i += gap * 2)
{
int begin1 = i, end1 = i + gap - 1;
int begin2 = i + gap, end2 = i+2*gap-1;
printf("[%d,%d][%d,%d] ", begin1, end1, begin2, end2);
int count = i;
if (end1 >= n)
break;
if (begin2 >= n)
break;
if (end2 >= n)
{
end2 = n - 1;
}
while (begin1 <= end1 && begin2 <= end2)
{
if (a[begin1] <= a[begin2])
tmp[count++] = a[begin1++];
else
tmp[count++] = a[begin2++];
}
//寻找小的环节已经结束,这个时候剩下的那个区间内所有剩下的数据一股脑传进去就好
while (begin1 <= end1)
tmp[count++] = a[begin1++];
while (begin2 <= end2)
tmp[count++] = a[begin2++];
memcpy(a + i , tmp + i, (end2 - i + 1) * sizeof(int));
}
gap *= 2;
printf("\n");
}
free(tmp);
tmp = NULL;
}
性能:
1. 归并的缺点在于需要O(N)的空间复杂度,归并排序的思考更多的是解决在磁盘中的外排序问题。
2. 时间复杂度:O(N*logN)
3. 空间复杂度:O(N)
4. 稳定性:稳定
8.计数排序
计数排序逻辑原理如下:
思路:相对映射(最大值-最小值+1所开空间,其中a[i]-min是算相对映射位置,回写时下标时相对位置i+min)
代码如下:
void CountSort(int* a, int n)
{
int max = a[0], min = a[0];
for (int i = 0; i < n; ++i)
{
if (max < a[i])
max = a[i];
if (min > a[i])
min = a[i];
}
int space = max - min + 1;
int* tmp = (int*)malloc(sizeof(int) * space);
if (tmp == NULL)
{
perror("calloc failed");
exit(-1);
}
memset(tmp, 0, sizeof(int) * space);
for (int i = 0; i < n; ++i)
{
tmp[a[i] - min]++;
}
int j = 0;
for (int i = 0; i < space; ++i)
{
while (tmp[i]--)
{
a[j] = i + min;
j++;
}
}
}
性能:
1. 计数排序在数据范围集中时,效率很高,但是适用范围及场景有限。
2. 时间复杂度:O(MAX(N,范围))
3. 空间复杂度:O(范围)
至此8大排序细述完毕,希望对你有点帮助!感谢阅读!