欢迎各位大佬光临本文章!!!
还请各位大佬提出宝贵的意见,如发现文章错误请联系冰冰,冰冰一定会虚心接受,及时改正。
本系列文章为冰冰学习编程的学习笔记,如果对您也有帮助,还请各位大佬、帅哥、美女点点支持,您的每一分关心都是我坚持的动力。
我的博客地址:bingbing~bang的博客_CSDN博客https://blog.csdn.net/bingbing_bang?type=blog
我的gitee:冰冰棒 (bingbingsupercool) - Gitee.comhttps://gitee.com/bingbingsurercool
系列文章推荐
目录
前言
在前面的章节中我们介绍了常见的排序方式以及最强大的排序,快速排序。这些算法基本都是适用于内排序而并非适用于外排序。归并排序是一种即可实现内排序,又可实现外排序的算法,在排序文件非常大时,文件只能写在磁盘上,无法直接将其写进内存进行排序,此时就需要使用归并排序的思想对磁盘上的文件进行归并排序。计数排序与基数排序是非比较排序算法,二者都是适用于特殊情况下的排序算法。
一、归并排序
归并排序是建立在归并操作上的一种有效的排序算法,该算法采用分治思想,将已经有序的子序列进行合并,得到完全有序的序列;
1.1归并排序的递归实现
归并排序是使用二路递归进行实现的,因此归并排序通常采用后续递归的方法实现。排序算法基本过程为:先对一组无序数组进行二分,将其拆分成区间只含有一个数字时即可视为该区间有序,然后返回对有序区间进行合并,然后逐步递归进行返回,直到整个区间有序。
那归并排序是如何使用递归进行拆分合并的呢?
任何一个区间只含有一个元素时就可以认为其为有序区间,因此我们可以将归并排序分为两部分实现,一部分为使用递归将其一直拆分区间,当拆分到只含有一个数字或者没有数字的区间时进行返回,先对左区间进行拆分,在对右区间进行拆分。当左右区间均只含有一个数字的时候,归并排序进行第二部分,即对有序区间进行合并。
既然是完全的二分,因此我们进入函数后需要先计算出左右区间的分界线mid,然后继续调用函数进行递归拆分。当完全拆分后,需要对有序区间进行合并,合并的区间即为begin到mid,以及mid+1到end。但是我们不能在原排序数组中进行直接归并排序,归并排序过程中有可能覆盖原数组的数据,因此我们需要额外开辟数组tmp来进行排序,排序完成后再将其拷贝回去。为了方便排序我们在进入排序函数后就向内存申请一块和待排数组完全一样大的空间,只需要将排序的元素放置到tmp数组中对应位置即可。
归并的部分代码实现起来并不是很难,我们需要将两个区间的元素进行比较,设计两个下标,begin1与begin2,分别指向两个区间的首元素位置。将两个下标指向的元素进行比较,哪个元素小,就将其放在tmp数组中,然后tmp下标向后移动。但是tmp下标开始的位置在哪呢?由于我们需要将tmp的内容完全拷贝到原数组中,因此tmp的下标应该和原数组中归并区间的范围一致,所以下标开始位置应该为begin1指向的位置。排序完一个元素后,并非两个区间的下标都向后移动,而是哪个区间的元素放到tmp数组中哪个区间向后移动。直到其中一个区间的下标移动超过了区间范围时,将停止比较移动,最后将还有元素的区间中的元素排到tmp数组中。
归并排序非递归代码:
void _MergeSort(DataType* a, int begin, int end, DataType* tmp)
{
if ( begin >= end )
{
return;
}
int mid = (begin + end) / 2;
//划分左
_MergeSort(a, begin, mid, tmp);
//划分右
_MergeSort(a, mid + 1, end, tmp);
//归并有序
int begin1 = begin; int end1 = mid;
int begin2 = mid + 1; int end2 = end;
int i = begin1;
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, (end - begin + 1) * sizeof(DataType));
}
void MergeSort(DataType* pa, int n)
{
assert(pa);
DataType* tmp = (DataType*)malloc(n * sizeof(DataType));
if ( tmp == NULL )
{
perror("malloc:");
exit(-1);
}
_MergeSort(pa, 0, n - 1, tmp);
free(tmp);
}
1.2归并排序的非递归实现
归并排序的递归实现方式还是比较容易理解的,正如快速排序一样,凡是采用递归实现的代码都具有栈溢出的风险。所以我们需要将归并排序改为非递归实现。在进行快速排序的非递归实现方式的时候,我们使用的是用栈来进行模拟递归的方式,那么归并排序是否可以使用呢?答案是否定的,归并排序采用的为后续递归的方式,如果将递归拆分的区间放入栈中进行保存,当进行归并的时候栈中的区间早已被释放,找不到原有的区间。
因此我们在对归并排序进行非递归的改写时采用的是循环方式。归并排序需要使用递归先将整个区间进行拆分,然后再对其进行归并排序,既然如此我们完全可以主动将区间进行划分,然后直接进行归并排序。创建gap变量为每次进行归并的元素个数,首先将元素进行一个元素与一个元素进行归并,然后进行两个元素与两个元素进行归并,然后gap每次都乘以2直到gap大于等于n时结束。
我们使用下标 i 来划分区间,一开始需要一个元素与相邻的另一个元素进行归并,因此 i 下次指向的归并区间应该跳过这两个元素,指向第三个元素,也就是上图中的元素7。下标从0增长为2,当一个一个元素之间归并结束后,我们需要两个元素与两个元素进行归并,gap变为2。此时对前4个元素进行归并后,i 需要跳过2倍的归并空间,指向上图中的元素3。下标从0变为4。所以每次 i 的变化都为增加了2倍的gap。还需要注意一点,我们使用begin1,end1,begin2,end2来表示归并的左右区间的范围,所以左区间begin1起始位置为 i,结束位置end1为i+gap-1。begin2为i+gap,end2为i+2gap-1。
还有一点需要特别注意,我们举的例子正好可以二分,但是如果数组的大小并非完全二分,此时就会出现数组越界的情况,如果进行归并将会导致越界访问,无法归并排序。例如数组含有9个元素,若不对区间进行修正将会出现下列情况:
因此我们需要对区间进行修正处理, 我们发现,begin1的取值与 i 有关,因此begin1并不存在越界的风险,但是begin2,end1,end2均有越界的风险。当end1已经越界时,begin2,end2必然越界,所以我们需要将end1修正为n-1,begin2与end2修正为一个不存在的区间,使其在进行归并时不会进入循环,直接将begin1与end1之间的元素进行归并即可。当begin2越界时,end2必然越界,处理方式也是将其变为不存在的区间。当end2越界时,我们需将其修正为n-1。
归并排序的非递归实现:
void MergeSortNonR(DataType*pa, int n)
{
assert(pa);
DataType* tmp = (DataType*)malloc(n * sizeof(DataType));
if ( tmp == NULL )
{
perror("malloc:");
exit(-1);
}
int gap = 1;
while (gap < n )
{
for ( int i = 0; i < n; i += 2* gap )
{
int begin1 = i; int end1 = i+gap-1;
int begin2 = i+gap; int end2 = i+2*gap-1;
//修正边界
if ( end1 >= n )
{
end1 = n - 1;
//修正成不存在区间
begin2 = n;
end2 = n - 1;
}
else if ( begin2 >= n )
{
begin2 = n;
end2 = n - 1;
}
else if ( end2 >= n )
{
end2 = n - 1;
}
int j = begin1;
while ( begin1 <= end1 && begin2 <= end2 )
{
if ( pa[begin1] <= pa[begin2] )
{
tmp[j++] = pa[begin1++];
}
else
{
tmp[j++] = pa[begin2++];
}
}
while ( begin1 <= end1 )
{
tmp[j++] = pa[begin1++];
}
while ( begin2 <= end2 )
{
tmp[j++] = pa[begin2++];
}
}
gap *= 2;
//拷贝回去
memcpy(pa, tmp, n * sizeof(DataType));
}
free(tmp);
}
除了此种方式,我们还可以在出现越界时将数组直接跳出归并,让数据留在原数组,只需要将end2越界的情况进行修正,此种状况下就得保证每次归并后都需要将数据进行拷贝回去,而并非放在最后进行拷贝。 避免数据未进行归并导致出现随机值。
void MergeSortNonR(DataType* pa, int n)
{
assert(pa);
DataType* tmp = (DataType*)malloc(n * sizeof(DataType));
if ( tmp == NULL )
{
perror("malloc:");
exit(-1);
}
int gap = 1;
while ( gap < n )
{
for ( int i = 0; i < n; i += 2 * gap )
{
int begin1 = i; int end1 = i + gap - 1;
int begin2 = i + gap; int end2 = i + 2 * gap - 1;
//不修边界,跳出循环
if ( end1 >= n || begin2 >= n )
{
break;
}
else if ( end2 >= n )
{
end2 = n - 1;
}
//计算拷贝区间大小
int size = end2 - begin1 + 1;
int j = i;
while ( begin1 <= end1 && begin2 <= end2 )
{
if ( pa[begin1] < pa[begin2] )
{
tmp[j++] = pa[begin1++];
}
else
{
tmp[j++] = pa[begin2++];
}
}
while ( begin1 <= end1 )
{
tmp[j++] = pa[begin1++];
}
while ( begin2 <= end2 )
{
tmp[j++] = pa[begin2++];
}
//拷贝回去
memcpy(pa + i, tmp + i, size * sizeof(DataType));
}
gap *= 2;
}
free(tmp);
}
1.3归并排序实现外排序
归并排序的思想天生可以作为外排序来进行文件排序,当一个文件非常大时,内存中无法将这些数据全部导入进来进行排序,此时我们便可以使用归并排序的思想对其进行排序。假设一个文件中含有100个数据,而我们的内存中最大只能处理10个数据,那怎么将其进行排序呢?
我们可以这样:先将100个数据的文件拆分成10个小文件,每个文件含有10个数据,然后将这些小文件导入内存中进行排序,将其排序为有序然后重新写入到每个小文件中。最后对这10个小文件进行归并处理,通过归并排序,将文件排列成一个有序的大文件。下面代码为文件排序的试例代码:
void _MergeSortFile(const char* file1, const char* file2, const char* mfile)
{
FILE* fout1 = fopen(file1, "r");
if ( fout1 == NULL )
{
printf("打开失败\n");
exit(-1);
}
FILE* fout2 = fopen(file2, "r");
if ( fout2 == NULL )
{
printf("打开失败\n");
exit(-1);
}
FILE* fin = fopen(mfile, "w");
if ( fin == NULL )
{
printf("打开失败\n");
exit(-1);
}
int num1, num2;
int ret1 = fscanf(fout1, "%d\n", &num1);
int ret2 = fscanf(fout2, "%d\n", &num2);
while ( ret1 != EOF && ret2!=EOF )
{
if ( num1 < num2 )
{
fprintf(fin, "%d\n", num1);
ret1= fscanf(fout1, "%d\n", &num1);
}
else
{
fprintf(fin, "%d\n", num2);
ret2 = fscanf(fout2, "%d\n", &num2);
}
}
while ( ret1 != EOF )
{
fprintf(fin, "%d\n", num1);
ret1 = fscanf(fout1, "%d\n", &num1);
}
while ( ret2 != EOF )
{
fprintf(fin, "%d\n", num2);
ret2 = fscanf(fout2, "%d\n", &num2);
}
fclose(fout1);
fclose(fout2);
fclose(fin);
}
//归并排序,外排序
void MergeSortFile( const char* file )
{
FILE* fout = fopen(file, "r");
if ( fout == NULL )
{
printf("打开失败\n");
exit(-1);
}
int num = 0;
int i = 0;
int n = 10;
int a[10] = { 0 };
char subfile[20];
int filei = 1;
memset(a, 0, n * sizeof(int));
//将大文件切分成小文件
while ( fscanf(fout, "%d\n", &num) != EOF )
{
if ( i < n - 1 )//读取9个数据
{
a[i++] = num;
}
else
{
a[i] = num;//将第10个数据读取放到数组里
//进行10个数据排序,并写到小文件中
QuickSort(a, 0, n - 1);
//创建文件名
sprintf(subfile, "sort\\%d", filei++);
//将数组内容写入切分的小文件
FILE* fin = fopen(subfile, "w");
if ( fin == NULL )
{
printf("打开失败\n");
exit(-1);
}
for ( int j = 0; j < n; j++ )
{
fprintf(fin,"%d\n", a[j]);
}
fclose(fin);
i = 0;
memset(a, 0, n * sizeof(int));
}
}
//将有序小文件归并为有序大文件
char file1[100] = "sort\\1";
char file2[100] = "sort\\2";
char mfile[100] = "sort\\12";
for ( int i = 2 ;i<=n;i++)
{
_MergeSortFile(file1, file2, mfile);
strcpy(file1, mfile);
sprintf(file2, "sort\\%d", i + 1);
sprintf(mfile, "%s%d", mfile, i + 1);
}
//回写到源文件
FILE* fsort1 = fopen(file1, "r");
if ( fsort1 == NULL )
{
printf("打开失败\n");
exit(-1);
}
FILE* fsort2 = fopen(file, "w");
if ( fsort2 == NULL )
{
printf("打开失败\n");
exit(-1);
}
while ( fscanf(fsort1, "%d\n", &num) != EOF )
{
fprintf(fsort2, "%d\n", num);
}
fclose(fout);
fclose(fsort1);
fclose(fsort2);
}
二、计数排序
计数排序和基数排序都是非比较排序。计数排序又称为鸽巢原理,是对哈希直接定址法的变形应用。具体思想为:首先根据数组范围创建一个计数用的数组,统计待排数组中每个数字出现的次数,然后计数数组相应的位置处的计数将会增加,最后通过计数数组将数据回写到待排数组中,完成排序。
计数排序适用的场景为范围不大并且数据相对集中的情况下,当数据不为整数时,该种方式无法适用,当数据范围过大时,也无法使用该方式。并且我们开辟的范围空间并不是绝对映射,而是相对映射,我们只需要找出该组数据中的最大值和最小值,然后计算出范围即可开辟计数数组。计数数组tmp[0]位置记录的就是该范围中最小的数字,回写时只需要计数数组相应位置加上min即为实际数据大小。计数排序算法的时间复杂度为O(max(N,range)),空间复杂度为O(range)。
计数排序代码:
void CountSort(DataType* pa, int n)
{
assert(pa);
DataType min = pa[0];
DataType max = pa[0];
for ( int i = 0; i < n; i++ )
{
if ( pa[i] < min )
{
min = pa[i];
}
if ( pa[i] > max )
{
max = pa[i];
}
}
int range = max - min + 1;
DataType* tmp = (DataType*)calloc(range, sizeof(DataType));
if ( tmp == NULL )
{
perror("malloc:");
exit(-1);
}
//统计次数
for ( int i = 0; i < n; i++ )
{
tmp[pa[i] - min]++;
}
//回写排序
int j = 0;
for ( int i = 0; i < range; i++ )
{
while ( tmp[i]--)
{
pa[j++] = i + min;
}
}
}
三、基数排序
基数排序是和前面的排序类型完全不相同的一种排序方式,前面的排序要么是基于比较数值的排序,要么是通过记录数值来进行的排序。基数排序是一种借助多关键字进行排序的方法。
例如在进行整数之间的排序时,我们可以按照个位先排,再利用十位,百位等等,经过几次的数据分发与回收,实现数据的整体有序。因此,基数排序的核心思想就是分发与回收数据的过程。
下面我们通过一组数据进行基数排序的讲解,我们对整数数组a[ ]={267,34,52,12,89,3,6,42,167,256}进行排序。
首先该组数据的关键字即为数字,我们知道,任何一个数字都是由0~9组成的,所以我们创建9个容器来存取数据。然后通过分发回收来将数据进行排序,那么数据需要分发回收几次呢?由于我们是按照位数进行分发回收的,所以就需要得到这组数据中最大数的位数。分发过程就是按照个位十位等关键字将数据放到容器中,回收过程则需要按照容器顺序回收即可。
基数排序代码:
int GetKey(DataType value, int k)
{
int key = 0;
while ( k >= 0 )
{
key = value % 10;
value /= 10;
k--;
}
return key;
}
int Maxbit(DataType x)
{
int count = 0;
while ( x )
{
count++;
x /= 10;
}
return count;
}
void Distripute(DataType* a, int begin, int end, int k, Queue q[10])
{
for ( int i = begin; i <= end; i++ )
{
int key = GetKey(a[i],k);
QueuePush(&(q[key]), a[i]);
}
}
void Collect(DataType* a,Queue q[10])
{
int i = 0;
int j = 0;
for ( i = 0; i < 10; i++ )
{
while ( !QueueEmpty(&(q[i])) )
{
a[j++] = QueueFront(&(q[i]));
QueuePop(&(q[i]));
}
}
}
//基数排序
void RadixSort(DataType* pa, int begin, int end)//[begin,end]
{
assert(pa);
//开辟基数队列
Queue q0, q1, q2, q3, q4, q5, q6, q7, q8, q9;
int i = 0;
QueueInit(&q0);
QueueInit(&q1);
QueueInit(&q2);
QueueInit(&q3);
QueueInit(&q4);
QueueInit(&q5);
QueueInit(&q6);
QueueInit(&q7);
QueueInit(&q8);
QueueInit(&q9);
Queue q[10] = { q0,q1,q2,q3,q4,q5,q6,q7,q8,q9 };
//计算最高位数
DataType max = pa[0];
for ( i = 0; i <= end; i++ )
{
if ( pa[i] > max )
{
max = pa[i];
}
}
int k = Maxbit(max);
for ( i = 0; i < k; i++ )
{
//分发数据
Distripute(pa, begin, end, i,q);
//回收数据
Collect(pa,q);
}
for ( i = 0; i < 10; i++ )
{
QueueDestroy(&q[i]);
}
}
总结
稳定性也是排序算法的一种特性,当排序完成后,原本有序的元素的相对顺序没有发生改变就称该排序算法稳定。归并排序作为常用排序中的一种,其具有相当优秀的效率,归并排序拆分过程中完全按照二分的思路进行分解归并,时间复杂度为O(N*logN),空间复杂度为O(N)。基数排序与计数排序为特殊情况下的排序算法,在特定场景下这两中排序算法具有相当大的优势。排序算法的总结暂告于此,排序算法作为相对重要且常用的算法,需要我们理解并熟稔于心,下面附上常见排序算法性能表格,仅供参考。
至此,数据结构的学习笔记章节已更新完毕,下面便开始C++的学习记录。