目录
归并排序递归实现
1.归并排序基本思想
2.归并排序单趟思路
3.代码思路步骤
3.1.归并排序实现思路步骤
3.2.总结
3.2.1.数组归并与链表归并的差异
(1)数组归并
(2)链表归并
(3)总结
3.2.2.归并排序的递归实现总结
4.归并排序递归实现代码
5.归并排序递归递归展开图
6.递归实现归并排序的时间复杂度、空间复杂度
6.1.对归并排序的时间复杂度是O(N*logN)进行分析
(1)归并排序的基本操作通常指的是以下两个步骤
(2)归并排序的时间复杂度的计算过程
6.2.对归并排序的空间复杂度是O(N)进行分析
7. 递归实现快速排序与递归实现归并排序的对比
7.1.快速排序与归并排序的对比
(1)分割与合并
(2)空间复杂度
(3)稳定性
(4)适用性
(5)时间复杂度对比
8.归并排序的特性总结
归并排序非递归实现
1.非递归实现归并排序思路
1.1.非递归实现归并排序单趟的思路
1.2.非递归归并排序实现思路步骤
2.归并排序非递归实现代码
2.1.写法1:采用归并完了整体拷贝
2.1.1.代码解析
(1)解析为什么写法1一定要修正各个左右有序子区间后才归并的过程
2.2.写法2:采用归并一部分又拷贝一部分
2.2.1.代码解析
(1)解析为什么写法2可以不用写修正左右有序子区间的区间范围代码
文件排序-归并排序实现
1.对文件归并排序的介绍
1.1.海量数据问题
1.2.内存限制
1.3.磁盘I/O效率
1.4.文件作为数据存储
1.5.归并排序的适用性
1.6.总结
2.文件归并排序的单趟
2.1.代码思路步骤
2.2.代码
3.文件归并排序实现过程
3.1.递归文件归并排序的实现思路步骤
(1)递归文件归并排序(即 MergeSortFile 函数)的实现思路可以分为以下几个步骤
分割大文件:
多趟归并:
(2)以下是递归文件归并排序的详细步骤
初始化:
分割并排序数据:
归并有序小文件:
结束:
3.2.文件归并排序递归写法1
3.2.1.代码解析
3.3.文件归并排序的时间复杂度和空间复杂度计算
(1)时间复杂度
①分割阶段
②归并阶段
(2)空间复杂度
①分割阶段
②归并阶段
4.用归并排序实现文件排序的整个工程
4.1.QuickSort.h快速排序头文件
4.2.QuickSort.c快速排序源文件
4.3.MergeSortFile.h文件归并排序头文件
4.4.MergeSortFile.c文件归并排序源文件
归并排序递归实现
1.归并排序基本思想
图形解析:
归并排序是建立在归并操作上的一种有效的排序算法,该算法是采用分治法的一个非常典型的应用。将已有序的子序列合并,得到完全有序的序列;即先使每个子序列有序,再使子序列段间有序。若将两个有序表合并成一个有序表,称为二路归并。 归并排序核心步骤:
2.归并排序单趟思路
图形解析:
- 单趟归并的目的是将两个已有序的子区间合并成一个有序的区间。具体步骤如下:
-
初始化遍历指针:为左右两个有序子区间分别设置开始和结束的指针(begin1, end1 和 begin2, end2)。
-
比较并合并:使用两个指针begin1、begin2分别遍历左右子区间,比较指针所指元素的大小,将较小的元素复制到临时数组 tmp 中,并将相应指针向后移动。
-
复制剩余元素:当一个子区间的元素全部复制完毕后,将另一个子区间剩余的元素复制到临时数组 tmp 中。
-
复制回原数组:将合并后的有序区间从临时数组 tmp 复制回原数组 a 的相应位置。
3.代码思路步骤
3.1.归并排序实现思路步骤
图形解析:
递归过程中原数组a和临时数组tmp的图形解析:
-
递归终止条件:如果当前处理的区间 [begin, end] 只有一个元素或者为空(begin >= end),则无需排序,直接返回。
-
分割区间:找到当前区间的中间位置 mid,将区间 [begin, end] 分割成两个子区间 [begin, mid] 和 [mid+1, end]。
-
递归排序子区间:分别对左右两个子区间 [begin, mid] 和 [mid+1, end] 进行递归排序。这一步骤模拟了二叉树的后序遍历,即先递归处理左右子树,然后处理根节点。
-
合并有序子区间:在递归完成后,左右子区间已经有序,接下来使用单趟归并的思路将它们合并成一个有序的区间。
-
分配和释放临时空间:在归并排序开始前,分配一个与原数组大小相同的临时数组 tmp 用于存放合并过程中的中间结果。在归并排序完成后,释放临时数组 tmp 的空间。
通过以上步骤,归并排序将整个数组分割成单个元素的子区间,然后两两合并,最终得到一个完全有序的数组。这个过程不断将问题分解成更小的子问题,并将子问题的解合并起来以得到原问题的解,体现了分治法的思想。
3.2.总结
3.2.1.数组归并与链表归并的差异
(1)数组归并
-
临时数组的使用:
- 当原数组的两个有序区间进行归并时,必须使用一个临时数组 tmp 来存放归并的结果。这样做的原因是在原数组上进行归并可能会导致数据覆盖和丢失,因为数组的空间是连续的,直接在原数组上操作可能会覆盖还未处理的元素。
-
归并过程:
- 在归并过程中,比较两个有序区间的元素,将较小的元素依次放入临时数组 tmp 中。当一个区间中的所有元素都被放入 tmp 后,将另一个区间剩余的元素也放入 tmp。
-
拷贝回原数组:
- 完成归并后,需要将临时数组 tmp 中的元素拷贝回原数组 a 的相应位置。这是因为归并排序在每一层递归中都需要一个干净的临时数组来存放归并结果,而不是等到整个排序完成后才进行拷贝。
-
空间复杂度:
- 数组归并的空间复杂度为 O(n),因为需要一个与原数组大小相同的临时数组。
(2)链表归并
-
无需额外空间:
- 链表的归并不需要额外的空间,因为链表节点的空间不是连续的,可以单独取出节点并重新链接。
-
归并过程:
- 在归并链表时,可以通过比较两个链表的头部节点,将较小的节点从原链表中取下,然后尾插到新链表中。这个过程不需要额外的数组空间。
-
空间复杂度:
- 链表归并的空间复杂度为 O(1),因为归并过程中只需要改变节点的指针,而不需要额外的存储空间。
(3)总结
- 数组归并:由于数组空间的连续性,归并时需要在临时数组中进行,以避免数据覆盖和丢失。归并完成后,必须将结果拷贝回原数组,导致空间复杂度为 O(n)。
- 链表归并:链表节点的独立性使得归并可以在不使用额外空间的情况下进行,只需调整指针即可,因此空间复杂度为 O(1)。
这两种数据结构的归并操作体现了它们在空间使用上的本质差异:数组以其连续的存储空间提供了快速的随机访问能力,但这也限制了它在归并操作中的灵活性;而链表虽然牺牲了随机访问能力,但其节点间的非连续性使得它在某些操作(如归并)中更为灵活和高效。
3.2.2.归并排序的递归实现总结
-
递归分割:
- 归并排序采用分治法,首先将原数组不断递归分割成更小的子区间,直到每个子区间只有一个元素或为空。这一过程模拟了二叉树的后序遍历,递归分割相当于在构建二叉树。
-
合并有序区间:
- 当递归到最底层,即子区间只有一个元素时,可以认为这些子区间是有序的。然后开始合并这些有序的子区间。
- 合并过程中,需要借助一个临时数组 tmp 来存放合并后的有序区间,以避免在原数组上直接合并可能导致的元素覆盖和数据丢失。
-
临时数组的使用:
- 在合并两个有序子区间时,依次比较两个子区间中的元素,将较小的元素放入临时数组 tmp 中。
- 当一个子区间中的元素全部放入临时数组后,将另一个子区间剩余的元素也放入临时数组中。
-
拷贝回原数组:
- 完成两个子区间的合并后,将临时数组 tmp 中的有序区间拷贝回原数组 a 的相应位置。
-
递归与迭代:
- 归并排序的递归实现中,递归用于分割区间,而合并操作是在递归返回的过程中迭代的进行的。
4.归并排序递归实现代码
// [begin, end]
//归并排序的子函数
void _MergeSort(int* a, int begin, int end, int* tmp)
{
//1.若当前区间的[begin,end]只有1个或者0个元素则认为区间[begin,end]是个有序区间,则不
需要递归把当前区间的[begin,end]排成有序区间。
if (begin >= end)
return;
//2.利用当前区间的[begin,end]的中间值下标mid来把当前区间的[begin,end]分割成有两个左
右子区间[begin, mid] [mid+1, end]
int mid = (begin + end) / 2;
// [begin, mid] [mid+1, end] 递归让子区间有序
//3.模拟二叉树的后序遍历来把当前区间的[begin,end]变成有序区间
//3.1.让当前区间的[begin,end]的左子区间[begin, mid]有序
_MergeSort(a, begin, mid, tmp);
//3.2.让当前区间的[begin,end]的右子区间[mid+1, end]有序
_MergeSort(a, mid + 1, end, tmp);
// 归并有序左子区间[begin, mid]和有序右子区间[mid+1, end]到新创建的数组空间后再把新数
组的所有元素都复制到原数组使得当前区间[begin,end]变得有序
//3.3.让通过归并当前区间的[begin,end]的左右有序子区间使得当前区间的[begin,end]变得有
序
//(1)定义begin1、end1来遍历有序的左子区间[begin,mid],定义begin2、end2来遍历有序的右
子区间[[mid+1,end]
int begin1 = begin, end1 = mid;
int begin2 = mid + 1, end2 = end;
//(2)定义临时变量i来遍历新创建的数组
//注意:在合并两个有序的子区间并取小的尾插到新数组时,一定是从新数组i = begin位置开始往
后进行尾插的。
int i = begin;
//(3)当前区间[begin,end]的有序左右子区间[begin1,end1]、[begin2,end2]合并成一个有序区
间的过程:(即归并排序单趟的过程)
while (begin1 <= end1 && begin2 <= end2)
{
//取有序左右子区间中较小的数尾插到新创建的数组tmp中
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++];
}
//总的来说,把新数组对应的有序区间复制到原数组对应的无序区间中,使得原数组对应区间变成
有序。
//(4)从新数组tmp + begin位置开始往后的把end - begin + 1个元素复制到原数组a + begin开始
往后的位置
memcpy(a + begin, tmp + begin, sizeof(int) * (end - begin + 1));
}
//归并排序的递归实现
void MergeSort(int* a, int n)
{
//1.额外开辟新数组的空间
int* tmp = (int*)malloc(sizeof(int) * n);
if (tmp == NULL)
{
perror("malloc fail");
exit(-1);
}
//2.利用递归实现归并排序从而把整个数组a排成升序。
_MergeSort(a, 0, n - 1, tmp);//指针tmp指向新开辟数组的动态空间;
//3.由于此时已经完成归并排序,则要释放掉临时开辟的动态数组tmp的空间
free(tmp);
tmp = NULL;
}
5.归并排序递归递归展开图
6.递归实现归并排序的时间复杂度、空间复杂度
6.1.对归并排序的时间复杂度是O(N*logN)进行分析
(1)归并排序的基本操作通常指的是以下两个步骤
-
比较操作:在归并排序的归并过程中,将两个已排序的子数组合并成一个有序数组时,需要对元素进行比较以确定它们的相对顺序。这个比较操作是归并排序中的基本操作之一。
-
复制操作:在归并过程中,一旦确定了元素的顺序,就需要将元素从原始数组复制到临时数组中,然后再将它们复制回原始数组。这个复制操作也是归并排序中的基本操作。
因此,归并排序的基本操作可以定义为:
- 比较两个元素的大小:用于确定元素在合并后的数组中的位置。
- 将元素从一个位置复制到另一个位置:用于在归并过程中重新排列元素。
这两个操作在归并排序的每个阶段都会重复执行,直到整个数组排序完成。归并排序的性能分析通常基于这些基本操作的次数,因为它们直接影响了算法的时间复杂度。
(2)归并排序的时间复杂度的计算过程
注意:①归并排序的时间复杂度是由分割过程和归并过程决定的;②归并排序的分解过程就像个二叉树而且二叉树的高度是logN;③归并排序的时间复杂度不能按单次来看而是要按整体来看;④二叉树每一层的归并过程的合计都是N即二叉树每一层都是遍历原数组的N个元素;⑤快速排序的时间复杂度之所以是O(N*logN)主要依赖于选key。
图形解析:
-
分割过程:
- 归并排序的分割过程是将数组递归地一分为二,直到每个子区间只有一个元素为止。这一过程可以视为构建一棵满二叉树,其中每个节点代表一个子区间。
-
二叉树高度:
- 由于每次分割都将数组大小减半,因此构建这棵二叉树的高度是 logN,其中 N 是数组的总元素个数。
-
归并过程:
- 在二叉树的每一层,归并操作将两个子区间合并成一个有序区间。每一层的归并操作总共会处理 N 个元素,因为每个元素都会在归并过程中被比较和放置到正确的位置。
-
总操作次数:
- 由于二叉树的高度是 logN,且每一层的归并操作涉及 N 个元素,所以总操作次数是每层操作次数的总和,即 N + N + … + N(共 logN 项),这可以表示为 N*logN。
因此,递归归并排序的时间复杂度是 O(N*logN)。
6.2.对归并排序的空间复杂度是O(N)进行分析
图形解析:
(注意:递归过程的空间复杂度的计算主要看的是算法递归的深度)
-
临时数组:
- 归并排序需要一个与原数组大小相同的临时数组来存放归并的结果。无论归并操作进行到哪一步,这个临时数组的大小始终是 N,因此这部分的空间复杂度是 O(N)。
-
递归栈空间:
- 递归归并排序的递归深度是 logN,因为每次递归调用都会将数组大小减半。每个递归调用都需要一定的栈空间来保存函数调用的上下文信息,因此递归栈的空间复杂度是 O(logN)。
-
总空间复杂度:
- 虽然递归栈的空间复杂度是 O(logN),但这与临时数组所需的 O(N) 空间相比可以忽略不计。因此,归并排序的总空间复杂度由临时数组决定,即 O(N)。
总的来说,由于归并排序要额外开辟一块临时数组tmp来存放每个当前子区间的左右子区间归并后的结果,这一过程消耗的空间复杂度是O(N),而归并排序的递归过程建立的函数栈帧的这一过程消耗的空间复杂度是O(logN),所以归并排序最终的空间复杂度是O(N)。
7. 递归实现快速排序与递归实现归并排序的对比
7.1.快速排序与归并排序的对比
(1)分割与合并
- 快速排序:通过单趟排序确定一个基准元素的位置,从而将数组分割成两个子区间,然后递归地对这两个子区间进行排序。这个过程类似于二叉树的前序遍历。
- 归并排序:先递归地将数组分割成单个元素的子区间,然后两两合并这些子区间。这个过程类似于二叉树的后序遍历。
(2)空间复杂度
- 快速排序:通常情况下,空间复杂度为 O(log n),因为它是原地排序,但最坏情况下会退化到 O(n)。
- 归并排序:空间复杂度为 O(n),因为需要额外的临时数组来存放合并后的有序区间。
(3)稳定性
- 快速排序:不稳定排序,因为相同的元素可能会在分割过程中交换位置。
- 归并排序:稳定排序,因为合并过程中相同元素的相对顺序不会改变。
(4)适用性
- 快速排序:适用于大多数情况,尤其是当内存使用需要优化时。
- 归并排序:适用于需要稳定排序或对大数据集进行外部排序的情况。
(5)时间复杂度对比
快速排序与归并排序的时间复杂度对比如下:
快速排序的时间复杂度:
- 最坏情况:O(n^2)
- 当每次分区操作选择的基准元素都是最大或最小元素时,会导致分区极度不平衡,此时快速排序的时间复杂度会退化到平方级别。但是若快速排序有三数取中函数优化的话,则快速排序可以把最坏情况变成最好情况从而使得即使快速排序遇到最坏情况的时间复杂度也是O(n*logn)
- 平均情况:O(n*log n)
- 在平均情况下,快速排序能够较为均匀地将数组分为两个子区间,此时时间复杂度为线性对数级别。
- 最好情况:O(n*log n)
- 当每次分区都能将数组分为两个大小相等的子区间时,快速排序达到最佳效率。
归并排序的时间复杂度:
- 最坏情况:O(n*log n)
- 无论输入数据的初始顺序如何,归并排序都会将数组分割成单个元素的子区间,然后再两两合并,因此最坏情况下的时间复杂度仍然是线性对数级别。
- 平均情况:O(n*log n)
- 归并排序在平均情况下的时间复杂度与最坏情况相同,因为它总是按照相同的逻辑进行分割和合并。
- 最好情况:O(n*log n)
- 即使输入数据已经是有序的,归并排序仍然会执行相同的分割和合并操作,因此最好情况下的时间复杂度也是线性对数级别。
总结:
- 快速排序在平均和最好情况下的效率非常高,但在最坏情况下效率会大幅下降。
- 归并排序则无论是在最好、平均还是最坏情况下都保持稳定的时间复杂度,这使得它在处理大数据集时表现更加可靠。
- 快速排序通常比归并排序快,因为归并排序需要额外的空间来存储临时数组,而快速排序是原地排序(不占用额外空间,除了递归调用栈)。
- 归并排序是稳定的排序算法,而快速排序是不稳定的。在某些应用场景中,排序算法的稳定性也是选择排序算法的一个重要因素。
8.归并排序的特性总结
(1)归并的缺点在于需要O(N)的空间复杂度,归并排序的思考更多的是解决在磁盘中的外排序问题。
(2)时间复杂度:O(N*logN)
(3)空间复杂度:O(N)
(4)稳定性:稳定
归并排序非递归实现
1.非递归实现归并排序思路
注意:以写法1为例进行解析。
图形解析:
1.1.非递归实现归并排序单趟的思路
-
确定子区间:在非递归版本中,我们通过变量
rangeN
来确定每次要归并的子区间的大小。每次迭代,rangeN
控制着两个相邻的子区间的大小,这两个子区间是接下来要归并的。 -
比较并合并:对于每一对相邻的子区间,我们使用两个指针begin1、begin2分别遍历这两个子区间,并将较小的元素依次放入临时数组
tmp
中。当一个子区间的元素全部放入tmp
后,将另一个子区间的剩余元素也放入tmp
。 -
修正区间范围:在合并过程中,需要确保不会访问数组的越界元素。因此,在合并前,我们要检查并修正子区间[begin1,end1]、[begin2,end2]的起始和结束索引,以避免越界。
1.2.非递归归并排序实现思路步骤
-
初始化:分配一个与原数组
a
等大的临时数组tmp
,用于归并操作。 -
控制归并大小:使用变量
rangeN
来控制每次归并操作中子区间的大小。初始时,rangeN
设置为 1,表示每个子区间只有一个元素。 -
归并过程:
- 在
rangeN < n
的循环中,每次迭代都会将数组中的所有相邻子区间(大小为rangeN
)进行归并。 - 对于数组中的每一对相邻子区间,使用两个指针begin1、begin2分别指向这两个子区间的起始位置,比较并合并这两个子区间到
tmp
中。 - 修正子区间的范围,以确保不会越界。
- 在
-
拷贝回原数组:在每次
rangeN
倍增之前,将tmp
中的内容拷贝回原数组a
,以便进行下一轮更大子区间的归并。 -
更新归并大小:将
rangeN
的值翻倍,以便在下一次迭代中归并更大的子区间。 -
重复归并:重复步骤 3 到 5,直到
rangeN
等于n
,此时整个数组已经有序。 -
释放资源:归并排序完成后,释放临时数组
tmp
占用的内存。
通过这种方式,非递归归并排序能够有效地将数组排序,其时间复杂度仍然是 O(NlogN),空间复杂度为 O(N),因为需要一个与原数组等大的临时数组来存储归并的中间结果。
2.归并排序非递归实现代码
2.1.写法1:采用归并完了整体拷贝
//非递归的归并排序写法1:若采用归并完了整体拷贝,则必须要有修正区间的代码,并且在修正完两个要进行归并的有序子区间的区间范围后,才能让两个有序子区间进行归并。
void MergeSortNonR(int* a, int n)
{
//1.额外开辟临时数组tmp
int* tmp = (int*)malloc(sizeof(int) * n);
if (tmp == NULL)
{
perror("malloc fail");
exit(-1);
}
//2.定义rangN来控制要进行归并的有序子区间的大小
//注意:rangN表示归并每组的数据个数,从1开始,因为1个认为是有序的,可以直接归并。
int rangeN = 1;
//3.归并的过程->通过不断的把数组中最小的有序区间归并成一个较大的有序区间,从而实现整个
数组变成有序的过程。
//注意:当要进行归并的有序子区间大小rangeN = n,则此时我们认为要归并的有序子区间就是整
个数组而且也说明了整个数组现在是有序了,则此时要结束归并。
while (rangeN < n)
{
//3.1.每次循环都是整个数组从左到右依次进行每一对相邻rangeN大小的有序子区间进行归
并。同时每次循环后要进行归并的有序子区间大小rangN在不断的扩大,但是第一次循环时
rangN一定是1,因为子区间只有1个元素时可以认为这个子区间是有序子区间而且还是最小的有
序子区间。
for (int i = 0; i < n; i += 2 * rangeN)
{
//[begin1,end1][begin2,end2]归并
//3.1.1.定义要进行归并的两个rangeN大小的有序子区间的各自子区间范围
//注意:由于这里规定了两个有序子区间的各自子区间范围,所以end1、begin2、end2会
有越界的风险,所以下面才会有修正区间。
//(1)左子区间范围
int begin1 = i, end1 = i + rangeN - 1;
//(2)右子区间范围
int begin2 = i + rangeN, end2 = i + 2 * rangeN - 1;
(3)利用打印两个左右子区间的范围来发现end1、begin2、end2中谁出现越界
//printf("[%d,%d][%d,%d]\n", begin1, end1, begin2, end2);
//3.1.2.由于两个有序子区间在进行合并时一定是尾插到新数组tmp对应的j = i开始往后
的位置中的,所以每次两两有序子区间合并成有序区间时j都是从i开始遍历新数组tmp的。
int j = i;//定义临时变量j来遍历新数组tmp。
//3.1.3.每次归并两组rangeN大小的有序子区间过程:
//(1)修正要进行归并两组有序子区间的区间范围。
//(注意:利用写法1实现非递归的归并排序最重要一步是修正两个有序子区间的区间范
围,以此来防止越界访问)
//越界的三种情况:end1、begin2、end2越界; begin2、end2越界; end2越界。
if (end1 >= n)
{
end1 = n - 1;
//由于end1 >= n使得end1、begin2、end2越界,所以要缩小区间[begin1,end1]的
范围以达到修正区间[begin1,end1]的效果,同时把区间[begin2,end2]修正成不存
在区间以此防止发生越界访问。
begin2 = n;
end2 = n - 1;
}
else if (begin2 >= n)
{
//由于end1 < n且begin2 >= n使得end1没有越界而begin2、end2越界,所以把区间
[begin2,end2]修正成不存在区间以此防止发生越界访问。
begin2 = n;
end2 = n - 1;
}
else if (end2 >= n)
{
//由于end1 < n、begin2 < n且end2 >= n使得end1、begin2都没有越界而end2发生
越界,所以要通过缩小区间[begin2,end2]的范围达到修正区间[begin2,end2]的效果。
end2 = n - 1;
}
//(2)两组rangeN大小的有序子区间归并成一个有序区间的过程:
//注意:由于上面存在修正要进行合并的有序子区间的各自的区间范围进而使得由于修正
区间导致可能存在begin2 > end2且不存在区间[begin2,end2]这种情况。由于修正区间防
止了当end1、begin2、end2发生越界后会把访问到的随机值为插到新数组tmp中。
while (begin1 <= end1 && begin2 <= end2)
{
//取小的尾插到新数组tmp中
if (a[begin1] <= a[begin2])
{
tmp[j++] = a[begin1++];
}
else
{
tmp[j++] = a[begin2++];
}
}
//把两个有序子区间中有剩余元素的子区间的所有元素尾插到新数组tmp中
while (begin1 <= end1)
{
tmp[j++] = a[begin1++];
}
while (begin2 <= end2)
{
tmp[j++] = a[begin2++];
}
}
//拷贝数据->归并完了整体拷贝 or 归并每组拷贝
//3.2.整体归并完了再拷贝
memcpy(a, tmp, sizeof(int) * (n));
//3.3.更新rangeN的值,说明下一次要进行归并的有序子区间大小rangeN。
rangeN *= 2;
}
//4.由于此时归并排序结束了,所以此时要释放掉额外开辟的数组tmp空间。
free(tmp);
tmp = NULL;
}
2.1.1.代码解析
(1)解析为什么写法1一定要修正各个左右有序子区间后才归并的过程
- end1、begin2、begin2发生越界的3种情况:
注意:由于begin1 = i且i < n则begin1是一定不会发生越界的,而end1 = i+rangeN-1、begin2 = i+rangeN、end2 = i+2*rangeN会导致end1、begin2、end2会有越界的风险。
图形解析:下面以int a[] = {1,6,2,7,9,3,4,5,6,8}数组为例来解析end1、begin2、begin2发生越界的3种情况。我们可以利用下面printf来看一下每次定义了左右子区间[begin1,end1]、[begin2,end2]之后end1、begin2、begin2是否发生越界的情况。
//3.1.1.定义要进行归并的两个rangeN大小的有序子区间的各自子区间范围
//注意:由于这里规定了两个有序子区间的各自子区间范围,所以end1、begin2、end2会
有越界的风险,所以下面才会有修正区间。
//(1)左子区间范围
int begin1 = i, end1 = i + rangeN - 1;
//(2)右子区间范围
int begin2 = i + rangeN, end2 = i + 2 * rangeN - 1;
//(3)利用打印两个左右子区间的范围来发现end1、begin2、end2中谁出现越界
printf("[%d,%d][%d,%d]\n", begin1, end1, begin2, end2);
图形解析:下面printf打印结果是int a[] = {1,6,2,7,9,3,4,5,6,8}(注:数组大小为N= 10)在归并排序过程中所有子区间的打印结果。从打印结果可以看成end1、begin2、begin2发生越界的3种情况:
①类型1:只要end1发生越界就会让begin2、end2都发生越界。
②类型2:end1没有越界,而begin2、end2发生越界。
③类型3:end1、begin2都没有越界,只有end2发生越界。
- 由于写法1采用的是每次循环把原数组a整体归并到临时数组tmp后再把临时数组tmp整体拷贝回原数组a中,若没有修正区间当发生子区间的下标越界时则会发生以下问题:在归并时出现原数组的数据被随机数给覆盖的问题。
- 为了解决end1、begin2、begin2发生越界的问题,则我们每次把左右有序子区间进行归并之前先用下面代码修正区间左右子区间[begin1,end1]、[begin2,end2]的范围。
//越界的三种情况:end1、begin2、end2越界; begin2、end2越界; end2越界。
if (end1 >= n)
{
end1 = n - 1;
//由于end1 >= n使得end1、begin2、end2越界,所以要缩小区间[begin1,end1]的
范围以达到修正区间[begin1,end1]的效果,同时把区间[begin2,end2]修正成不存
在区间以此防止发生越界访问。
begin2 = n;
end2 = n - 1;
}
else if (begin2 >= n)
{
//由于end1 < n且begin2 >= n使得end1没有越界而begin2、end2越界,所以把区间
[begin2,end2]修正成不存在区间以此防止发生越界访问。
begin2 = n;
end2 = n - 1;
}
else if (end2 >= n)
{
//由于end1 < n、begin2 < n且end2 >= n使得end1、begin2都没有越界而end2发生
越界,所以要通过缩小区间[begin2,end2]的范围达到修正区间[begin2,end2]的效果。
end2 = n - 1;
}
2.2.写法2:采用归并一部分又拷贝一部分
//非递归的归并排序写法2:若采用归并一部分又拷贝一部分,则可以没有修正空间的代码而是直接使用if语句中的break停止当前两个有序子区间的合并。
void MergeSortNonR(int* a, int n)
{
int* tmp = (int*)malloc(sizeof(int)*n);
if (tmp == NULL)
{
perror("malloc fail");
exit(-1);
}
// 归并每组数据个数,从1开始,因为1个认为是有序的,可以直接归并
int rangeN = 1;
while (rangeN < n)
{
for (int i = 0; i < n; i += 2 * rangeN)
{
// [begin1,end1][begin2,end2] 归并
int begin1 = i, end1 = i + rangeN - 1;
int begin2 = i + rangeN, end2 = i + 2 * rangeN - 1;
printf("[%d,%d][%d,%d]\n", begin1, end1, begin2, end2);
int j = i;
// end1 begin2 end2 越界
if (end1 >= n)
{
break;
}
else if (begin2 >= n)
{
break;
}
else if (end2 >= n)
{
end2 = n - 1;
}
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++];
}
// 归并一部分,拷贝一部分。
//注意:一定不能用2*rangeN表示两个有序子区间大小的总和,因为当在数组的尾部时可
能两个有序子区间中存在一个是空的有序子区间或者存在一个大小小于rangeN的有序子区间。
memcpy(a + i, tmp + i, sizeof(int)*(end2 - i + 1));
}
rangeN *= 2;
}
free(tmp);
tmp = NULL;
}
2.2.1.代码解析
(1)解析为什么写法2可以不用写修正左右有序子区间的区间范围代码
由于写法2是采用每次归并完左右子区间后就直接把归并结果复制回原数组a中的这种归并一部分又拷贝一部分的策略和if语句的判断,即使发生上面提到的3种越界情况也不会发生原数组a的数据被随机数覆盖的事情。
图形解析:
while (rangeN < n)
{
for (int i = 0; i < n; i += 2 * rangeN)
{
//…………省略
// end1 begin2 end2 越界
//由于写法2是采用归并一部分就拷贝一部分的策略。若end1发生越界则直接用break退出for
语句本次循环的左右有序子区间[begin1,end1]、[begin2,end2]的归并,使得通过下标
end1、begin2、end2越界访问到的随机数不会归并到原数组a中,这样就不会发生原
数组a的数据被随机数覆盖的问题。
if (end1 >= n)
{
break;
}
//若begin2发生越界则直接用break退出for语句本次循环的左右有序子区间[begin1,
end1]、[begin2,end2]的归并。
else if (begin2 >= n)
{
break;
}
//若begin2没有越界,而end2发生越界,则此时必须要修正右子区间[begin2,end2]的区
间范围依次来防止归并后发生原数组a的数据被随机数覆盖的问题。
else if (end2 >= n)
{
end2 = n - 1;
}
//…………省略
// 归并一部分,拷贝一部分。
memcpy(a + i, tmp + i, sizeof(int)*(end2 - i + 1));
}
rangeN *= 2;
}
文件排序-归并排序实现
1.对文件归并排序的介绍
文件归并排序的背景通常涉及处理大量数据的情况,这些数据量超出了计算机内存的容量。以下是文件归并排序背景的详细说明:
1.1.海量数据问题
在信息技术和大数据领域,我们经常需要处理的数据量远远超出了计算机内存的限制。例如,数据库、日志文件、大型的数据集等,这些数据集的大小可能达到数GB、数十GB甚至更多,无法一次性全部加载到内存中进行处理。
1.2.内存限制
由于内存大小有限,传统的排序算法(如快速排序、堆排序等)无法直接应用于这些大数据集,因为它们需要在内存中对所有数据进行操作。如果尝试将所有数据加载到内存中,将会导致内存溢出,程序崩溃。
1.3.磁盘I/O效率
与内存相比,磁盘I/O操作(读写操作)的速度要慢得多。因此,需要一种排序方法,它能够在尽量减少磁盘I/O操作的同时完成对大数据集的排序。
1.4.文件作为数据存储
对于海量数据,我们通常选择将数据存储在磁盘上的文件中。文件可以存储大量数据,并且可以通过文件系统进行管理。
1.5.归并排序的适用性
归并排序算法特别适合于文件排序的场景,原因如下:
- 外排序算法:归并排序是一种外排序算法,它可以在数据部分在内存、部分在磁盘的情况下工作。
- 稳定性:归并排序是稳定的排序算法,可以保持相等元素的相对顺序不变,这在某些应用中是非常重要的。
- 分治策略:归并排序采用分治策略,可以将大文件分割成多个小文件,每个小文件的大小可以适应内存的限制。
- 合并过程:归并排序通过合并有序的小文件来生成最终的大文件,这个过程可以有效地在磁盘上执行。
1.6.总结
文件归并排序是为了解决当数据量太大而无法全部加载到内存时,如何对存储在磁盘文件中的数据进行有效排序的问题。它通过将大文件分割成多个小文件,逐个对小文件进行内存排序,然后通过归并这些有序小文件来构建最终有序的大文件,从而在内存和磁盘之间找到了一种平衡,使得排序操作成为可能。
2.文件归并排序的单趟
2.1.代码思路步骤
单趟归并(即 _MergeFile
函数)的思路是合并两个已经有序的小文件,生成一个新的有序文件。以下是详细步骤:
- 打开两个有序小文件用于读取数据,同时创建一个新的文件用于写入合并后的有序数据。
- 从两个有序小文件中分别读取一个整数,比较这两个整数的大小。
- 将较小的整数写入新文件,并从该小文件中读取下一个整数。
- 如果一个小文件的数据已经读取完毕,则将另一个小文件剩余的所有数据写入新文件。
- 关闭所有文件。
这个过程保证了合并后的文件也是有序的。
2.2.代码
//文件归并排序子函数(文件归并排序单趟)
void _MergeFile(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);
}
3.文件归并排序实现过程
3.1.递归文件归并排序的实现思路步骤
图形解析:
(1)递归文件归并排序(即 MergeSortFile
函数)的实现思路可以分为以下几个步骤
-
分割大文件:
- 打开原始大文件进行读取。
- 根据内存大小确定每个小文件可以存储的数据量。
- 读取一定数量的数据到内存数组中,使用快速排序对数组进行排序。
- 将排序后的数组写入新的小文件中,并重复该过程直到大文件的所有数据都被处理。
-
多趟归并:
- 初始化用于归并的文件名。
- 进行多趟归并操作,每趟归并两个有序小文件,生成一个新的有序文件。
- 在每趟归并后,更新文件名,为下一趟归并做准备。
- 当只剩下一个文件时,该文件即为最终排序后的文件。
(2)以下是递归文件归并排序的详细步骤
-
初始化:
- 打开原始文件。
- 定义小文件的大小(这里
MergeSortFile
函数代码中为n = 10)。 - 初始化用于存储数据的数组和相关变量。
-
分割并排序数据:
- 读取原始文件中的数据,直到文件结束。
- 将读取的数据存储到数组中,并在数组填满时进行快速排序。
- 将排序后的数组写入新创建的小文件中。
- 重置数组,并继续读取和排序,直到所有数据都被处理。
-
归并有序小文件:
- 初始化归并过程中的文件名。
- 使用
_MergeFile
函数,按顺序归并相邻的两个小文件。 - 每次归并后,更新文件名,以便下一次归并使用。
- 重复归并过程,直到只剩下一个文件,该文件就是排序后的文件。
-
结束:
- 打印排序成功的信息。
- 关闭原始文件。
注意:下面 MergeSortFile
函数实现了一个简化的文件归并排序过程,但在实际应用中,可能需要处理更多的细节,例如错误处理、内存管理、大文件的读取和写入效率优化等。
3.2.文件归并排序递归写法1
//文件归并排序函数
void MergeSortFile(const char* file)
{
//以读的方式打开要进行归并排序的源文件file
FILE* fout = fopen(file, "r");
if (fout == NULL)
{
printf("打开文件失败\n");
exit(-1);
}
//把源文件file分割成一段一段数据并依次存入内存数组中(注:每段数据有n个元素,而每次只把
源文件的n个元素存入数组中),并在内存中利用快速排序对数组进行排序,在内存排完序后把数组
中的元素写到小文件中,最终形成多个有序小文件。
//定义变量n表示每个小文件中包含的数据量(这里设为10),并定义一个数组a用于在内存中暂存
数据。
int n = 10;
int a[10];
//定义i来遍历数组a
int i = 0;
//定义num用来临时存在从源文件file中读取到的数据
int num = 0;
//定义一个字符数组subfile用来临时存储小文件的文件名
char subfile[20];
//filei 用于生成小文件的序号
int filei = 1;
//把数组a初始为0.
memset(a, 0, sizeof(int) * n);
//将大文件分割成多个小文件,每个小文件包含n个数据,并在内存中对这些数据进行排序后写入小
文件。
//使用 while 循环和 fscanf 读取文件中的整数,直到文件末尾(EOF)。
while (fscanf(fout, "%d\n", &num) != EOF)
{
if (i < n - 1)
{
a[i++] = num;//通过循环先读取n - 1个数据存入内存的数组a中。
}
else
{
//再把第n个数据读取进数组a中
a[i] = num;
//对数组a中的n个数据进行快速排序
QuickSort(a, 0, n - 1);
//使用 sprintf 生成小文件的文件名并存入字符数组subfile
sprintf(subfile, "%d", filei++);
//利用字符数组subfile中存放的小文件的文件名创建小文件并以写的方式打开小文件
subfile。
FILE* fin = fopen(subfile, "w");
if (fin == NULL)
{
printf("打开文件失败\n");
exit(-1);
}
//使用fprintf将数组a中排序后源文件file的n个数据写入小文件中,从而创建出大小为n
的有序小文件。
for (int j = 0; j < n; j++)
{
fprintf(fin, "%d\n", a[j]);
}
fclose(fin);
//在处理完数组a中的n个数据后,我们可以重置索引i为0,并清空数组a以便用
于下一轮读取。这样可以保证 while 循环可以持续运行,直到文件结束。
i = 0;
//对数组a进行初始化
memset(a, 0, sizeof(int) * n);
}
}
//通过两两归并小文件,逐步合并成更大的有序文件,直到最终合并成一个完整的有序文件。
char file1[100] = "1";
char file2[100] = "2";
char mfile[100] = "12";
for (int i = 2; i <= n; ++i)
{
// 读取file1和file2,进行归并出mfile
//把文件1和文件2归并到mfile文件中
_MergeFile(file1, file2, mfile);//_MergeFile函数是把两个小文件归并到新文件的过程
//更新文件1的文件名
strcpy(file1, mfile);
//更新文件2的文件名
sprintf(file2, "%d", i + 1);//i + 1控制的是下一次要进行归并的文件2的文件名
//更新文件1与文件2合并之后的文件名
sprintf(mfile, "%s%d", mfile, i + 1);//i + 1控制的是下一次文件1和文件2要归并到的mfile文件的文件名
}
printf("%s文件排序成功\n", file);
fclose(fout);
}
3.2.1.代码解析
(1)再把源文件file中的n个数据存入数组a中进行排序时,必须先在数组a中存入n-1个数据后,再把第n个数据存入数组a中的原因:
代码:
//将大文件分割成多个小文件,每个小文件包含n个数据,并在内存中对这些数据进行排序后写入小 文件。
//使用 while 循环和 fscanf 读取文件中的整数,直到文件末尾(EOF)。
while (fscanf(fout, "%d\n", &num) != EOF)
{
if (i < n - 1)
{
a[i++] = num;//通过循环先读取n - 1个数据存入内存的数组a中。
}
else
{
//再把第n个数据读取进数组a中
a[i] = num;
//…………省略
}
}
这部分代码的目的是确保在将数据读入数组 a
时不会丢失任何数据。以下是为什么需要这样读取数据的详细解释:
-
避免数据丢失:如果我们在数组
a
中填满n
个数据,那么在下一轮while
循环开始时,fscanf(fout, "%d\n", &num)
会立即读取下一个数据。如果这个数据不是数组a
的一部分,那么它将被跳过,并且永远无法被读取到,因为它已经被文件指针fout
越过了。 -
保持数据连续性:通过在数组
a
中留下一个空位,我们确保在完成一轮读取和排序后,可以立即开始下一轮读取,而不需要额外的逻辑来处理文件指针的位置。这意味着我们可以无缝地继续读取文件,而不必担心遗漏数据。 -
方便排序和写入:一旦数组
a
填满了n-1
个数据,并且我们已经读取了第n
个数据,我们就可以对数组a
进行排序,并将所有n
个数据写入小文件。这样做可以保证每次写入小文件的数据量都是一致的。 -
循环的连续性:在处理完数组
a
中的n
个数据后,我们可以重置索引i
为0,并清空数组a
以便用于下一轮读取。这样可以保证while
循环可以持续运行,直到文件结束。
总之,这种方法确保了文件中的每个数据都被正确读取、排序并最终写入小文件,没有数据被遗漏。
(2))再把源文件file中的n个数据存入数组a中进行排序时,也可以写成下面这样:
//ret用来判断是否读取完源文件file中的所有数据,即用ret判断是否读取到源文件file的末尾位置。
int ret = fscanf(fout, "%d\n", &num);
while (ret != EOF)
{
int i = 0;
//连续读取源文件file中的n个数据存入数组a中。
for (i = 0; i < n; i++)
{
a[i] = num;
ret = fscanf(fout, "%d\n", &num);
}
//对数组a中的n个数据进行快速排序
QuickSort(a, 0, n - 1);
//使用sprintf生成小文件的文件名并存入字符数组subfile
sprintf(subfile, "%d", filei++);
FILE* fin = fopen(subfile, "w");
if (fin == NULL)
{
perror("文件打开失败\n");
exit(-1);
}
//使用fprintf将数组a中排序后源文件file的n个数据写入小文件中,从而创建出大小为n
的有序小文件。
for (i = 0; i < n; i++)
fprintf(fin,"%d\n",a[i]);
//把数组a中的所有元素初始化为0.
memset(a, 0, sizeof(int) * n);
fclose(fin);
pfi = NULL;
}
代码解析:
下面是代码的逻辑流程:
- 在
while
循环的第一次迭代开始之前,第一个数据已经被读取到num
中。 for
循环执行,将num
的值存入a[0]
,然后读取下一个数据到num
。for
循环继续,每次迭代都将num
的当前值存入数组a
的下一个位置,并读取下一个数据。- 当
for
循环完成时,数组a
包含了n
个连续读取的数据。 while
循环继续,直到文件末尾。
通过这种方式,代码能够确保每次循环迭代都正确地读取 n
个数据,并且不会丢失任何数据,也不会重复读取数据。当文件中的数据不足以填满数组 a
时,循环将结束,此时 ret
将是 EOF。
(3)上面提到的两种读取源文件file中的n个数据存入数组a中的方式的对比:
区别和正确性分析
-
区别:
- 第二段代码在
for
循环中连续读取n
个数据,然后立即开始读取下一个数据。 - 第一段代码在读取
n-1
个数据后,停止读取,直到下一次循环开始才读取第n
个数据。
- 第二段代码在
-
正确性:
- 第二段代码在读取完
n
个数据后,文件指针fout
已经指向了下一个数据,这样在下次循环时,可以继续读取而不丢失数据。 - 第一段代码在读取
n-1
个数据后,文件指针fout
指向的是第n
个数据,因此在下一次循环开始时,可以正确地读取这个数据而不会多读取一个数据。
- 第二段代码在读取完
结论
第二段代码通过在 for
循环结束时立即读取下一个数据,确保了文件指针 fout
始终指向下一个要读取的数据,避免了数据丢失的问题。而第一段代码则通过在读取 n-1
个数据后停止,确保了不会在循环结束时多读取一个数据,这样在下一次循环开始时,可以正确读取第 n
个数据。两种方法都可以正确读取数据,但它们在循环的内部逻辑和文件指针的处理上有所不同。
3.3.文件归并排序的时间复杂度和空间复杂度计算
文件归并排序的时间复杂度和空间复杂度可以从其算法的基本操作中推导出来。以下是计算这两个复杂度的过程:
(1)时间复杂度
①分割阶段
- 假设文件大小为
N
,每次分割成大小为n
的块。 - 需要读取
N/n
次文件,每次读取n
个元素并对其进行快速排序。 - 快速排序的平均时间复杂度为
O(n log n)
。 - 因此,分割阶段的总时间复杂度为
O((N/n) * n log n) = O(N log n)
。
②归并阶段
- 每次归并两个大小为
n
的有序块,最终合并成大小为N
的有序文件。 - 归并两个大小为
n
的块的时间复杂度为O(n)
。 - 在归并排序的过程中,需要归并的次数是
log(N/n)
(因为每次归并操作后,块的大小翻倍)。 - 因此,归并阶段的总时间复杂度为
O((N/n) * n * log(N/n)) = O(N log(N/n))
。
综合两个阶段,文件归并排序的总时间复杂度为 O(N log n + N log(N/n))
。如果 n
足够小,那么 log n
可以忽略不计,最终时间复杂度简化为 O(N log N)
。
(2)空间复杂度
①分割阶段
- 需要读取数据到内存中的数组,大小为
n
。 - 因此,空间复杂度为
O(n)
。
②归并阶段
- 需要额外的空间来存储归并过程中产生的临时文件。
- 每次归并最多需要
2n
的空间(两个大小为n
的块)。 - 因此,归并阶段的空间复杂度也是
O(n)
。
综上所述,文件归并排序的空间复杂度为 O(n)
,这是因为整个过程中任何时候都只需要处理大小为 n
的数据块。
需要注意的是,这里的分析假设了每次读取和归并操作都是最优的,并且没有考虑磁盘I/O操作的时间开销,这在实际情况中可能会影响算法的实际表现。此外,如果归并操作不是在内存中完成,而是直接在磁盘文件上操作,那么空间复杂度可能会有所不同。
4.用归并排序实现文件排序的整个工程
4.1.QuickSort.h快速排序头文件
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
//打印函数
void PrintArray(int* a, int n);
//交换函数
void Swap(int* p1, int* p2);
//直接插入排序->排升序
void InsertSort(int* a, int n);
//三数取中函数
// begin mid end
int GetMidIndex(int* a, int begin, int end);
//Hoare版本的单趟
int PartSort1(int* a, int begin, int end);
//挖坑法的单趟
int PartSort2(int* a, int begin, int end);
//前后指针的单趟
int PartSort3(int* a, int begin, int end);
//二路划分版本的递归快速排序
void QuickSort(int* a, int begin, int end);
4.2.QuickSort.c快速排序源文件
#include "QuickSort.h"
//打印函数
void PrintArray(int* a, int n)
{
int i = 0;
for (i = 0; i < n; i++)
{
printf("%d ", a[i]);
}
//换行
printf("\n");
}
//交换函数
void Swap(int* p1, int* p2)
{
int tmp = *p1;
*p1 = *p2;
*p2 = tmp;
}
//直接插入排序
void InsertSort(int* a, int n)
{
//for循环的作用是:进行n - 1次直接插入排序单趟把数组第2个元素开始往后的n - 1个数据插入
到有序序列进而把数组中n个数据排序成升序。
for (int i = 0; i < n-1; ++i)
{
//注意:我们一开始不认为整个要排序的数组是个有序序列,而是一开始我们认为数组的第一
个元素就是个有序序列而数组第二个元素就是要插入到有序序列中的数据,等到数组第二个元
素插入到有序序列之后会使得数组前2个元素组成一个有序序列,进而使得数组第三个元素就是
插入到有序序列中的据,以此类推下去。
//在一个有序序列中插入一个数据进行排序的单趟过程:
//1.用end指向数组中的有序序列最后一个元素
//一开始end表示的数组中的有序序列最后一个元素的下标,而i是数组元素的下标。
int end = i;
//2.用变量tmp临时存储插入到有序序列的数据。而且这个插入数据是在有序序列最后一个元素
的后面。
//一开始下标end+1就是插入到有序序列中插入数据的下标。由于插入数据a[end + 1]在插入
的过程中有可能被有序序列中的其他数据覆盖掉所以我们要用临时变量tmp把插入
数据存储起来。
int tmp = a[end + 1];
//3.插入数据tmp在有序序列中插入的过程
//3.1.在有序序列中找到比插入数据tmp要小的数
while (end >= 0)
{
//注意:即使一开始end表示的是有序序列最后一个元素的下标,但是我们是用end从后往
前遍历整个有序序列
//若插入数据tmp比有序序列元素a[end]要小,则有序序列元素a[end]要往后挪动一步。
if (tmp < a[end])
{
//挪动数据
a[end + 1] = a[end];
--end;
}
//若插入数据tmp大于或等于有序序列的元素a[end],则停止比较有序序列元素和插入数据
tmp,然后在此时下标end+1的位置插入数据。
else
{
break;
}
}
//3.2.插入数据tmp插入到有序序列中比自己要小的数据的后面一个位置。
//插入数据tmp永远是在数组下标end的后一个位置插入的,即插入数据在数组下标end+1的位
置插入数据。
a[end + 1] = tmp;
}
}
// 三数取中函数->作用:选出下标begin、(begin + end) / 2、 end的中间值并返回。
// begin mid end
int GetMidIndex(int* a, int begin, int end)
{
int mid = (begin + end) / 2;
if (a[begin] < a[mid])
{
if (a[mid] < a[end])
{
return mid;
}
else if (a[begin] > a[end])
{
return begin;
}
else
{
return end;
}
}
else //a[begin] >= a[mid]
{
if (a[mid] > a[end])
{
return mid;
}
else if (a[begin] < a[end])
{
return begin;
}
else
{
return end;
}
}
}
//Hoare版本单趟
//注意:下面说的区间[begin,end]就是数组中待排序数据对应的下标访问范围。
int PartSort1(int* a, int begin, int end)
{
//1.利用三数取中函数GetMidIndex选出区间[begin,end]中下标begin、(begin + end) / 2、
end位置的3个元素的中间值作为Hoare版本单趟的基准值key。
//注意:三数取中的目的是防止选的key是区间[begin,end]中最大的或者是最小的数进而导致快
速排序的时间复杂度是最坏的情况O(N^2)
int mid = GetMidIndex(a, begin, end);
//2.让中间值a[mid]存放在区间[begin,end]最左边begin位置,以此方便取位于区间[begin,
end]最左边begin位 置的区间中间值作为基准值key。
Swap(&a[begin], &a[mid]);
//3.让L指向区间[begin,end]最左边的位置,让R指向区间[begin,end]最右边的位置。
int left = begin, right = end;
//4.选区间[begin,end]最左边begin位置的元素作为基准值key,并且用keyi标记基准值key在数
组中的位置。
int keyi = left;
//5.Hoare版本单趟的过程
while (left < right)
{
//注意:由于我们选的是区间[begin,end]最左边begin位置的元素作为基准值key,所以在开
始进行Hoare版本单趟时一定让R从右边先走。
//5.1.R从右往左一直走直到找到比key要小的值就停下来去等L在左边找到比key要大的数。
//R在右边找小的数。
while (left < right && a[right] >= a[keyi])
{
--right;
}
//5.2.L从左往右一直走直到找到比key要大的值就停下来
//L在左边找的的数。
while (left < right && a[left] <= a[keyi])
{
++left;
}
//5.3.由于此时R找到比key要小的值而L找到比key要大的值所以此时交换R和L之间的值
Swap(&a[left], &a[right]);
}
//5.4.当while (left < right)循环结束后,由于此时R和L相遇,则相遇之后就把基准值key和相
遇点的元素a[left]发生交换,当交换完后就结束了单趟的过程。
Swap(&a[left], &a[keyi]);
//6.由于Hoare版本的单趟结束后,基准值key来到了相遇点的位置,则要更新keyi的值使得下标
keyi始终指向基准值key。
keyi = left;
//7.由于一次Hoare版本的单趟结束后,基准值key已经落到它快速排序后最终位置,所以这里返回
基准值keyi的下标来告诉主调函数基准值key在整个数组中的位置,以防止主调函数继续对基准值
key进行排序。
return keyi;
}
//挖坑法单趟
int PartSort2(int* a, int begin, int end)
{
//1.利用三数取中函数选出这3个下标begin、(begin+end)/2 、end的中间值。目的是把快速排序
最坏情况变成最好情况。
int mid = GetMidIndex(a, begin, end);
//2.把中间值下标mid位置的元素放到区间[begin,end]最左边begin位置。
Swap(&a[begin], &a[mid]);
//3.让L指向区间[begin,end]最左边的位置,让R指向区间[begin,end]最右边的位置。
// (注意:L和R的作用是从区间[begin,end]的两边遍历整个区间)
int left = begin, right = end;
//4.选区间最左边位置begin的元素作为基准值key
int key = a[left];
//5.选区间[begin,end]最左边位置left作为坑位hole。
//(注意:由于一开始选左边位坑位则一定要让R从右边先走)
int hole = left;
//6.挖坑法版本的单趟过程
while (left < right)
{
//6.1.由于一开始选区间最左边元素作为基准值key=所以一开始是在左边挖坑,所以R从右往
左找比key要小的值,找到后就把R停下来,并把R停下来位置的元素填到左边的坑位中。
//总的来说,右边找小,填到左边坑里面。
//注意:left < right的作用是防止R发生越界。若没有写left < right,当区间[begin,end]
的所有元素都比key要大时导致R一直在走,最终使得R从左边滑出造成发生越界。
//(1)R从右往左找小的数
while (left < right && a[right] >= key)
{
--right;
}
//(2)R找到小的数后就停下来,并把R位置比key要小的数填到左边坑位中
a[hole] = a[right];
//(3)更新此时坑位的新位置
hole = right;//R填完坑后,此时R停下来的位置形成新坑位
//6.2.R填完坑后,L从左往右找比key要大的值,找到后就把L停下来,并把L停下来位置的元素
填到右边的坑位中。
//总的来说,左边找大,填到右边坑里面。
//注意:left < right的作用是防止L发生越界。若没有写left < right,当区间
[begin,end]的所有元素都比key要小时导致L一直在走,最终使得L从右边滑出造成发生越界。
//(1)L从左往右找大的数
while (left < right && a[left] <= key)
{
++left;
}
//(2)//L找到大的数就停下来,并把L位置比key要大的数填到右边坑位中
a[hole] = a[left];
//(3)更新此时坑位的新位置
hole = left;//填完坑后,此时L停下来的位置形成新坑位
}
//7.由于此时R和L在坑位相遇,所以此时把基准值key放到相遇点的坑位中。把基准值key放到相遇
点的坑位后,则此时基准值在区间[begin,end]的下标Hoare位置。
//注意:相遇点的坑位元素一定比基准值key要小。
a[hole] = key;
//8.由于一次挖坑法版本的单趟结束后,基准值key已经落到它排序后最终位置,所以这里返回基
准值key的下标hole来告诉主调函数基准值key在整个数组中的位置,以防止主调函数继续对基准值
key进行排序。
return hole;
}
//前后指针版本单趟
int PartSort3(int* a, int begin, int end)
{
//printf("%d,%d\n", begin, end);
//1.三数取中
int mid = GetMidIndex(a, begin, end);
Swap(&a[begin], &a[mid]);
//2.选区间[begin,end]的最左边begin位置的元素做key
int keyi = begin;
//3.定义一前一后两个指针。而且无论什么时候指针cur都在指针prev的前面。
int prev = begin, cur = begin + 1;
//4.前后指针版本单趟的过程
while (cur <= end)
{
//4.1.cur从左往右一直走直到cur找比基准值key要小的数才会停下来,然后先++prev,再交
换cur和prev位置的元素。
//注意:if(a[cur] < a[keyi] && ++prev != cur) 语句中的++prev != cur作用是防止下面
这种情况:若prev是一直紧跟着cur的,当a[cur] < a[keyi]成立时我们没有必要用
Swap(&a[prev], &a[cur])函数交换cur位置和++prev位置的元素,因为此时++prev = cur使得
自己交换自己是没有必要的的。案例:int a[] = {6,1,2,7,9,3,4,5,10,8}.
if (a[cur] < a[keyi] && ++prev != cur)
Swap(&a[prev], &a[cur]);
//4.2.若cur从左往右一直没有找到比基准值key要小的数则cur会通过++cur一直走。即使cur
找到比基准值key要小的数则cur与++prev位置的元素交换完后也会用++cur让cur往后走一步。
++cur;
}
//4.3.当cur > end时,就把prev位置的元素与基准值key进行交换。交换完后前后指针版本的单趟
就结束了。注意:此时prev位置的元素一定比基准值key要小。
Swap(&a[prev], &a[keyi]);
//5.由于前后指针版本的单趟结束后,基准值key来到了下标prev的位置,更则要更新keyi的值使
得下标keyi始终指向基准值key。
keyi = prev;
//6.由于一次前后指针版本的单趟结束后,基准值key已经落到它排序后最终位置,所以这里返回基
准值keyi的下标来告诉主调函数基准值key在整个数组中的位置,以防止主调函数继续对基准值key
进行排序。
return keyi;
}
void QuickSort(int* a, int begin, int end)
{
//1.当begin >= end时说明当前要排序的区间[begin,end]中只有1个或者0个元素则此时可以认为当前排序区间[begin,end]是个有序区间则此时不需要对该区间进行快速排序。
if (begin >= end)
{
return;
}
//2.把当前区间[begin,end]排序成有序的过程:
//2.1.若区间[begin,end]中的元素个数 < 15的话,则直接利用直接插入排序对区间[begin,end]
中的所有元素排序成有序,排完后就直接结束本次快速排序。
//(注意:元素数量的计算方式end - begin + 1)
if ((end - begin + 1) < 15)
{
//(小区间优化)小区间用直接插入替代,减少递归调用次数。(注意:这里不利用希尔排序把小
区间排成有序的原因是当数据量少时体现不出希尔排序的效率而且也没必要使用希尔排序)
//注意:直接插入排序一定是在区间 [begin,end]在数组中的起始位置a + begin开始把区间
[begin,end]排序成有序。
InsertSort(a + begin, end - begin + 1);
}
else//2.2.若区间[begin,end]中的元素数量 >= 15的话,则选择递归思路的快速排序来对区间
[begin,end]中的所有元素排序成有序。
{
//2.2.1.模拟二叉树前序遍历的递归思路来让快速排序函数QuickSort把区间[begin,end]排
序成有序。
//(1)利用快速排序单趟排序函数PartSort1或者PartSort2或者PartSort3确定当前区间
[begin,end]选的基准值key(即相等于根结点)在快速排序后的最终的位置。
int keyi = PartSort3(a, begin, end);
//由于单趟结束后确定了基准值key在区间[begin,end]最终的位置,进而把当前要待排序区
间[begin,ene]分割成左子区间[begin, keyi-1]、基准值下标keyi、右子区间[keyi+1, end],
类似于二叉树前序遍历在一次递归调用后当前二叉树被分割成左子树、根结点、右子树。
//(2)递归利用快速排序函数QuickSort把当前区间[begin,end]的左右子区间排成有序后,则
当前区间[begin,end]就会变得有序。
//递归把左子区间[begin, keyi-1]排成有序
QuickSort(a, begin, keyi - 1);
//递归把右子区间[keyi+1, end]排成有序
QuickSort(a, keyi + 1, end);
}
}
4.3.MergeSortFile.h文件归并排序头文件
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
//文件归并排序的子函数(文件归并排序单趟)->作用:把两个有序小文件合并成一个有序的文件
void _MergeFile(const char* file1, const char* file2, const char* mfile);
//文件归并排序
void MergeSortFile(const char* file);
4.4.MergeSortFile.c文件归并排序源文件
#include "MergeSort.h"
#include "QuickSort.h"
//文件归并排序子函数(文件归并排序单趟)
void _MergeFile(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
FILE* fout = fopen(file, "r");
if (fout == NULL)
{
printf("打开文件失败\n");
exit(-1);
}
//把源文件file分割成一段一段数据并依次存入内存数组中(注:每段数据有n个元素,而每次只把
源文件的n个元素存入数组中),并在内存中利用快速排序对数组进行排序,在内存排完序后把数组
中的元素写到小文件中,最终形成多个有序小文件。
//定义变量n表示每个小文件中包含的数据量(这里设为10),并定义一个数组a用于在内存中暂存
数据。
int n = 10;
int a[10];
//定义i来遍历数组a
int i = 0;
//定义num用来临时存在从源文件file中读取到的数据
int num = 0;
//定义一个字符数组subfile用来临时存储小文件的文件名
char subfile[20];
//filei 用于生成小文件的序号
int filei = 1;
//把数组a初始为0.
memset(a, 0, sizeof(int) * n);
//将大文件分割成多个小文件,每个小文件包含n个数据,并在内存中对这些数据进行排序后写入小
文件。
//使用 while 循环和 fscanf 读取文件中的整数,直到文件末尾(EOF)。
while (fscanf(fout, "%d\n", &num) != EOF)
{
if (i < n - 1)
{
a[i++] = num;//通过循环先读取n - 1个数据存入内存的数组a中。
}
else
{
//再把第n个数据读取进数组a中
a[i] = num;
//对数组a中的n个数据进行快速排序
QuickSort(a, 0, n - 1);
//使用 sprintf 生成小文件的文件名并存入字符数组subfile
sprintf(subfile, "%d", filei++);
//利用字符数组subfile中存放的小文件的文件名创建小文件并以写的方式打开小文件
subfile。
FILE* fin = fopen(subfile, "w");
if (fin == NULL)
{
printf("打开文件失败\n");
exit(-1);
}
//使用fprintf将数组a中排序后源文件file的n个数据写入小文件中,从而创建出大小为n
的有序小文件。
for (int j = 0; j < n; j++)
{
fprintf(fin, "%d\n", a[j]);
}
fclose(fin);
//在处理完数组a中的n个数据后,我们可以重置索引i为0,并清空数组a以便用
于下一轮读取。这样可以保证 while 循环可以持续运行,直到文件结束。
i = 0;
//对数组a进行初始化
memset(a, 0, sizeof(int) * n);
}
}
//通过两两归并小文件,逐步合并成更大的有序文件,直到最终合并成一个完整的有序文件。
char file1[100] = "1";
char file2[100] = "2";
char mfile[100] = "12";
for (int i = 2; i <= n; ++i)
{
// 读取file1和file2,进行归并出mfile
//把文件1和文件2归并到mfile文件中
_MergeFile(file1, file2, mfile);//_MergeFile函数是把两个小文件归并到新文件的过程
//更新文件1的文件名
strcpy(file1, mfile);
//更新文件2的文件名
sprintf(file2, "%d", i + 1);//i + 1控制的是下一次要进行归并的文件2的文件名
//更新文件1与文件2合并之后的文件名
sprintf(mfile, "%s%d", mfile, i + 1);//i + 1控制的是下一次文件1和文件2要归并到的
mfile文件的文件名
}
printf("%s文件排序成功\n", file);
fclose(fout);
}