欢迎光临
我们一直在努力

排序(四)“归并排序”

目录

一、归并排序

(一)算法思想

(二)代码实现

(三)复杂度与稳定性

二、非递归版本归并排序

(一)代码实现步骤

(二)排序示例展示图

(三)代码实现

三、外排序之文件归并排序实现

(一)外排序介绍

(二)代码实现步骤

(三)代码实现

(四)时间复杂度与稳定性分析


一、归并排序

(一)算法思想

        归并排序基于 “分治法”,核心是 “先分后合”:

1、分解

        将数组递归二分,直至每个子区间仅含一个元素(视为有序)。

        例如数组 [5,3,9,6,2,4,7,1,8],分解为 [5]、[3]、[9]、[6]、[2]、[4]、[7]、[1]、[8]。

2、合并

        将两个相邻的有序子区间合并为一个有序区间,重复合并直至得到完整有序数组。例如 [5] 与 [3] 合并为 [3,5],[9] 与 [6] 合并为 [6,9],再合并 [3,5] 与 [6,9] 为 [3,5,6,9],以此类推。

(二)代码实现

1、具体操作图

(1)简略版

(2)完整版★(保姆版)

2、具体代码

(1)分解阶段

        _MergeSort递归二分数组,如n = 9时,mid = (0 + 8) / 2 = 4,左区间 [0,4]、右区间 [5,8],继续二分直至每个区间长度为 1。

(2)合并阶段

        Merge函数合并两个有序区间,如合并 [0,0](5)与 [1,1](3)为 [3,5],合并 [2,2](9)与 [3,3](6)为 [6,9],再合并 [0,1](3,5)与 [2,3](6,9)为 [3,5,6,9]。

Tip:这里[0.0](5)的意思是,最左边与最右边都是5,即只剩一个数据。

(3)临时数组作用

        tmp用于暂存合并后的有序数据,避免原数组元素被覆盖,合并后再拷贝回原数组。

(4)代码的理解

        在完整版的流程图中,已经清楚展示了分解的时机与合并的时机,所以在这里就不多作说明,我们来关注一下是怎么合并的。

        我们将 begin1 指向第一个数组头部,end1 指向第一个数组尾部;begin2 指向第二个数组头部,end2 指向第二个数组尾部。

        然后通过判断 begin1位置与 begin2 位置对应数据的大小,决定插入临时数组的数据是谁。插入之后,对应的 begin 就加 1,直到超过 end 为止。

        一旦有一个数组遍历完成了,就退出第一个循环。然后剩下那个数组的数据,直接插到临时数组的尾部,从而完成数据的合并。

//只要涉及到二分,就会产生递归
//归并排序的递归算法
void _MergeSort(int* arr, int left, int right, int* tmp)
{
//1、分解
//递归结束的条件
if (left >= right)
return;
int mid = (left + right) / 2;
//根据mid 划分左右两个序列:[left,mid] [mid+1,right]
_MergeSort(arr, left, mid, tmp);
_MergeSort(arr, mid + 1, right, tmp);

//2、合并
//合并两个序列:[left,mid] [mid+1,right]
//创建临时数据,进行合并,然后再覆盖到原数组合
int begin1 = left, end1 = mid;
int begin2 = mid + 1, end2 = right;
int index = begin1;//作为临时数组移动的下表
while (begin1 <= end1 && begin2 <= end2)
{
//这里就是将两个数组合并为一个有序的数组
if (arr[begin1] <= arr[begin2])
tmp[index++] = arr[begin1++];
else
tmp[index++] = arr[begin2++];
}
//左序列数据没有全部放到tmp数组中
//右序列数据没有全部放到tmp数组中
while (begin1 <= end1)
tmp[index++] = arr[begin1++];
while (begin2 <= end2)
tmp[index++] = arr[begin2++];

//tmp中有序的数据导入原数组
for (int i = left; i <= right; i++)
arr[i] = tmp[i];

}

