提示:本篇文章只讨论比较类算法,不包含基数排序,计数排序等
前言
我最近复习C语言时对库函数qsort采用的泛型设计方法产生了兴趣,因此尝试利用泛型设计的思想设计其他的一些简单排序。希望能帮助到有缘人。
一、代码设计
为了设计的函数更加通用,所有的函数均采用与qsort相同的参数,即:
//提高代码兼容性
#define true 1
#define false 0
typedef int bool;
//比较函数:
//返回值:等于返回0,大于返回正数,小于返回负数;
//参数:元素指针,元素指针;
typedef int (*COMPARE)(const void*, const void*);
//排序函数
//无返回值
//参数:待排序数组,数组元素个数,元素大小,比较函数
void Sort(void* arr, int n, int size, COMPARE _cmp);
与参数相对应地,交换函数:
//交换函数:
//无返回值;
//参数:元素指针,元素指针,元素大小;
void SwapElem(void* pa, void* pb, int size) {
if (pa == pb) {
return;
}
char tmp;
for (int i = 0; i < size; ++i) {
tmp = *((char*)pa + i);
*((char*)pa + i) = *((char*)pb + i);
*((char*)pb + i) = tmp;
}
}
二、编写排序代码
一共设计八种排序代码:冒泡、选择、插入、希尔、归并、快速、堆,外加一个自己瞎写的娱乐代码,代码如下:
1.冒泡排序
时间复杂度O(n^2),很慢,稳定,比较和交换的次数非常多,代码如下:
//冒泡排序:
//无返回值;
//参数:数组,元素个数,元素大小,排序算法
void BubbleSort(void* arr, int n, int size, COMPARE _cmp) {
if (arr == NULL || _cmp == NULL) {
return;
}
bool flag = true;
char* arr_tmp = (char*)arr;
for (int i = 0; i < n - 1; ++i) {
for (int k = 0; k < n - i - 1; ++k) {
if (_cmp(arr_tmp + k * size, arr_tmp + (k + 1) * size) > 0) {
SwapElem(arr_tmp + k * size, arr_tmp + (k + 1) * size, size);
flag = false;
}
}
if (flag) { //小优化
break;
}
flag = true;
}
}
冒泡没啥好说的,简单才是硬道理。
2.选择排序
时间复杂度O(n^2),较慢,不稳定,比冒泡好一点的是交换次数少,仅此而已,代码如下:
//选择排序:
//无返回值;
//参数:数组,元素个数,元素大小,排序算法
void SelecteSort(void* arr, int n, int size, COMPARE _cmp) {
if (arr == NULL || _cmp == NULL) {
return;
}
int min; //cmp比较下的最小元素的下标
char* arr_tmp = (char*)arr;
for (int i = 0; i < n; ++i) {
min = i;
for (int k = i + 1; k < n; ++k) {
if (_cmp(arr_tmp + min * size, arr_tmp + k * size) > 0) {
min = k;
}
}
SwapElem(arr_tmp + i * size, arr_tmp + min * size, size);
}
}
由于是从前向后依次比较,所以相同较后的排序后可能靠前,此种实现排序不稳定。
3.插入排序
复杂度O(n^2),较慢,稳定,比较和交换次数多,适用于本身较为有序且小的数组,代码如下:
//插入排序:
//无返回值;
//参数:数组,元素个数,元素大小,排序算法
void InsertSort(void* arr, int n, int size, COMPARE _cmp) {
if (arr == NULL || _cmp == NULL) {
return;
}
char* arr_tmp = (char*)arr;
char* tmp = (char*)malloc(size);
int k;
for (int i = 1; i < n; ++i) {
SwapElem(tmp, arr_tmp + i * size, size);
for (k = i - 1; k >= 0 && _cmp(arr_tmp + k * size, tmp) > 0; --k) {
SwapElem(arr_tmp + k * size, arr_tmp + (k + 1) * size, size);
}
SwapElem(tmp, arr_tmp + (k + 1) * size, size);
}
free(tmp);
}
很多时候数据大部分有序,此时采用插入排序会更加实用。
4.希尔排序
时间复杂度O(n*logn),较快,不稳定,分步分组插入排序,用于处理大量数据。
既然插入排序更加偏爱较小且较有序的数组,那就可以将一个较长的无序数组分割成若干个较小的数组分别采用插入排序,使数组整体有序化;接着减少数组的个数,再分组再排序,不断有序化,以此类推,直至所有元素全分到一个组中,最后采用插入排序。按照前人经验,最优情况下组的个数变化满足函数:n = n / 3 + 1,代码如下:
//希尔排序:
//无返回值;
//参数:数组,元素个数,元素大小,排序算法
void ShellSort(void* arr, int n, int size, COMPARE _cmp) {
if (arr == NULL || _cmp == NULL) {
return;
}
char* arr_tmp = (char*)arr;
char* tmp = (char*)malloc(size);
int increasement = n;
do {//分组
increasement = increasement / 3 + 1;
for (int i = 0; i < increasement; ++i) {
//对每组进行插入排序
for (int a = increasement + i; a < n; a += increasement) {
SwapElem(tmp, arr_tmp + a * size, size);
int k;
for (k = a - increasement; k >= 0 && _cmp(arr_tmp + k * size, tmp) > 0; k -= increasement) {
SwapElem(arr_tmp + k * size, arr_tmp + (k + increasement) * size, size);
}
SwapElem(tmp, arr_tmp + (k + increasement) * size, size);
}
}
} while (increasement > 1);
free(tmp);
}
5.快速排序
复杂度O(n*logn),很快,不稳定,采用分治法,挖坑填数。由于库中已经实现了快速排序算法,我只好拙劣模仿一下,代码如下:
//快速排序:
//无返回值;
//参数:数组,元素个数,元素大小,排序算法
/*
注:最坏情况(如先调用up升序函数,再对同一数组调用down降序函数的情况)下,
最大递归层数逼近元素个数,会导致栈溢出,
所以在添加了尾递归优化(2023.7.27)
*/
void QuickSort(void* arr, int n, int size, COMPARE _cmp) {
//#define STD_QSORT
#ifdef STD_QSORT //采用库函数,建议
qsort(arr, n, size, _cmp);
return;
#else //采用自行实现的快速排序函数
static int depth = 0; //进入层数增加,退出减少
++depth;
if (arr == NULL || _cmp == NULL) {
--depth;
return;
}
if (n <= 1) {
--depth;
return;
}
if (depth >= 20) { //递归过深调用其他函数,至于为啥是20,我随便敲的
//ShellSort(arr, n, size, _cmp);
MergeSort(arr, n, size, _cmp);
--depth;
return;
}
//确定基准数,初始化为首元素
char* arr_tmp = (char*)arr;
char* tmp = (char*)malloc(size);
SwapElem(tmp, arr_tmp, size);
//双指针遍历分界
bool flag = true;
int i = 0, j = n - 1;
while (i < j) {
if (flag) {
while (i < j && _cmp(arr_tmp + j * size, tmp) > 0) {
--j;
}
flag = false;
}
else { //注:考虑到两元素相等=0,这里必须用!取反表示 <= ,不然会死循环
while (i < j && !(_cmp(arr_tmp + i * size, tmp) > 0)) {
++i;
}
flag = true;
}
SwapElem(arr_tmp + i * size, arr_tmp + j * size, size);
}
SwapElem(tmp, arr_tmp + i * size, size);
free(tmp);
//对左右两边递归采用此法
QuickSort(arr, i, size, _cmp);
QuickSort((char*)arr + (i + 1) * size, n - i - 1, size, _cmp);
--depth;
#endif // STD_QSORT
#undef STD_QSORT
}
这里快速排序采用最经典的挖坑排序,没有什么特殊优化,不再赘述思路了,只加了一个尾递归判断方式栈溢出。如果想直接采用库函数,就在编译时定义宏STD_QSORT即可。
6.归并排序
复杂度O(n*logn),很快,稳定,数组不断二分成小数组,两个有序小数组合成新有序数组,非常经典且快速有效的分类方案,代码如下:
//归并排序:
//无返回值;
//参数:数组,元素个数,元素大小,排序算法
void MergeSort(void* arr, int n, int size, COMPARE _cmp) {
if (arr == NULL || _cmp == NULL) {
return;
}
if (n <= 1) {
return;
}
int mid = (n - 1) / 2;
MergeSort(arr, mid + 1, size, _cmp);
MergeSort((char*)arr + (mid + 1) * size, n - mid - 1, size, _cmp);
//合并(0 ~ mid 与 mid+1 ~ n-1)
int left = 0, right = mid + 1, ii = 0;
char* tmp = (char*)malloc(n * size);
char* arr_tmp = (char*)arr;
while (left <= mid && right <= n - 1) {
if (_cmp(arr_tmp + left * size, arr_tmp + right * size) > 0) {
SwapElem(tmp + ii++ * size, arr_tmp + right++ * size, size);
}
else {
SwapElem(tmp + ii++ * size, arr_tmp + left++ * size, size);
}
}
while (left <= mid) {
SwapElem(tmp + ii++ * size, arr_tmp + left++ * size, size);
}
while (right <= n - 1) {
SwapElem(tmp + ii++ * size, arr_tmp + right++ * size, size);
}
memcpy(arr, tmp, n * size);
free(tmp);
}
不过这里有个缺陷,就是每次递归都会新申请一块空间,十分浪费。但是我又不想修改原参数列表,所以有以下方案:
/*
这里完全可以优化,因为每次递归都申请空间太浪费,同时还耗时
浪费空间接近原空间2倍,若数据量较大可能栈溢出
可以变成这样,但数据不想再测试了,简单写下实现方法:
//归并函数接口
void MergeSort(void* arr, int n, int size, COMPARE _cmp){
if(...)
return;
char* tmp = (char*)malloc(n * size);
__mergesort(arr, n, size, _cmp, tmp); //从这开始递归,只传空间的指针
free(tmp);
}
//真正的归并函数
void __mergesort(void* arr, int n, int size, COMPARE _cmp, char* tmp){...}
*/
7.堆排序
复杂度O(n*logn),很快,不稳定,利用完全二叉树排序,本应是比较快速的排序算法,但我不小心写错了代码导致时间复杂度飙升至n^2,好在最后发现了错误。代码如下:
先看错误的:
//错误写法
/*
//堆排序:无返回值;数组,元素个数,元素大小,排序算法
void HeapSort(void* arr, int n, int size, COMPARE _cmp) {
if (arr == NULL || _cmp == NULL) {
return;
}
char* arr_tmp = (char*)arr;
//初始化堆,_cmp大顶堆
for (int i = 0; i < n; ++i) {
int cur = i; //当前索引
int father = (cur - 1) / 2; //父节点索引
//相等不交换,不稳定
while (father >= 0 && _cmp(arr_tmp + cur * size, arr_tmp + father * size) > 0) {
SwapElem(arr_tmp + cur * size, arr_tmp + father * size, size);
cur = father;
father = (cur - 1) / 2;
}
}
while (n--) {
//交换堆顶与最后一个元素
SwapElem(arr_tmp, arr_tmp + n * size, size);
for (int i = 0; i < n; ++i) {
int cur = i; //当前索引
int father = (cur - 1) / 2; //父节点索引
while (father >= 0 && _cmp(arr_tmp + cur * size, arr_tmp + father * size) > 0) {
SwapElem(arr_tmp + cur * size, arr_tmp + father * size, size);
cur = father;
father = (cur - 1) / 2;
}
}
}
}
*/
乍一看可能没问题(确实能排序),但是每次交换完元素之后都重新构建了堆,怪不得慢。当时天色渐晚,我不想改,索性交给GPT帮改,发现写的还不错。这是我修改之后的GPT生成的代码:
// 堆排序内部函数:下沉操作,维护堆的性质
void sink(void* arr, int k, int n, int size, COMPARE _cmp) {
char* arr_tmp = (char*)arr;
while (2 * k + 1 < n) {
int j = 2 * k + 1;
if (j + 1 < n && _cmp(arr_tmp + j * size, arr_tmp + (j + 1) * size) < 0) {
j++;
}
if (_cmp(arr_tmp + k * size, arr_tmp + j * size) >= 0) {
break;
}
SwapElem(arr_tmp + k * size, arr_tmp + j * size, size);
k = j;
}
}
//堆排序:无返回值;数组,元素个数,元素大小,排序算法
void HeapSort(void* arr, int n, int size, COMPARE _cmp) {
if (arr == NULL || _cmp == NULL || n <= 1) {
return;
}
//构建堆
for (int i = n / 2; i >= 0; i--) {
sink(arr, i, n, size, _cmp);
}
char* arr_tmp = (char*)arr;
while (n--) {
SwapElem(arr_tmp, arr_tmp + n * size, size);
sink(arr_tmp, 0, n, size, _cmp);
}
}
8.随缘排序
我写快速排序时突发奇想,排序算法都是朝着有序性开刀,为啥能不先交换再判断有序性。搜了一下发现真的有猴子排序这种玩意,我也弄了个类似的:
//警告:玩玩得啦,大小不要超过10,不然可能算不出来
//瞎排序:无返回值;数组,元素个数,元素大小,排序算法
void OhhMySort(void* arr, int n, int size, COMPARE _cmp) {
if (arr == NULL || _cmp == NULL) {
return;
}
srand((unsigned int)time(NULL));
bool flag = true;
char* arr_tmp = (char*)arr;
int int_rand_1, int_rand_2;
while (flag) {
//随机交换两个元素
int_rand_1 = rand() % n;
int_rand_2 = rand() % n;
SwapElem(arr_tmp + int_rand_1 * size, arr_tmp + int_rand_2 * size, size);
flag = false;
//看是不是有序
for (int i = 0; i < n - 1; ++i) {
if (_cmp(arr_tmp + i * size, arr_tmp + (i + 1) * size) >= 0) {
flag = true;
break;
}
}
}
}
三、测试
为了方便测试这些排序函数,先完成几个测试函数:
//常用的比较函数声明
//这里内建了常用的比较函数,不过测试的时候只用int_up_cmp(升序)和int_down_cmp(降序)
#define STATEMENT(name) int name(const void*, const void*);
//升序
STATEMENT(char_up_cmp)
STATEMENT(int_up_cmp)
STATEMENT(long_up_cmp)
STATEMENT(float_up_cmp)
STATEMENT(double_up_cmp)
STATEMENT(ldouble_up_cmp)
//降序
STATEMENT(char_down_cmp)
STATEMENT(int_down_cmp)
STATEMENT(long_down_cmp)
STATEMENT(float_down_cmp)
STATEMENT(double_down_cmp)
STATEMENT(ldouble_down_cmp)
/-------------------------/
//常用的比较函数的实现
//升序
#define MY_UP_COMP(type)\
int type##_up_cmp(const void* pa,const void* pb) {\
return *((type*)pa) - *((type*)pb);\
}
typedef long double ldouble;
MY_UP_COMP(char)
MY_UP_COMP(int)
MY_UP_COMP(long)
MY_UP_COMP(float)
MY_UP_COMP(double)
MY_UP_COMP(ldouble)
#undef MY_UP_COMP
//降序
#define MY_DOWN_COMP(type)\
int type##_down_cmp(const void* pa,const void* pb) {\
return *((type*)pb) - *((type*)pa);\
}
MY_DOWN_COMP(char)
MY_DOWN_COMP(int)
MY_DOWN_COMP(long)
MY_DOWN_COMP(float)
MY_DOWN_COMP(double)
MY_DOWN_COMP(ldouble)
#undef MY_DOWN_COMP
//计时函数,获取系统当前时间(毫秒级)
long GetSystemTime() {
struct timeb tb;
ftime(&tb);
return tb.time * 1000 + tb.millitm;
}
//测试函数
int test()
{
srand((unsigned int)time(NULL));
//以int型数据为例做测试,arr为数组,num为数组元素个数
int num, *arr;
while (true) {
scanf_s("%d", &num);
arr = (int*)malloc(num * sizeof(int));
if (arr == NULL) {
printf("所用数据量过大,请重新输入");
continue;
}
break;
}
//随机初始化数组
for (int i = 0; i < num; ++i) {
arr[i] = rand() % 100; //限制范围0-100方便查看数据
}
//测试排序效率区域,数据单位为ms
//测试环境VS2022releas
//数据量分别为10000(一万),100000(十万),1000000(百万),10000000(千万)
//结果取三次平均值,此处回调函数用int_down_cmp或int_up_cmp所得结果接近
long start = GetSystemTime();
{
......
}
long end = GetSystemTime();
//数据量过大不打印结果(比较耗时)
if (num <= 100000) {
for (int i = 0; i < num; ++i) {
printf("%d ", arr[i]);
}
}
printf("\ntime(ms): %d\n", end - start);
free(arr); //别忘释放,我说给自己听的
return 0;
}
这是测试样例和结果:
//测试排序效率区域,数据单位为ms
//测试环境VS2022releas
//数据量分别为10000(一万),100000(十万),1000000(百万),10000000(千万)
//结果取三次平均值,此处回调函数用int_down_cmp或int_up_cmp所得结果接近
long start = GetSystemTime();
{
//BubbleSort(arr, num, sizeof(int), int_down_cmp);
//冒泡 144.4 19777.3 超时 超时
//SelecteSort(arr, num, sizeof(int), int_down_cmp);
//选择 134 13649 超时 超时
//InsertSort(arr, num, sizeof(int), int_down_cmp);
//插入 29 2887.3 超时 超时
//ShellSort(arr, num, sizeof(int), int_down_cmp);
//希尔 1 13.7 153.3 1785
//qsort(arr, num, sizeof(int), int_down_cmp);
//C语言库快速 0.3 4 39 377
//QuickSort(arr, num, sizeof(int), int_down_cmp);
//我仿照的快速 1 7.3 88.3 965.7
//MergeSort(arr, num, sizeof(int), int_down_cmp);
//归并 0.7 9.7 99 1075
//HeapSort(arr, num, sizeof(int), int_down_cmp);
//原来的堆排序的实现有问题,是n^2的情况
//堆 57.3 4295.6 超时 超时
//后来发现我晕乎乎地在每次交换元素后都重新构建了堆,怪不得慢
//改进之后符合n*logn的复杂度了
//堆 1 8 103 1261
//OhhMySort(arr, num, sizeof(int), int_down_cmp);
//瞎排序 无穷 —— —— ——
//num <= 10时相当于程序随机中断0-20s(bushi)
}
long end = GetSystemTime();
可见库函数qsort优化的很到位,或者说其他的这些函数还有好多进步空间。欢迎大家交流和批评指正。
总结
困死我了先歇会()。