目录
外部排序
- 当待排序的文件比内存的可使用容量还大时,文件无法一次性放到内存中进行排序,需要借助于外部存储器;对于外部排序算法来说,影响整体排序效率的因素主要取决于读写外存的次数
- 外部排序算法由两个阶段构成:
- 按照内存大小,将大文件分成若干子文件(子文件应小于内存的可使用容量),然后将各个子文件依次读入内存,使用适当的内部排序算法对其进行排序(排好序的子文件统称为“归并段”或者“顺段”),将排好序的归并段重新写入外存,为下一个子文件排序腾出内存空间;
- 对得到的顺段进行合并,直至得到整个有序的文件为止;在实际归并的过程中,由于内存容量的限制不能满足同时将 2 个归并段全部完整的读入内存进行归并,只能不断地取 2 个归并段中的每一小部分进行归并,通过不断地读数据和向外存写数据,直至 2 个归并段完成归并变为 1 个大的有序文件
对于具有 m m m 个初始归并段进行 k k k-路平衡归并时,归并的次数为: ⌊ l o g k m ⌋ ⌊log_km ⌋ ⌊logkm⌋;因此, k k k-路平衡归并中,增加 k k k / 减少初始归并段的数量 m m m 可以减少归并的次数,从而减少外存读写的次数,提高算法效率
多路平衡归并排序算法(多路归并排序、胜者树、败者树)
参考:CSDN
- 由上面的讨论可知,增加 k k k 可以减少外存读写的次数,但却增加了内部归并的时间 ( k k k-路平衡归并中每次归并得到一个最小值就要比较 k − 1 k-1 k−1 次)
- 为了避免在增加 k k k 值的过程中影响内部归并的效率,在进行 k k k-路归并时可以用 “败者树” / 胜者树 来实现
胜者树
- 胜者树为一棵完全二叉树;如下图所示,
b
0
b_0
b0~
b
4
b_4
b4 为叶结点,分别为 5 个归并段中存储的记录的关键字。
l
s
ls
ls 为一维数组,存储胜利的数 (这里的“胜利”指数的值更小,因为我们的目的就是找到各个归并段中最小的数) 的归并段的序号。
l
s
[
1
]
ls[1]
ls[1] 存储最终的胜者,表示当前第 3 归并段中的关键字最小
- 当最终胜者判断完成后,只需要更新叶子结点
b
3
b_3
b3 的值,即导入关键字 11,然后让该结点沿着从该结点到根结点的路径同其兄弟结点所表示的关键字进行比较,修改这棵二叉树,这样就可以更快地进行归并
败者树
- 败者树是胜者树的一种变体。在败者树中,用父结点记录其左右子结点进行比赛的败者,而让胜者参加下一轮的比赛。败者树的根结点记录的是败者,需要加一个结点来记录整个比赛的胜利者。采用败者树可以简化重构的过程
- 下图为一棵 5-路归并的败者树。
l
s
[
0
]
ls[0]
ls[0] 中存储的为最终的胜者,表示当前第 3 归并段中的关键字最小。边上的数字表示参加下一轮比赛的胜者序号
- 当最终胜者判断完成后,只需要更新叶子结点 b 3 b_3 b3 的值,即导入关键字 15,然后让该结点不断同其父结点所表示的关键字进行比较,败者留在父结点中,胜者继续向上比较 (这里与胜者树的区别就在于重构过程只需要访问父结点,而胜者树需要先访问父结点再访问兄弟结点,因此败者树简化了重构过程),最终找到一个最小值只需要 O ( l o g k ) O(logk) O(logk) 的时间
为了防止在归并过程中某个归并段变为空,处理的办法为:可以在每个归并段最后附加一个关键字为最大值的记录。这样当某一时刻选出的冠军为最大值时,表明 5 个归并段已全部归并完成
败者树的效率
- 设总共有
n
n
n 个数据,分成
k
k
k 组,每组
n
/
k
n/k
n/k 个数据
- 开始对每组进行内部排序的时间是 k l o g ( n / k ) n / k = n l o g ( n / k ) klog(n/k)n/k=nlog(n/k) klog(n/k)n/k=nlog(n/k)
- 进行败者树归并排序的时间为 n l o g ( k ) nlog(k) nlog(k)
- 因此,总时间为 n ( l o g ( n / k ) + l o g ( k ) ) = n l o g ( n ) n(log(n/k)+log(k))=nlog(n) n(log(n/k)+log(k))=nlog(n) 也就是最好的速度 O ( n l o g ( n ) ) O(nlog(n)) O(nlog(n))
败者树实现外部排序
- 下面是我算法课的作业,主要就是自己生成随机数 (代码中是一亿个) 然后进行外排
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
/*
* 首先生成大量随机数 (具体数量多少以及值的范围可以更改宏定义来修改) 存储在文件 random_num 中
*
* 之后对随机数进行外部排序 (升序),内部排序可以选择使用冒泡排序、快排、堆排,用于比较它们的性能差距;
*
* 不同归并段的归并工作则用多路平衡归并排序 (败者树) 来完成,各个归并段的结果存在文件 0, 1, 2, 3, ... 中,最后经过归并后写入文件 res,可以选择是否输出到屏幕
*
* 注:因为是在 VS 上写的,所以如果 fscanf_s fprintf_s 这些VS特有的函数编译报错,需要把它们改成不加 _s 的版本
*
*/
/***********************随机数生成**********************************/
#define MAX_VAL 10000000 // 随机数的最大值
#define MIN_VAL 0 // 随机数的最小值
#define NUM 100000000 // 用于确定随机数的个数,这里用的一亿个数,速度稍微快一点;如果十亿个数的话,就改成 10000000000
// 把这个数改大了之后需要增大下面的 BATCH_SIZE 的值来一次从外存读入更多的数据,不然外排速度太慢
#define RANDOM_FILE_NAME "random_num" // 存储数据的文件名
// 产生随机数,写入对应的文件 file_name 中,min 和 max 为生成随机数的最大、最小值,num 为生成的个数
void gen_random_num(const char file_name[], long long num, int min, int max)
{
FILE* fp = NULL;
fopen_s(&fp, file_name, "w");
int range = max - min;
while (num--)
{
int x = rand() % range + min;
fprintf(fp, "%d ", x);
}
fclose(fp);
}
/*************************排序时需要用到的数据结构、宏定义、接口函数********************************/
// 这里每次读入 BATCH_SIZE 个 int 型数据;这个值不宜过小,否则外排效率会降低,同时也不能太大,要在内存可接受的范围内
#define BATCH_SIZE 26214400 // 这里选择一次读入 100MB 的数据,栈大小设置为 120MB
typedef int Key_t; //关键字类型
typedef struct {
Key_t rec[BATCH_SIZE + 1]; // rec[0]用作哨兵
int len; // 实际的元素个数
}SqList_t;
// 接口函数,可以通过函数指针选择不同的内排方法,进行一次 BATCH_SIZE 大小的内排,排序结果写入到 sort_res 文件中
void Sort(void (*sort_func)(SqList_t* list))
{
int file_num = NUM / BATCH_SIZE + 1;
long long left_num = NUM % BATCH_SIZE;
SqList_t list;
char file_name[20] = ""; // 归并段要存入的文件名
FILE* src_fp = NULL;
fopen_s(&src_fp, RANDOM_FILE_NAME, "r");
// 将原文件分成多份,分别使用内排
for (int i = 1; i <= file_num; ++i)
{
FILE* res_fp = NULL;
int batch_size = (i == file_num) ? left_num : BATCH_SIZE;
_itoa_s(i - 1, file_name, 10, 10); // 加上文件名序号
fopen_s(&res_fp, file_name, "w");
for (int j = 1; j <= batch_size; ++j)
{
fscanf_s(src_fp, "%d", &(list.rec[j]));
}
// 对归并段进行内排
list.len = batch_size;
sort_func(&list);
for (int j = 1; j <= batch_size; ++j)
{
fprintf_s(res_fp, "%d ", list.rec[j]);
}
fclose(res_fp);
}
fclose(src_fp);
}
/*************************冒泡排序********************************/
void Bubble_sort(SqList_t* list)
{
for (int i = 0; i < list->len - 1; ++i)
{
int flag = 0;
for (int j = 1; j < list->len - i; ++j)
{
if (list->rec[j] > list->rec[j + 1])
{
list->rec[0] = list->rec[j];
list->rec[j] = list->rec[j + 1];
list->rec[j + 1] = list->rec[0];
flag = 1;
}
}
if (0 == flag)
{
break;
}
}
}
/*************************快排********************************/
//对指定序列进行一趟快排
int Partiton(SqList_t* list, int low, int high)
{
list->rec[0] = list->rec[low]; //选择枢轴
while (low < high)
{
while (high > low && list->rec[high] >= list->rec[0])
{
--high;
}
list->rec[low] = list->rec[high];
while (high > low && list->rec[low] <= list->rec[0])
{
++low;
}
list->rec[high] = list->rec[low];
}
list->rec[low] = list->rec[0];
return low; //返回枢轴位置
}
void Quick_sort_reccurent(SqList_t* list, int low, int high)
{
if (low < high)
{
int pivot_loc = Partiton(list, low, high);
Quick_sort_reccurent(list, low, pivot_loc - 1);
Quick_sort_reccurent(list, pivot_loc + 1, high);
}
}
void Quick_sort(SqList_t* list)
{
Quick_sort_reccurent(list, 1, list->len);
}
/*************************堆排********************************/
void Heap_adjust(SqList_t* list, int root, int len)
{
int min_child;
list->rec[0] = list->rec[root];
while (root * 2 <= len)
{
min_child = root * 2;
if (min_child + 1 <= len
&& list->rec[min_child + 1] > list->rec[min_child])
{
++min_child;
}
if (list->rec[min_child] > list->rec[0])
{
list->rec[root] = list->rec[min_child];
root = min_child;
}
else {
break;
}
}
list->rec[root] = list->rec[0];
}
void Heap_sort(SqList_t* list)
{
//初建堆
for (int i = list->len / 2; i >= 1; --i)
{
Heap_adjust(list, i, list->len);
}
//输出 n-1 次,调整堆 n-2 次
for (int i = 0; i < list->len - 1; ++i)
{
if (i > 0)
{
Heap_adjust(list, 1, list->len - i);
}
list->rec[0] = list->rec[1];
list->rec[1] = list->rec[list->len - i];
list->rec[list->len - i] = list->rec[0];
}
}
/*************************归并 (败者树)********************************/
#define K ((NUM % BATCH_SIZE == 0) ? NUM / BATCH_SIZE : NUM / BATCH_SIZE + 1) // K 路平衡归并排序 K 为归并段的个数
typedef struct LoserTree{
Key_t leaf[K]; // 败者树的叶结点,每个归并段都将数据送到对应的叶结点进行归并
int tree[K]; // 非叶节点,用来指示败者序号,tree[0]为最后的胜者序号
}LoserTree_t;
// 进行一次败者树重构
int LoserTree_adjust(LoserTree_t* loser_tree, int winner_idx)
{
int father_idx = (winner_idx + K) / 2; // 父结点的序号
while (father_idx != 0)
{
if (loser_tree->leaf[winner_idx] > loser_tree->leaf[loser_tree->tree[father_idx]])
{
// winner_idx 为败者
int tmp = winner_idx;
winner_idx = loser_tree->tree[father_idx]; // 重新记录胜者名
loser_tree->tree[father_idx] = tmp; // 把败者的名字写到败者树上去
}
father_idx /= 2; // 向上层移动
}
loser_tree->tree[0] = winner_idx; // 记录最后的胜者
// 最后选出的胜者为最大的可能值 + 1,则表明所有归并段都已读完,外排结束
if (loser_tree->leaf[loser_tree->tree[0]] == MAX_VAL)
{
return 0;
}
return 1;
}
// 构建败者树
void LoserTree_build(LoserTree_t* loser_tree)
{
int winner_idx[K];
for (int i = K - 1; i != 0; --i) // 从第一个非叶节点开始更新
{
Key_t left_child = (2 * i > K - 1) ? loser_tree->leaf[2 * i - K] : loser_tree->leaf[winner_idx[2 * i]];
Key_t right_child = (2 * i + 1 > K - 1) ? loser_tree->leaf[2 * i + 1 - K] : loser_tree->leaf[winner_idx[2 * i + 1]];
// 记录败者名
if (left_child > right_child)
{
loser_tree->tree[i] = (2 * i > K - 1) ? (2 * i - K) : winner_idx[2 * i];
winner_idx[i] = (2 * i + 1 > K - 1) ? (2 * i + 1 - K) : winner_idx[2 * i + 1];
}
else {
loser_tree->tree[i] = (2 * i + 1 > K - 1) ? (2 * i + 1 - K) : winner_idx[2 * i + 1];
winner_idx[i] = (2 * i > K - 1) ? (2 * i - K) : winner_idx[2 * i];
}
}
loser_tree->tree[0] = winner_idx[1];
}
// 败者树归并
void LoserTree_merge(LoserTree_t* loser_tree, int print_flag)
{
// 先从所有归并段中读出一个数据存入败者树的叶结点中
FILE* files[K];
char file_name[20];
FILE* res_flie = NULL;
Key_t last_num = MIN_VAL;
fopen_s(&res_flie, "res", "w");
for (int i = 0; i < K; ++i)
{
// 初始化文件指针
_itoa_s(i, file_name, 10, 10); // 加上文件名序号
fopen_s(&files[i], file_name, "r");
fscanf_s(files[i], "%d", &(loser_tree->leaf[i]));
}
LoserTree_build(loser_tree);
int output_file_idx; // 包含最小值的归并段的序号
do {
output_file_idx = loser_tree->tree[0]; // 包含最小值的归并段的序号
fprintf_s(res_flie, "%d ", loser_tree->leaf[output_file_idx]); // 输出最小值
if (print_flag)
{
printf("%d ", loser_tree->leaf[output_file_idx]);
}
if (last_num > loser_tree->leaf[output_file_idx])
{
// 如果不是升序,则报错
printf("\nERR!!!\n");
return;
}
last_num = loser_tree->leaf[output_file_idx];
if (!feof(files[output_file_idx]))
{
fscanf_s(files[output_file_idx], "%d", &(loser_tree->leaf[output_file_idx]));
}
else {
loser_tree->leaf[output_file_idx] = MAX_VAL; // 如果一个归并段读完了,就在最后插入一个最大值+1的数,表示该归并段结束
}
} while (LoserTree_adjust(loser_tree, output_file_idx));
for (int i = 0; i < K; ++i)
{
fclose(files[i]);
}
}
int main(int argc, const char* argv[])
{
/*************************生成随机数*************************/
// 不需要生成随机数时可以注释掉
printf("Generating random num...\n");
gen_random_num("random_num", NUM, MIN_VAL, MAX_VAL);
printf("random num generated! saved to file \"random_num\"\n");
/****************将大量数据分成多个归并段进行内排,下面三种内排方法可以随便选择一个********************/
printf("正在内排...\n");
// Sort(Quick_sort);
// Sort(Bubble_sort);
Sort(Heap_sort);
printf("内排完成! 归并段结果保存在文件 \"0\", \"1\", \"2\"... 中\n");
/****************用败者树对多个归并段进行归并*********************/
printf("正在归并所有归并段...\n");
LoserTree_t loser_tree;
LoserTree_merge(&loser_tree, 0); // 1 表示同时输出到屏幕,0 表示只输出到文件
printf("归并完成! 最终结果保存在文件 \"res\" 中\n");
return 0;
}
置换选择排序算法
- 除了增加 k k k 一次对更多的归并段进行归并以外,减少初始归并段的个数 m m m (即增加每个初始归并段中包含的记录数) 也可以提高外排效率。但如果仍然使用内部排序来生成初始归并段,每个初始归并段的大小就必然小于内存容量,因此就需要改为采用 置换选择排序算法
- 通过置换选择排序算法得到的初始归并段,其长度并不会受内存容量的限制,且通过证明得知使用该方法所获得的归并段的平均长度为内存工作区大小的两倍
算法步骤
- 具体操作过程为:
- 首先从初始文件中输入 n n n 个记录到内存工作区中, n n n 为内存工作区的最大容量
- 从内存工作区中选出关键字最小的记录,将其记为
MINIMAX
记录 - 将
MINIMAX
记录输出到归并段文件中 - 若初始文件不为空,则从初始文件中输入下一个记录到内存工作区中;从内存工作区中的所有比
MINIMAX
值大的记录中选出值最小的关键字的记录,作为新的MINIMAX
记录; - 重复上面两步,直至在内存工作区中选不出新的
MINIMAX
记录为止,由此就得到了一个初始归并段; - 重复上面四步,直至内存工作为空,由此就可以得到全部的初始归并段
- 例如,已知初始文件中总共有 24 个记录,假设内存工作区最多可容纳 6 个记录,按照之前的选择排序算法最少也只能分为 4 个初始归并段。而如果使用置换—选择排序,可以实现将 24 个记录分为 3 个初始归并段;操作步骤如下:
- 首先输入前 6 个记录到内存工作区,其中关键字最小的为 29,所以选其为
MINIMAX
记录,同时将其输出到归并段文件中,如下图所示:
- 此时初始文件不为空,所以从中输入下一个记录 14 到内存工作区中,然后从内存工作区中的比 29 大的记录中,选择一个最小值作为新的
MINIMAX
值输出到 归并段文件中,如下图所示:
- 初始文件还不为空,所以继续输入 61 到内存工作区中,从内存工作区中的所有关键字比 38 大的记录中,选择一个最小值作为新的
MINIMAX
值输出到归并段文件中,如下图所示:
- 如此重复性进行,直至选不出
MINIMAX
值为止,表示一个归并段已经生成,则开始下一个归并段的创建,如下图所示:
- 最终可以生成 3 个归并段
- 首先输入前 6 个记录到内存工作区,其中关键字最小的为 29,所以选其为
在内存工作区中选择新的 MINIMAX
记录
- 利用 “败者树” 来实现;为了防止新加入的关键字值小的的影响,每个叶子结点附加一个序号位,当进行关键字的比较时,先比较序号,序号小的为胜者;序号相同的关键字值小的为胜者
- 序号其实就表示该记录属于哪个归并段;假设正在处理的归并段序号为
x
x
x,当读入新的记录时,先将它的值与
MINIMAX
进行判断,如果新的记录的值比之前的MINIMAX
大,说明其也属于归并段 x x x,因此保持其序号不变,为 x x x;反之则将序号改为 x + 1 x+1 x+1,说明它属于新的归并段 - 刚开始未读入任何数据时,可以先把叶结点的序号都设为 0
- 序号其实就表示该记录属于哪个归并段;假设正在处理的归并段序号为
x
x
x,当读入新的记录时,先将它的值与
- 这样,通过置换选择排序生成初始归并段的所需时间为 O ( n l o g w ) O(nlogw) O(nlogw)(其中 n n n 为记录数, w w w 为内存工作区的大小)
最佳归并树
- 问题:无论是通过等分还是置换-选择排序得到的归并段,如何设置它们的归并顺序,可以使得对外存的访问次数降到最低?
- 例如,现有通过置换选择排序算法所得到的 9 个初始归并段,其长度分别为:9,30,12,18,3,17,2,6,24。在对其采用 3-路平衡归并的方式时可能出现下图所示的情况 (树中的权值表示归并段包含的记录个数);
- 假设在进行平衡归并时,操作每个记录都需要单独进行一次对外存的读写,那么上图中的归并过程需要对外存进行读和写的次数为:
( 9 + 30 + 12 + 18 + 3 + 17 + 2 + 6 + 24 ) × 2 × 2 = 484 (9+30+12+18+3+17+2+6+24)\times2\times2=484 (9+30+12+18+3+17+2+6+24)×2×2=484也即为树的带权路径长度的 2 倍,因此上述问题就等价于构造 Huffman 树使带权路径长度最短;如下图所示的 Huffman 树,对外存的读写次数为
( 2 × 3 + 3 × 3 + 6 × 3 + 9 × 2 + 12 × 2 + 17 × 2 + 18 × 2 + 24 × 2 + 30 ) × 2 = 446 (2\times3+3\times3+6\times3+9\times2+12\times2+17\times2+18\times2+24\times2+30)\times2=446 (2×3+3×3+6×3+9×2+12×2+17×2+18×2+24×2+30)×2=446
- 例如,现有通过置换选择排序算法所得到的 9 个初始归并段,其长度分别为:9,30,12,18,3,17,2,6,24。在对其采用 3-路平衡归并的方式时可能出现下图所示的情况 (树中的权值表示归并段包含的记录个数);
附加“虚段”的归并树
- 上图中构建的是一棵真正的 3叉树(树中各结点的度不是 3 就是 0),而若 9 个初始归并段改为 8 个,在做 3-路平衡归并的时候就需要有一个结点的度为 2
- 对于具体设置哪个结点的度为 2,为了使总的带权路径长度最短,正确的选择方法是:附加一个权值为 0 的结点(称为“虚段”),然后再构建赫夫曼树。例如图 2 中若去掉权值为 30 的结点,其附加虚段的最佳归并树如下图所示:
- 对于
k
k
k–路平衡归并来说,若
(
m
−
1
)
M
O
D
(
k
−
1
)
=
0
(m-1)MOD(k-1)=0
(m−1)MOD(k−1)=0,则不需要增加虚段;否则需附加
k
−
1
−
(
m
−
1
)
M
O
D
(
k
−
1
)
k-1-(m-1)MOD(k-1)
k−1−(m−1)MOD(k−1) 个虚段
- 证明:设树的总结点数为 n n n, k k k 度结点的个数为 n k n_k nk,则 n = n 0 + n k 且 k n k = n − 1 n=n_0+n_k且kn_k=n-1 n=n0+nk且knk=n−1,因此 k n k = n k + n 0 − 1 kn_k=n_k+n_0-1 knk=nk+n0−1,进而得到 ( k − 1 ) n k = n 0 − 1 (k-1)n_k=n_0-1 (k−1)nk=n0−1,即 ( k − 1 ) n k = m − 1 (k-1)n_k=m-1 (k−1)nk=m−1,添加虚段让 n k n_k nk 是整数即可