//归并排序
void MergeSort(int* arr, int n)
{
//因为不知道n的大小,所以要使用动态开辟内存
int* tmp = (int*)malloc(sizeof(int) * n);
_MergeSort(arr, 0, n – 1, tmp);
free(tmp);
tmp = NULL;
}

(三)复杂度与稳定性

1、时间复杂度

(1)分解阶段:递归深度O(logn)。

(2)合并阶段:每趟合并O(n),总合并次数O(nlogn)。

(3)整体时间复杂度:O(nlogn)(最好、最坏、平均情况均为此复杂度)。

2、空间复杂度

        O(n)(需申请大小为n的临时数组tmp)。

3、稳定性

        稳定,合并时,若两个区间元素相等,优先选择左区间元素,保持相对顺序。

Tip:非递归版本的归并排序的复杂度和稳定性也是这样。

二、非递归版本归并排序

(一)代码实现步骤

        递归版归并排序是自顶向下地将大数组不断分割为小数组,直到子数组长度为 1,再向上合并。而非递归版则采用自底向上的思路:

(1)先将数组中相邻的长度为 1的子数组两两合并,得到若干长度为 2 的有序子数组;

(2)再将相邻的长度为 2的子数组两两合并,得到若干长度为 4 的有序子数组;

(3)以此类推,直到合并出长度大于等于原数组长度的有序数组,排序完成。

        这种 “从小到大一阶阶合并” 的策略,完全通过循环实现,避免了递归的函数调用开销。

(二)排序示例展示图

(三)代码实现

//非递归版本的归并排序
void MergeSortNonR(int* arr, int n)
{
if (n <= 1)
return;
int* tmp = (int*)malloc(sizeof(int) * n);
if (tmp == NULL)
{
perror("malloc fail!\\n");
exit(1);
}

// 自底向上合并:子数组长度从 1 开始,每次翻倍
int gap = 1;
while (gap < n)
{
// 根据当前 gap 分组,两两合并相邻子数组
for (int left = 0; left < n; left += 2 * gap)
{
int begin1 = left, end1 = left + gap – 1; // 左子数组的起止索引
int begin2 = left + gap, end2 = left + 2 * gap – 1; //右子数组的起止索引(可能越界)
// 处理越界:如果右子数组的起始位置已经超出数组长度,说明无右子数组可合并,直接跳出循环
// 主要是处理奇数个数据的情况
if (begin2 >= n)
break;
// begin2没有越界,那么说明最后一个序列还是有数据的,只是说不一定有gap个
// 此时将将 end2 修正为数组末尾
if (end2 >= n)
end2 = n – 1;

// 两个有序序列进行合并
// 临时数组 tmp 中当前填充的位置
int index = begin1;
while (begin1 <= end1 && begin2 <= end2)
{
//这里就是将两个数组合并为一个有序的数组
if (arr[begin1] <= arr[begin2])
tmp[index++] = arr[begin1++];
else
tmp[index++] = arr[begin2++];
}
//左序列数据没有全部放到tmp数组中
while (begin1 <= end1)
tmp[index++] = arr[begin1++];
//右序列数据没有全部放到tmp数组中
while (begin2 <= end2)
tmp[index++] = arr[begin2++];

// 将临时数组的结果复制回原数组
memcpy(arr + left, tmp + left, (end2 – left + 1) * sizeof(int));
}
gap *= 2; // 合并粒度翻倍
}
free(tmp);
tmp = NULL;
}

三、外排序之文件归并排序实现

(一)外排序介绍

1、什么是外排序

        外排序是指能够处理极大量数据的排序算法。

        通常来说,外排序处理的数据不能一次装入内存,只能放在读写较慢的外存储器(通常是硬盘)上。外排序通常采用的是一种"排序﹣归并"的策略。

        在排序阶段,先读入能放在内存中的数据量,将其排序输出到一个临时文件,依此进行,将待排序数据组织为多个有序的临时文件。然后在归并阶段将这些临时文件组合为一个大的有序文件,也即排序结果。

        跟外排序对应的就是内排序,我们之前讲的常见的排序,都是内排序,他们排序思想适应的是数据在内存中,支持随机访问。归并排序的思想不需要随机访问数据,只需要依次按序列读取数据,所以归并排序既是一个内排序,也是一个外排序。

