【算法概论】k路归并

 

首先了解两个概念,胜者树败者树

胜者树和败者树都是二叉排序树,是树形选择排序的一种变形。每个叶子节点相当于一位选手,每个中间结点相当于一场比赛,每一层相当于一轮比赛。胜者树的中间结点记录的是胜者的标号,败者树的中间结点记录败者的标号。因此,胜者树和败者树的根节点,就是最终的胜者或败者 —— 反映到一组数据中,可以是最大值或最小值。

胜者树与败者树可以在log(n)的时间内找到最值。任何一个叶子结点的值改变后,利用中间结点的信息,还是能够快速地找到最值。在k路归并排序中经常用到。


胜者树

【算法概论】k路归并

规定数小者胜。

b3 PK b4,b3胜b4负,内部结点ls[4]的值为3;

b3 PK b0,b3胜b0负,内部结点ls[2]的值为3;

b1 PK b2,b1胜b2负,内部结点ls[3]的值为1;

b3 PK b1,b3胜b1负,内部结点ls[1]的值为3。

如果我们将b3的值改为11,就需要对该胜者树进行重构,重构后的胜者树如下所示:

【算法概论】k路归并

b3 PK b4,b3胜b4负,内部结点ls[4]的值为3;

b3 PK b0,b0胜b3负,内部结点ls[2]的值为0;

b1 PK b2,b1胜b2负,内部结点ls[3]的值为1;

b0 PK b1,b1胜b0负,内部结点ls[1]的值为1。

由上面的例子,可以很容易的理解:

胜者树的一个优点是,如果一个选手的值改变了,只需要沿着从该结点到根结点的路径修改这棵二叉树,而不必改变其他比赛的结果。

 

败者树:

败者树是胜者树的一种变体。在败者树中,用父结点记录其左右子结点进行比赛的败者,而让胜者参加下一轮的比赛。败者树的根结点记录的是败者,因此需要加一个结点来记录整个比赛的胜利者。

【算法概论】k路归并

规定数大者败。

b3 PK b4,b3胜b4负,将败者的标号赋给父结点,内部结点ls[4]的值为4;

b3 PK b0,b3胜b0负,内部结点ls[2]的值为0;

b1 PK b2,b1胜b2负,内部结点ls[3]的值为2;

b3 PK b1,b3胜b1负,内部结点ls[1]的值为1;

在根结点ls[1]上又加了一个结点ls[0]=3,记录的最后的胜者。

如果我们把b3的值改为13,重构后的图如下所示:

【算法概论】k路归并

将新进入选择树的结点与其父结点进行比赛:将败者存放在父结点中;而胜者再与父结点的父结点(即它爷爷)进行比较;

比赛沿着到根结点的路径不断进行,直到ls[1]处。把败者存放在结点ls[1]中,胜者存放在ls[0]中。

败者树的重构只是与该结点的父结点的记录有关,而胜者树的重构还与该结点的兄弟结点有关。


好了,接下来我们看k路归并的问题。

k路归并一般都用在外排序上,是为了减少访问磁盘的次数。那么,基于两路归并的有什么缺点呢,我们可以参照下图看一看:

【算法概论】k路归并

假设初始化归并段有m个,则二路归并需要访问硬盘的次数为【算法概论】k路归并。按照这个方法,我们只要增加k就可以减少次数。就是说是k路归并的话,访问硬盘次数就是【算法概论】k路归并。但是这里边存在一个矛盾:如果增大k,归并的时候比较次数增加了。那我们只要找到一种可以增大k,然后比较次数又比较少的方法就行了,这就是多路归并-败者树。看下面推导:

【算法概论】k路归并

败者树编程(k路归并)

在实现败者树编程中,我们把败者树的叶子节点和非叶子结点分开定义:

(1)k路归并,k为需要归并的数组个数;

(2)叶子结点存放在b数组中:b[0]、b[1]、b[2]、……、b[k],最后一个叶子节点即b[k],存放一个比所有记录结点都小的值,我们可以记为INT_MIN;

(3)非叶子结点定义为ls[k]、ls[k-1]、ls[k-2]、……ls[1],存放各次比较的败者索引,ls[0]存放最后的冠军。

定义完成啦,然后我们就开始编程:

1、败者树的创建:

#include <iostream>

using namespace std;

#define K 5		//表示5路归并
#define MIN INT_MIN

void Adjust(int s);
void CreateLoser();

int b[K + 1] = { 17, 5, 10, 39, 15 };	//叶子结点的值
int ls[K] = { 0 };//记录败者的序号

int main()
{
	CreateLoser();
	system("pause");
	return 0;
}

/******
调整败者树
s为待调整的结点在b数组中的索引
******/
void Adjust(int s)
{
	for (int t = (s + K) / 2; t > 0; t = t / 2)
	{
		if (b[s] > b[ls[t]])	//如果待调整结点大于父结点指向的元素值,说明目前的b[s]值为败者
		{
			int temp = s;
                        s = ls[t];		//s永远是指向这一轮比赛最小节点 —— 胜者
			ls[t] = temp;	//将败者的索引 —— s赋给父结点
		}
	}

	ls[0] = s;	//将最小节点的索引存储在ls[0]

	return;
}

