对于外部排序算法来说,直接影响算法效率的因素为读写外存的次数,即次数越多,算法效率越低。若想提高算法的效率,即减少算法运行过程中读写外存的次数,可以增加 k–路平衡归并中的 k 值。
经过计算得知,如果毫无限度地增加 k 值,虽然会减少读写外存数据的次数,但会增加内部归并的时间,得不偿失。
对于 10 个临时文件,当采用 2-路平衡归并时,若每次从 2 个文件中想得到一个最小值时只需比较 1 次;而采用 5-路平衡归并时,若每次从 5 个文件中想得到一个最小值就需要比较 4 次。以上仅仅是得到一个最小值记录,如要得到整个临时文件,其耗费的时间就会相差很大。
为避免在增加 k 值的过程中影响内部归并的效率,在进行 k-路归并时可使用败者树来实现,该方法在增加 k 值时不会影响其内部归并的效率。
败者树实现内部归并
败者树是树形选择排序的一种变形,本身是一棵完全二叉树。
对于无序表{49,38,65,97,76,13,27,49}创建的完全二叉树如图1所示,构建此树的目的是选出无序表中的最小值。
图 1 胜者树
这棵树与败者树正好相反,是一棵胜者树。因为树中每个非终端结点(除叶子结点之外的其它结点)中的值都表示的是左右孩子相比较后的较小值(谁最小即为胜者)。例如叶子结点 49 和 38 相对比,由于 38 更小,所以其双亲结点中的值保留的是胜者 38。然后用 38 去继续同上层去比较,一直比较到树的根结点。
而败者树恰好相反,其双亲结点存储的是左右孩子比较之后的失败者,而胜利者则继续同其它的胜者去比较。
例如图 1 中,叶子结点 49 和 38 比较,38 更小,所以 38 是胜利者,49 为失败者,但由于是败者树,所以其双亲结点存储的应该是 49;同样,叶子结点 65 和 97 比较,其双亲结点中存储的是 97 ,而 65 则用来同 38 进行比较,65 会存储到 97 和 49 的双亲结点的位置,38 继续做后续的胜者比较,依次类推。
胜者树和败者树的区别就是:胜者树中的非终端结点中存储的是胜利的一方;而败者树中的非终端结点存储的是失败的一方。而在比较过程中,都是拿胜者去比较。
图 2 败者树
如图 2 所示为一棵 5-路归并的败者树,其中 b0—b4 为树的叶子结点,分别为 5 个归并段中存储的记录的关键字。 ls 为一维数组,表示的是非终端结点,其中存储的数值表示第几归并段(例如 b0 为第 0 个归并段)。ls[0] 中存储的为最终的胜者,表示当前第 3 归并段中的关键字最小。
当最终胜者判断完成后,只需要更新叶子结点 b3 的值,即导入关键字 15,然后让该结点不断同其双亲结点所表示的关键字进行比较,败者留在双亲结点中,胜者继续向上比较。
例如,叶子结点 15 先同其双亲结点 ls[4] 中表示的 b4 中的 12 进行比较,12 为胜利者,则 ls[4] 改为 15,然后 12 继续同 ls[2] 中表示的 10 做比较,10 为胜者,然后 10 继续同其双亲结点 ls[1] 表示的 b1(关键字 9)作比较,最终 9 为胜者。整个过程如下图所示:
注意:
为了防止在归并过程中某个归并段变为空,处理的办法为:可以在每个归并段最后附加一个关键字为最大值的记录。这样当某一时刻选出的冠军为最大值时,表明 5 个归并段已全部归并完成。(因为只要还有记录,最终的胜者就不可能是附加的最大值)。
败者树:
败者树是胜者树的一种变体。在败者树中,用父结点记录其左右子结点进行比赛的败者,而让胜者参加下一轮的比赛。败者树的根结点记录的是败者(数值大的),需要加一个结点来记录整个比赛的胜利者。采用败者树可以简化重构的过程。
/**
* 实验题目:
* 实现多路平衡归并算法
* 实验目的:
* 领会外排序中多路平衡归并的执行过程和算法设计
* 实验内容:
* 编写程序,模拟利用败者树实现5路归并算法的过程以求解以下问题:
* 设有5个文件中记录关键字如下:
* F0:{17, 21, ∞} F1:{5, 44, ∞} F2:{10, 12, ∞} F3{29, 32, ∞} F4{15, 56, ∞}
* 要求将其归并为一个有序段并输出。假设这些输入文件数据存放在内存中,输出
* 结果直接在屏幕上显示。
*/
#include <stdio.h>
#define MAX_SIZE 20 // 每个文件中的最多记录
#define K 5 // 5路平衡归并
#define MAX_KEY 32767 // 最大关键字值∞
#define MIN_KEY -32768 // 最小关键字值-∞
typedef int info_type;
typedef int key_type;
typedef struct
{
key_type key; // 关键字项
info_type other_info; // 其他数据项,具体类型在主程序中定义
}rec_type; // 文件中记录的类型
typedef struct
{
rec_type recs[MAX_SIZE];
int cur_rec;
}file_type; // 模拟的文件类型
typedef int loser_tree; // 败者树为loser_tree[K]
/*------------------------以下为全局变量------------------------*/
rec_type b[K]; // b中存放各段中取出的当前记录
file_type F[K]; // 存放文件记录的数组
/*-----------------初始化存放文件记录的数组F---------------------*/
static void initial(void)
{
int i;
// 第1个初始文件
F[0].recs[0].key = 17;
F[0].recs[1].key = 21;
F[0].recs[2].key = MAX_KEY;
// 第2个初始文件
F[1].recs[0].key = 5;
F[1].recs[1].key = 44;
F[1].recs[2].key = MAX_KEY;
// 第3个初始文件
F[2].recs[0].key = 10;
F[2].recs[1].key = 12严蔚敏,;
F[2].recs[2].key = MAX_KEY;
// 第4个初始文件
F[3].recs[0].key = 29;
F[3].recs[1].key = 32;
F[3].recs[2].key = MAX_KEY;
// 第5个初始文件
F[4].recs[0].key = 15;
F[4].recs[1].key = 56;
F[4].recs[2].key = MAX_KEY;
// 5个初始文件,当前读记录号为-1
for(i = 0; i < K; i++)
F[i].cur_rec = -1;
}
/*--------------从F[i]文件中读一个记录到b[i]中---------------*/
static void input(int i, int &key)
{
F[i].cur_rec++;
key = F[i].recs[F[i].cur_rec].key; // F[0].recs[0].key,F[1].recs[0].key,...,F[4].recs[0].key
}
static int cnt = 0;
/*--------------沿从叶子结点b[s]到根结点ls[0]=5的路径调整败者树--------------*/
// ls为一维数组,表示的是非终端结点,其存储的数值表示第几归并段,例如b[0]为第0个归并段
static void adjust(loser_tree ls[K], int s) // s=4,3,2,1,0
{
int i;
int t;
t = (s + K) / 2; // ls[t]是b[s]的双亲结点
printf(" 调整如下:\n");
printf("\tt = %d, 沿从叶子结点b[%d] = %2d到根结点ls[0]的路径调整败者树\n", t, s, b[s]);
while(t > 0)
{
printf("\tb[%d].key = %d, t = %d, ls[%d] = %d, b[ls[%d]].key = %d\n", s, b[s].key, t, t, ls[t], t, b[ls[t]].key);
if(b[s].key > b[ls[t]].key)
{
i = s; // i保存当前第几个归并段
s = ls[t]; // s指示新的胜者,数值小的获胜
ls[t] = i; // 双亲结点ls[t]存放败者,表示第几个归并段
printf("\ti = %d, s = %d, ls[%d] = %d\n", i, s, t, ls[t]);
}
t = t / 2;
}
ls[0] = s; // 根结点存放第几个归并段
printf("\tls[0] = %d\n", ls[0]);
}
/*--------------创建败者树ls--------------*/
static void create_loser_tree(loser_tree ls[K])
{
int s;
b[K].key = MIN_KEY;
for(s = 0; s < K; s++) // 设置ls中"败者"的初值,全部为最小关键字MIN_KEY的段号K=5
ls[s] = K;
for(s = K - 1; s >= 0; --s) // 依次从b[4],b[3],...,b[0]出发调整败者
adjust(ls, s);
}
/*--------------显示败者树----------------*/
static void display(loser_tree ls[K])
{
int i;
printf("(%d)败者树(归并段,关键字):\n", ++cnt);
for(i = 0; i < K; i++)
{
if(b[ls[i]].key == MAX_KEY)
{
printf("(ls[%d] = %d, key = ∞) ", i, ls[i]);
}
else if(b[ls[i]].key == MIN_KEY)
{
printf("(ls[%d] = %d, key = -∞) ", i, ls[i]);
}
else
{
printf("(ls[%d] = %d, key = %d) ", i, ls[i], b[ls[i]].key);
}
}
printf("\n");
}
/*--------------输出F[q]中的当前记录--------------*/
static void output(int q)
{
printf("输出F[%d]的关键字%d\n", q, F[q].recs[F[q].cur_rec].key);
}
/*--------------利用败者树ls进行k路归并到输出-----------------*/
static void k_merge(loser_tree ls[K])
{
int i; // 循环变量
int rno; // rno指示当前最小关键字所在归并段
for(i = 0; i < K; i++) // 分别从k个输入归并段读入该段当前第一个记录的关键字到b
{
input(i, b[i].key);
printf("从文件读记录到b->b[%d] = %2d\n", i, b[i].key);
}
create_loser_tree(ls); // 创建败者树ls,选得最小关键字为b[ls[0]].key
display(ls);
while(b[ls[0]].key != MAX_KEY)
{
rno = ls[0]; // rno指示当前最小关键字所在归并段
output(rno); // 将编号为rno的归并段中当前关键字为b[rno].key的记录输出
input(rno, b[rno].key); // 从编号为rno的输入归并段中读入下一个记录的关键字
printf("从输入归并段%d读记录关键字%d\n", rno, b[rno].key);
if(b[rno].key == MAX_KEY)
printf("从F[%d]中添加关键字∞并调整\n", rno);
else
printf("从F[%d]中添加关键字%d并调整\n", rno, b[rno].key);
adjust(ls, rno); // 调整败者树,选择新的最小关键字
display(ls); // 输出败者树
}
}
int main(void)
{
loser_tree ls[K];
printf("F0:{17, 21, ∞} F1:{5, 44, ∞} F2:{10, 12, ∞} F3{29, 32, ∞} F4{15, 56, ∞}\n");
initial();
k_merge(ls);
return 0;
}