2、为什么使用归并排序进行外排序

        希尔排序、堆排序、快速排序,这些效率较高的排序方式,其实都只能在内存中处理数据。

        因为它们都是要访问内存当中的一个下标位置的,磁盘可以支持我们访问某一个位置,但是你不断得变换这个位置,性能会很慢。

        它更适应的是序列式得去写或者读在这个地方。

        即使是序列式的,都比内存慢很多,更不要说是随机的这种了,虽然接口上支持,但是完全是不可行的。

        所以也就意味着其他排序的思想,当你有大量数据在磁盘上时,这些思想都用不上。那为什么归并排序可以呢?

        因为归并的思想是不需要进行下标位置的随机访问的,只需要线性得挨着读和写即可,将两个有序的数据,写入一个新数组,使其中内容都变得有序。 

(二)代码实现步骤

       归并要求数列是有序的,但是不要求数列元素的个数相同。

        我们可以把一个大文件,变成很多个小文件,然后把这些小文件里面的内容变得有序。方法是:此时小文件,可以写入内存,在内存中变得有序,然后将他两两归并即可。

        但是这种思路不那么好实现,因为创建了中间这些小文件,你也不知道分成多少份,具体的文件大小你是不可控的,这个思路没有这么好。具体的实现思路如下:

        第一步:读取 n 个值排序后写到file1,再读取n个值排序后写到file2。(这里的排序是在内存中排序,选择尽可能块的排序方式即可,这个 n 可以取大一些,但是尽量要让内存可以放下)

        第二步:file1 和 file2利用归并排序的思想,依次读取比较,取小的尾插到 mfile,mfile 归并为一个有序文件

                     

        第三步:将 file1 和 file2 删掉,mfile 重命名为 mfile1

        第四步:再次读取 n 个数据排序后写到 file2

                          

        第五步:继续 file1 和 file2 归并,重复步骤2,直到文件中无法读出数据。最后归并出的有序数据放到了 file1 中。(最后一个 file2 不一定有 n 个数据)

        Tip:下图还有最后一步没有描述到位,最后还要删除file1,再删除file2,mfile 重命名为 mfile1,再尝试从 file 中读取数据,没有读取到,则程序结束,排序完成。

                       

(三)代码实现

1、先创建N个随机数,写到文件中

// 生成N个随机整数并写入文件data.txt
void CreateDate()
{
//造数据
int n = 10000;
srand(time(0));
const char* file = "data.txt";
FILE* fin = fopen(file, "w");
if (fin == NULL) { perror("fopen error"); return; }

for (int i = 0; i < n; i++)
{
// 生成随机数:rand()的范围有限(0~RAND_MAX,通常为32767)
// 此处加上循环变量i,目的是减少随机数重复的概率(尤其当n较大时)
int x = rand() + i;

// 将生成的整数以换行分隔的形式写入文件
fprintf(fin, "%d\\n", x);
}
fclose(fin);
}

2、将部分数据写到内容,进行排序,再写回小文件

// 比较函数,用于qsort的升序排序
int compare(const void* a, const void* b)
{
return (*(int*)a – *(int*)b);
}

// 从文件读取n个数据,内存排序后写入新文件
// 返回实际读取的数据个数,无数据返回0
// 一般情况下返回n,最后一次可能不足n个,所以返回值用j表示
int ReadDataSortToFile(FILE* fout, int n, const char* file1)
{
// 动态开辟数组存储数据
int* arr = malloc(sizeof(int) * n);
if (arr == NULL) { perror("malloc error"); return 0; }

// 目标是读取n个数据之后排序,如果遇到文件结束,应该读到j个数据
int x = 0;
int j = 0; // 记录实际读取的数据个数
for (int i = 0; i < n; i++)
{
// 读取数据,遇文件结束则终止
if (fscanf(fout, "%d\\n", &x) == EOF)
break;
arr[j++] = x;
}

// 无数据可读时释放资源并返回
if (j == 0)
{
free(arr);
arr = NULL;
return 0;
}

// 对读取的数据进行排序
qsort(arr, j, sizeof(int), compare);

// 打开目标文件用于写入
FILE* fin = fopen(file1, "w");
if (fin == NULL) { free(arr); arr = NULL; perror("fopen error"); return 0; }

// 将排序后的数据写入文件
for (int i = 0; i < j; i++)
fprintf(fin, "%d\\n", arr[i]);

// 释放资源并关闭文件
free(arr);
arr = NULL;
fclose(fin);

return j;
}

