?这篇文章主要记录各种排序算法的思想及实现代码,最后对各种算法的性能进行了对比。
目录
所谓排序,就是使一串记录,按照其中的某个或某些关键字的大小,递增或递减的排列起来的操作。
稳定性:假定在待排序的记录序列中,存在多个具有相同的关键字的记录,若经过排序,这些记录的相对次序保持不变,即在原序列中,r[i]=r[j],且r[i]在r[j]之前,而在排序后的序列中,r[i]仍在r[j]之前,则称这种排序算法是稳定的;否则称为不稳定的。
内部排序:数据元素全部放在内存中的排序。
外部排序:数据元素太多不能同时放在内存中,根据排序过程的要求不能在内外存之间移动数据的排序。
// 排序实现的接口
// 插入排序
void InsertSort(int* a, int n);
// 希尔排序
void ShellSort(int* a, int n);
// 选择排序
void SelectSort(int* a, int n);
// 堆排序
void AdjustDwon(int* a, int n, int root);
void HeapSort(int* a, int n);
// 冒泡排序
void BubbleSort(int* a, int n)
// 快速排序递归实现
// 快速排序hoare版本
int PartSort1(int* a, int left, int right);
// 快速排序挖坑法
int PartSort2(int* a, int left, int right);
// 快速排序前后指针法
int PartSort3(int* a, int left, int right);
void QuickSort(int* a, int left, int right);
// 快速排序 非递归实现
void QuickSortNonR(int* a, int left, int right)
// 归并排序递归实现
void MergeSort(int* a, int n)
// 归并排序非递归实现
void MergeSortNonR(int* a, int n)
// 计数排序
void CountSort(int* a, int n)
// 测试排序的性能对比
void TestOP()
{
srand(time(0));
const int N = 100000;
int* a1 = (int*)malloc(sizeof(int) * N);
int* a2 = (int*)malloc(sizeof(int) * N);
int* a3 = (int*)malloc(sizeof(int) * N);
int* a4 = (int*)malloc(sizeof(int) * N);
int* a5 = (int*)malloc(sizeof(int) * N);
int* a6 = (int*)malloc(sizeof(int) * N);
for (int i = 0; i < N; ++i)
{
a1[i] = rand();
a2[i] = a1[i];
a3[i] = a1[i];
a4[i] = a1[i];
a5[i] = a1[i];
a6[i] = a1[i];
}
int begin1 = clock();
InsertSort(a1, N);
int end1 = clock();
int begin2 = clock();
ShellSort(a2, N);
int end2 = clock();
int begin3 = clock();
SelectSort(a3, N);
int end3 = clock();
int begin4 = clock();
HeapSort(a4, N);
int end4 = clock();
int begin5 = clock();
QuickSort(a5, 0, N - 1);
int end5 = clock();
int begin6 = clock();
MergeSort(a6, N);
int end6 = clock();
printf("InsertSort:%d\n", end1 - begin1);
printf("ShellSort:%d\n", end2 - begin2);
printf("SelectSort:%d\n", end3 - begin3);
printf("HeapSort:%d\n", end4 - begin4);
printf("QuickSort:%d\n", end5 - begin5);
printf("MergeSort:%d\n", end6 - begin6);
free(a1);
free(a2);
free(a3);
free(a4);
free(a5);
free(a6);
}
直接插入排序是一种简单的插入排序法,其基本思想是:将待排序的序列按照值大小逐个插入到一个有序序列的合适位置中,直到这个待排序的序列被插完,就可以得到一个新的有序序列。
实际上,我们在玩扑克牌时,也是利用直接插入排序的思想:
当插入第i(i>=1)个元素时,前面的a[0]、a[1]、...a[i-1]已经排好序,让a[i]依次与a[i-1]、a[i-2]、...进行比较,找到插入位置将a[i]插入即可,原来位置上的元素依次后移。
其代码实现如下:
void InsertSort(int* a, int n)
{
int i = 0;
for (i = 0; i < n-1; i++)
{
//[0,end] end+1
int end = i;
int tmp = a[end + 1];
while (end >= 0)
{
if (tmp < a[end])
{
a[end + 1] = a[end];
end--;
}
else
{
break;
}
}
a[end + 1] = tmp;
}
}
在这段代码中,把[0,end]看成有序序列,将end+1插入到这个有序序列中。tmp用来保存待插入的值(end+1),因为在查到插入位置时,需要从有序序列最后一个元素开始向后挪,会把end+1处的值覆盖掉。当退出while(end >=0)循环时,有两种可能:第一,因为tmp>=a[end],此时找到了该插入的位置,即end+1;第二,因为end<0退出循环,这是由于原有序序列所有值都比待插入元素大,待插入元素需要插入到首元素位置处,首元素位置0,由于此时end=-1,待插入位置也end+1。因此,不管哪种情况,都需要将tmp插入到end+1位置处。
希尔排序其实是直接插入排序的plus版,主要分为两步:1.预排序,得到一个接近有序的序列;2.在对这个接近有序的序列进行直接插入排序。这样貌似看起来很麻烦,效率好像也不高,但是希尔排序的效率真的会让人眼前一亮!
①预排序
我们以上图序列为例,设定gap=3,即9、6、4、1为一组,8、6、3、0为一组,7、5、2为一组,分别对每个分组进行插入排序。
在预排序时:
gap越大,大的值更快调到后面,小的值可以更快调到前面,但是越不接近有序。
gap越小,调的越慢,但是越接近有序。如果gap==1就是直接插入排序。
int gap = 3;
for (j = 0; j < gap; j++)
{
for (i = j; i < n - gap; i += gap)
{
//[0,end] end+1
int end = i;
int tmp = a[end + gap];
while (end >= 0)
{
if (tmp < a[end])
{
a[end + gap] = a[end];
end -= gap;
}
else
{
break;
}
}
a[end + gap] = tmp;
}
}
这段代码中,j==0是在为红色这一组插入排序,j==1是在为蓝色这一组插入排序,j==2是在为绿色这一组插入排序。这是在一组一组排,先排好红色一组,再排蓝色一组,最后排绿色一组。
然而,这段代码嵌套三层循环,是否可以精简一下呢?答案是肯定的!
改进如下:
for (i = 0; i < n - gap; i++)
{
//[0,end] end+1
int end = i;
int tmp = a[end + gap];
while (end >= 0)
{
if (tmp < a[end])
{
a[end + gap] = a[end];
end -= gap;
}
else
{
break;
}
}
a[end + gap] = tmp;
}
改进在于for循环的递增改为i++,而不是i+=gap,在这种情况下,变成了多组并排。
②直接插入排序
在进行完预排序后,已经使序列接近有序,再在这个基础上直接插入排序效率就会高很多。
在实际用希尔排序时,我们并不清楚序列到底有多少个数,因此gap不能为定值,下面是希尔排序的实现代码:
void ShellSort(int* a, int n)
{
int i = 0;
int gap = n;
while (gap > 1)
{
//gap = gap / 2;
gap = gap / 3 + 1;
for (i = 0; i < n - gap; i++)
{
//[0,end] end+1
int end = i;
int tmp = a[end + gap];
while (end >= 0)
{
if (tmp < a[end])
{
a[end + gap] = a[end];
end -= gap;
}
else
{
break;
}
}
a[end + gap] = tmp;
}
}
}
当gap>1时是预排序,目的是让数组接近有序;当gap==1时是直接插入排序,目的是让数组有序。
值得一提的是,希尔排序的时间复杂度不好计算,有人在大量实验基础上,给出希尔排序的时间复杂度大约为O(N^1.3)。
每一次从待排序的数据元素中选出最小和最大的一个元素,分别存放在序列的起始位置和末尾位置,直到全部待排序的数据元素排完 。
在元素集合a[0]--a[n-1]中遍历找出最大的和最小的元素下标,将最小元素和起始位置元素交换,将最大元素和末尾位置元素交换,然后在剩下的a[1]--a[n-2]中,重复上述步骤,直到集合中只剩下1个或者0个元素。直接选择排序的时间复杂度为O(N^2)。为下面是直接选择排序的代码实现:
void SelectSort(int* a, int n)
{
int begin = 0;
int end = n - 1;
int i = 0;
while (begin < end)
{
int maxi = begin;
int mini = begin;
for (i = begin + 1; i <= end; i++)
{
if (a[i] > a[maxi])
{
maxi = i;
}
if (a[i] < a[mini])
{
mini = i;
}
}
Swap(&a[begin], &a[mini]);
if (begin == maxi)
maxi = mini;
Swap(&a[end], &a[maxi]);
end--;
begin++;
}
}
其中,
if (begin == maxi)
maxi = mini;
这两行代码的作用是,如果序列中第一个元素是最大元素所在位置,而Swap(&a[begin], &a[mini]);将最小元素交换到了起始位置,那么找到的maxi不再是最大元素所在位置,需要调整maxi,因为最大元素被交换到了mini的位置,因此,让maxi=mini,这样再次让maxi为最大元素位置。
堆排序是指利用堆这种数据结构所设计的一种排序算法,它是选择排序的一种。它是通过堆来进行选择数据。需要注意的是排升序要建大堆,排降序建小堆。
堆排序在博主之前的文章介绍过-->堆排序
基本思想:所谓交换,就是根据序列中两个记录键值的比较结果来对换这两个记录在序列中的位置,交换排 序的特点是:将键值较大的记录向序列的尾部移动,键值较小的记录向序列的前部移动。
冒泡排序的主要思想是两两相邻的元素进行比较,这在博主之前的文章也详细介绍过:冒泡排序
快速排序是Hoare于1962年提出的一种二叉树结构的交换排序方法,其基本思想为:任取待排序元素序列中的某元素作为基准值,按照该排序码将待排序集合分割成两子序列,左子序列中所有元素均小于基准值,右子序列中所有元素均大于基准值,然后最左右子序列重复该过程,直到所有元素都排列在相应位置上为止。
快速排序思想和二叉树前序遍历思想很相似,也是使用递归方式实现。
快速排序有几个版本:
1.【hoare版本】
将最左边key作为基准值,先从右边开始找小(小于基准值的元素,并记录其下标),找到后再从左边找大(大于基准值的元素,并记录其下标),然后交换这两个元素,继续重复上述步骤,直到L和R相遇,相遇处的值一定小于基准值key,将相遇处的值和key交换,交换后,6在“中间”,6左边的值均小于6,6右边的值均大于6,也就是说,6排在了其该在的位置,接下来就要继续排6左边和6右边,使用递归实现即可。
实现代码如下:
int GetMidi(int* a, int begin, int end)
{
int midi = (begin + end) / 2;
//begin end midi三个数选中位数
if (a[begin] > a[midi])
{
if (a[midi] > a[end])
{
return midi;
}
else if (a[begin] > a[end])
{
return end;
}
else
return begin;
}
else
{
if (a[begin] > a[end])
{
return begin;
}
else if (a[midi] < a[end])
{
return midi;
}
else
return end;
}
}
void QuickSort(int* a, int begin, int end)
{
if (begin >= end)
return;
int midi = GetMidi(a, begin, end);
Swap(&a[midi], &a[begin]);
int left = begin;
int right = end;
int keyi = begin;
while (left < right)
{
//右边找小
while (left < right && a[right] >= a[keyi])
{
right--;
}
//左边找大
while (left < right && a[left] <= a[keyi])
{
left++;
}
Swap(&a[left], &a[right]);
}
Swap(&a[left], &a[keyi]);
keyi = left;
//[begin,keyi-1] keyi [keyi+1,end]
QuickSort(a, begin, keyi - 1);
QuickSort(a, keyi + 1, end);
}
将相遇处的值和key交换,这默认相遇位置的值比key小,那么为什么相遇位置的值比key小?因为右边先走!换言之,如果左边先走,那么相遇位置的值不一定比key小。具体原因是:
相遇有两种情况:
1.R遇L:R一直走,直到遇到L,相遇位置是L,L位置比key小。
2.L遇R:R先走,找到小的停下来,L找到,没有找到,遇到R停下来了,相遇位置是R,比key小。
因此,相遇位置的值一定比key小。
值得注意的是,上段代码有这样几行:
int midi = GetMidi(a, begin, end);
Swap(&a[midi], &a[begin]);
这几行代码的含义是,如果序列本身就是有序的,那么当使用快速排序时,会开辟序列元素个数个函数栈帧,导致栈溢出;当加上这几行代码后,序列最左边的值就不是最小的,因此只会开辟logN个栈帧,几乎不会导致栈溢出。
那么我们再来看另一个问题,叫做小区间优化,我们知道,快速排序是利用递归,利用了二叉树的思想,那么二叉树的最后三层占了总结点的87.5%,同样地,在快速排序时,为了让最后几层有序,递归了很多次,付出了很大代价,那有没有其他解决方案?有的!在最后几层递归时,我们可以采用其他排序,推荐插入排序(适应性强)。
?实现代码如下:
void QuickSort(int* a, int begin, int end)
{
if (begin >= end)
return;
//小区间优化
if (end - begin + 1 < 10)
{
InsertSort(a + begin, end - begin + 1);
}
else
{
int midi = GetMidi(a, begin, end);
Swap(&a[midi], &a[begin]);
int left = begin;
int right = end;
int keyi = begin;
while (left < right)
{
//右边找小
while (left < right && a[right] >= a[keyi])
{
right--;
}
//左边找大
while (left < right && a[left] <= a[keyi])
{
left++;
}
Swap(&a[left], &a[right]);
}
Swap(&a[left], &a[keyi]);
keyi = left;
//[begin,keyi-1] keyi [keyi+1,end]
QuickSort1(a, begin, keyi - 1);
QuickSort1(a, keyi + 1, end);
}
}
虽然小区间优化听起来很牛,但是在Release状态下,会有一些优化效果,但是可能不明显,因为此状态下对建立递归栈帧已经优化到足够小了。
2.【挖坑法】
在上面的hoare方法实现中,需要注意到很多问题,一不小心就会出错,那么,针对这些问题,有人提出对hoare方法的改进--挖坑法。
挖坑法的思想和hoare法很相近:
?把左边第一个值临时保存到key中,形成坑位,然后从右边end开始找小于key的值,当找到以后,把小值放到坑位中,然后小值的位置就形成了新的坑位,
?接着从左边begin开始找大于key的值,当找到以后,把大值放到刚才的坑位中,进而形成新的坑位,继续重复以上步骤,直到begin和end相遇,相遇时肯定在坑位上,然后把key放到begin和end相遇位置,此时,左边都是小于key的,右边都是大于key的,然后左边和右边分别作为新的序列递归。
实现代码如下:
int PartSort2(int* a, int begin, int end)
{
int midi = GetMidi(a, begin, end);
Swap(&a[midi], &a[begin]);
int key = a[begin];
int holei = begin;
while (begin < end)
{
//右边找小,填到左边的坑
while (begin < end && a[end] >= key)
{
end--;
}
a[holei] = a[end];
holei = end;
//左边找大,填到右边的坑
while (begin < end && a[begin] <= key)
{
begin++;
}
a[holei] = a[begin];
holei = begin;
}
a[holei] = key;
return holei;
}
void QuickSort(int* a, int begin, int end)
{
if (begin >= end)
return;
int keyi = PartSort2(a, begin, end);
QuickSort(a, begin, keyi - 1);
QuickSort(a, keyi + 1, end);
}
挖坑法相对于hoare写起来更不容易犯错,也更好理解,因此,在没有要求的情况下,推荐使用挖坑法!
3.【前后指针版本】
这种方法和前面两种思路上会有很大不同,但是相对hoare来说,也是容易理解,也不容易犯错,因此,这种方法也很推荐!
这种方法是思想是,创建前后两个指针prev和cur,初始时,prev指针指向序列开头,cur指针指向prev指针的后一个位置,
1.当cur遇到比key大的值,++cur
2.当cur遇到比key小的值,++prev,交换prev和cur位置的值,++cur
直到cur>end结束。
实现代码如下:
int PartSort3(int* a, int begin, int end)
{
int midi = GetMidi(a, begin, end);
Swap(&a[midi], &a[begin]);
int keyi = begin;
int cur = begin + 1;
int prev = begin;
while (cur <= end)
{
//cur找小
//注释掉的这种情况可能存在自己和自己交换的情况
/*if (a[cur] < a[keyi])
{
prev++;
Swap(&a[prev], &a[keyi]);
}*/
if (a[cur] < a[keyi] && prev++ != cur)
Swap(&a[prev], &a[cur]);
cur++;
}
Swap(&a[prev], &a[keyi]);
keyi = prev;
return keyi;
}
void QuickSort(int* a, int begin, int end)
{
if (begin >= end)
return;
int keyi = PartSort3(a, begin, end);
QuickSort(a, begin, keyi - 1);
QuickSort(a, keyi + 1, end);
}
以上三种方式是使用递归来实现快速排序,那么,可以使用非递归实现快速排序吗?可以!使用非递归形式实现快速排序,不是递归,胜似递归!
但是,要需要借助栈,
先把0? 9压栈,再出栈,形成两部分0? 4和6? 9,再将0? 4和6? 9压栈,...,当栈为空时就结束,如图所示。
实现代码如下:
void QuickSortNonR(int* a, int begin, int end)
{
ST s;
STInit(&s);
STPush(&s, end);
STPush(&s, begin);
while (!STEmpty(&s))
{
int left = STTop(&s);
STPop(&s);
int right = STTop(&s);
STPop(&s);
int keyi = PartSort3(a, left, right);
//[left ,keyi-1] keyi [keyi+1,right]
if (left < keyi - 1)
{
STPush(&s, keyi - 1);
STPush(&s, left);
}
if (keyi+1 < right)
{
STPush(&s, right);
STPush(&s, keyi+1);
}
}
}
基本思想:将已有序的子序列合并,得到完全有序的序列;即先使每个子序列有序,再使子序列段间有序。
?如果两个子序列是有序的,那么为了使将这个两个子序列合并成一个有序序列,只需要取这两个子序列中小的值尾插即可,最终将排序好的序列拷贝到原数组。
实现代码如下:
void _MergeSort(int* a, int begin, int end, int* tmp)
{
if (begin >= end)
return;
int mid = (begin + end) / 2;
//[begin,mid][mid+1,end]
_MergeSort(a, begin, mid, tmp);
_MergeSort(a, mid+1, end, tmp);
//[begin,mid][mid+1,end]归并
int begin1 = begin, end1 = mid;
int begin2 = mid + 1, end2 = end;
int i = begin;
while (begin1 <= end1 && begin2 <= end2)
{
if (a[begin1] < a[begin2])
{
tmp[i++] = a[begin1++];
}
else
{
tmp[i++] = a[begin2++];
}
}
while (begin1 <= end1)
{
tmp[i++] = a[begin1++];
}
while (begin2 <= end2)
{
tmp[i++] = a[begin2++];
}
memcpy(a + begin, tmp + begin, sizeof(int) * (end - begin + 1));
}
void MergeSort(int* a, int n)
{
int* tmp = (int*)malloc(sizeof(int) * n);
if (tmp == NULL)
{
perror("malloc fail");
return;
}
_MergeSort(a, 0,n - 1, tmp);
free(tmp);
}
归并的缺点在于需要O(N)的空间复杂度,时间复杂度为O(N*logN)。
在非递归实现时,每个元素都是有序的,一个和一个有序归成两个,两个和两个有序归成四个,四个和四个有序归成八个。
实现代码如下:
void MergeSortNonR(int* a, int n)
{
int* tmp = (int*)malloc(sizeof(int) * n);
if (tmp == NULL)
{
perror("malloc fail");
return;
}
int gap = 1;
while (gap < n)
{
int i = 0;
printf("gap:%d->", gap);
for (i = 0; i < n; i += 2 * gap)
{
int begin1 = i, end1 = i + gap - 1;
int begin2 = i + gap, end2 = i + 2 * gap - 1;
//[begin1,end1][begin2,end2]归并
printf("[%2d,%2d][%2d,%2d] ", begin1, end1, begin2, end2);
//边界的处理
if (end1 >= n || begin2>=n)
{
break;
}
if (end2 >= n)
{
end2 = n - 1;
}
int j = begin1;
while (begin1 <= end1 && begin2 <= end2)
{
if (a[begin1] < a[begin2])
{
tmp[j++] = a[begin1++];
}
else
{
tmp[j++] = a[begin2++];
}
}
while (begin1 <= end1)
{
tmp[j++] = a[begin1++];
}
while (begin2 <= end2)
{
tmp[j++] = a[begin2++];
}
memcpy(a + i, tmp + i, sizeof(int) * (end2-i+1));
}
printf("\n");
gap *= 2;
}
free(tmp);
}
在上面的代码中,设置一个gap,表示归并的每组数据有几个,刚开始gap=1,表示归并的每组数据有一个,之后gap=2,表示归并的每组数据有两个,最后gap=4,表示归并的每组数据有四个。
在上面的这段代码中,有这样几句:
//边界的处理
if (end1 >= n || begin2>=n)
{
break;
}
if (end2 >= n)
{
end2 = n - 1;
}
这几句代码是在处理最后归并的一组越界的情况,如果end1>=n,那么begin1和其之后不越界的元素肯定是有序的,直接break跳出;如果begin2>=n,那么直接break跳出,此时的begin1和end1之间也已经是有序的;如果end2>=n,那么begin1-end1和begin2和其之后不越界的元素需要归并,但是end2要改为n-1。
接下来,我们了解一下内排序和外排序:
内排序,就是在内存中进行排序;外排序就是在硬盘中进行排序。上图中的几种排序都是内排序,但是归并排序同时也是外排序。
假设我们有100亿个整数,大约40G大小的文件,那么对这40G文件如何排序呢?利用归并排序,有这样一种思路:每次读1G数据到内存,然后快速排序,写到一个小文件中,最后得到40个1G的小文件,然后对这40个文件两两归并,得到20个文件,依次类推,最终得到一个大文件,这个文件是有序的。
因此,在实际中,往往在硬盘中排序时,才会用到归并排序。
这是一种非比较排序,即不需要比较元素大小,只需要统计相同元素出现次数。
例如,遍历一遍数组a,a中1出现了3次,那么count中下标为1的位置就是3,2出现了1次,那么count中下标为2的位置就是1,...,9出现了1次,那么count中下标为9的位置就是1,
for(int i=0;i<n;i++)
{
count[a[i]]++;
}
然后根据count中记录的结果,1位置是3,那么写3次1到a,2位置是1,那么写1次2到a,3位置是2,那么写2次3到a,...,得到下面的结果
特点:效率极高,时间复杂度O(aN+countN(范围)),空间复杂度O(countN(范围))
局限性:1.不适合分散的数据,更适合集中数据2.不适合浮点数、字符串、结构体数据排序,只适合整数。
那么,现在有这样一个问题,假设我们要排的数据是1000 1999 1888...(在1000-1999之间),那么我们仍需要开辟0-999这么多的空间,很浪费,如下图
那么,我们变通一下,做一个相对映射:
也就是说,1000在0位置处,1005在5位置处,极大节约了空间。
代码实现如下:
void CountSort(int* a, int n)
{
int min = a[0], max = a[0];
for (int i = 0; i < n; i++)
{
if (a[i] < min)
min = a[i];
if (a[i] > max)
max = a[i];
}
int range = max - min + 1;
int* count = (int*)calloc(range, sizeof(int));
if (count == NULL)
{
perror("calloc fail");
return;
}
//统计次数
for (int i = 0; i < n; i++)
{
count[a[i] - min]++;
}
//排序
int i = 0;
for (int j = 0; j < range; j++)
{
while (count[j]--)
{
a[i++] = j + min;
}
}
}