/*****
创建一个败者树
*****/
void CreateLoser()
{
	b[K] = MIN;		//b[K]存放一个比所有记录都小的值,作为虚结点

	//将ls[0...k-1] = K,表示第K+1(虚设)个归并段的记录当前最小
	for (int i = 0; i < K; ++i)
	{
		ls[i] = K;
	}
        //从k-1到0,每次加入一个记录进行一次调整,算法自顶向下,直到所有记录加进来
	for (int i = K - 1; i >= 0; --i)
	{
		Adjust(i);	//加入一个基点,要进行调整
	}

	return;
}

创建树的过程如下图所示: 

【算法概论】k路归并

【算法概论】k路归并

 

【算法概论】k路归并

2、归并排序:

读入数据,创建归并树,判断b[ls[0]] == MAX,等于表示所有记录都已输出。不等于,输出当前冠军,然后从相应归并段读入数据填上。注意,如果相应的归并段已经空了,则填上MAX。下面给出伪代码:

void K_Merge()
{
	for (int i = 0; i < K; ++i)
	{
		input(i);	//伪代码,输入到b[i]
	}

	CreateLoser();

	while (b[ls[0]] != MAX) 
	{
		//只要不是最大值
		int q = ls[0];	//得到冠军的索引
		output(b[q]);	//伪代码
		input(b[q]);	//伪代码
		Adjust(q);
	}
}

3、整篇代码:

首先将k个归并段中的首元素关键字依次存入External[0] ~ External[k-1]的叶子结点空间里,然后调用CreateLoserTree创建败者树,创建完毕之后最小的关键字下标(即所在归并段的序号)便被存入LoserTree[0]中。然后不断循环:把LoserTree[0]所存最小关键字来自于哪个归并段的序号记为p,将该归并段的首元素输出到有序归并段里,然后把下一个元素关键字放入上一个元素本来所在的叶子结点External[p]中,调用Adjust函数顺着External[p]这个叶子结点往上调整败者树,直到新的最小的关键字被选出来,其下标同样存在LoserTree[0]中。循环这个操作过程直至所有元素被写到有序归并段里。

/*
设计和实现一个有效的分治算法解决 k-路合并操作 问题,并分析时间复杂度。
*/

#include <iostream>
#include <fstream>

using namespace std;

#define LEN 10        //最大归并段长
#define MINKEY -1     //默认全为正数
#define MAXKEY 100    //最大值,当一个段全部输出后的赋值

void K_Merge();
void Adjust(int s);
void CreateLoserTree();

struct Array
{
	int arr[LEN];
	int num;	//每个数组的长度
	int pos;
}*A;

int k;	//k路归并
int cnt;	//所有数组的长度和
int *LoserTree, *External;

int main()
{
	ifstream in("in.txt");

	cnt = 0;
	in >> k;	//读取数组的个数

	A = (Array *)malloc(sizeof(Array)*k);	//开辟k个数组的空间

	for (int i = 0; i < k; i++)
	{
		in >> A[i].num;		//读取每一个数组的长度
		cnt = cnt + A[i].num;	//cnt为所有数组的长度和
		for (int j = 0; j < A[i].num; j++)
		{
			in >> A[i].arr[j];		//读取每个数组中的元素
		}
		A[i].pos = 0;	//每次将所有数组的第pos个元素提取出来,创建败者树,先提取第0个元素,因此pos初始值为0
	}

	LoserTree = (int *)malloc(sizeof(int) * k);		//为败者树的非叶子结点开辟k个int空间
	External = (int *)malloc(sizeof(int) * (k + 1));	//为败者树的叶子节点开辟k+1个int空间

	K_Merge();	//k路归并

	return 0;
}

/******
调整败者树
s为待调整的结点在b数组中的索引
******/
void Adjust(int s)
{
	int t = (s + k) / 2;
	int temp;
	while (t > 0)
	{
		if (External[s] > External[LoserTree[t]])	//如果待调整结点大于父结点指向的元素值,说明目前的External[s]值为败者
		{
			temp = s;
			s = LoserTree[t];	//s永远是指向这一轮比赛最小节点 —— 胜者
			LoserTree[t] = temp;	//将败者的索引 —— s赋给父结点
		}
		t = t / 2;
	}

	LoserTree[0] = s;	//将最小节点的索引存储在败者树的最顶端
}

/*****
创建败者树
*****/
void CreateLoserTree()
{
	External[k] = MINKEY;

	for (int i = 0; i < k; ++i)
	{
		LoserTree[i] = k;
	}
	
	//从最底层调整败者树
	for (int i = k - 1; i >= 0; --i)
	{
		Adjust(i);
	}

	return;
}

/*****
k路归并
*****/
void K_Merge()
{
	int p;

	//给External数组赋值,分别为A[0、1、…… k].arr[i]
	for (int i = 0; i < k; i++)
	{
		p = A[i].pos;
		External[i] = A[i].arr[p];	//每次将所有数组的第pos个元素提取出来,创建败者树
		A[i].pos++;
	}

	//创建败者树
	CreateLoserTree();

	int n = 0;
	while (n < cnt)
	{
		p = LoserTree[0];
		cout << External[p] << ", ";		//输出败者树最顶端存储的索引在External数组中对应的元素,即最小值
		n += 1;
		if (A[p].pos >= A[p].num)	//如果第p个数组中的数据均已进入有序归并段,将External[p]赋值为MAXKEY
		{
			External[p] = MAXKEY;
		}
		else
		{
			External[p] = A[p].arr[A[p].pos];
			A[p].pos++;
		}
		Adjust(p);
	}
	cout << endl;

	return;
}

文件中的数据为:

【算法概论】k路归并

运行结果:

【算法概论】k路归并

文章参考:https://blog.****.net/lsjseu/article/details/11708587