3、对于两个小文件进行归并排序,合成一个较大的文件,同时删除掉两个小文件

// 归并两个有序文件到目标文件
void MergeFile(const char* file1, const char* file2, const char* mfile)
{
// 打开第一个源文件(只读)
FILE* fout1 = fopen(file1, "r");
if (fout1 == NULL) { perror("fopen error"); return; }

// 打开第二个源文件(只读)
FILE* fout2 = fopen(file2, "r");
if (fout2 == NULL) { perror("fopen error"); return; }

// 打开目标归并文件(只写)
FILE* mfin = fopen(mfile, "w");
if (mfin == NULL) { perror("fopen error"); return; }

// 依次读取
// 使用归并逻辑:谁小谁写入,谁没完谁尾插
// 读取两个文件的首个数据,ret记录是否读到,x1与x2记录读到的值
int x1 = 0, x2 = 0;
int ret1 = fscanf(fout1, "%d", &x1);
int ret2 = fscanf(fout2, "%d", &x2);

// 归并核心逻辑:比较两个文件当前数据,小的写入目标文件并读取下一个数据
// 写入之后,立刻读取该文件下一个数据,用于接下来的比较
// 文件的读取是有记忆的,光标会跟随移动,所以读取的就是下一个数据。
while (ret1 != EOF && ret2 != EOF)
{
if (x1 <= x2)
{
fprintf(mfin, "%d\\n", x1);
ret1 = fscanf(fout1, "%d", &x1);
}
else
{
fprintf(mfin, "%d\\n", x2);
ret2 = fscanf(fout2, "%d", &x2);
}
}

// 处理第一个文件剩余数据
while (ret1 != EOF)
{
fprintf(mfin, "%d\\n", x1);
ret1 = fscanf(fout1, "%d", &x1);
}

// 处理第二个文件剩余数据
while (ret2 != EOF)
{
fprintf(mfin, "%d\\n", x2);
ret2 = fscanf(fout2, "%d", &x2);
}

// 关闭所有文件
fclose(fout1);
fclose(fout2);
fclose(mfin);
}

4、主函数对它们依次调用

int main()
{
// 1、生成初始随机数据文件data.txt
CreateDate();

// 2、读到内容排序,并写入小文件
const char* file1 = "file1.txt"; // 归并过程中的临时文件1
const char* file2 = "file2.txt"; // 归并过程中的临时文件2
const char* mfile = "mfile.txt"; // 归并结果的临时文件

// 打开原始数据文件(只读)
FILE* fout = fopen("data.txt", "r");
if (fout == NULL) { perror("fopen error"); return 0; }

// 先读取两批数据到file1和file2,每批100个并排序
int num = 100;
int count1 = ReadDataSortToFile(fout, num, file1);
int count2 = ReadDataSortToFile(fout, num, file2);

//3、进行归并排序
// 若只有一批数据,直接作为结果(无需归并)
if (count1 > 0 && count2 == 0)
{
fclose(fout);
return 0;
}

// 循环归并:每次归并file1和file2到mfile,再读取新数据到file2
while (count2 > 0)
{
MergeFile(file1, file2, mfile); // 归并两个有序文件

// 替换file1为归并结果,清理临时文件
remove(file1);
remove(file2);
rename(mfile, file1);

// 读取下一批数据到file2并排序
count2 = ReadDataSortToFile(fout, num, file2);
}

printf("排序完成,结果在 %s 中\\n", file1); // 输出结果文件信息
fclose(fout);
return 0;
}

(四)时间复杂度与稳定性分析

        该代码实现了外部排序,核心步骤为:

        ① 生成随机数据文件 data.txt(10000 个数据)。

        ② 分批次读取数据到内存,每批 num=100 个,排序后写入临时文件 file1.txt 或 file2.txt。

        ③ 循环归并两个临时文件到 mfile.txt,替换 file1.txt 为归并结果,直到所有数据处理完毕,最终结果保存在 file1.txt 中。

1、时间复杂度分析

(1)整体流程拆解

        外部排序的时间复杂度由两部分构成:内存排序时间 + 归并时间。

        在这个代码中,总数据量为 N(此处 N=10000);每批读取到内存排序的数据量为 B(此处 B=100);临时文件数量为 k = N/B(向上取整,此处 k=100)。

(2)各阶段时间复杂度

① 分批内存排序(ReadDataSortToFile)

        每次读取 B 个数据,用 qsort(快速排序)排序,单批排序时间为 O(B log B);共需 k 批,总排序时间为 O(k * B log B) = O(N log B),因为 k*B ≈ N。

        代入数值:100・O (100 log 100) ≈ 100・(100×6.6) = O (66000),属于低阶项

归并阶段(MergeFile 循环)

        归并过程是多轮两两归并:每次将 file1(已归并的结果)与 file2(新分块的排序数据)归并到 mfile,循环直到所有分块处理完毕。

        第 1 次归并:M=B(第 1 块)+ 新块 B → 时间 O (B+B)=O (B)

        第 2 次归并:M=2B(前 2 块合并结果)+ 新块 B → 时间 O (2B+B)=O (3B)

        第 3 次归并:M=3B(前 3 块合并结果)+ 新块 B → 时间 O (3B+B)=O (4B)

        …

        第 K-1 次归并:M=(K-1) B(前 K-1 块合并结果)+ 新块 B → 时间 O ((K-1) B+B)=O (K・B)

        归并时间总和是等差数列求和:O (B + 3B + 4B + … + K・B) ≈ O (K²・B)(忽略首项和系数,取最高阶项)。

        因 K = N/B,代入得:O ((N / B)²・B) =  O(N² / B)。

        代入数值:O (10⁸/100)=O (10⁶),是分块排序的 15 倍 +,属于高阶项

(3)整体时间复杂度

        分批内存排序(O (N log B))是低阶项,累积归并 O(N² / B) 是主导项,因此整体时间复杂度为:O(N²/B)。

2、空间复杂度分析

        内存中最大的额外空间是分块排序时开辟的数组 arr,大小为 num(固定值),此处的空间复杂度为O(num),且与 n 无关。

        函数中定义的局部变量(如 x、i、j 等)和文件指针,占用的空间是常数级,即 O(1)。

        代码中使用了 file1.txt、file2.txt、mfile.txt 等临时文件存储分块排序结果和归并结果,这些文件的总大小最多为 O(N),但属于外部存储,不纳入算法的空间复杂度分析。

        因此整体空间复杂度为:O(num)(即 O(B),B 为每批处理的数据量)。

3、稳定性分析

        排序算法的稳定性取决于分块排序和归并两个步骤是否稳定:

        (1)分块排序:使用 qsort(快速排序),不稳定。快速排序在交换元素时可能改变相等元素的相对顺序。

        (2)归并过程:代码中归并逻辑为 if (x1 < x2) 时写入 x1,否则写入 x2;当 x1 == x2 时,优先写入 x1(来自 file1),这样保证了归并的稳定性。但由于 file1 和 file2 本身是不稳定排序的结果,因此整体不稳定。  

        以上即为 排序(四)“归并” 的全部内容,创作不易,麻烦三连支持一下呗~  

赞(0)
未经允许不得转载:171主机测评 » 排序(四)“归并排序”
分享到: 更多 (0)

评论 抢沙发

  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址